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_rr: float | None = Field(default=None, ge=0)
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
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_rr": "activation_min_rr",
"min_confidence": "activation_min_confidence",
"min_target_probability": "activation_min_target_probability",
}
_ACTIVATION_BOOL_KEYS: dict[str, str] = {
"require_high_conviction": "activation_require_high_conviction",
@@ -56,7 +55,6 @@ ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_momentum_percentile": 80.0,
"min_rr": 1.2,
"min_confidence": 55.0,
"min_target_probability": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
}
@@ -207,8 +205,6 @@ async def update_activation_config(
raise ValidationError("min_rr must be >= 0")
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 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():
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
``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
remain as floors, and conviction/conflict/target-probability survive as optional
tighteners (off by default). The momentum percentile is computed across the
universe and attached to each setup upstream; when it's absent the gate falls
back to the floors.
remain as floors, and conviction/conflict survive as optional tighteners (off by
default). The momentum percentile is computed across the universe and attached to
each setup upstream; when it's absent the gate falls back to the floors.
"""
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.
Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum
percentile (the core selection) → optional conviction / conflict /
target-probability tighteners. ``min_momentum_percentile`` defaults to 0 (off)
for callers that pass a legacy config without the key.
percentile (the core selection) → optional conviction / conflict tighteners.
``min_momentum_percentile`` defaults to 0 (off) for callers that pass a legacy
config without the key.
"""
if setup.rr_ratio < config["min_rr"]:
return False
@@ -85,7 +84,4 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
if config.get("exclude_conflicts"):
if (setup.risk_level or "") != "Low":
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
@@ -7,7 +7,6 @@ const DEFAULTS: ActivationConfig = {
min_momentum_percentile: 80,
min_rr: 1.2,
min_confidence: 55,
min_target_probability: 0,
require_high_conviction: false,
exclude_conflicts: false,
};
@@ -91,20 +90,7 @@ export function ActivationSettings() {
<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 momentum 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>
<div className="mt-3 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"
@@ -51,6 +51,26 @@ interface TooltipState {
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) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
@@ -67,11 +87,13 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
start: 0,
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(() => {
setVisibleRange({ start: 0, end: data.length });
}, [data]);
setVisibleRange({ start: startIndexForPreset(data, preset), end: data.length });
}, [data, preset]);
const draw = useCallback(() => {
const canvas = canvasRef.current;
@@ -627,12 +649,33 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
}
return (
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
<canvas
ref={canvasRef}
className="w-full"
style={{ height: 400 }}
/>
<div className="w-full">
<div className="mb-2 flex flex-wrap items-center gap-1">
{RANGE_PRESETS.map((p) => (
<button
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
ref={overlayCanvasRef}
className="absolute top-0 left-0 w-full cursor-crosshair"
@@ -643,11 +686,12 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
onMouseLeave={handleMouseLeave}
onWheel={handleWheel}
/>
<div
ref={tooltipRef}
className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50"
style={{ display: 'none' }}
/>
<div
ref={tooltipRef}
className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50"
style={{ display: 'none' }}
/>
</div>
</div>
);
}
-4
View File
@@ -46,9 +46,6 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
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;
}
@@ -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)}%`);
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(' · ');
}
-1
View File
@@ -162,7 +162,6 @@ export interface ActivationConfig {
min_momentum_percentile: number;
min_rr: number;
min_confidence: number;
min_target_probability: number;
require_high_conviction: boolean;
exclude_conflicts: boolean;
}
+3 -9
View File
@@ -28,7 +28,6 @@ class TestActivationConfig:
"min_momentum_percentile": 80.0,
"min_rr": 1.2,
"min_confidence": 55.0,
"min_target_probability": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
}
@@ -57,12 +56,11 @@ class TestActivationConfig:
async def test_conviction_flags_round_trip(self, session: AsyncSession):
await update_activation_config(
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)
assert config["require_high_conviction"] is False
assert config["exclude_conflicts"] is False
assert config["min_target_probability"] == 45.0
assert config["require_high_conviction"] is True
assert config["exclude_conflicts"] is True
async def test_rejects_negative_rr(self, session: AsyncSession):
with pytest.raises(ValidationError):
@@ -71,7 +69,3 @@ class TestActivationConfig:
async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
with pytest.raises(ValidationError):
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_rr": 1.2,
"min_confidence": 55.0,
"min_target_probability": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
}
@@ -25,7 +24,6 @@ STRICT_GATE = {
"min_momentum_percentile": 0.0,
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 60.0,
"require_high_conviction": True,
"exclude_conflicts": True,
}
@@ -112,9 +110,6 @@ class TestStrictTighteners:
s = _setup(risk_level="Medium", targets=[{"probability": 65.0, "is_primary": True}])
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:
def test_returns_max(self):