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:
@@ -4,6 +4,8 @@ import type {
|
||||
AdminUser,
|
||||
PipelineReadiness,
|
||||
RecommendationConfig,
|
||||
SentimentProviderConfig,
|
||||
SentimentTestResult,
|
||||
SystemSetting,
|
||||
TickerUniverse,
|
||||
TickerUniverseBootstrapResult,
|
||||
@@ -81,6 +83,28 @@ export function updateActivationSettings(payload: Partial<ActivationConfig>) {
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getSentimentSettings() {
|
||||
return apiClient
|
||||
.get<SentimentProviderConfig>('admin/settings/sentiment')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updateSentimentSettings(payload: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
}) {
|
||||
return apiClient
|
||||
.put<SentimentProviderConfig>('admin/settings/sentiment', payload)
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function testSentimentSettings(ticker: string) {
|
||||
return apiClient
|
||||
.post<SentimentTestResult>('admin/settings/sentiment/test', { ticker })
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getTickerUniverseSetting() {
|
||||
return apiClient
|
||||
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
|
||||
|
||||
@@ -2,8 +2,7 @@ import apiClient from './client';
|
||||
import type { PerformanceStats } from '../lib/types';
|
||||
|
||||
export interface PerformanceParams {
|
||||
min_rr?: number;
|
||||
min_confidence?: number;
|
||||
qualified_only?: boolean;
|
||||
}
|
||||
|
||||
export function getPerformance(params?: PerformanceParams) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -126,13 +126,13 @@ export function useUpdateActivationSettings() {
|
||||
const { addToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: Record<string, number>) =>
|
||||
mutationFn: (payload: Partial<import('../lib/types').ActivationConfig>) =>
|
||||
adminApi.updateActivationSettings(payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'activation-settings'] });
|
||||
qc.invalidateQueries({ queryKey: ['activation'] });
|
||||
qc.invalidateQueries({ queryKey: ['performance'] });
|
||||
addToast('success', 'Activation thresholds updated');
|
||||
addToast('success', 'Activation gate updated');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast('error', error.message || 'Failed to update activation thresholds');
|
||||
@@ -140,6 +140,41 @@ export function useUpdateActivationSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSentimentSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'sentiment-settings'],
|
||||
queryFn: () => adminApi.getSentimentSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSentimentSettings() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { provider?: string; model?: string; api_key?: string }) =>
|
||||
adminApi.updateSentimentSettings(payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'sentiment-settings'] });
|
||||
addToast('success', 'Sentiment provider updated');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast('error', error.message || 'Failed to update sentiment provider');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestSentimentProvider() {
|
||||
const { addToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (ticker: string) => adminApi.testSentimentSettings(ticker),
|
||||
onError: (error: Error) => {
|
||||
addToast('error', error.message || 'Sentiment test failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTickerUniverseSetting() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'ticker-universe'],
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ActivationConfig, TradeSetup } from './types';
|
||||
|
||||
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
|
||||
|
||||
export function bestTargetProbability(setup: TradeSetup): number {
|
||||
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a setup clears the activation gate. Mirrors the backend predicate in
|
||||
* app/services/qualification.py — keep the two in sync.
|
||||
*/
|
||||
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
|
||||
if (setup.rr_ratio < config.min_rr) return false;
|
||||
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
|
||||
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
||||
return false;
|
||||
}
|
||||
if (config.exclude_conflicts && (setup.risk_level ?? '') !== 'Low') return false;
|
||||
if (config.min_target_probability > 0 && bestTargetProbability(setup) < config.min_target_probability) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Short human summary of the active gate, e.g. for tooltips/labels. */
|
||||
export function activationSummary(config: ActivationConfig): string {
|
||||
const parts = [`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`];
|
||||
if (config.require_high_conviction) parts.push('high-conviction');
|
||||
if (config.exclude_conflicts) parts.push('clean');
|
||||
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -152,10 +152,34 @@ export interface PerformanceStats {
|
||||
by_confidence: Record<string, OutcomeBucketStats>;
|
||||
}
|
||||
|
||||
// Activation thresholds: what counts as an actionable signal
|
||||
// Activation gate: what counts as an actionable signal
|
||||
export interface ActivationConfig {
|
||||
min_rr: number;
|
||||
min_confidence: number;
|
||||
min_target_probability: number;
|
||||
require_high_conviction: boolean;
|
||||
exclude_conflicts: boolean;
|
||||
}
|
||||
|
||||
// Runtime sentiment LLM configuration
|
||||
export interface SentimentProviderConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
api_key_configured: boolean;
|
||||
api_key_source: 'database' | 'environment' | 'none';
|
||||
valid_providers: string[];
|
||||
default_models: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SentimentTestResult {
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
ticker?: string;
|
||||
classification?: string;
|
||||
confidence?: number;
|
||||
reasoning?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TradeTarget {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { ActivationSettings } from '../components/admin/ActivationSettings';
|
||||
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||
import { JobControls } from '../components/admin/JobControls';
|
||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||
@@ -30,6 +31,7 @@ export default function AdminPage() {
|
||||
{activeTab === 'Settings' && (
|
||||
<div className="space-y-4">
|
||||
<ActivationSettings />
|
||||
<SentimentProviderSettings />
|
||||
<TickerUniverseBootstrap />
|
||||
<RecommendationSettings />
|
||||
<SettingsForm />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Section } from '../components/ui/Section';
|
||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { formatPrice } from '../lib/format';
|
||||
import { recommendationActionLabel } from '../lib/recommendation';
|
||||
import { qualifiesSetup, activationSummary } from '../lib/qualification';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
|
||||
function fmtR(value: number | null): string {
|
||||
@@ -55,15 +56,12 @@ export default function DashboardPage() {
|
||||
const activation = useActivation();
|
||||
const performance = usePerformance();
|
||||
|
||||
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],
|
||||
activation.data
|
||||
? (trades.data ?? []).filter((t) => qualifiesSetup(t, activation.data!))
|
||||
: [],
|
||||
[trades.data, activation.data],
|
||||
);
|
||||
|
||||
// Show qualified setups first; fall back to the full list when none qualify
|
||||
@@ -112,7 +110,7 @@ export default function DashboardPage() {
|
||||
<Metric
|
||||
label="Qualified"
|
||||
value={String(qualifiedSetups.length)}
|
||||
sub={`R:R ≥ ${minRR.toFixed(1)} & conf ≥ ${minConfidence.toFixed(0)}%`}
|
||||
sub={activation.data ? activationSummary(activation.data) : 'clears the activation gate'}
|
||||
valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||
/>
|
||||
<Metric
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user