"""Bounded, reproducible strategy search for the simulation application.""" from __future__ import annotations from random import Random from math import isfinite from statistics import pstdev from typing import Any, Callable from .simulation import simulate, payoff_statistics from .trade_audit import digest, pack_snapshot, trade_tape from .costs import FEE_SCHEDULE, net_return SEARCH_SPACE = { "bb_period": [14, 20, 26], "bb_deviation": [1.6, 2.0, 2.4], "rsi_period": [10, 14, 18], "rsi_oversold": [30, 35, 40], "atr_period": [10, 14, 20], "atr_min_percent": [0.08, 0.15, 0.25], "atr_max_percent": [2.0, 4.0, 7.0], "min_volume_ratio": [0.6, 0.9, 1.2], "min_quote_volume_usdt": [5000, 20000, 50000], "rebound_min_percent": [0.12, 0.25, 0.4], "rebound_max_percent": [0.45, 0.7, 1.0], "stop_loss_percent": [2.5, 4.0, 6.0], "trailing_start_percent": [0.7, 1.1, 1.6], "trailing_distance_percent": [0.3, 0.5, 0.8], } def candidates(base: dict[str, Any], count: int) -> list[dict[str, Any]]: rng = Random(42) result = [{key: base[key] for key in SEARCH_SPACE}] while len(result) < count: item = {key: rng.choice(values) for key, values in SEARCH_SPACE.items()} if item["rebound_max_percent"] <= item["rebound_min_percent"]: item["rebound_max_percent"] = min(1.0, item["rebound_min_percent"] + 0.35) if item not in result: result.append(item) return result PRIMARY_TIMEFRAMES = {"5m", "15m"} COST_PER_SIDE = {"1m": 0.0022, "3m": 0.0018, "5m": 0.0015, "15m": 0.0013} MIN_VALIDATION_TRADES = 40 MIN_HOLDOUT_TRADES = 10 MIN_PROFITABLE_WF_WINDOWS = 3 WF_WINDOW_COUNT = 5 MAX_DRAWDOWN_PERCENT = 15.0 EXPECTANCY_DECAY_FLOOR = 0.50 SELECTION_POLICY_VERSION = 4 def score(metrics: dict[str, Any], capital: float, stability_penalty: float) -> float: profit_pct = float(metrics["realized_profit"]) / capital * 100 drawdown = float(metrics["max_drawdown_percent"]) trades = int(metrics["closed_trades"]) low_trade_penalty = max(0, MIN_VALIDATION_TRADES - trades) * 0.15 return round(profit_pct - 1.5 * drawdown - stability_penalty - low_trade_penalty + min(trades, 40) * 0.02, 5) def aggregate_metrics(items: list[dict[str, Any]], capital: float) -> dict[str, Any]: trades = sum(int(item["closed_trades"]) for item in items) wins = sum(int(round(int(item["closed_trades"]) * float(item["win_rate"]) / 100)) for item in items) profit = sum(float(item["realized_profit"]) for item in items) statistics = {} if items and all("winning_pnl" in item for item in items): statistics = payoff_statistics( sum(item["winning_trades"] for item in items), sum(item["losing_trades"] for item in items), sum(item["breakeven_trades"] for item in items), sum(item["winning_pnl"] for item in items), sum(item["losing_pnl"] for item in items)) return { **statistics, "initial_capital": capital, "portfolio_value": round(capital + profit, 4), "realized_profit": round(profit, 4), "unrealized_profit": 0, "closed_trades": trades, "win_rate": round(wins / trades * 100, 1) if trades else 0, "max_drawdown_percent": round(max((float(item["max_drawdown_percent"]) for item in items), default=0), 2), } def validation_rank(row: dict[str, Any]) -> tuple[float, float, float]: """Policy v4: one predeclared risk-adjusted OOS score, then sample size, then lower DD.""" metrics = row["walk_forward_metrics"] if not row["validation_passed"] or int(metrics["closed_trades"]) < MIN_VALIDATION_TRADES: return (float("-inf"), float("-inf"), float("-inf")) return ( float(row.get("risk_adjusted_oos_score", float("-inf"))), float(metrics.get("closed_trades", 0)), -float(metrics["max_drawdown_percent"]), ) def diagnostic_rank(row: dict) -> tuple: """Distance to validation gates only; never a candidate or holdout ranking.""" m = row.get("walk_forward_metrics") or {} windows = row.get("validation_windows") or [] other_failures = (int(not float(m.get("realized_profit", 0)) > 0) + int(float(m.get("max_drawdown_percent", 100)) > MAX_DRAWDOWN_PERCENT) + int(sum(float(w.get("realized_profit", 0)) > 0 for w in windows) < MIN_PROFITABLE_WF_WINDOWS)) return (other_failures, max(0, MIN_VALIDATION_TRADES - int(m.get("closed_trades", 0))), -float(m.get("realized_profit", 0)), float(m.get("max_drawdown_percent", 100)), row.get("variant_id", "")) def payoff_note(metrics: dict[str, Any], minimum: int, *, beats_hold: bool = False) -> str | None: payoff = metrics.get("payoff") if int(metrics.get("closed_trades", 0)) < minimum or payoff is None: return None if payoff < .8: return "slaby_pomer" if payoff < 1: return "podmienecne_ok" if float(metrics.get("win_rate", 0)) >= 55 and beats_hold else "upozornenie_pomer" return "slusny_pomer" def walk_forward_windows(candles: list[dict[str, Any]], warmup: int = 40) -> tuple[list[tuple[list[dict[str, Any]], list[dict[str, Any]]]], list[dict[str, Any]]]: """Keep the final 20% untouched and build five chronological expanding OOS windows.""" holdout_start = max(120, int(len(candles) * 0.8)) development, holdout = candles[:holdout_start], candles[max(0, holdout_start - warmup):] boundaries = ((0.40, 0.52), (0.52, 0.64), (0.64, 0.76), (0.76, 0.88), (0.88, 1.0)) windows = [] for train_ratio, validation_ratio in boundaries: train_end = max(80, int(len(development) * train_ratio)) validation_end = max(train_end + 1, int(len(development) * validation_ratio)) train = development[:train_end] validation = development[max(0, train_end - warmup):min(len(development), validation_end)] windows.append((train, validation)) return windows, holdout def buy_and_hold_percent(candles: list[dict[str, Any]], cost_per_side: float) -> float: if len(candles) < 2: return 0.0 first, last = float(candles[0]["close"]), float(candles[-1]["close"]) return round(((last / first - 1) - 2 * cost_per_side) * 100, 4) if first else 0.0 def exposure_percent(trades: list[dict[str, Any]], period_minutes: float) -> float: """Average capital exposure for max_open_trades=1, based on recorded holding time.""" if period_minutes <= 0: return 0.0 held = sum(max(0.0, float(trade.get("hold_minutes") or trade.get("duration_min") or 0.0)) for trade in trades) return round(min(100.0, held / period_minutes * 100.0), 4) def risk_adjusted_score(strategy_return_percent: float, benchmark_return_percent: float, drawdown_percent: float) -> float: excess = strategy_return_percent - benchmark_return_percent return round(excess / max(drawdown_percent, 1.0), 6) def finite_metric(metrics: dict[str, Any], key: str) -> float: """Missing, malformed and non-finite numbers cannot satisfy a gate.""" try: return float(metrics[key]) except (KeyError, TypeError, ValueError, OverflowError): return float("nan") def assess_validation(metrics: dict[str, Any], windows: list[dict[str, Any]]) -> dict[str, Any]: """Policy v4 eligibility uses stitched OOS validation only.""" profitable = sum(isfinite(finite_metric(window, "realized_profit")) and finite_metric(window, "realized_profit") > 0 for window in windows) reasons = [] required_metrics = ("closed_trades", "realized_profit", "max_drawdown_percent") if any(not isfinite(finite_metric(item, key)) for item in [metrics, *windows] for key in required_metrics): reasons.append("invalid_metrics") if any(item.get(key) is not None and not isfinite(finite_metric(item, key)) for item in [metrics, *windows] for key in ("expectancy", "win_rate", "payoff")): reasons.append("invalid_metrics") trades = finite_metric(metrics, "closed_trades") if not isfinite(trades) or int(trades) < MIN_VALIDATION_TRADES: reasons.append("malo_obchodov") if not finite_metric(metrics, "realized_profit") > 0: reasons.append("zaporny_pnl") if not 0 <= finite_metric(metrics, "max_drawdown_percent") <= MAX_DRAWDOWN_PERCENT: reasons.append("drawdown") if len(windows) != WF_WINDOW_COUNT or profitable < MIN_PROFITABLE_WF_WINDOWS: reasons.append("nestabilita") if any(not 0 <= finite_metric(window, "max_drawdown_percent") <= MAX_DRAWDOWN_PERCENT for window in windows): reasons.append("window_drawdown") return {"validation_passed": not reasons, "validation_rejection_reasons": list(dict.fromkeys(reasons)), "profitable_validation_windows": profitable} def assess_candidate(row: dict[str, Any], locked_pairs: list[str]) -> dict[str, Any]: """Policy v4 verdict. Raw buy-and-hold is diagnostic, not a veto.""" validation = row.get("walk_forward_metrics") or {} holdout = row.get("holdout_metrics") or {} validation_trades = int(validation.get("closed_trades", 0)) if not row.get("validation_passed"): codes = row.get("validation_rejection_reasons") or [] only_underpowered = codes and set(codes).issubset({"malo_obchodov"}) status = "unproven" if only_underpowered else "rejected" verdict = "NEPOTVRDENÉ — nedostatok dát" if only_underpowered else ( "ZAMIETNUTÉ — drawdown validácie" if "drawdown" in codes or "window_drawdown" in codes else "ZAMIETNUTÉ — PnL validácie nie je kladné" if "zaporny_pnl" in codes else "ZAMIETNUTÉ — nestabilné WF okná" if "nestabilita" in codes else "ZAMIETNUTÉ — validácia" ) reasons = ["walk_forward_failed"] if validation_trades < MIN_VALIDATION_TRADES: reasons.append("insufficient_validation_trades") if row.get("pair") not in locked_pairs: reasons.append("outside_locked_universe") return {"qualified": False, "result_status": status, "verdict": verdict, "rejection_reasons": reasons, "holdout_profit_percent": None} holdout_trades = int(holdout.get("closed_trades", 0)) capital = float(row.get("settings", {}).get("initial_capital", 0)) profit = float(holdout.get("realized_profit", float("nan"))) drawdown = float(holdout.get("max_drawdown_percent", float("nan"))) profit_pct = profit / capital * 100 if capital > 0 else float("nan") validation_expectancy = finite_metric(validation, "expectancy") holdout_expectancy = holdout.get("expectancy") exposure_benchmark = row.get("holdout_exposure_matched_bh_percent") reasons = [] if validation_trades < MIN_VALIDATION_TRADES: reasons.append("insufficient_validation_trades") if holdout_trades < MIN_HOLDOUT_TRADES: reasons.append("insufficient_holdout_trades") finite_values = [profit, drawdown, profit_pct] if validation_expectancy is not None: finite_values.append(float(validation_expectancy)) if holdout_expectancy is not None: finite_values.append(float(holdout_expectancy)) if exposure_benchmark is not None: finite_values.append(float(exposure_benchmark)) if not all(isfinite(value) for value in finite_values): reasons.append("invalid_metrics") if not isfinite(validation_expectancy) or validation_expectancy <= 0: reasons.append("invalid_validation_expectancy") if not profit > 0: reasons.append("non_positive_holdout_pnl") if not 0 <= drawdown <= MAX_DRAWDOWN_PERCENT: reasons.append("drawdown_limit_exceeded") if holdout_expectancy is None or float(holdout_expectancy) <= 0: reasons.append("non_positive_holdout_expectancy") if validation_expectancy is not None and holdout_expectancy is not None and float(validation_expectancy) > 0: if float(holdout_expectancy) < EXPECTANCY_DECAY_FLOOR * float(validation_expectancy): reasons.append("expectancy_decay") if exposure_benchmark is None or not profit_pct >= float(exposure_benchmark): reasons.append("underperformed_exposure_matched_benchmark") if row.get("pair") not in locked_pairs: reasons.append("outside_locked_universe") underpowered = any(reason in reasons for reason in ("insufficient_validation_trades", "insufficient_holdout_trades")) hard_failures = [reason for reason in reasons if reason not in ("insufficient_validation_trades", "insufficient_holdout_trades")] if not reasons: status, verdict = "qualified", "QUALIFIED — metodika v4 potvrdená pre paper režim" elif underpowered and not hard_failures: status, verdict = "unproven", "UNPROVEN — nedostatok dát" elif "drawdown_limit_exceeded" in reasons: status, verdict = "rejected", "REJECTED — drawdown" elif "non_positive_holdout_pnl" in reasons or "non_positive_holdout_expectancy" in reasons: status, verdict = "rejected", "REJECTED — negatívny holdout edge" elif "expectancy_decay" in reasons: status, verdict = "rejected", "REJECTED — edge sa v holdoute rozpadol" elif "underperformed_exposure_matched_benchmark" in reasons: status, verdict = "rejected", "REJECTED — timing nepridal hodnotu voči exposure-matched hold" else: status, verdict = "rejected", "REJECTED — qualification v4" return {"qualified": not reasons, "result_status": status, "verdict": verdict, "rejection_reasons": reasons, "holdout_profit_percent": round(profit_pct, 4) if isfinite(profit_pct) else None} def variant_diagnostic(row: dict[str, Any]) -> dict[str, Any]: """Serialise evidence without evaluating any additional holdouts.""" evaluated = bool(row["validation_passed"] and row.get("holdout_evaluated")) reason_map = {"insufficient_holdout_trades": "holdout_malo_obchodov", "non_positive_holdout_pnl": "zaporny_pnl", "non_positive_holdout_expectancy": "zaporny_expectancy", "expectancy_decay": "expectancy_decay", "underperformed_exposure_matched_benchmark": "horsie_ako_exposure_matched_hold", "drawdown_limit_exceeded": "drawdown"} reasons = list(row["validation_rejection_reasons"]) if evaluated: reasons.extend(reason_map[reason] for reason in row.get("rejection_reasons", []) if reason in reason_map) return {**{key: row.get(key) for key in ("pair", "timeframe", "variant", "variant_id", "score", "settings", "validation_windows", "entry_diagnostics_windows", "walk_forward_metrics", "validation_passed", "profitable_validation_windows", "cost_per_side", "trades", "trade_tape_version", "validation_boundaries", "snapshot_id", "settings_sha256", "cost_components")}, "is_finalist": bool(row.get("is_finalist")), "holdout_evaluated": evaluated, "holdout_metrics": row.get("holdout_metrics") if evaluated else None, "holdout_profit_percent": row.get("holdout_profit_percent") if evaluated else None, "buy_hold_percent": row.get("buy_hold_percent") if evaluated else None, "holdout_exposure_percent": row.get("holdout_exposure_percent") if evaluated else None, "holdout_exposure_matched_bh_percent": row.get("holdout_exposure_matched_bh_percent") if evaluated else None, "holdout_excess_return_percent": row.get("holdout_excess_return_percent") if evaluated else None, "validation_exposure_percent": row.get("validation_exposure_percent"), "validation_exposure_matched_bh_percent": row.get("validation_exposure_matched_bh_percent"), "validation_excess_return_percent": row.get("validation_excess_return_percent"), "risk_adjusted_oos_score": row.get("risk_adjusted_oos_score"), "vs_hold_percentage_points": round(row["holdout_profit_percent"] - row["buy_hold_percent"], 4) if evaluated else None, "rejection_reasons": list(dict.fromkeys(reasons)), "rejection_phase": "holdout" if evaluated else "validation" if reasons else None, "payoff_warning": payoff_note(row.get("holdout_metrics") or {}, MIN_HOLDOUT_TRADES, beats_hold=bool(evaluated and row["holdout_profit_percent"] >= row["buy_hold_percent"])) if evaluated else None, "status": "rejected" if reasons else "accepted" if evaluated and row.get("qualified") else "not_selected"} def present_optimizer_record(record: dict[str, Any] | None) -> dict[str, Any] | None: """Downgrade legacy stored results on read, without rewriting history.""" if not record or not record.get("result"): return record previous = record["result"] if previous.get("selection_policy_version") == SELECTION_POLICY_VERSION: variants = previous.get("variant_results") or [] if variants and not previous.get("qualified") and not any(r.get("validation_passed") for r in variants): diagnostic = min(variants, key=diagnostic_rank) result = {**previous, **diagnostic, "validation_rejection_reasons": diagnostic.get("rejection_reasons", []), "winner": None, "strategy_code": None, "diagnostic_only": True} result.update(assess_candidate(result, previous.get("locked_pairs", []))) return {**record, "result": result} return record result = {**previous} capital = float((result.get("settings") or {}).get("initial_capital", 100)) result["walk_forward_metrics"] = aggregate_metrics(result.get("validation_windows") or [], capital) result["holdout_metrics"] = result.get("holdout_metrics") or result.get("validation_metrics") or {} result.update(assess_candidate(result, [])) result.update({"qualified": False, "winner": None, "strategy_code": None, "job_verdict": "ŽIADNY PLATNÝ VARIANT", "requires_retest": True}) result["rejection_reasons"].append("legacy_methodology") return {**record, "result": result} def generate_freqtrade_strategy(pair: str, timeframe: str, settings: dict[str, Any]) -> str: return f'''from freqtrade.strategy import IStrategy import talib.abstract as ta from technical import qtpylib class RevoltisAIOptimized_v1(IStrategy): # Optimalizované pre {pair}, {timeframe}. Pred reálnym použitím vykonaj dry-run. timeframe = "{timeframe}" startup_candle_count = {max(int(settings['bb_period']), int(settings['rsi_period']), int(settings['atr_period'])) + 5} stoploss = -{float(settings['stop_loss_percent']) / 100:.4f} trailing_stop = True trailing_stop_positive = {float(settings['trailing_distance_percent']) / 100:.4f} trailing_stop_positive_offset = {float(settings['trailing_start_percent']) / 100:.4f} trailing_only_offset_is_reached = True minimal_roi = {{"0": 10.0}} def populate_indicators(self, dataframe, metadata): bands = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window={int(settings['bb_period'])}, stds={float(settings['bb_deviation'])}) dataframe['bb_lower'] = bands['lower'] dataframe['rsi'] = ta.RSI(dataframe, timeperiod={int(settings['rsi_period'])}) dataframe['atr_pct'] = ta.ATR(dataframe, timeperiod={int(settings['atr_period'])}) / dataframe['close'] * 100 dataframe['volume_mean'] = dataframe['volume'].rolling(20).mean() dataframe['rebound_pct'] = (dataframe['close'] / dataframe['low'].shift(1) - 1) * 100 return dataframe def populate_entry_trend(self, dataframe, metadata): dataframe.loc[(dataframe['close'].shift(1) < dataframe['bb_lower']) & (dataframe['rsi'].shift(1) < {float(settings['rsi_oversold'])}) & (dataframe['close'] > dataframe['close'].shift(1)) & (dataframe['close'] > dataframe['open']) & (dataframe['rebound_pct'] >= {float(settings['rebound_min_percent'])}) & (dataframe['rebound_pct'] <= {float(settings['rebound_max_percent'])}) & (dataframe['atr_pct'] >= {float(settings['atr_min_percent'])}) & (dataframe['atr_pct'] <= {float(settings['atr_max_percent'])}) & (dataframe['volume'] >= dataframe['volume_mean'] * {float(settings['min_volume_ratio'])}) & (dataframe['volume'] * dataframe['close'] >= {float(settings['min_quote_volume_usdt'])}), 'enter_long'] = 1 return dataframe def populate_exit_trend(self, dataframe, metadata): dataframe['exit_long'] = 0 return dataframe ''' def optimize(candle_sets: dict[tuple[str, str], list[dict[str, Any]]], base: dict[str, Any], trials: int, progress: Callable[[int, int, str], None] | None = None, *, locked_pairs: list[str] | None = None, cost_models: dict[str, dict] | None = None) -> dict[str, Any]: variants = candidates(base, trials) total = len(candle_sets) * len(variants) completed, results = 0, [] snapshots = {} capital = float(base["initial_capital"]) locked_pairs = list(locked_pairs or []) if capital <= 0 or not candle_sets: raise ValueError("Chýba kapitál alebo dáta pre optimalizáciu.") # An identical prefix warms every variant. Its candles cannot create trades. warmup = max(40, *(int(v[key]) + 2 for v in variants for key in ("bb_period", "rsi_period", "atr_period"))) for (pair, timeframe), candles in candle_sets.items(): if timeframe not in PRIMARY_TIMEFRAMES: raise ValueError("Optimalizácia podporuje iba intervaly 15m a 5m.") if len(candles) < 200: raise ValueError("Nedostatok sviečok pre päť validačných okien a holdout.") windows, _ = walk_forward_windows(candles, warmup) boundary = max(120, int(len(candles) * .8)) snapshot_id = f"{pair}:{timeframe}" snapshots[snapshot_id] = pack_snapshot(candles[:boundary], int(candles[boundary]["open_time"])) boundaries = [{"window": f"wf{number}", "warmup_start_index": max(0, len(train) - warmup), "trading_start_index": len(train), "trading_start_ms": int(candles[len(train)]["open_time"]), "stop_index": max(0, len(train) - warmup) + len(validation)} for number, (train, validation) in enumerate(windows, 1)] cost_per_side = cost_models[pair]["fee_rate"] if cost_models is not None else COST_PER_SIDE.get(timeframe, 0.0018) cost_options = {"cost_models": {pair: cost_models[pair]}} if cost_models is not None else {} for index, variant in enumerate(variants): settings = base | variant | {"selected_pairs": [pair], "timeframe": timeframe, "max_open_trades": 1} variant_id = f"{pair}:{timeframe}:v{index + 1}" train_metrics, validation_window_metrics, entry_diagnostics_windows, trades = [], [], [], [] for number, (train, validation) in enumerate(windows, 1): train_metrics.append(simulate({pair: train}, settings, fee=cost_per_side, force_close_at_end=True, **cost_options)["metrics"]) validation_start = int(candles[len(train)]["open_time"]) validation_run = simulate( {pair: validation}, settings, fee=cost_per_side, force_close_at_end=True, trading_start_time=validation_start, **cost_options, ) validation_window_metrics.append(validation_run["metrics"]) entry_diagnostics_windows.append({ "window": f"wf{number}", **validation_run.get("entry_diagnostics", {}), }) trades.extend(trade_tape(validation_run, pair, timeframe, variant_id, f"wf{number}", cost_per_side)) validation_metrics = aggregate_metrics(validation_window_metrics, capital) validation_period_minutes = sum( max(0.0, (int(validation[-1]["close_time"]) - int(candles[len(train)]["open_time"])) / 60000) for train, validation in windows if validation ) validation_exposure = exposure_percent(trades, validation_period_minutes) validation_bh = sum( buy_and_hold_percent(validation[max(0, warmup):], cost_per_side) * exposure_percent( [trade for trade in trades if trade["window"] == f"wf{number}"], max(1.0, (int(validation[-1]["close_time"]) - int(candles[len(train)]["open_time"])) / 60000), ) / 100.0 for number, (train, validation) in enumerate(windows, 1) if validation ) validation_return = float(validation_metrics["realized_profit"]) / capital * 100 validation_excess = validation_return - validation_bh validation_risk_score = risk_adjusted_score( validation_return, validation_bh, float(validation_metrics["max_drawdown_percent"]) ) window_profit_pcts = [float(item["realized_profit"]) / capital * 100 for item in validation_window_metrics] stability = pstdev(window_profit_pcts) if len(window_profit_pcts) > 1 else 0.0 candidate_score = score(validation_metrics, capital, stability) validation = assess_validation(validation_metrics, validation_window_metrics) results.append({ "pair": pair, "timeframe": timeframe, "variant": index + 1, "settings": settings, "train_metrics": train_metrics, "walk_forward_metrics": validation_metrics, "validation_metrics": validation_metrics, "validation_windows": validation_window_metrics, "entry_diagnostics_windows": entry_diagnostics_windows, "cost_per_side": cost_per_side, "score": candidate_score, **validation, "validation_exposure_percent": validation_exposure, "validation_exposure_matched_bh_percent": round(validation_bh, 4), "validation_excess_return_percent": round(validation_excess, 4), "risk_adjusted_oos_score": validation_risk_score, "cost_components": cost_models[pair] if cost_models is not None else None, "variant_id": variant_id, "trades": trades, "trade_tape_version": 1, "validation_boundaries": boundaries, "snapshot_id": snapshot_id, "settings_sha256": digest(settings), "holdout_evaluated": False, "holdout_metrics": None, "buy_hold_percent": None, "holdout_profit_percent": None, "holdout_exposure_percent": None, "holdout_exposure_matched_bh_percent": None, "holdout_excess_return_percent": None, "qualified": False, "strategy_code": None, }) completed += 1 if progress: progress(completed, total, f"{pair} · {timeframe}") # Freeze ONE eligible finalist per coin using validation only, across both timeframes. # Holdout is a veto, never a second parameter search or a fallback loop. results.sort(key=lambda row: (row["validation_passed"], *validation_rank(row)), reverse=True) finalists: dict[str, dict[str, Any]] = {} for row in results: finalists.setdefault(row["pair"], row) for row in finalists.values(): if not row["validation_passed"]: row.update(assess_candidate(row, locked_pairs)) continue row["is_finalist"] = True candles = candle_sets[(row["pair"], row["timeframe"])] _, holdout = walk_forward_windows(candles, warmup) boundary = max(120, int(len(candles) * 0.8)) holdout_start = int(candles[boundary]["open_time"]) holdout_run = simulate( {row["pair"]: holdout}, row["settings"], fee=row["cost_per_side"], force_close_at_end=True, trading_start_time=holdout_start, **({"cost_models": {row["pair"]: row["cost_components"]}} if row["cost_components"] else {}), ) row["holdout_metrics"] = holdout_run["metrics"] holdout_tape = trade_tape(holdout_run, row["pair"], row["timeframe"], row["variant_id"], "holdout", row["cost_per_side"]) row["trades"].extend(holdout_tape) # Raw buy-and-hold remains diagnostic. Qualification uses an exposure-matched benchmark. row["buy_hold_percent"] = (round(net_return(float(candles[-1]["close"]) / float(candles[boundary]["close"]), row["cost_components"]) * 100, 4) if row["cost_components"] else buy_and_hold_percent(candles[boundary:], row["cost_per_side"])) holdout_period_minutes = max(1.0, (int(candles[-1]["close_time"]) - holdout_start) / 60000) row["holdout_exposure_percent"] = exposure_percent(holdout_tape, holdout_period_minutes) row["holdout_exposure_matched_bh_percent"] = round( row["buy_hold_percent"] * row["holdout_exposure_percent"] / 100.0, 4 ) holdout_return = float(row["holdout_metrics"]["realized_profit"]) / capital * 100 row["holdout_excess_return_percent"] = round( holdout_return - row["holdout_exposure_matched_bh_percent"], 4 ) row["holdout_from_ms"] = holdout_start row["holdout_to_ms"] = int(candles[-1]["close_time"]) row["holdout_evaluated"] = True row.update(assess_candidate(row, locked_pairs)) row["strategy_code"] = generate_freqtrade_strategy(row["pair"], row["timeframe"], row["settings"]) if row["qualified"] else None qualified = [row for row in finalists.values() if row["qualified"]] selected = qualified[0] if qualified else next(iter(finalists.values())) if not any(row["validation_passed"] for row in results): selected = min(results, key=diagnostic_rank) selected.update(assess_candidate(selected, locked_pairs)) summary_keys = ("pair", "timeframe", "variant", "score", "walk_forward_metrics", "holdout_metrics", "buy_hold_percent", "holdout_profit_percent", "holdout_exposure_percent", "holdout_exposure_matched_bh_percent", "holdout_excess_return_percent", "validation_exposure_percent", "validation_exposure_matched_bh_percent", "validation_excess_return_percent", "risk_adjusted_oos_score", "qualified", "result_status", "verdict", "rejection_reasons", "validation_passed", "holdout_evaluated", "profitable_validation_windows") return { **selected, "winner": {**selected} if qualified else None, "selection_policy_version": SELECTION_POLICY_VERSION, "selection_method": "policy_v4_validation_only_per_coin_then_single_terminal_holdout", "ranking_method": "risk_adjusted_oos_excess_return_over_drawdown", "locked_pairs": locked_pairs, "tested_combinations": total, "max_validation_trades": max(int(row["walk_forward_metrics"]["closed_trades"]) for row in results), "minimum_validation_trades": MIN_VALIDATION_TRADES, "minimum_holdout_trades": MIN_HOLDOUT_TRADES, "max_drawdown_limit_percent": MAX_DRAWDOWN_PERCENT, "job_verdict": "QUALIFIED" if qualified else ("UNPROVEN" if any(row.get("result_status") == "unproven" for row in finalists.values()) else "REJECTED"), "method": "5× walk-forward OOS + 20 % nedotknutý holdout · qualification policy v4", "fee_schedule": FEE_SCHEDULE if cost_models is not None else "legacy_combined_cost", "cost_profiles": cost_models, "cost_model": "Taker 0,10 % + half-spread + impact podľa knihy každého páru. Kniha je aktuálny odhad, nie historické L2." if cost_models is not None else f"Historický model: {selected['cost_per_side'] * 100:.2f} % na každej strane (fee + spread/sklz)", "per_coin_results": [{key: row[key] for key in summary_keys} for row in finalists.values()], "variant_results": [variant_diagnostic(row) for row in sorted(results, key=lambda item: (item["pair"], item["timeframe"], item["variant"]))], "replay_snapshots": snapshots, "all_variants_insufficient_trades": all("malo_obchodov" in row["validation_rejection_reasons"] for row in results), "top_results": [{key: row[key] for key in ("pair", "timeframe", "variant", "score", "validation_metrics")} for row in results[:5]], }