Add activation thresholds: qualified-signal defaults and views
Admin-configurable thresholds (min R:R, default 2.0; min confidence, default 70%) defining what counts as an actionable signal: - Admin Settings: new Activation Thresholds panel (GET/PUT /admin/settings/activation) - GET /trades/activation exposes values to all users with access - Signals/Setups: filters initialize from activation values - Track Record: "Qualified signals only" toggle (default on) via min_rr/min_confidence params on /trades/performance; the confidence breakdown always covers the full population so the thresholds can be validated against outcomes - Dashboard: "Qualified" metric and qualified-first Top Setups - Outcome evaluator unchanged: every setup is still evaluated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import apiClient from './client';
|
||||
import type { ActivationConfig } from '../lib/types';
|
||||
|
||||
export function getActivation() {
|
||||
return apiClient.get<ActivationConfig>('trades/activation').then((r) => r.data);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
ActivationConfig,
|
||||
AdminUser,
|
||||
PipelineReadiness,
|
||||
RecommendationConfig,
|
||||
@@ -68,6 +69,18 @@ export function updateRecommendationSettings(payload: Partial<RecommendationConf
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getActivationSettings() {
|
||||
return apiClient
|
||||
.get<ActivationConfig>('admin/settings/activation')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updateActivationSettings(payload: Partial<ActivationConfig>) {
|
||||
return apiClient
|
||||
.put<ActivationConfig>('admin/settings/activation', payload)
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getTickerUniverseSetting() {
|
||||
return apiClient
|
||||
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import apiClient from './client';
|
||||
import type { PerformanceStats } from '../lib/types';
|
||||
|
||||
export function getPerformance() {
|
||||
return apiClient.get<PerformanceStats>('trades/performance').then((r) => r.data);
|
||||
export interface PerformanceParams {
|
||||
min_rr?: number;
|
||||
min_confidence?: number;
|
||||
}
|
||||
|
||||
export function getPerformance(params?: PerformanceParams) {
|
||||
return apiClient
|
||||
.get<PerformanceStats>('trades/performance', { params })
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ActivationConfig } from '../../lib/types';
|
||||
import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
|
||||
const DEFAULTS: ActivationConfig = {
|
||||
min_rr: 2,
|
||||
min_confidence: 70,
|
||||
};
|
||||
|
||||
export function ActivationSettings() {
|
||||
const { data, isLoading, isError, error } = useActivationSettings();
|
||||
const update = useUpdateActivationSettings();
|
||||
|
||||
const [form, setForm] = useState<ActivationConfig>(DEFAULTS);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setForm(data);
|
||||
}, [data]);
|
||||
|
||||
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={2} cols={2} />;
|
||||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load activation thresholds'}</p>;
|
||||
|
||||
return (
|
||||
<div className="glass p-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200">Activation Thresholds</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
What counts as a signal worth acting on. Used as the default Signals filters, the
|
||||
Dashboard's qualified-setup metrics, and the Track Record's "qualified only" view.
|
||||
All setups are still evaluated regardless, so these thresholds can be validated
|
||||
against the confidence breakdown.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={form.min_rr}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Min Confidence (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={form.min_confidence}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, min_confidence: Number(e.target.value) }))}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
|
||||
{update.isPending ? 'Saving…' : 'Save Thresholds'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useActivation } from '../../hooks/useActivation';
|
||||
import { useTrades } from '../../hooks/useTrades';
|
||||
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
@@ -94,16 +95,22 @@ function sortTrades(
|
||||
|
||||
export function SetupsPanel() {
|
||||
const { data: trades, isLoading, isError, error } = useTrades();
|
||||
const activation = useActivation();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
const [minRR, setMinRR] = useState(0);
|
||||
// null = user hasn't touched the filter; falls back to admin-configured
|
||||
// activation thresholds once loaded
|
||||
const [minRROverride, setMinRROverride] = useState<number | null>(null);
|
||||
const [minConfidenceOverride, setMinConfidenceOverride] = useState<number | null>(null);
|
||||
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
|
||||
const [minConfidence, setMinConfidence] = useState(0);
|
||||
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
const minRR = minRROverride ?? activation.data?.min_rr ?? 0;
|
||||
const minConfidence = minConfidenceOverride ?? activation.data?.min_confidence ?? 0;
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: () => triggerJob('rr_scanner'),
|
||||
onSuccess: () => {
|
||||
@@ -143,7 +150,7 @@ export function SetupsPanel() {
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={minRR}
|
||||
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
|
||||
onChange={(e) => setMinRROverride(Number(e.target.value) || 0)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
@@ -167,7 +174,7 @@ export function SetupsPanel() {
|
||||
max={100}
|
||||
step={1}
|
||||
value={minConfidence}
|
||||
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
|
||||
onChange={(e) => setMinConfidenceOverride(Number(e.target.value) || 0)}
|
||||
className="w-24"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useActivation } from '../../hooks/useActivation';
|
||||
import { usePerformance } from '../../hooks/usePerformance';
|
||||
import { triggerJob } from '../../api/admin';
|
||||
import { Button } from '../ui/Button';
|
||||
@@ -89,7 +91,14 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
|
||||
}
|
||||
|
||||
export function TrackRecordPanel() {
|
||||
const { data, isLoading, isError, error } = usePerformance();
|
||||
const [qualifiedOnly, setQualifiedOnly] = useState(true);
|
||||
const activation = useActivation();
|
||||
|
||||
const params = qualifiedOnly && activation.data
|
||||
? { min_rr: activation.data.min_rr, min_confidence: activation.data.min_confidence }
|
||||
: undefined;
|
||||
|
||||
const { data, isLoading, isError, error } = usePerformance(params);
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -106,6 +115,26 @@ export function TrackRecordPanel() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="glass-sm flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||
<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 signals only
|
||||
{activation.data && (
|
||||
<span className="num ml-2 text-xs text-gray-500">
|
||||
R:R ≥ {activation.data.min_rr.toFixed(1)} · conf ≥ {activation.data.min_confidence.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Confidence breakdown always covers all setups.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Disclosure summary="How outcomes are measured">
|
||||
<p className="text-xs text-gray-400">
|
||||
@@ -138,8 +167,9 @@ export function TrackRecordPanel() {
|
||||
|
||||
{data && data.overall.total === 0 && (
|
||||
<Callout variant="empty">
|
||||
No evaluated setups yet. Outcomes appear once setups are old enough for their stop or
|
||||
target to be hit — the evaluator runs nightly, or click Evaluate Now.
|
||||
{qualifiedOnly
|
||||
? 'No evaluated setups meet the activation thresholds yet. Untick "Qualified signals only" to see all evaluated setups, or wait for more outcomes.'
|
||||
: 'No evaluated setups yet. Outcomes appear once setups are old enough for their stop or target to be hit — the evaluator runs nightly, or click Evaluate Now.'}
|
||||
{data.pending > 0 && ` ${data.pending} setup${data.pending === 1 ? '' : 's'} pending evaluation.`}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getActivation } from '../api/activation';
|
||||
|
||||
export function useActivation() {
|
||||
return useQuery({
|
||||
queryKey: ['activation'],
|
||||
queryFn: getActivation,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -114,6 +114,32 @@ export function useUpdateRecommendationSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivationSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'activation-settings'],
|
||||
queryFn: () => adminApi.getActivationSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateActivationSettings() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: Record<string, number>) =>
|
||||
adminApi.updateActivationSettings(payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'activation-settings'] });
|
||||
qc.invalidateQueries({ queryKey: ['activation'] });
|
||||
qc.invalidateQueries({ queryKey: ['performance'] });
|
||||
addToast('success', 'Activation thresholds updated');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast('error', error.message || 'Failed to update activation thresholds');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTickerUniverseSetting() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'ticker-universe'],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPerformance } from '../api/performance';
|
||||
import { getPerformance, type PerformanceParams } from '../api/performance';
|
||||
|
||||
export function usePerformance() {
|
||||
export function usePerformance(params?: PerformanceParams) {
|
||||
return useQuery({
|
||||
queryKey: ['performance'],
|
||||
queryFn: getPerformance,
|
||||
queryKey: ['performance', params ?? null],
|
||||
queryFn: () => getPerformance(params),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,6 +152,12 @@ export interface PerformanceStats {
|
||||
by_confidence: Record<string, OutcomeBucketStats>;
|
||||
}
|
||||
|
||||
// Activation thresholds: what counts as an actionable signal
|
||||
export interface ActivationConfig {
|
||||
min_rr: number;
|
||||
min_confidence: number;
|
||||
}
|
||||
|
||||
export interface TradeTarget {
|
||||
price: number;
|
||||
distance_from_entry: number;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { ActivationSettings } from '../components/admin/ActivationSettings';
|
||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||
import { JobControls } from '../components/admin/JobControls';
|
||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||
@@ -28,6 +29,7 @@ export default function AdminPage() {
|
||||
{activeTab === 'Tickers' && <TickerManagement />}
|
||||
{activeTab === 'Settings' && (
|
||||
<div className="space-y-4">
|
||||
<ActivationSettings />
|
||||
<TickerUniverseBootstrap />
|
||||
<RecommendationSettings />
|
||||
<SettingsForm />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useActivation } from '../hooks/useActivation';
|
||||
import { useTrades } from '../hooks/useTrades';
|
||||
import { useWatchlist } from '../hooks/useWatchlist';
|
||||
import { usePerformance } from '../hooks/usePerformance';
|
||||
@@ -51,16 +52,25 @@ function DirectionTag({ direction }: { direction: string }) {
|
||||
export default function DashboardPage() {
|
||||
const trades = useTrades();
|
||||
const watchlist = useWatchlist();
|
||||
const activation = useActivation();
|
||||
const performance = usePerformance();
|
||||
|
||||
const topSetups: TradeSetup[] = useMemo(
|
||||
() => (trades.data ?? []).slice(0, 5),
|
||||
[trades.data],
|
||||
const minRR = activation.data?.min_rr ?? 2;
|
||||
const minConfidence = activation.data?.min_confidence ?? 70;
|
||||
|
||||
const qualifiedSetups = useMemo(
|
||||
() =>
|
||||
(trades.data ?? []).filter(
|
||||
(t) => t.rr_ratio >= minRR && (t.confidence_score ?? 0) >= minConfidence,
|
||||
),
|
||||
[trades.data, minRR, minConfidence],
|
||||
);
|
||||
|
||||
const highConfidenceCount = useMemo(
|
||||
() => (trades.data ?? []).filter((t) => (t.confidence_score ?? 0) >= 70).length,
|
||||
[trades.data],
|
||||
// Show qualified setups first; fall back to the full list when none qualify
|
||||
const showingQualified = qualifiedSetups.length > 0;
|
||||
const topSetups: TradeSetup[] = useMemo(
|
||||
() => (showingQualified ? qualifiedSetups : trades.data ?? []).slice(0, 5),
|
||||
[showingQualified, qualifiedSetups, trades.data],
|
||||
);
|
||||
|
||||
const topWatchlist = useMemo(
|
||||
@@ -100,10 +110,10 @@ export default function DashboardPage() {
|
||||
sub="latest per ticker & direction"
|
||||
/>
|
||||
<Metric
|
||||
label="High Confidence"
|
||||
value={String(highConfidenceCount)}
|
||||
sub="confidence ≥ 70%"
|
||||
valueClass={highConfidenceCount > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||
label="Qualified"
|
||||
value={String(qualifiedSetups.length)}
|
||||
sub={`R:R ≥ ${minRR.toFixed(1)} & conf ≥ ${minConfidence.toFixed(0)}%`}
|
||||
valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||
/>
|
||||
<Metric
|
||||
label="Hit Rate"
|
||||
@@ -122,7 +132,7 @@ export default function DashboardPage() {
|
||||
<div className="grid gap-8 xl:grid-cols-5">
|
||||
{/* Top setups */}
|
||||
<div className="xl:col-span-3">
|
||||
<Section title="Top Setups" hint="by confidence">
|
||||
<Section title="Top Setups" hint={showingQualified ? 'qualified, by confidence' : 'none qualified — showing all'}>
|
||||
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||
{trades.data && topSetups.length === 0 && (
|
||||
|
||||
Reference in New Issue
Block a user