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_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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(' · ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user