Add multi-factor conviction gate to activation
Make "qualified" mean an edge candidate, not just R:R + confidence. The gate now also requires (all admin-configurable, defaults on): - high conviction: recommended_action LONG_HIGH / SHORT_HIGH only - clean read: risk_level Low (no contradicting signals) - probable primary target: best target probability >= min (default 60) - Shared predicate: app/services/qualification.py + frontend/src/lib/qualification.ts (mirrored) - Activation config extended (min_target_probability, require_high_conviction, exclude_conflicts) with bool-aware get/update + validation - /trades/performance switched to ?qualified_only=true, applying the full gate server-side; confidence breakdown stays unfiltered - Dashboard "Qualified", Signals "Qualified only" toggle, and Track Record all use the one gate; Admin gains the new controls Sentiment provider runtime config (prior change) included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ import { SkeletonTable } from '../ui/Skeleton';
|
||||
const DEFAULTS: ActivationConfig = {
|
||||
min_rr: 2,
|
||||
min_confidence: 70,
|
||||
min_target_probability: 60,
|
||||
require_high_conviction: true,
|
||||
exclude_conflicts: true,
|
||||
};
|
||||
|
||||
export function ActivationSettings() {
|
||||
@@ -19,12 +22,12 @@ export function ActivationSettings() {
|
||||
}, [data]);
|
||||
|
||||
const onSave = () => {
|
||||
update.mutate(form as unknown as Record<string, number>);
|
||||
update.mutate(form);
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
setForm(DEFAULTS);
|
||||
update.mutate(DEFAULTS as unknown as Record<string, number>);
|
||||
update.mutate(DEFAULTS);
|
||||
};
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={2} cols={2} />;
|
||||
@@ -33,16 +36,16 @@ export function ActivationSettings() {
|
||||
return (
|
||||
<div className="glass p-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200">Activation Thresholds</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</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.
|
||||
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
|
||||
Signals "Qualified only" view, and the Track Record's qualified stats. All setups are
|
||||
still evaluated regardless — tighten the gate, then watch qualified expectancy in the
|
||||
Track Record to find what actually wins.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
||||
<input
|
||||
@@ -53,6 +56,7 @@ export function ActivationSettings() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">Set above your scanner floor or it does nothing.</span>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Min Confidence (%)</span>
|
||||
@@ -66,6 +70,50 @@ export function ActivationSettings() {
|
||||
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 Target Probability (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={form.min_target_probability}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, min_target_probability: Number(e.target.value) }))}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">Best target's probability must clear this. 0 disables.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.require_high_conviction}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, require_high_conviction: e.target.checked }))}
|
||||
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
|
||||
/>
|
||||
<span>
|
||||
Require high conviction
|
||||
<span className="mt-0.5 block text-[11px] text-gray-500">
|
||||
Only LONG (High) / SHORT (High) — the signals must clearly pick a side.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.exclude_conflicts}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, exclude_conflicts: e.target.checked }))}
|
||||
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
|
||||
/>
|
||||
<span>
|
||||
Exclude conflicted setups
|
||||
<span className="mt-0.5 block text-[11px] text-gray-500">
|
||||
Risk level must be Low — drops setups with contradicting signals.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
useSentimentSettings,
|
||||
useUpdateSentimentSettings,
|
||||
useTestSentimentProvider,
|
||||
} from '../../hooks/useAdmin';
|
||||
import { Select } from '../ui/Field';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import type { SentimentTestResult } from '../../lib/types';
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
database: 'configured here',
|
||||
environment: 'from environment (.env)',
|
||||
none: 'not configured',
|
||||
};
|
||||
|
||||
export function SentimentProviderSettings() {
|
||||
const { data, isLoading, isError, error } = useSentimentSettings();
|
||||
const update = useUpdateSentimentSettings();
|
||||
const test = useTestSentimentProvider();
|
||||
|
||||
const [provider, setProvider] = useState('openai');
|
||||
const [model, setModel] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [testResult, setTestResult] = useState<SentimentTestResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setProvider(data.provider);
|
||||
setModel(data.model);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={3} cols={2} />;
|
||||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load sentiment settings'}</p>;
|
||||
if (!data) return null;
|
||||
|
||||
const onProviderChange = (next: string) => {
|
||||
setProvider(next);
|
||||
// Auto-fill the model with the new provider's default unless the user has a
|
||||
// custom value that isn't the previous provider's default.
|
||||
const defaults = data.default_models;
|
||||
if (!model || Object.values(defaults).includes(model)) {
|
||||
setModel(defaults[next] ?? '');
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
setTestResult(null);
|
||||
update.mutate({
|
||||
provider,
|
||||
model,
|
||||
...(apiKey ? { api_key: apiKey } : {}),
|
||||
});
|
||||
setApiKey('');
|
||||
};
|
||||
|
||||
const onTest = () => {
|
||||
test.mutate('AAPL', { onSuccess: (res) => setTestResult(res) });
|
||||
};
|
||||
|
||||
const keyConfigured = data.api_key_configured;
|
||||
|
||||
return (
|
||||
<div className="glass p-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200">Sentiment LLM Provider</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Switch the model that powers sentiment analysis without redeploying. Applies to the
|
||||
scheduled sentiment job and manual fetches on the next run.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Provider</span>
|
||||
<Select value={provider} onChange={(e) => onProviderChange(e.target.value)} className="w-full !py-2">
|
||||
{data.valid_providers.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={data.default_models[provider] ?? ''}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">API Key</span>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder={keyConfigured ? '•••••••••• (leave blank to keep current)' : 'Paste API key…'}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
Key status:{' '}
|
||||
<span className={keyConfigured ? 'text-emerald-400' : 'text-amber-400'}>
|
||||
{SOURCE_LABEL[data.api_key_source] ?? data.api_key_source}
|
||||
</span>
|
||||
{' '}· write-only, never displayed
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
|
||||
{update.isPending ? 'Saving…' : 'Save Provider'}
|
||||
</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={onTest}
|
||||
disabled={test.isPending}
|
||||
>
|
||||
{test.isPending ? 'Testing (AAPL)…' : 'Test Connection'}
|
||||
</button>
|
||||
<span className="text-[11px] text-gray-500">Test fetches live sentiment for AAPL with the saved config.</span>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`rounded-lg border px-4 py-3 text-sm ${
|
||||
testResult.ok
|
||||
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-red-500/20 bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<>
|
||||
<span className="font-medium">✓ {testResult.provider} / {testResult.model}</span> — {testResult.ticker}:{' '}
|
||||
<span className="font-semibold">{testResult.classification}</span>{' '}
|
||||
<span className="num">({testResult.confidence}%)</span>
|
||||
{testResult.reasoning && <p className="mt-1 text-xs text-emerald-300/80">{testResult.reasoning}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">✗ Test failed</span>
|
||||
<p className="mt-1 text-xs">{testResult.error}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useActivation } from '../../hooks/useActivation';
|
||||
import { useTrades } from '../../hooks/useTrades';
|
||||
import { qualifiesSetup, activationSummary } from '../../lib/qualification';
|
||||
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import { useToast } from '../ui/Toast';
|
||||
@@ -99,18 +100,16 @@ export function SetupsPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
// 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);
|
||||
// "Qualified only" applies the admin activation gate; the manual filters
|
||||
// below refine within whatever is shown.
|
||||
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');
|
||||
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: () => {
|
||||
@@ -133,12 +132,35 @@ export function SetupsPanel() {
|
||||
|
||||
const processed = useMemo(() => {
|
||||
if (!trades) return [];
|
||||
const filtered = filterTrades(trades, minRR, directionFilter, minConfidence, actionFilter);
|
||||
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, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
|
||||
}, [trades, qualifiedOnly, activation.data, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Qualified gate toggle */}
|
||||
<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 only
|
||||
{activation.data && (
|
||||
<span className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<span className="text-xs text-gray-500">Manual filters below refine within this.</span>
|
||||
</div>
|
||||
|
||||
{/* Filter toolbar */}
|
||||
<div className="glass-sm flex flex-wrap items-end gap-4 p-4">
|
||||
<Field label="Min Risk:Reward" htmlFor="min-rr">
|
||||
@@ -150,7 +172,7 @@ export function SetupsPanel() {
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={minRR}
|
||||
onChange={(e) => setMinRROverride(Number(e.target.value) || 0)}
|
||||
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
@@ -174,7 +196,7 @@ export function SetupsPanel() {
|
||||
max={100}
|
||||
step={1}
|
||||
value={minConfidence}
|
||||
onChange={(e) => setMinConfidenceOverride(Number(e.target.value) || 0)}
|
||||
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
|
||||
className="w-24"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useActivation } from '../../hooks/useActivation';
|
||||
import { activationSummary } from '../../lib/qualification';
|
||||
import { usePerformance } from '../../hooks/usePerformance';
|
||||
import { triggerJob } from '../../api/admin';
|
||||
import { Button } from '../ui/Button';
|
||||
@@ -94,11 +95,9 @@ export function TrackRecordPanel() {
|
||||
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 { data, isLoading, isError, error } = usePerformance(
|
||||
qualifiedOnly ? { qualified_only: true } : undefined,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -126,9 +125,7 @@ export function TrackRecordPanel() {
|
||||
<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 className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user