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>
);
}