feat: Smart Autonomous 3-Phase Optimizer — complete redesign
PROBLEM: Old system repeated identical params, all scores flat at 0.2500,
user had zero control over symbol/TF/dates. Not smart, not dynamic.
NEW ARCHITECTURE:
optimizer/ (NEW package)
├── __init__.py
├── session_config.py User choices (EA, symbol, TF, dates, budget, objective)
├── lhs_sampler.py Latin Hypercube Sampling — diverse exploration
├── result_ranker.py Relative scoring (best in session=1.0, worst=0.0)
├── budget.py Time budget tracker
└── pipeline.py 3-phase orchestrator
Phase 1 — Broad Discovery (LHS, 20-26 runs):
Samples FULL parameter space, not just defaults±tiny step
LHS guarantees coverage: all 17 optimizable LEGSTECH params explored
Relative ranking: profitable configs float top, losers score 0
Phase 2 — Refinement (9 runs):
Neighbor search around top 3 configs at ±20% range (not ±0.5 step)
Keeps best of Phase1 vs Phase2 — never regresses
Phase 3 — Validation (5 runs):
OOS backtest on unseen data period
Sensitivity test: nudge params ±20%, detect fragility
Verdict: RECOMMENDED / RISKY / NOT_RELIABLE
Output: Clean downloadable .set file via /download_set/<run_id>
UI REDESIGN:
ui/templates/landing.html New / homepage (was old dashboard)
ui/templates/setup.html New /setup — EA, symbol, TF, dates, budget, objective
ui/templates/dashboard.html Updated /dashboard with:
- 5-step phase indicator
- Real progress bar per run
- Phase 1 results table (top 5 after phase1)
- Verdict banner with download button
- No-profitable-config warning
ui/static/js/dashboard.js Handles 8 new pipeline SocketIO events
app.py New routes: /, /setup, /dashboard
/api/start accepts full SessionConfig JSON
/download_set/<id> serves optimized .set
ea/registry.py +list_all() for setup page dropdown
ui/templates/reports_index.html Back to Dashboard → /dashboard (was /)
ui/static/css/style.css +dot-warn, dot-done, profit-pos/neg, aliases
FIXES:
Score no longer flat 0.2500 (was: absolute thresholds on losing EA)
User now controls: symbol, timeframe, dates, budget, objective
Parameters now span full range (was: tiny step from defaults)
Verdict is actionable: RECOMMENDED / RISKY / NOT_RELIABLE with reason
TESTED:
8/8 pre-flight checks pass
Browser test: landing ✓, setup form ✓, /dashboard ✓,
phase indicator active ✓, /reports ✓, back link ✓
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
optimizer/lhs_sampler.py
|
||||
Latin Hypercube Sampling — generates diverse parameter sets that
|
||||
span the FULL optimization range with minimum samples.
|
||||
|
||||
Why LHS instead of random:
|
||||
With 20 samples and 8 parameters:
|
||||
- Pure random: clusters near the center, misses extremes
|
||||
- LHS: divides each parameter into 20 equal bands, samples exactly
|
||||
one value per band — GUARANTEED coverage of the full space.
|
||||
|
||||
Result: 20 LHS samples cover the space better than 200 random samples.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from ea.schema import ParameterSchema, ParameterDef
|
||||
|
||||
|
||||
class LatinHypercubeSampler:
|
||||
"""
|
||||
Generates n_samples diverse parameter dicts from a ParameterSchema.
|
||||
|
||||
Usage:
|
||||
sampler = LatinHypercubeSampler(seed=42)
|
||||
samples = sampler.sample(schema, n_samples=20)
|
||||
# → list of 20 dicts, each covering different regions
|
||||
|
||||
Each sample is a complete param dict (all params, optimizable + fixed).
|
||||
Fixed params always take their automation-safe default value.
|
||||
"""
|
||||
|
||||
def __init__(self, seed: int = None):
|
||||
self.rng = random.Random(seed)
|
||||
|
||||
def sample(self, schema: ParameterSchema, n_samples: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Generate n_samples diverse parameter sets.
|
||||
Returns list of complete param dicts (ready for IniBuilder).
|
||||
"""
|
||||
opts = schema.optimizable()
|
||||
if not opts:
|
||||
logger.warning("LHSampler: no optimizable params — returning n_samples copies of defaults")
|
||||
return [schema.defaults() for _ in range(n_samples)]
|
||||
|
||||
logger.info(
|
||||
f"LHSampler: generating {n_samples} samples over "
|
||||
f"{len(opts)} optimizable params: {[p.name for p in opts]}"
|
||||
)
|
||||
|
||||
# Build LHS columns: one per optimizable param
|
||||
# Each column is a shuffled list of n_samples values — each from a different band
|
||||
columns: dict[str, list[Any]] = {}
|
||||
for param in opts:
|
||||
columns[param.name] = self._lhs_column(param, n_samples)
|
||||
|
||||
# Assemble rows: combine column values
|
||||
base = schema.defaults()
|
||||
samples = []
|
||||
for i in range(n_samples):
|
||||
row = dict(base) # start with all defaults (includes fixed params)
|
||||
for param in opts:
|
||||
row[param.name] = columns[param.name][i]
|
||||
samples.append(row)
|
||||
|
||||
return samples
|
||||
|
||||
# ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _lhs_column(self, param: ParameterDef, n: int) -> list[Any]:
|
||||
"""
|
||||
Generate n values for one parameter using Latin Hypercube spacing.
|
||||
Each value comes from a different equally-sized band of the range.
|
||||
The list is shuffled so rows don't correlate across parameters.
|
||||
"""
|
||||
if param.type == "bool":
|
||||
# Alternate True/False, shuffled
|
||||
vals = [True if i < n // 2 else False for i in range(n)]
|
||||
# Ensure roughly 50/50
|
||||
if n % 2 == 1:
|
||||
vals.append(self.rng.choice([True, False]))
|
||||
vals = vals[:n]
|
||||
self.rng.shuffle(vals)
|
||||
return vals
|
||||
|
||||
if param.type == "enum":
|
||||
# Cycle through enum values with shuffled repetition
|
||||
enum_vals = param.enum_values
|
||||
vals = []
|
||||
while len(vals) < n:
|
||||
cycle = list(enum_vals)
|
||||
self.rng.shuffle(cycle)
|
||||
vals.extend(cycle)
|
||||
self.rng.shuffle(vals)
|
||||
return vals[:n]
|
||||
|
||||
if param.type in ("int", "float"):
|
||||
return self._lhs_continuous(param, n)
|
||||
|
||||
# fixed: return default repeated
|
||||
return [param.default] * n
|
||||
|
||||
def _lhs_continuous(self, param: ParameterDef, n: int) -> list[Any]:
|
||||
"""LHS for continuous (float) or discrete (int) parameters."""
|
||||
lo = float(param.min)
|
||||
hi = float(param.max)
|
||||
step = float(param.step) if param.step else (hi - lo) / n
|
||||
|
||||
band_size = (hi - lo) / n
|
||||
|
||||
values = []
|
||||
for i in range(n):
|
||||
band_lo = lo + i * band_size
|
||||
band_hi = band_lo + band_size
|
||||
|
||||
# Random point within the band
|
||||
raw = self.rng.uniform(band_lo, band_hi)
|
||||
|
||||
# Snap to valid step grid
|
||||
if step > 0:
|
||||
steps_from_lo = round((raw - lo) / step)
|
||||
snapped = lo + steps_from_lo * step
|
||||
# Clamp to [lo, hi]
|
||||
snapped = max(lo, min(hi, snapped))
|
||||
else:
|
||||
snapped = raw
|
||||
|
||||
values.append(param.clamp(snapped))
|
||||
|
||||
# Shuffle so the ordering isn't correlated with other params
|
||||
self.rng.shuffle(values)
|
||||
return values
|
||||
|
||||
def sample_neighbors(
|
||||
self,
|
||||
base_params: dict[str, Any],
|
||||
schema: ParameterSchema,
|
||||
n_neighbors: int,
|
||||
step_pct: float = 0.20,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Generate n_neighbors variants of base_params by nudging
|
||||
the most impactful optimizable params by ±step_pct of their range.
|
||||
|
||||
Used in Phase 2 refinement.
|
||||
|
||||
step_pct=0.20 means ±20% of (max - min). Much larger
|
||||
than the old ±0.5 step — actually explores the space.
|
||||
"""
|
||||
opts = schema.optimizable()
|
||||
if not opts:
|
||||
return [dict(base_params)]
|
||||
|
||||
neighbors = []
|
||||
for _ in range(n_neighbors):
|
||||
candidate = dict(base_params)
|
||||
|
||||
# Perturb 2–4 random optimizable params
|
||||
n_perturb = min(len(opts), self.rng.randint(2, 4))
|
||||
to_perturb = self.rng.sample(opts, n_perturb)
|
||||
|
||||
for param in to_perturb:
|
||||
current = candidate[param.name]
|
||||
if param.type == "bool":
|
||||
# 50% chance to flip
|
||||
if self.rng.random() < 0.5:
|
||||
candidate[param.name] = not current
|
||||
continue
|
||||
if param.type == "enum":
|
||||
candidate[param.name] = self.rng.choice(param.enum_values)
|
||||
continue
|
||||
|
||||
# float / int: nudge by ±step_pct of range
|
||||
span = float(param.max) - float(param.min)
|
||||
delta = span * step_pct * self.rng.choice([-1, 1])
|
||||
raw = float(current) + delta
|
||||
candidate[param.name] = param.clamp(raw)
|
||||
|
||||
neighbors.append(candidate)
|
||||
|
||||
return neighbors
|
||||
Reference in New Issue
Block a user