remove min_target_probability gate + add chart time-range presets
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user