redesign activation gate to expected value + make pipelines cron-configurable
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 46s
Deploy / deploy (push) Successful in 28s

Diagnosing "no qualified signals for 5 days": setups were generated but none
qualified. The gate required BOTH a high min_rr (2.0) AND a high
min_target_probability (60), which became contradictory after the Jun-15
probability recalibration — probability already embeds R:R via the 1/(rr+1) ruin
term, so high-R:R targets are inherently low-probability and nothing cleared both.

Gate is now expected value (R): p*rr - (1-p) from the primary target's
probability. R:R and confidence stay as floors; high-conviction / exclude-conflicts
/ min-target-probability become optional tighteners (default off). Defaults:
min_expected_value=0.15, min_rr=1.2, min_confidence=55. EV is only enforced when
computable. Migration 009 clears stored activation_* rows so the new defaults
apply. Backtest sweeps min_expected_value instead of target probability.

Scheduling: pipelines are now cron-configurable in Admin -> Jobs. daily_pipeline
(full, default 0 7 * * *) plus a new light intraday_pipeline (OHLCV + outcome eval,
default hourly US session) that keeps prices/live-R:R current without setup churn.
Fundamentals on its own early weekly cron. Timezone configurable (default
Europe/Berlin). Moving interval->CronTrigger also fixes the restart-deferral bug
where an interval job's countdown resets on every process restart.

