Add multi-factor conviction gate to activation
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 26s

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:
dennisthiessenandClaude Fable 5 committed 2026-06-13 11:50:42 +02:00
1 parent 6da65b8d8f
commit d53ed972d1
25 files changed
+924 -110

No files matched your search

@@ -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>
);
}
+33 -11
View File
@@ -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>