Isolate legacy range-expansion factor
This commit is contained in:
@@ -92,6 +92,12 @@ STEP_DAYS = 5 # weekly cadence (≈ 5 trading days)
|
||||
MIN_LOOKBACK = 60 # bars needed before D for indicators (EMA cross needs 51)
|
||||
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
|
||||
ATR_MULTIPLIER = 1.5
|
||||
RANGE_FACTOR_LOOKBACK = 504
|
||||
RANGE_FACTOR_MIN_LOG = 1.0 # approximately a 2.7x high/low span
|
||||
RANGE_FACTOR_VARIANTS = {
|
||||
"production_range504",
|
||||
"rewrite_range504_legacy_primary",
|
||||
}
|
||||
|
||||
# Cross-sectional signal evaluation (factor IC). Each candidate signal is a
|
||||
# point-in-time number computed from closes alone (sentiment/fundamentals have no
|
||||
@@ -146,6 +152,7 @@ SR_RESEARCH_VARIANTS = {
|
||||
"legacy_traffic_grid_only",
|
||||
"legacy_range_grid_touch",
|
||||
"legacy_range_grid_neutral",
|
||||
*RANGE_FACTOR_VARIANTS,
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +165,36 @@ def _sr_research_variant() -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _sr_detector_variant(sr_variant: str) -> str:
|
||||
"""Map factor-gated research arms to the detector they hold fixed."""
|
||||
if sr_variant == "production_range504":
|
||||
return "production_control"
|
||||
if sr_variant == "rewrite_range504_legacy_primary":
|
||||
return "rewrite"
|
||||
return sr_variant.removesuffix("_legacy_primary")
|
||||
|
||||
|
||||
def _range_504_log(highs: list[float], lows: list[float]) -> float:
|
||||
"""Multiplicative high/low range over the last two trading years."""
|
||||
window_highs = highs[-RANGE_FACTOR_LOOKBACK:]
|
||||
window_lows = lows[-RANGE_FACTOR_LOOKBACK:]
|
||||
if not window_highs or not window_lows:
|
||||
return 0.0
|
||||
high = max(window_highs)
|
||||
low = min(window_lows)
|
||||
if high <= 0 or low <= 0 or high < low:
|
||||
return 0.0
|
||||
return math.log(high / low)
|
||||
|
||||
|
||||
def _range_factor_allows(sr_variant: str, range_504_log: float) -> bool:
|
||||
"""Apply the explicit range factor only in its diagnostic arms."""
|
||||
return (
|
||||
sr_variant not in RANGE_FACTOR_VARIANTS
|
||||
or range_504_log >= RANGE_FACTOR_MIN_LOG
|
||||
)
|
||||
|
||||
|
||||
def _apply_zone_strength_variant(zone_levels: list[Any], sr_variant: str) -> list[Any]:
|
||||
"""Apply post-cluster research controls without changing zone geometry."""
|
||||
if sr_variant in {"legacy_geometry_neutral", "legacy_range_grid_neutral"}:
|
||||
@@ -288,7 +325,8 @@ def _window_setups(
|
||||
return []
|
||||
|
||||
sr_variant = _sr_research_variant()
|
||||
detector_variant = sr_variant.removesuffix("_legacy_primary")
|
||||
detector_variant = _sr_detector_variant(sr_variant)
|
||||
range_504_log = _range_504_log(highs, lows)
|
||||
if sr_variant == "legacy_geometry_neutral":
|
||||
detected_levels = detect_sr_levels_legacy(
|
||||
highs, lows, closes, volumes, neutral_strength=True
|
||||
@@ -370,7 +408,7 @@ def _window_setups(
|
||||
targets = _prune_floor_pinned_targets(targets)
|
||||
primary_min_rr = (
|
||||
1.5
|
||||
if sr_variant == "production_control"
|
||||
if sr_variant in {"production_control", "production_range504"}
|
||||
or sr_variant.endswith("_legacy_primary")
|
||||
or sr_variant.startswith("legacy_")
|
||||
else float(activation.get("min_rr", 0.0))
|
||||
@@ -419,6 +457,10 @@ def _window_setups(
|
||||
# week are known. run_backtest ranks momentum and finalizes `qualified`.
|
||||
core_config = {**activation, "min_momentum_percentile": 0.0}
|
||||
meets_core = setup_qualifies(setup_ns, core_config)
|
||||
meets_core = meets_core and _range_factor_allows(
|
||||
sr_variant,
|
||||
range_504_log,
|
||||
)
|
||||
best_prob = best_target_probability(setup_ns)
|
||||
out.append({
|
||||
"direction": direction,
|
||||
@@ -445,6 +487,9 @@ def _window_setups(
|
||||
),
|
||||
"raw_level_count": len(sr_levels),
|
||||
"gate_level_count": len(gate_levels),
|
||||
"range_504_log": range_504_log,
|
||||
"range_504_ratio": math.exp(range_504_log),
|
||||
"range_factor_pass": range_504_log >= RANGE_FACTOR_MIN_LOG,
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -608,6 +653,9 @@ def _replay_ticker(
|
||||
"primary_distance_atr": s["primary_distance_atr"],
|
||||
"raw_level_count": s["raw_level_count"],
|
||||
"gate_level_count": s["gate_level_count"],
|
||||
"range_504_log": s["range_504_log"],
|
||||
"range_504_ratio": s["range_504_ratio"],
|
||||
"range_factor_pass": s["range_factor_pass"],
|
||||
"outcome": outcome,
|
||||
"target_hit": target_hit,
|
||||
"realized_r": realized_r,
|
||||
@@ -677,6 +725,7 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
|
||||
rejections: list[int] = []
|
||||
raw_counts: list[int] = []
|
||||
gate_counts: list[int] = []
|
||||
range_logs: list[float] = []
|
||||
for cand in candidates:
|
||||
sources = list(cand.get("primary_sources") or [])
|
||||
for source in sources:
|
||||
@@ -688,6 +737,7 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
|
||||
rejections.append(int(cand.get("primary_rejection_count", 0) or 0))
|
||||
raw_counts.append(int(cand.get("raw_level_count", 0) or 0))
|
||||
gate_counts.append(int(cand.get("gate_level_count", 0) or 0))
|
||||
range_logs.append(float(cand.get("range_504_log", 0.0) or 0.0))
|
||||
|
||||
def avg(values: list[float] | list[int]) -> float | None:
|
||||
return round(sum(values) / len(values), 3) if values else None
|
||||
@@ -703,6 +753,10 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
|
||||
"avg_primary_rejection_count": avg(rejections),
|
||||
"avg_raw_level_count": avg(raw_counts),
|
||||
"avg_gate_level_count": avg(gate_counts),
|
||||
"avg_range_504_log": avg(range_logs),
|
||||
"range_factor_pass": sum(
|
||||
1 for value in range_logs if value >= RANGE_FACTOR_MIN_LOG
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -738,6 +792,9 @@ def _sr_candidate_audit(candidates: list[dict], min_percentile: float) -> list[d
|
||||
"primary_distance_atr": round(float(cand.get("primary_distance_atr", 0.0)), 6),
|
||||
"raw_level_count": int(cand.get("raw_level_count", 0) or 0),
|
||||
"gate_level_count": int(cand.get("gate_level_count", 0) or 0),
|
||||
"range_504_log": round(float(cand.get("range_504_log", 0.0)), 6),
|
||||
"range_504_ratio": round(float(cand.get("range_504_ratio", 1.0)), 6),
|
||||
"range_factor_pass": bool(cand.get("range_factor_pass")),
|
||||
"outcome": cand.get("outcome"),
|
||||
"net_r": round(float(cand.get("realized_r", 0.0)) - _cost_r(cand), 6),
|
||||
"hold30_r": round(float((cand.get("time_r") or {}).get(30, 0.0)), 6),
|
||||
@@ -3089,6 +3146,9 @@ async def run_backtest(
|
||||
"min_lookback": MIN_LOOKBACK,
|
||||
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
||||
"sr_variant": _sr_research_variant(),
|
||||
"range_factor_lookback": RANGE_FACTOR_LOOKBACK,
|
||||
"range_factor_min_log": RANGE_FACTOR_MIN_LOG,
|
||||
"range_factor_min_ratio": round(math.exp(RANGE_FACTOR_MIN_LOG), 4),
|
||||
"entry_start": (
|
||||
_backtest_entry_bounds()[0].isoformat()
|
||||
if _backtest_entry_bounds()[0] is not None else None
|
||||
|
||||
@@ -541,10 +541,53 @@ Run only these new arms on macOS:
|
||||
--only-arm legacy_range_grid_neutral --workers 14
|
||||
```
|
||||
|
||||
The touch arm should closely reproduce `legacy_traffic_grid_only`; that is the
|
||||
volume-removal parity check. The touch-versus-neutral comparison then attributes
|
||||
any remaining difference to occupancy strength. Freeze the winner before running
|
||||
the post-2024-06-30 validation command; do not tune the bin count on training data.
|
||||
The touch arm reproduced `legacy_traffic_grid_only` exactly: all 121,464
|
||||
candidates, 504 qualified setups, cohort membership, expectancy, and portfolio
|
||||
metrics match. Volume contributes nothing. Neutral strength won the training
|
||||
portfolio comparison (Sharpe 1.98 versus 1.72), but failed the locked validation:
|
||||
|
||||
| validation arm | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| production control | **2.78** | **73.3%** | **11.7%** | 0.174 | 0.022 |
|
||||
| neutral range grid | 1.85 | 43.2% | 15.2% | **0.178** | **0.039** |
|
||||
|
||||
The neutral grid is a no-ship. The validation failure prompted a causal audit of
|
||||
the control rather than another detector sweep. One relationship survives both
|
||||
periods: dense legacy ladders are a proxy for a wide multiplicative price range.
|
||||
|
||||
| control cohort | training ex-top-5% | validation ex-top-5% |
|
||||
|---|---:|---:|
|
||||
| at least 70 legacy levels | +0.165R | +0.185R |
|
||||
| fewer than 70 levels | +0.004R | -0.379R |
|
||||
|
||||
Level count is not independently useful after controlling for the last 504
|
||||
trading days' range. For `log(max(high) / min(low)) >= 1.0315` (about a 2.8x
|
||||
high/low ratio), the overlap cohort returns +0.292R training and +0.322R
|
||||
validation ex-top-5%. High density without high range returns -0.096R and
|
||||
+0.029R. Correlation between the explicit range and legacy level count is 0.864
|
||||
training and 0.822 validation.
|
||||
|
||||
This isolates the hidden feature as a two-year realized price-excursion factor,
|
||||
accidentally encoded by how many full-history pivots survive a 0.5% merge. It is
|
||||
not evidence that the arbitrary lines are structural. A rounded threshold of
|
||||
`log range >= 1.0` remains positive across a 0.9/1.0/1.1 sensitivity plateau.
|
||||
|
||||
Two diagnostic-only arms now test whether the explicit scalar replaces the side
|
||||
effect:
|
||||
|
||||
- `production_range504`: deployed targets plus the explicit range gate;
|
||||
- `rewrite_range504_legacy_primary`: clean targets, frozen primary selection,
|
||||
plus the identical range gate.
|
||||
|
||||
Run on pre-2024 training data only:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/run_sr_v2_matrix.py factor --workers 14
|
||||
```
|
||||
|
||||
The post-2024 window has been opened and is now analysis data, not a valid final
|
||||
promotion holdout. These arms can isolate mechanism, but neither may ship without
|
||||
new future data or a separately pre-registered walk-forward protocol.
|
||||
|
||||
**Next runs, if picked back up:**
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ def _parse_args() -> argparse.Namespace:
|
||||
"legacy_geometry_neutral", "legacy_pivots_only",
|
||||
"legacy_traffic_grid_only", "legacy_range_grid_touch",
|
||||
"legacy_range_grid_neutral",
|
||||
"production_range504", "rewrite_range504_legacy_primary",
|
||||
),
|
||||
default=None,
|
||||
help="Research-only S/R detector/gate arm.",
|
||||
|
||||
@@ -34,6 +34,10 @@ TRAFFIC_ARMS = (
|
||||
"legacy_pivots_only",
|
||||
*RANGE_GRID_ARMS,
|
||||
)
|
||||
RANGE_FACTOR_ARMS = (
|
||||
"production_range504",
|
||||
"rewrite_range504_legacy_primary",
|
||||
)
|
||||
|
||||
|
||||
def _add_common(parser: argparse.ArgumentParser) -> None:
|
||||
@@ -61,6 +65,17 @@ def _args() -> argparse.Namespace:
|
||||
default=None,
|
||||
help="Rerun one hidden-feature arm without repeating the full matrix.",
|
||||
)
|
||||
factor = commands.add_parser(
|
||||
"factor",
|
||||
help="Test the explicit 504-day range factor with old and clean detectors.",
|
||||
)
|
||||
_add_common(factor)
|
||||
factor.add_argument(
|
||||
"--only-arm",
|
||||
choices=RANGE_FACTOR_ARMS,
|
||||
default=None,
|
||||
help="Run one range-factor arm without repeating the pair.",
|
||||
)
|
||||
validate = commands.add_parser(
|
||||
"validate",
|
||||
help="Run production control and one locked arm from 2024-07-01.",
|
||||
@@ -148,6 +163,9 @@ def main() -> None:
|
||||
elif args.command == "traffic":
|
||||
arms = (args.only_arm,) if args.only_arm else TRAFFIC_ARMS
|
||||
_train(args, arms, "backtest-sr-traffic-train")
|
||||
elif args.command == "factor":
|
||||
arms = (args.only_arm,) if args.only_arm else RANGE_FACTOR_ARMS
|
||||
_train(args, arms, "backtest-sr-range-factor-train")
|
||||
else:
|
||||
_validate(args)
|
||||
|
||||
|
||||
@@ -763,6 +763,8 @@ def test_sr_research_variant_is_explicit_and_validated(monkeypatch):
|
||||
"production_control",
|
||||
"legacy_range_grid_touch",
|
||||
"legacy_range_grid_neutral",
|
||||
"production_range504",
|
||||
"rewrite_range504_legacy_primary",
|
||||
):
|
||||
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
|
||||
assert bt._sr_research_variant() == variant
|
||||
@@ -771,6 +773,29 @@ def test_sr_research_variant_is_explicit_and_validated(monkeypatch):
|
||||
bt._sr_research_variant()
|
||||
|
||||
|
||||
def test_range_factor_detector_mapping_is_explicit():
|
||||
assert bt._sr_detector_variant("production_range504") == "production_control"
|
||||
assert bt._sr_detector_variant("rewrite_range504_legacy_primary") == "rewrite"
|
||||
assert bt._sr_detector_variant("soft_zones_legacy_primary") == "soft_zones"
|
||||
|
||||
|
||||
def test_range_504_log_uses_only_the_bounded_window():
|
||||
highs = [1_000.0, *([100.0] * bt.RANGE_FACTOR_LOOKBACK)]
|
||||
lows = [10.0, *([50.0] * bt.RANGE_FACTOR_LOOKBACK)]
|
||||
assert bt._range_504_log(highs, lows) == pytest.approx(math.log(2.0))
|
||||
|
||||
|
||||
def test_range_factor_gate_is_research_arm_only():
|
||||
below = bt.RANGE_FACTOR_MIN_LOG - 0.01
|
||||
assert not bt._range_factor_allows("production_range504", below)
|
||||
assert not bt._range_factor_allows("rewrite_range504_legacy_primary", below)
|
||||
assert bt._range_factor_allows(
|
||||
"production_range504",
|
||||
bt.RANGE_FACTOR_MIN_LOG,
|
||||
)
|
||||
assert bt._range_factor_allows("production_control", below)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variant", "neutral_strength"),
|
||||
[
|
||||
@@ -863,6 +888,9 @@ def test_replay_ticker_candidates_carry_gate_fields():
|
||||
for c in cands:
|
||||
assert c.get("action") is not None
|
||||
assert "risk_level" in c
|
||||
assert c["range_504_log"] >= 0.0
|
||||
assert c["range_504_ratio"] >= 1.0
|
||||
assert isinstance(c["range_factor_pass"], bool)
|
||||
|
||||
|
||||
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
|
||||
|
||||
Reference in New Issue
Block a user