319 backend unit tests pass; frontend tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:46:38 +02:00
parent d53b4ffb57
commit c34f3cb1a4
22 changed files with 777 additions and 171 deletions
+13
View File
@@ -6,6 +6,7 @@ import type {
AlertTestResult,
PipelineReadiness,
RecommendationConfig,
ScheduleConfig,
SentimentProviderConfig,
SentimentTestResult,
SystemSetting,
@@ -85,6 +86,18 @@ export function updateActivationSettings(payload: Partial<ActivationConfig>) {
.then((r) => r.data);
}
export function getScheduleSettings() {
return apiClient
.get<ScheduleConfig>('admin/settings/schedule')
.then((r) => r.data);
}
export function updateScheduleSettings(payload: Partial<ScheduleConfig>) {
return apiClient
.put<ScheduleConfig>('admin/settings/schedule', payload)
.then((r) => r.data);
}
export function getSentimentSettings() {
return apiClient
.get<SentimentProviderConfig>('admin/settings/sentiment')
@@ -4,11 +4,12 @@ import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/
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,
min_expected_value: 0.15,
min_rr: 1.2,
min_confidence: 55,
min_target_probability: 0,
require_high_conviction: false,
exclude_conflicts: false,
};
export function ActivationSettings() {
@@ -39,13 +40,27 @@ export function ActivationSettings() {
<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. 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.
Signals "Qualified only" view, and the Track Record's qualified stats. The core test is
<span className="text-gray-300"> expected value</span> probability-weighted asymmetry
so R:R and target probability no longer fight each other. All setups are still evaluated
regardless; tune the EV floor against the Track Record's EV sweep to see what actually wins.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Expected Value (R)</span>
<input
type="number"
min={-1}
max={10}
step={0.05}
value={form.min_expected_value}
onChange={(e) => setForm((prev) => ({ ...prev, min_expected_value: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">p·R:R (1p), in R. 0.15 ≈ +0.15× risk/trade. The core gate.</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
<input
@@ -56,7 +71,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>
<span className="text-[11px] text-gray-600">Floor only — keeps symmetric/negative trades out.</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Confidence (%)</span>
@@ -70,50 +85,54 @@ 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.
<div className="border-t border-white/[0.06] pt-4">
<p className="text-xs font-medium uppercase tracking-widest text-gray-500">Optional tighteners</p>
<p className="mt-1 text-[11px] text-gray-600">Off by default — turn on to be more selective on top of the EV gate.</p>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<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>
<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>
</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.
</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>
</span>
</label>
</label>
</div>
</div>
<div className="flex items-center gap-2">
@@ -147,7 +147,7 @@ export function JobControls() {
: 'Inactive'}
</span>
{job.via_pipeline ? (
<span className="text-[11px] text-gray-500">runs in daily pipeline</span>
<span className="text-[11px] text-gray-500">runs via pipeline</span>
) : (
job.enabled && job.next_run_at && (
<span className="text-[11px] text-gray-500">
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import type { ScheduleConfig } from '../../lib/types';
import { useScheduleSettings, useUpdateScheduleSettings } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: ScheduleConfig = {
schedule_timezone: 'Europe/Berlin',
schedule_daily_pipeline_cron: '0 7 * * *',
schedule_intraday_pipeline_cron: '0 14-22 * * 1-5',
schedule_fundamentals_cron: '0 4 * * 1',
};
const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [
{
key: 'schedule_timezone',
label: 'Timezone',
hint: 'IANA name, e.g. Europe/Berlin. All times below are in this zone.',
},
{
key: 'schedule_daily_pipeline_cron',
label: 'Daily pipeline (full)',
hint: 'OHLCV → sentiment → R:R scan → outcomes → regime. Default 07:00 so data is ready by 8.',
mono: true,
},
{
key: 'schedule_intraday_pipeline_cron',
label: 'Intraday pipeline (light)',
hint: 'Refresh prices + resolve outcomes. Default hourly across the US session, weekdays.',
mono: true,
},
{
key: 'schedule_fundamentals_cron',
label: 'Fundamentals (weekly)',
hint: 'Slow, rate-limited. Default early Monday so it finishes well before the day starts.',
mono: true,
},
];
export function ScheduleSettings() {
const { data, isLoading, isError, error } = useScheduleSettings();
const update = useUpdateScheduleSettings();
const [form, setForm] = useState<ScheduleConfig>(DEFAULTS);
useEffect(() => {
if (data) setForm(data);
}, [data]);
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 schedule'}</p>;
return (
<div className="glass p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-200">Pipeline Schedule</h3>
<p className="mt-1 text-xs text-gray-500">
When the jobs run, as 5-field cron (<span className="num">min hour day month weekday</span>).
Saved changes apply to the running scheduler immediately no redeploy. The big nightly run
does the full refresh; the light intraday run just keeps prices current.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{FIELDS.map((f) => (
<label key={f.key} className="block space-y-1">
<span className="text-xs text-gray-400">{f.label}</span>
<input
type="text"
value={form[f.key]}
spellCheck={false}
onChange={(e) => setForm((prev) => ({ ...prev, [f.key]: e.target.value }))}
className={`w-full input-glass px-3 py-2 text-sm ${f.mono ? 'num' : ''}`}
/>
<span className="block text-[11px] text-gray-600">{f.hint}</span>
</label>
))}
</div>
<div className="flex items-center gap-2">
<button
className="btn-primary px-4 py-2 text-sm"
onClick={() => update.mutate(form)}
disabled={update.isPending}
>
{update.isPending ? 'Saving…' : 'Save Schedule'}
</button>
<button
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white"
onClick={() => {
setForm(DEFAULTS);
update.mutate(DEFAULTS);
}}
disabled={update.isPending}
>
Reset to Defaults
</button>
</div>
</div>
);
}
@@ -161,18 +161,19 @@ export function BacktestPanel() {
{report.sweep && report.sweep.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Min target-probability sweep
Min expected-value sweep
</p>
<p className="mb-2 text-[11px] text-gray-500">
How many setups qualify and how they perform at each gate threshold (other
gate conditions held fixed). Lower = more trades, watch that expectancy holds.
Your current setting is highlighted; set it in Admin Settings Activation.
How many setups qualify and how they perform at each expected-value gate (other
gate conditions held fixed). EV is in R: 0.15 means +0.15× your risk per trade on
average. Lower = more trades, watch that expectancy holds. Your current setting is
highlighted; set it in Admin Settings Activation.
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Min Target Prob</th>
<th className="px-4 py-2.5">Min EV (R)</th>
<th className="px-4 py-2.5 text-right">Qualified</th>
<th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th>
@@ -183,12 +184,12 @@ export function BacktestPanel() {
</thead>
<tbody>
{report.sweep.map((row) => {
const current = Math.abs(row.min_target_probability - report.min_target_probability) < 0.5;
const current = Math.abs(row.min_expected_value - report.min_expected_value) < 0.001;
return (
<tr key={row.min_target_probability} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
<tr key={row.min_expected_value} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
<td className="num px-4 py-2.5 text-gray-200">
{current && <span className="mr-1 text-blue-300"></span>}
{row.min_target_probability}%
{row.min_expected_value.toFixed(2)}
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
+25
View File
@@ -140,6 +140,31 @@ export function useUpdateActivationSettings() {
});
}
export function useScheduleSettings() {
return useQuery({
queryKey: ['admin', 'schedule-settings'],
queryFn: () => adminApi.getScheduleSettings(),
});
}
export function useUpdateScheduleSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (payload: Partial<import('../lib/types').ScheduleConfig>) =>
adminApi.updateScheduleSettings(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'schedule-settings'] });
qc.invalidateQueries({ queryKey: ['admin', 'jobs'] });
addToast('success', 'Schedule updated');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update schedule');
},
});
}
export function useSentimentSettings() {
return useQuery({
queryKey: ['admin', 'sentiment-settings'],
+5 -1
View File
@@ -48,6 +48,10 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return false;
}
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
// Expected value (R) is the core gate. Only enforced when computable — setups
// without target probabilities defer to the R:R + confidence floors above.
const ev = expectedValueR(setup);
if (ev != null && ev < config.min_expected_value) return false;
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false;
}
@@ -60,7 +64,7 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
/** 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)}%`];
const parts = [`EV ≥ ${config.min_expected_value.toFixed(2)}R`, `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)}%`);
+11 -2
View File
@@ -158,6 +158,7 @@ export interface PerformanceStats {
// Activation gate: what counts as an actionable signal
export interface ActivationConfig {
min_expected_value: number;
min_rr: number;
min_confidence: number;
min_target_probability: number;
@@ -165,6 +166,14 @@ export interface ActivationConfig {
exclude_conflicts: boolean;
}
// Cron schedule for the daily/intraday pipelines + fundamentals
export interface ScheduleConfig {
schedule_timezone: string;
schedule_daily_pipeline_cron: string;
schedule_intraday_pipeline_cron: string;
schedule_fundamentals_cron: string;
}
// Runtime sentiment LLM configuration
export interface SentimentProviderConfig {
provider: string;
@@ -212,7 +221,7 @@ export interface BacktestCalibrationRow {
}
export interface BacktestSweepRow extends BacktestBucket {
min_target_probability: number;
min_expected_value: number;
}
export interface BacktestReport {
@@ -224,7 +233,7 @@ export interface BacktestReport {
overall_qualified: BacktestBucket;
overall_all: BacktestBucket;
by_direction: Record<string, BacktestBucket>;
min_target_probability: number;
min_expected_value: number;
sweep: BacktestSweepRow[];
calibration: BacktestCalibrationRow[];
note: string;
+2
View File
@@ -6,6 +6,7 @@ import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
import { ScheduleSettings } from '../components/admin/ScheduleSettings';
import { SettingsForm } from '../components/admin/SettingsForm';
import { TickerManagement } from '../components/admin/TickerManagement';
import { TickerUniverseBootstrap } from '../components/admin/TickerUniverseBootstrap';
@@ -41,6 +42,7 @@ export default function AdminPage() {
)}
{activeTab === 'Jobs' && (
<div className="space-y-4">
<ScheduleSettings />
<JobControls />
<PipelineReadinessPanel />
</div>