remove min_target_probability gate + add chart time-range presets
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 39s
Deploy / deploy (push) Successful in 24s

min_target_probability is gone: it filtered on the probability model the
calibration has repeatedly shown to be weak and overconfident, it was redundant
with the momentum gate, and as an off-by-default knob it just invited bad tuning.
Removed from the backend gate, activation config/schema, the frontend mirror
(qualifiesSetup / activationSummary), and ActivationSettings. The probability
model stays where it does real work (primary-target selection + display).

Charts: with multi-year history the all-bars default was unreadable. Added
time-range presets (1M / 3M / 6M / YTD / 1Y / 3Y / 5Y / All), defaulting to 1Y;
clicking a preset always re-applies (snaps back after a manual zoom). Y-axis
autoscale and wheel-zoom / drag-pan were already there.

339 backend tests pass; frontend build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 09:24:35 +02:00
parent 605f95098c
commit f48d8705de
9 changed files with 68 additions and 63 deletions
-1
View File
@@ -62,7 +62,6 @@ class ActivationConfigUpdate(BaseModel):
min_momentum_percentile: float | None = Field(default=None, ge=0, le=100) min_momentum_percentile: float | None = Field(default=None, ge=0, le=100)
min_rr: float | None = Field(default=None, ge=0) min_rr: float | None = Field(default=None, ge=0)
min_confidence: float | None = Field(default=None, ge=0, le=100) min_confidence: float | None = Field(default=None, ge=0, le=100)
min_target_probability: float | None = Field(default=None, ge=0, le=100)
require_high_conviction: bool | None = None require_high_conviction: bool | None = None
exclude_conflicts: bool | None = None exclude_conflicts: bool | None = None
-4
View File
@@ -46,7 +46,6 @@ _ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_momentum_percentile": "activation_min_momentum_percentile", "min_momentum_percentile": "activation_min_momentum_percentile",
"min_rr": "activation_min_rr", "min_rr": "activation_min_rr",
"min_confidence": "activation_min_confidence", "min_confidence": "activation_min_confidence",
"min_target_probability": "activation_min_target_probability",
} }
_ACTIVATION_BOOL_KEYS: dict[str, str] = { _ACTIVATION_BOOL_KEYS: dict[str, str] = {
"require_high_conviction": "activation_require_high_conviction", "require_high_conviction": "activation_require_high_conviction",
@@ -56,7 +55,6 @@ ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_momentum_percentile": 80.0, "min_momentum_percentile": 80.0,
"min_rr": 1.2, "min_rr": 1.2,
"min_confidence": 55.0, "min_confidence": 55.0,
"min_target_probability": 0.0,
"require_high_conviction": False, "require_high_conviction": False,
"exclude_conflicts": False, "exclude_conflicts": False,
} }
@@ -207,8 +205,6 @@ async def update_activation_config(
raise ValidationError("min_rr must be >= 0") raise ValidationError("min_rr must be >= 0")
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100: if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100:
raise ValidationError("min_confidence must be between 0 and 100") raise ValidationError("min_confidence must be between 0 and 100")
if "min_target_probability" in updates and not 0 <= updates["min_target_probability"] <= 100:
raise ValidationError("min_target_probability must be between 0 and 100")
for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items(): for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items():
if public_key in updates and updates[public_key] is not None: if public_key in updates and updates[public_key] is not None:
+6 -10
View File
@@ -5,10 +5,9 @@ performance stats (server) and mirrored on the frontend. The core selection is
cross-sectional momentum: a setup's ticker must rank in the top cross-sectional momentum: a setup's ticker must rank in the top
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one ``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
signal the backtest showed actually sorts forward returns. R:R and confidence signal the backtest showed actually sorts forward returns. R:R and confidence
remain as floors, and conviction/conflict/target-probability survive as optional remain as floors, and conviction/conflict survive as optional tighteners (off by
tighteners (off by default). The momentum percentile is computed across the default). The momentum percentile is computed across the universe and attached to
universe and attached to each setup upstream; when it's absent the gate falls each setup upstream; when it's absent the gate falls back to the floors.
back to the floors.
""" """
from __future__ import annotations from __future__ import annotations
@@ -50,9 +49,9 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
recommended_action, risk_level and a ``targets`` list of dicts. recommended_action, risk_level and a ``targets`` list of dicts.
Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum
percentile (the core selection) → optional conviction / conflict / percentile (the core selection) → optional conviction / conflict tighteners.
target-probability tighteners. ``min_momentum_percentile`` defaults to 0 (off) ``min_momentum_percentile`` defaults to 0 (off) for callers that pass a legacy
for callers that pass a legacy config without the key. config without the key.
""" """
if setup.rr_ratio < config["min_rr"]: if setup.rr_ratio < config["min_rr"]:
return False return False
@@ -85,7 +84,4 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
if config.get("exclude_conflicts"): if config.get("exclude_conflicts"):
if (setup.risk_level or "") != "Low": if (setup.risk_level or "") != "Low":
return False return False
min_tp = float(config.get("min_target_probability", 0.0))
if min_tp > 0 and best_target_probability(setup) < min_tp:
return False
return True return True
@@ -7,7 +7,6 @@ const DEFAULTS: ActivationConfig = {
min_momentum_percentile: 80, min_momentum_percentile: 80,
min_rr: 1.2, min_rr: 1.2,
min_confidence: 55, min_confidence: 55,
min_target_probability: 0,
require_high_conviction: false, require_high_conviction: false,
exclude_conflicts: false, exclude_conflicts: false,
}; };
@@ -91,20 +90,7 @@ export function ActivationSettings() {
<div className="border-t border-white/[0.06] pt-4"> <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="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 momentum gate.</p> <p className="mt-1 text-[11px] text-gray-600">Off by default turn on to be more selective on top of the momentum gate.</p>
<div className="mt-3 grid gap-3 md:grid-cols-3"> <div className="mt-3 grid gap-3 md:grid-cols-2">
<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"> <label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
<input <input
type="checkbox" type="checkbox"
@@ -51,6 +51,26 @@ interface TooltipState {
const MIN_VISIBLE_BARS = 10; const MIN_VISIBLE_BARS = 10;
type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All'];
const PRESET_MONTHS: Record<string, number> = { '1M': 1, '3M': 3, '6M': 6, '1Y': 12, '3Y': 36, '5Y': 60 };
const DEFAULT_PRESET: RangePreset = '1Y';
/** First bar index to show for a time-range preset (data is ascending by date). */
function startIndexForPreset(data: OHLCVBar[], preset: RangePreset): number {
if (preset === 'All' || data.length === 0) return 0;
const last = new Date(data[data.length - 1].date);
let cutoff: Date;
if (preset === 'YTD') {
cutoff = new Date(last.getFullYear(), 0, 1);
} else {
cutoff = new Date(last);
cutoff.setMonth(cutoff.getMonth() - PRESET_MONTHS[preset]);
}
const idx = data.findIndex((b) => new Date(b.date) >= cutoff);
return idx < 0 ? 0 : idx;
}
export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, currentPrice }: CandlestickChartProps) { export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, currentPrice }: CandlestickChartProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement>(null); const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
@@ -67,11 +87,13 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
start: 0, start: 0,
end: data.length, end: data.length,
}); });
const [preset, setPreset] = useState<RangePreset>(DEFAULT_PRESET);
// Reset visible range when data changes // Apply the active time-range preset when the data or preset changes (so the
// default view is a readable window, not the whole multi-year history).
useEffect(() => { useEffect(() => {
setVisibleRange({ start: 0, end: data.length }); setVisibleRange({ start: startIndexForPreset(data, preset), end: data.length });
}, [data]); }, [data, preset]);
const draw = useCallback(() => { const draw = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -627,12 +649,33 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
} }
return ( return (
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}> <div className="w-full">
<canvas <div className="mb-2 flex flex-wrap items-center gap-1">
ref={canvasRef} {RANGE_PRESETS.map((p) => (
className="w-full" <button
style={{ height: 400 }} key={p}
/> type="button"
onClick={() => {
// Re-apply the range directly so clicking the active preset still
// snaps back after a manual wheel-zoom / pan.
setPreset(p);
setVisibleRange({ start: startIndexForPreset(data, p), end: data.length });
}}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
preset === p ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{p}
</button>
))}
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
</div>
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
<canvas
ref={canvasRef}
className="w-full"
style={{ height: 400 }}
/>
<canvas <canvas
ref={overlayCanvasRef} ref={overlayCanvasRef}
className="absolute top-0 left-0 w-full cursor-crosshair" className="absolute top-0 left-0 w-full cursor-crosshair"
@@ -643,11 +686,12 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onWheel={handleWheel} onWheel={handleWheel}
/> />
<div <div
ref={tooltipRef} ref={tooltipRef}
className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50" className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50"
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
</div>
</div> </div>
); );
} }
-4
View File
@@ -46,9 +46,6 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return false; return false;
} }
if (config.exclude_conflicts && (setup.risk_level ?? '') !== 'Low') 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; return true;
} }
@@ -59,6 +56,5 @@ export function activationSummary(config: ActivationConfig): string {
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`); parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
if (config.require_high_conviction) parts.push('high-conviction'); if (config.require_high_conviction) parts.push('high-conviction');
if (config.exclude_conflicts) parts.push('clean'); 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(' · '); return parts.join(' · ');
} }
-1
View File
@@ -162,7 +162,6 @@ export interface ActivationConfig {
min_momentum_percentile: number; min_momentum_percentile: number;
min_rr: number; min_rr: number;
min_confidence: number; min_confidence: number;
min_target_probability: number;
require_high_conviction: boolean; require_high_conviction: boolean;
exclude_conflicts: boolean; exclude_conflicts: boolean;
} }
+3 -9
View File
@@ -28,7 +28,6 @@ class TestActivationConfig:
"min_momentum_percentile": 80.0, "min_momentum_percentile": 80.0,
"min_rr": 1.2, "min_rr": 1.2,
"min_confidence": 55.0, "min_confidence": 55.0,
"min_target_probability": 0.0,
"require_high_conviction": False, "require_high_conviction": False,
"exclude_conflicts": False, "exclude_conflicts": False,
} }
@@ -57,12 +56,11 @@ class TestActivationConfig:
async def test_conviction_flags_round_trip(self, session: AsyncSession): async def test_conviction_flags_round_trip(self, session: AsyncSession):
await update_activation_config( await update_activation_config(
session, session,
{"require_high_conviction": False, "exclude_conflicts": False, "min_target_probability": 45.0}, {"require_high_conviction": True, "exclude_conflicts": True},
) )
config = await get_activation_config(session) config = await get_activation_config(session)
assert config["require_high_conviction"] is False assert config["require_high_conviction"] is True
assert config["exclude_conflicts"] is False assert config["exclude_conflicts"] is True
assert config["min_target_probability"] == 45.0
async def test_rejects_negative_rr(self, session: AsyncSession): async def test_rejects_negative_rr(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
@@ -71,7 +69,3 @@ class TestActivationConfig:
async def test_rejects_out_of_range_confidence(self, session: AsyncSession): async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await update_activation_config(session, {"min_confidence": 120.0}) await update_activation_config(session, {"min_confidence": 120.0})
async def test_rejects_out_of_range_target_probability(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_target_probability": 150.0})
-5
View File
@@ -12,7 +12,6 @@ DEFAULT_GATE = {
"min_momentum_percentile": 0.0, "min_momentum_percentile": 0.0,
"min_rr": 1.2, "min_rr": 1.2,
"min_confidence": 55.0, "min_confidence": 55.0,
"min_target_probability": 0.0,
"require_high_conviction": False, "require_high_conviction": False,
"exclude_conflicts": False, "exclude_conflicts": False,
} }
@@ -25,7 +24,6 @@ STRICT_GATE = {
"min_momentum_percentile": 0.0, "min_momentum_percentile": 0.0,
"min_rr": 2.0, "min_rr": 2.0,
"min_confidence": 70.0, "min_confidence": 70.0,
"min_target_probability": 60.0,
"require_high_conviction": True, "require_high_conviction": True,
"exclude_conflicts": True, "exclude_conflicts": True,
} }
@@ -112,9 +110,6 @@ class TestStrictTighteners:
s = _setup(risk_level="Medium", targets=[{"probability": 65.0, "is_primary": True}]) s = _setup(risk_level="Medium", targets=[{"probability": 65.0, "is_primary": True}])
assert setup_qualifies(s, STRICT_GATE) is False assert setup_qualifies(s, STRICT_GATE) is False
def test_low_target_probability_fails(self):
assert setup_qualifies(_setup(targets=[{"probability": 40.0, "is_primary": True}]), STRICT_GATE) is False
class TestBestTargetProbability: class TestBestTargetProbability:
def test_returns_max(self): def test_returns_max(self):