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)
|
MIN_LOOKBACK = 60 # bars needed before D for indicators (EMA cross needs 51)
|
||||||
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
|
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
|
||||||
ATR_MULTIPLIER = 1.5
|
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
|
# Cross-sectional signal evaluation (factor IC). Each candidate signal is a
|
||||||
# point-in-time number computed from closes alone (sentiment/fundamentals have no
|
# point-in-time number computed from closes alone (sentiment/fundamentals have no
|
||||||
@@ -146,6 +152,7 @@ SR_RESEARCH_VARIANTS = {
|
|||||||
"legacy_traffic_grid_only",
|
"legacy_traffic_grid_only",
|
||||||
"legacy_range_grid_touch",
|
"legacy_range_grid_touch",
|
||||||
"legacy_range_grid_neutral",
|
"legacy_range_grid_neutral",
|
||||||
|
*RANGE_FACTOR_VARIANTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -158,6 +165,36 @@ def _sr_research_variant() -> str:
|
|||||||
return value
|
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]:
|
def _apply_zone_strength_variant(zone_levels: list[Any], sr_variant: str) -> list[Any]:
|
||||||
"""Apply post-cluster research controls without changing zone geometry."""
|
"""Apply post-cluster research controls without changing zone geometry."""
|
||||||
if sr_variant in {"legacy_geometry_neutral", "legacy_range_grid_neutral"}:
|
if sr_variant in {"legacy_geometry_neutral", "legacy_range_grid_neutral"}:
|
||||||
@@ -288,7 +325,8 @@ def _window_setups(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
sr_variant = _sr_research_variant()
|
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":
|
if sr_variant == "legacy_geometry_neutral":
|
||||||
detected_levels = detect_sr_levels_legacy(
|
detected_levels = detect_sr_levels_legacy(
|
||||||
highs, lows, closes, volumes, neutral_strength=True
|
highs, lows, closes, volumes, neutral_strength=True
|
||||||
@@ -370,7 +408,7 @@ def _window_setups(
|
|||||||
targets = _prune_floor_pinned_targets(targets)
|
targets = _prune_floor_pinned_targets(targets)
|
||||||
primary_min_rr = (
|
primary_min_rr = (
|
||||||
1.5
|
1.5
|
||||||
if sr_variant == "production_control"
|
if sr_variant in {"production_control", "production_range504"}
|
||||||
or sr_variant.endswith("_legacy_primary")
|
or sr_variant.endswith("_legacy_primary")
|
||||||
or sr_variant.startswith("legacy_")
|
or sr_variant.startswith("legacy_")
|
||||||
else float(activation.get("min_rr", 0.0))
|
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`.
|
# week are known. run_backtest ranks momentum and finalizes `qualified`.
|
||||||
core_config = {**activation, "min_momentum_percentile": 0.0}
|
core_config = {**activation, "min_momentum_percentile": 0.0}
|
||||||
meets_core = setup_qualifies(setup_ns, core_config)
|
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)
|
best_prob = best_target_probability(setup_ns)
|
||||||
out.append({
|
out.append({
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
@@ -445,6 +487,9 @@ def _window_setups(
|
|||||||
),
|
),
|
||||||
"raw_level_count": len(sr_levels),
|
"raw_level_count": len(sr_levels),
|
||||||
"gate_level_count": len(gate_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
|
return out
|
||||||
|
|
||||||
@@ -608,6 +653,9 @@ def _replay_ticker(
|
|||||||
"primary_distance_atr": s["primary_distance_atr"],
|
"primary_distance_atr": s["primary_distance_atr"],
|
||||||
"raw_level_count": s["raw_level_count"],
|
"raw_level_count": s["raw_level_count"],
|
||||||
"gate_level_count": s["gate_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,
|
"outcome": outcome,
|
||||||
"target_hit": target_hit,
|
"target_hit": target_hit,
|
||||||
"realized_r": realized_r,
|
"realized_r": realized_r,
|
||||||
@@ -677,6 +725,7 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
|
|||||||
rejections: list[int] = []
|
rejections: list[int] = []
|
||||||
raw_counts: list[int] = []
|
raw_counts: list[int] = []
|
||||||
gate_counts: list[int] = []
|
gate_counts: list[int] = []
|
||||||
|
range_logs: list[float] = []
|
||||||
for cand in candidates:
|
for cand in candidates:
|
||||||
sources = list(cand.get("primary_sources") or [])
|
sources = list(cand.get("primary_sources") or [])
|
||||||
for source in sources:
|
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))
|
rejections.append(int(cand.get("primary_rejection_count", 0) or 0))
|
||||||
raw_counts.append(int(cand.get("raw_level_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))
|
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:
|
def avg(values: list[float] | list[int]) -> float | None:
|
||||||
return round(sum(values) / len(values), 3) if values else 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_primary_rejection_count": avg(rejections),
|
||||||
"avg_raw_level_count": avg(raw_counts),
|
"avg_raw_level_count": avg(raw_counts),
|
||||||
"avg_gate_level_count": avg(gate_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),
|
"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),
|
"raw_level_count": int(cand.get("raw_level_count", 0) or 0),
|
||||||
"gate_level_count": int(cand.get("gate_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"),
|
"outcome": cand.get("outcome"),
|
||||||
"net_r": round(float(cand.get("realized_r", 0.0)) - _cost_r(cand), 6),
|
"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),
|
"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,
|
"min_lookback": MIN_LOOKBACK,
|
||||||
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
||||||
"sr_variant": _sr_research_variant(),
|
"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": (
|
"entry_start": (
|
||||||
_backtest_entry_bounds()[0].isoformat()
|
_backtest_entry_bounds()[0].isoformat()
|
||||||
if _backtest_entry_bounds()[0] is not None else None
|
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
|
--only-arm legacy_range_grid_neutral --workers 14
|
||||||
```
|
```
|
||||||
|
|
||||||
The touch arm should closely reproduce `legacy_traffic_grid_only`; that is the
|
The touch arm reproduced `legacy_traffic_grid_only` exactly: all 121,464
|
||||||
volume-removal parity check. The touch-versus-neutral comparison then attributes
|
candidates, 504 qualified setups, cohort membership, expectancy, and portfolio
|
||||||
any remaining difference to occupancy strength. Freeze the winner before running
|
metrics match. Volume contributes nothing. Neutral strength won the training
|
||||||
the post-2024-06-30 validation command; do not tune the bin count on training data.
|
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:**
|
**Next runs, if picked back up:**
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
"legacy_geometry_neutral", "legacy_pivots_only",
|
"legacy_geometry_neutral", "legacy_pivots_only",
|
||||||
"legacy_traffic_grid_only", "legacy_range_grid_touch",
|
"legacy_traffic_grid_only", "legacy_range_grid_touch",
|
||||||
"legacy_range_grid_neutral",
|
"legacy_range_grid_neutral",
|
||||||
|
"production_range504", "rewrite_range504_legacy_primary",
|
||||||
),
|
),
|
||||||
default=None,
|
default=None,
|
||||||
help="Research-only S/R detector/gate arm.",
|
help="Research-only S/R detector/gate arm.",
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ TRAFFIC_ARMS = (
|
|||||||
"legacy_pivots_only",
|
"legacy_pivots_only",
|
||||||
*RANGE_GRID_ARMS,
|
*RANGE_GRID_ARMS,
|
||||||
)
|
)
|
||||||
|
RANGE_FACTOR_ARMS = (
|
||||||
|
"production_range504",
|
||||||
|
"rewrite_range504_legacy_primary",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _add_common(parser: argparse.ArgumentParser) -> None:
|
def _add_common(parser: argparse.ArgumentParser) -> None:
|
||||||
@@ -61,6 +65,17 @@ def _args() -> argparse.Namespace:
|
|||||||
default=None,
|
default=None,
|
||||||
help="Rerun one hidden-feature arm without repeating the full matrix.",
|
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 = commands.add_parser(
|
||||||
"validate",
|
"validate",
|
||||||
help="Run production control and one locked arm from 2024-07-01.",
|
help="Run production control and one locked arm from 2024-07-01.",
|
||||||
@@ -148,6 +163,9 @@ def main() -> None:
|
|||||||
elif args.command == "traffic":
|
elif args.command == "traffic":
|
||||||
arms = (args.only_arm,) if args.only_arm else TRAFFIC_ARMS
|
arms = (args.only_arm,) if args.only_arm else TRAFFIC_ARMS
|
||||||
_train(args, arms, "backtest-sr-traffic-train")
|
_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:
|
else:
|
||||||
_validate(args)
|
_validate(args)
|
||||||
|
|
||||||
|
|||||||
@@ -763,6 +763,8 @@ def test_sr_research_variant_is_explicit_and_validated(monkeypatch):
|
|||||||
"production_control",
|
"production_control",
|
||||||
"legacy_range_grid_touch",
|
"legacy_range_grid_touch",
|
||||||
"legacy_range_grid_neutral",
|
"legacy_range_grid_neutral",
|
||||||
|
"production_range504",
|
||||||
|
"rewrite_range504_legacy_primary",
|
||||||
):
|
):
|
||||||
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
|
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
|
||||||
assert bt._sr_research_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()
|
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(
|
@pytest.mark.parametrize(
|
||||||
("variant", "neutral_strength"),
|
("variant", "neutral_strength"),
|
||||||
[
|
[
|
||||||
@@ -863,6 +888,9 @@ def test_replay_ticker_candidates_carry_gate_fields():
|
|||||||
for c in cands:
|
for c in cands:
|
||||||
assert c.get("action") is not None
|
assert c.get("action") is not None
|
||||||
assert "risk_level" in c
|
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:
|
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user