Add S/R v2 research and validation harness
This commit is contained in:
@@ -430,6 +430,10 @@ Research-only flags, all off by default (the default report is byte-identical to
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD` | Adds a `holdout` section: train (entries before) vs test (entries on/after), as disjoint books |
|
| `BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD` | Adds a `holdout` section: train (entries before) vs test (entries on/after), as disjoint books |
|
||||||
| `BACKTEST_MIN_RR_SWEEP=1` | Sweeps the activation R:R floor against portfolio Sharpe. Combine with `BACKTEST_HOLDOUT_SPLIT` to sweep out-of-sample |
|
| `BACKTEST_MIN_RR_SWEEP=1` | Sweeps the activation R:R floor against portfolio Sharpe. Combine with `BACKTEST_HOLDOUT_SPLIT` to sweep out-of-sample |
|
||||||
|
| `BACKTEST_SR_VARIANT=<arm>` | Research-only S/R arm: `production_control`, `rr_aligned_control`, `rewrite`, `soft_zones`, `confirmed_rounds`, or `gate_v2` |
|
||||||
|
| `BACKTEST_ENTRY_START=YYYY-MM-DD` | Restrict candidate entry dates to a validation window |
|
||||||
|
| `BACKTEST_ENTRY_END=YYYY-MM-DD` | Restrict candidate entry dates to a training window |
|
||||||
|
| `BACKTEST_SR_AUDIT=1` | Add momentum-slice candidate rows for paired S/R cohort comparison |
|
||||||
| `BACKTEST_RESEARCH_EXITS=1` | Adds the rejected take-profit exit rows to the exit comparison |
|
| `BACKTEST_RESEARCH_EXITS=1` | Adds the rejected take-profit exit rows to the exit comparison |
|
||||||
| `BACKTEST_ATR_TARGET_FALLBACK=k` | Synthesizes a k×ATR target where S/R offers none |
|
| `BACKTEST_ATR_TARGET_FALLBACK=k` | Synthesizes a k×ATR target where S/R offers none |
|
||||||
| `BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` | Restricts that fallback to setups with genuinely no structure ahead |
|
| `BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` | Restricts that fallback to setups with genuinely no structure ahead |
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ router = APIRouter(tags=["sr-levels"])
|
|||||||
@router.get("/sr-levels/{symbol}", response_model=APIEnvelope)
|
@router.get("/sr-levels/{symbol}", response_model=APIEnvelope)
|
||||||
async def read_sr_levels(
|
async def read_sr_levels(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
tolerance: float = Query(0.005, ge=0, le=0.1, description="Merge tolerance (default 0.5%)"),
|
tolerance: float | None = Query(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
le=0.1,
|
||||||
|
description="Merge tolerance as fraction of price; omit for ATR-adaptive default",
|
||||||
|
),
|
||||||
max_zones: int = Query(6, ge=0, description="Max S/R zones to return (default 6)"),
|
max_zones: int = Query(6, ge=0, description="Max S/R zones to return (default 6)"),
|
||||||
_user=Depends(require_access),
|
_user=Depends(require_access),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ class SRLevelResult(BaseModel):
|
|||||||
price_level: float
|
price_level: float
|
||||||
type: Literal["support", "resistance"]
|
type: Literal["support", "resistance"]
|
||||||
strength: int = Field(ge=0, le=100)
|
strength: int = Field(ge=0, le=100)
|
||||||
detection_method: Literal["volume_profile", "pivot_point", "merged"]
|
detection_method: Literal[
|
||||||
|
"volume_profile", "pivot_point", "merged", "round_number"
|
||||||
|
]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ from app.services.qualification import (
|
|||||||
from app.services.recommendation_service import (
|
from app.services.recommendation_service import (
|
||||||
_choose_recommended_action,
|
_choose_recommended_action,
|
||||||
_classify_by_probability,
|
_classify_by_probability,
|
||||||
|
_gate_eligible_levels,
|
||||||
_prune_floor_pinned_targets,
|
_prune_floor_pinned_targets,
|
||||||
_risk_level_from_conflicts,
|
_risk_level_from_conflicts,
|
||||||
_select_primary_target,
|
_select_primary_target,
|
||||||
@@ -81,7 +82,7 @@ from app.services.scoring_service import (
|
|||||||
compute_momentum_from_closes,
|
compute_momentum_from_closes,
|
||||||
compute_technical_from_arrays,
|
compute_technical_from_arrays,
|
||||||
)
|
)
|
||||||
from app.services.sr_service import detect_sr_levels
|
from app.services.sr_service import MAX_LEVELS, detect_sr_levels, detect_sr_levels_legacy
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -120,11 +121,58 @@ def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
|
|||||||
price_level=float(d["price_level"]),
|
price_level=float(d["price_level"]),
|
||||||
type=d["type"],
|
type=d["type"],
|
||||||
strength=int(d["strength"]),
|
strength=int(d["strength"]),
|
||||||
|
detection_method=d.get("detection_method", "unknown"),
|
||||||
|
sources=list(d.get("sources") or [d.get("detection_method", "unknown")]),
|
||||||
|
rejection_count=int(d.get("rejection_count", 0) or 0),
|
||||||
|
last_rejection_age=d.get("last_rejection_age"),
|
||||||
)
|
)
|
||||||
for i, d in enumerate(level_dicts)
|
for i, d in enumerate(level_dicts)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
SR_RESEARCH_VARIANTS = {
|
||||||
|
"production_control",
|
||||||
|
"rr_aligned_control",
|
||||||
|
"rewrite",
|
||||||
|
"soft_zones",
|
||||||
|
"confirmed_rounds",
|
||||||
|
"gate_v2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sr_research_variant() -> str:
|
||||||
|
"""S/R policy arm for local research; never read by the live scanner."""
|
||||||
|
value = os.getenv("BACKTEST_SR_VARIANT", "rewrite").strip().lower()
|
||||||
|
if value not in SR_RESEARCH_VARIANTS:
|
||||||
|
allowed = ", ".join(sorted(SR_RESEARCH_VARIANTS))
|
||||||
|
raise ValueError(f"Unknown BACKTEST_SR_VARIANT={value!r}; expected one of {allowed}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _backtest_entry_bounds() -> tuple[date | None, date | None]:
|
||||||
|
"""Optional research-only entry bounds used to protect validation data."""
|
||||||
|
parsed: list[date | None] = []
|
||||||
|
for key in ("BACKTEST_ENTRY_START", "BACKTEST_ENTRY_END"):
|
||||||
|
raw = os.getenv(key, "").strip()
|
||||||
|
if not raw:
|
||||||
|
parsed.append(None)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed.append(date.fromisoformat(raw))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{key} must be YYYY-MM-DD, got {raw!r}") from exc
|
||||||
|
start, end = parsed
|
||||||
|
if start is not None and end is not None and start > end:
|
||||||
|
raise ValueError("BACKTEST_ENTRY_START must be on or before BACKTEST_ENTRY_END")
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _sr_audit_enabled() -> bool:
|
||||||
|
return os.getenv("BACKTEST_SR_AUDIT", "").strip().lower() in {
|
||||||
|
"1", "true", "yes", "on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _atr_target_fallback_k() -> float | None:
|
def _atr_target_fallback_k() -> float | None:
|
||||||
"""Research ablation: k for a synthetic k*ATR target when a direction has no
|
"""Research ablation: k for a synthetic k*ATR target when a direction has no
|
||||||
S/R level to aim at. Off (None) by default, which is production behavior —
|
S/R level to aim at. Off (None) by default, which is production behavior —
|
||||||
@@ -219,10 +267,28 @@ def _window_setups(
|
|||||||
if atr <= 0:
|
if atr <= 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
sr_levels = _wrap_levels(detect_sr_levels(highs, lows, closes, volumes))
|
sr_variant = _sr_research_variant()
|
||||||
|
if sr_variant in {"production_control", "rr_aligned_control"}:
|
||||||
|
detected_levels = detect_sr_levels_legacy(highs, lows, closes, volumes)
|
||||||
|
else:
|
||||||
|
detector_cap = 0 if sr_variant == "gate_v2" else MAX_LEVELS
|
||||||
|
detected_levels = detect_sr_levels(
|
||||||
|
highs, lows, closes, volumes, max_levels=detector_cap
|
||||||
|
)
|
||||||
|
sr_levels = _wrap_levels(detected_levels)
|
||||||
if not sr_levels:
|
if not sr_levels:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
gate_levels = _gate_eligible_levels(
|
||||||
|
sr_levels,
|
||||||
|
confirmed_rounds_only=sr_variant in {"confirmed_rounds", "gate_v2"},
|
||||||
|
)
|
||||||
|
zone_strength_mode = (
|
||||||
|
"soft"
|
||||||
|
if sr_variant in {"soft_zones", "confirmed_rounds", "gate_v2"}
|
||||||
|
else "sum"
|
||||||
|
)
|
||||||
|
|
||||||
technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0
|
technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0
|
||||||
momentum = (compute_momentum_from_closes(closes)[0]) or 50.0
|
momentum = (compute_momentum_from_closes(closes)[0]) or 50.0
|
||||||
dim_scores = {"technical": technical, "momentum": momentum}
|
dim_scores = {"technical": technical, "momentum": momentum}
|
||||||
@@ -237,7 +303,11 @@ def _window_setups(
|
|||||||
per_dir: dict[str, dict] = {}
|
per_dir: dict[str, dict] = {}
|
||||||
for direction in ("long", "short"):
|
for direction in ("long", "short"):
|
||||||
stop = entry - atr * ATR_MULTIPLIER if direction == "long" else entry + atr * ATR_MULTIPLIER
|
stop = entry - atr * ATR_MULTIPLIER if direction == "long" else entry + atr * ATR_MULTIPLIER
|
||||||
zone_levels = _zone_representative_levels(sr_levels, entry)
|
zone_levels = _zone_representative_levels(
|
||||||
|
gate_levels,
|
||||||
|
entry,
|
||||||
|
strength_mode=zone_strength_mode,
|
||||||
|
)
|
||||||
targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr)
|
targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr)
|
||||||
if not targets:
|
if not targets:
|
||||||
fallback_k = _atr_target_fallback_k()
|
fallback_k = _atr_target_fallback_k()
|
||||||
@@ -254,7 +324,15 @@ def _window_setups(
|
|||||||
# Collapse duplicate floor-pinned lottery targets (parity with
|
# Collapse duplicate floor-pinned lottery targets (parity with
|
||||||
# enhance_trade_setup).
|
# enhance_trade_setup).
|
||||||
targets = _prune_floor_pinned_targets(targets)
|
targets = _prune_floor_pinned_targets(targets)
|
||||||
primary = _select_primary_target(targets)
|
primary_min_rr = (
|
||||||
|
1.5
|
||||||
|
if sr_variant == "production_control"
|
||||||
|
else float(activation.get("min_rr", 0.0))
|
||||||
|
)
|
||||||
|
primary = _select_primary_target(
|
||||||
|
targets,
|
||||||
|
min_rr=primary_min_rr,
|
||||||
|
)
|
||||||
if primary is None:
|
if primary is None:
|
||||||
continue
|
continue
|
||||||
# Flag the primary so qualification's EV uses the primary target's
|
# Flag the primary so qualification's EV uses the primary target's
|
||||||
@@ -309,6 +387,18 @@ def _window_setups(
|
|||||||
"meets_core": meets_core,
|
"meets_core": meets_core,
|
||||||
"action": action,
|
"action": action,
|
||||||
"risk_level": risk_level,
|
"risk_level": risk_level,
|
||||||
|
"sr_variant": sr_variant,
|
||||||
|
"primary_sources": list(primary.get("sr_sources") or []),
|
||||||
|
"primary_strength": float(primary.get("sr_strength", 0.0)),
|
||||||
|
"primary_rejection_count": int(
|
||||||
|
primary.get("sr_rejection_count", 0) or 0
|
||||||
|
),
|
||||||
|
"primary_last_rejection_age": primary.get("sr_last_rejection_age"),
|
||||||
|
"primary_distance_atr": float(
|
||||||
|
primary.get("distance_atr_multiple", 0.0)
|
||||||
|
),
|
||||||
|
"raw_level_count": len(sr_levels),
|
||||||
|
"gate_level_count": len(gate_levels),
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -399,7 +489,13 @@ def _replay_ticker(
|
|||||||
if n < MIN_LOOKBACK + HORIZON:
|
if n < MIN_LOOKBACK + HORIZON:
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
entry_start, entry_end = _backtest_entry_bounds()
|
||||||
for i in range(MIN_LOOKBACK - 1, n - HORIZON, STEP_DAYS):
|
for i in range(MIN_LOOKBACK - 1, n - HORIZON, STEP_DAYS):
|
||||||
|
as_of = records[i].date
|
||||||
|
if entry_start is not None and as_of < entry_start:
|
||||||
|
continue
|
||||||
|
if entry_end is not None and as_of > entry_end:
|
||||||
|
continue
|
||||||
window = records[: i + 1]
|
window = records[: i + 1]
|
||||||
forward = records[i + 1 :]
|
forward = records[i + 1 :]
|
||||||
forward_bars = [Bar(date=r.date, high=r.high, low=r.low) for r in forward]
|
forward_bars = [Bar(date=r.date, high=r.high, low=r.low) for r in forward]
|
||||||
@@ -458,6 +554,14 @@ def _replay_ticker(
|
|||||||
# every candidate looks NEUTRAL and the ablation rows collapse.
|
# every candidate looks NEUTRAL and the ablation rows collapse.
|
||||||
"action": s["action"],
|
"action": s["action"],
|
||||||
"risk_level": s["risk_level"],
|
"risk_level": s["risk_level"],
|
||||||
|
"sr_variant": s["sr_variant"],
|
||||||
|
"primary_sources": s["primary_sources"],
|
||||||
|
"primary_strength": s["primary_strength"],
|
||||||
|
"primary_rejection_count": s["primary_rejection_count"],
|
||||||
|
"primary_last_rejection_age": s["primary_last_rejection_age"],
|
||||||
|
"primary_distance_atr": s["primary_distance_atr"],
|
||||||
|
"raw_level_count": s["raw_level_count"],
|
||||||
|
"gate_level_count": s["gate_level_count"],
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"target_hit": target_hit,
|
"target_hit": target_hit,
|
||||||
"realized_r": realized_r,
|
"realized_r": realized_r,
|
||||||
@@ -518,6 +622,84 @@ def _robustness_stats(net_rs: list[float]) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
|
||||||
|
"""Compact evidence audit for the active local S/R research arm."""
|
||||||
|
source_counts: dict[str, int] = defaultdict(int)
|
||||||
|
round_only = 0
|
||||||
|
strengths: list[float] = []
|
||||||
|
distances: list[float] = []
|
||||||
|
rejections: list[int] = []
|
||||||
|
raw_counts: list[int] = []
|
||||||
|
gate_counts: list[int] = []
|
||||||
|
for cand in candidates:
|
||||||
|
sources = list(cand.get("primary_sources") or [])
|
||||||
|
for source in sources:
|
||||||
|
source_counts[str(source)] += 1
|
||||||
|
if set(sources) == {"round_number"}:
|
||||||
|
round_only += 1
|
||||||
|
strengths.append(float(cand.get("primary_strength", 0.0)))
|
||||||
|
distances.append(float(cand.get("primary_distance_atr", 0.0)))
|
||||||
|
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))
|
||||||
|
|
||||||
|
def avg(values: list[float] | list[int]) -> float | None:
|
||||||
|
return round(sum(values) / len(values), 3) if values else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"variant": _sr_research_variant(),
|
||||||
|
"candidate_count": len(candidates),
|
||||||
|
"primary_source_counts": dict(sorted(source_counts.items())),
|
||||||
|
"primary_round_only": round_only,
|
||||||
|
"primary_strength_100": sum(1 for value in strengths if value >= 100.0),
|
||||||
|
"avg_primary_strength": avg(strengths),
|
||||||
|
"avg_primary_distance_atr": avg(distances),
|
||||||
|
"avg_primary_rejection_count": avg(rejections),
|
||||||
|
"avg_raw_level_count": avg(raw_counts),
|
||||||
|
"avg_gate_level_count": avg(gate_counts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sr_candidate_audit(candidates: list[dict], min_percentile: float) -> list[dict] | None:
|
||||||
|
"""Candidate-level audit for paired S/R variant comparisons.
|
||||||
|
|
||||||
|
Limit the sidecar population to the long momentum slice that could reach
|
||||||
|
production qualification. This keeps reports reviewable while retaining
|
||||||
|
gate failures, additions, removals, and portfolio-relevant near misses.
|
||||||
|
"""
|
||||||
|
if not _sr_audit_enabled():
|
||||||
|
return None
|
||||||
|
rows: list[dict] = []
|
||||||
|
for cand in candidates:
|
||||||
|
percentile = cand.get(PRODUCTION_PERCENTILE_KEY)
|
||||||
|
if cand.get("direction") != "long" or percentile is None:
|
||||||
|
continue
|
||||||
|
if float(percentile) < min_percentile:
|
||||||
|
continue
|
||||||
|
rows.append({
|
||||||
|
"symbol": cand["symbol"],
|
||||||
|
"date": cand["date"],
|
||||||
|
"direction": cand["direction"],
|
||||||
|
"qualified": bool(cand.get("qualified")),
|
||||||
|
"meets_core": bool(cand.get("meets_core")),
|
||||||
|
"momentum_percentile": round(float(percentile), 6),
|
||||||
|
"strategy_rank": round(float(cand.get(RESIDUAL_HIGH_VOL_BLEND_KEY, 0.0) or 0.0), 6),
|
||||||
|
"rr": round(float(cand.get("rr", 0.0)), 6),
|
||||||
|
"primary_prob": round(float(cand.get("primary_prob", 0.0)), 6),
|
||||||
|
"primary_sources": list(cand.get("primary_sources") or []),
|
||||||
|
"primary_strength": round(float(cand.get("primary_strength", 0.0)), 3),
|
||||||
|
"primary_rejection_count": int(cand.get("primary_rejection_count", 0) or 0),
|
||||||
|
"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),
|
||||||
|
"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),
|
||||||
|
})
|
||||||
|
rows.sort(key=lambda row: (row["date"], row["symbol"], row["direction"]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
# The fixed take-profit and trailing-stop sweeps were retired 2026-07: swept
|
# The fixed take-profit and trailing-stop sweeps were retired 2026-07: swept
|
||||||
# TPs never found an interior optimum (momentum's edge lives in the right tail)
|
# TPs never found an interior optimum (momentum's edge lives in the right tail)
|
||||||
# and wide trails converged to the hold-to-horizon exit, so the time-exit sweep
|
# and wide trails converged to the hold-to-horizon exit, so the time-exit sweep
|
||||||
@@ -2851,6 +3033,15 @@ async def run_backtest(
|
|||||||
"horizon_days": HORIZON,
|
"horizon_days": HORIZON,
|
||||||
"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(),
|
||||||
|
"entry_start": (
|
||||||
|
_backtest_entry_bounds()[0].isoformat()
|
||||||
|
if _backtest_entry_bounds()[0] is not None else None
|
||||||
|
),
|
||||||
|
"entry_end": (
|
||||||
|
_backtest_entry_bounds()[1].isoformat()
|
||||||
|
if _backtest_entry_bounds()[1] is not None else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"activation": activation,
|
"activation": activation,
|
||||||
"overall_qualified": _bucket_stats(qualified),
|
"overall_qualified": _bucket_stats(qualified),
|
||||||
@@ -2914,6 +3105,8 @@ async def run_backtest(
|
|||||||
"portfolio_monitor": portfolio_monitor_report,
|
"portfolio_monitor": portfolio_monitor_report,
|
||||||
"holdout": holdout_report,
|
"holdout": holdout_report,
|
||||||
"min_rr_sweep": min_rr_sweep_report,
|
"min_rr_sweep": min_rr_sweep_report,
|
||||||
|
"sr_variant_diagnostics": _sr_variant_diagnostics(candidates),
|
||||||
|
"sr_candidate_audit": _sr_candidate_audit(candidates, current_min_pct),
|
||||||
"signal_eval": _signal_evaluation(collected),
|
"signal_eval": _signal_evaluation(collected),
|
||||||
"signal_eval_note": (
|
"signal_eval_note": (
|
||||||
"Cross-sectional rank-IC of price-only signals vs the forward "
|
"Cross-sectional rank-IC of price-only signals vs the forward "
|
||||||
|
|||||||
@@ -256,6 +256,12 @@ def compute_volume_profile(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Compute Volume Profile: POC, Value Area, HVN, LVN.
|
"""Compute Volume Profile: POC, Value Area, HVN, LVN.
|
||||||
|
|
||||||
|
Volume is assigned to the bin containing each bar's **close** (no
|
||||||
|
double-counting across the high–low span).
|
||||||
|
|
||||||
|
HVN = local peaks in the volume histogram (not every bin above mean).
|
||||||
|
LVN = local valleys in the histogram.
|
||||||
|
|
||||||
Score: proximity of latest close to POC (closer = higher).
|
Score: proximity of latest close to POC (closer = higher).
|
||||||
"""
|
"""
|
||||||
n = len(closes)
|
n = len(closes)
|
||||||
@@ -275,13 +281,17 @@ def compute_volume_profile(
|
|||||||
price_min + (i + 0.5) * bin_width for i in range(num_bins)
|
price_min + (i + 0.5) * bin_width for i in range(num_bins)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Assign each bar's full volume to the close's bin only.
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
# Distribute volume across bins the bar spans
|
c = closes[i]
|
||||||
bar_low, bar_high = lows[i], highs[i]
|
if c <= price_min:
|
||||||
for b in range(num_bins):
|
b = 0
|
||||||
bl = price_min + b * bin_width
|
elif c >= price_max:
|
||||||
bh = bl + bin_width
|
b = num_bins - 1
|
||||||
if bar_high >= bl and bar_low <= bh:
|
else:
|
||||||
|
b = int((c - price_min) / bin_width)
|
||||||
|
if b >= num_bins:
|
||||||
|
b = num_bins - 1
|
||||||
bins[b] += volumes[i]
|
bins[b] += volumes[i]
|
||||||
|
|
||||||
total_vol = sum(bins)
|
total_vol = sum(bins)
|
||||||
@@ -304,10 +314,17 @@ def compute_volume_profile(
|
|||||||
va_low = round(price_min + min(va_indices) * bin_width, 4)
|
va_low = round(price_min + min(va_indices) * bin_width, 4)
|
||||||
va_high = round(price_min + (max(va_indices) + 1) * bin_width, 4)
|
va_high = round(price_min + (max(va_indices) + 1) * bin_width, 4)
|
||||||
|
|
||||||
# HVN / LVN: bins above/below average volume
|
# HVN / LVN: local peaks / valleys (require above/below mean to skip noise)
|
||||||
avg_vol = total_vol / num_bins
|
avg_vol = total_vol / num_bins
|
||||||
hvn = [round(bin_prices[i], 4) for i in range(num_bins) if bins[i] > avg_vol]
|
hvn: list[float] = []
|
||||||
lvn = [round(bin_prices[i], 4) for i in range(num_bins) if bins[i] < avg_vol]
|
lvn: list[float] = []
|
||||||
|
for i in range(num_bins):
|
||||||
|
left = bins[i - 1] if i > 0 else bins[i]
|
||||||
|
right = bins[i + 1] if i < num_bins - 1 else bins[i]
|
||||||
|
if bins[i] > left and bins[i] > right and bins[i] > avg_vol:
|
||||||
|
hvn.append(round(bin_prices[i], 4))
|
||||||
|
elif bins[i] < left and bins[i] < right and bins[i] < avg_vol:
|
||||||
|
lvn.append(round(bin_prices[i], 4))
|
||||||
|
|
||||||
# Score: proximity of latest close to POC
|
# Score: proximity of latest close to POC
|
||||||
latest = closes[-1]
|
latest = closes[-1]
|
||||||
@@ -333,10 +350,14 @@ def compute_pivot_points(
|
|||||||
lows: list[float],
|
lows: list[float],
|
||||||
closes: list[float],
|
closes: list[float],
|
||||||
window: int = 2,
|
window: int = 2,
|
||||||
|
min_prominence: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Detect swing highs/lows as pivot points.
|
"""Detect swing highs/lows as pivot points.
|
||||||
|
|
||||||
A swing high at index *i* means highs[i] >= all highs in [i-window, i+window].
|
A swing high at index *i* means highs[i] >= all highs in [i-window, i+window].
|
||||||
|
When *min_prominence* is set, only keep swings whose window range
|
||||||
|
(max high − min low) is at least that amount — filters tiny noise fractals.
|
||||||
|
|
||||||
Score: based on number of pivots near current price.
|
Score: based on number of pivots near current price.
|
||||||
"""
|
"""
|
||||||
n = len(closes)
|
n = len(closes)
|
||||||
@@ -349,11 +370,23 @@ def compute_pivot_points(
|
|||||||
swing_lows: list[float] = []
|
swing_lows: list[float] = []
|
||||||
|
|
||||||
for i in range(window, n - window):
|
for i in range(window, n - window):
|
||||||
|
lo = i - window
|
||||||
|
hi = i + window + 1
|
||||||
# Swing high
|
# Swing high
|
||||||
if all(highs[i] >= highs[j] for j in range(i - window, i + window + 1)):
|
if all(highs[i] >= highs[j] for j in range(lo, hi)):
|
||||||
|
if min_prominence is None or min_prominence <= 0:
|
||||||
|
swing_highs.append(round(highs[i], 4))
|
||||||
|
else:
|
||||||
|
depth = highs[i] - min(lows[j] for j in range(lo, hi))
|
||||||
|
if depth >= min_prominence:
|
||||||
swing_highs.append(round(highs[i], 4))
|
swing_highs.append(round(highs[i], 4))
|
||||||
# Swing low
|
# Swing low
|
||||||
if all(lows[i] <= lows[j] for j in range(i - window, i + window + 1)):
|
if all(lows[i] <= lows[j] for j in range(lo, hi)):
|
||||||
|
if min_prominence is None or min_prominence <= 0:
|
||||||
|
swing_lows.append(round(lows[i], 4))
|
||||||
|
else:
|
||||||
|
depth = max(highs[j] for j in range(lo, hi)) - lows[i]
|
||||||
|
if depth >= min_prominence:
|
||||||
swing_lows.append(round(lows[i], 4))
|
swing_lows.append(round(lows[i], 4))
|
||||||
|
|
||||||
all_pivots = swing_highs + swing_lows
|
all_pivots = swing_highs + swing_lows
|
||||||
|
|||||||
@@ -56,7 +56,40 @@ def _clamp(value: float, low: float, high: float) -> float:
|
|||||||
return max(low, min(high, value))
|
return max(low, min(high, value))
|
||||||
|
|
||||||
|
|
||||||
def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) -> list[Any]:
|
def _gate_eligible_levels(
|
||||||
|
sr_levels: list[Any],
|
||||||
|
*,
|
||||||
|
confirmed_rounds_only: bool = False,
|
||||||
|
min_round_rejections: int = 2,
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Return structures allowed to influence entry qualification.
|
||||||
|
|
||||||
|
Round numbers remain useful visual landmarks, but an untouched standalone
|
||||||
|
round number is not observed market structure. Research variants can require
|
||||||
|
either confluence with a pivot/volume source or distinct rejection clusters
|
||||||
|
before such a level is allowed to manufacture a gate target.
|
||||||
|
"""
|
||||||
|
if not confirmed_rounds_only:
|
||||||
|
return list(sr_levels)
|
||||||
|
|
||||||
|
eligible: list[Any] = []
|
||||||
|
for level in sr_levels:
|
||||||
|
sources = set(getattr(level, "sources", None) or [
|
||||||
|
getattr(level, "detection_method", "unknown")
|
||||||
|
])
|
||||||
|
is_round_only = sources == {"round_number"}
|
||||||
|
rejections = int(getattr(level, "rejection_count", 0) or 0)
|
||||||
|
if not is_round_only or rejections >= min_round_rejections:
|
||||||
|
eligible.append(level)
|
||||||
|
return eligible
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_representative_levels(
|
||||||
|
sr_levels: list[SRLevel],
|
||||||
|
entry_price: float,
|
||||||
|
*,
|
||||||
|
strength_mode: str = "sum",
|
||||||
|
) -> list[Any]:
|
||||||
"""Collapse near-duplicate S/R levels into one representative per zone.
|
"""Collapse near-duplicate S/R levels into one representative per zone.
|
||||||
|
|
||||||
Targets are generated from these representatives, so a clustered wall (e.g.
|
Targets are generated from these representatives, so a clustered wall (e.g.
|
||||||
@@ -71,11 +104,25 @@ def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) ->
|
|||||||
if not sr_levels or entry_price <= 0:
|
if not sr_levels or entry_price <= 0:
|
||||||
return list(sr_levels)
|
return list(sr_levels)
|
||||||
|
|
||||||
level_dicts = [
|
level_dicts = []
|
||||||
{"price_level": float(lv.price_level), "strength": int(lv.strength), "type": lv.type}
|
for lv in sr_levels:
|
||||||
for lv in sr_levels
|
level_dicts.append({
|
||||||
]
|
"price_level": float(lv.price_level),
|
||||||
zones = cluster_sr_zones(level_dicts, entry_price, tolerance=_SR_ZONE_TOLERANCE)
|
"strength": int(lv.strength),
|
||||||
|
"type": lv.type,
|
||||||
|
"detection_method": getattr(lv, "detection_method", "unknown"),
|
||||||
|
"sources": list(getattr(lv, "sources", None) or [
|
||||||
|
getattr(lv, "detection_method", "unknown")
|
||||||
|
]),
|
||||||
|
"rejection_count": int(getattr(lv, "rejection_count", 0) or 0),
|
||||||
|
"last_rejection_age": getattr(lv, "last_rejection_age", None),
|
||||||
|
})
|
||||||
|
zones = cluster_sr_zones(
|
||||||
|
level_dicts,
|
||||||
|
entry_price,
|
||||||
|
tolerance=_SR_ZONE_TOLERANCE,
|
||||||
|
strength_mode=strength_mode,
|
||||||
|
)
|
||||||
|
|
||||||
reps: list[Any] = []
|
reps: list[Any] = []
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
@@ -94,6 +141,10 @@ def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) ->
|
|||||||
price_level=float(near_edge),
|
price_level=float(near_edge),
|
||||||
type=zone["type"],
|
type=zone["type"],
|
||||||
strength=int(zone["strength"]),
|
strength=int(zone["strength"]),
|
||||||
|
detection_method=getattr(strongest, "detection_method", "unknown"),
|
||||||
|
sources=list(zone.get("sources") or []),
|
||||||
|
rejection_count=int(zone.get("rejection_count", 0)),
|
||||||
|
last_rejection_age=zone.get("last_rejection_age"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return reps
|
return reps
|
||||||
@@ -312,6 +363,15 @@ class TargetGenerator:
|
|||||||
"classification": "Moderate",
|
"classification": "Moderate",
|
||||||
"sr_level_id": int(level.id),
|
"sr_level_id": int(level.id),
|
||||||
"sr_strength": float(level.strength),
|
"sr_strength": float(level.strength),
|
||||||
|
"sr_sources": list(getattr(level, "sources", None) or [
|
||||||
|
getattr(level, "detection_method", "unknown")
|
||||||
|
]),
|
||||||
|
"sr_rejection_count": int(
|
||||||
|
getattr(level, "rejection_count", 0) or 0
|
||||||
|
),
|
||||||
|
"sr_last_rejection_age": getattr(
|
||||||
|
level, "last_rejection_age", None
|
||||||
|
),
|
||||||
"quality": float(quality),
|
"quality": float(quality),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -581,7 +641,6 @@ def build_recommendation_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
PRIMARY_TARGET_MIN_RR = 1.5
|
|
||||||
# Below this the target is a lottery ticket. Shared with the activation gate
|
# Below this the target is a lottery ticket. Shared with the activation gate
|
||||||
# (qualification.MIN_TARGET_PROBABILITY) so the primary selection and the gate
|
# (qualification.MIN_TARGET_PROBABILITY) so the primary selection and the gate
|
||||||
# agree on what counts as a probability-backed target.
|
# agree on what counts as a probability-backed target.
|
||||||
@@ -610,7 +669,7 @@ def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]:
|
|||||||
|
|
||||||
def _select_primary_target(
|
def _select_primary_target(
|
||||||
targets: list[dict],
|
targets: list[dict],
|
||||||
min_rr: float = PRIMARY_TARGET_MIN_RR,
|
min_rr: float,
|
||||||
min_probability: float = PRIMARY_TARGET_MIN_PROBABILITY,
|
min_probability: float = PRIMARY_TARGET_MIN_PROBABILITY,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Primary = the most LIKELY target that still offers real asymmetry.
|
"""Primary = the most LIKELY target that still offers real asymmetry.
|
||||||
@@ -651,6 +710,7 @@ async def enhance_trade_setup(
|
|||||||
sr_levels: list[SRLevel],
|
sr_levels: list[SRLevel],
|
||||||
sentiment_classification: str | None,
|
sentiment_classification: str | None,
|
||||||
atr_value: float,
|
atr_value: float,
|
||||||
|
primary_min_rr: float,
|
||||||
available_directions: set[str] | None = None,
|
available_directions: set[str] | None = None,
|
||||||
) -> TradeSetup:
|
) -> TradeSetup:
|
||||||
config = await get_recommendation_config(db)
|
config = await get_recommendation_config(db)
|
||||||
@@ -698,7 +758,7 @@ async def enhance_trade_setup(
|
|||||||
# _select_primary_target), not the old quality-score pick that ignored
|
# _select_primary_target), not the old quality-score pick that ignored
|
||||||
# probability. Sync the setup's headline target/rr_ratio so the chart, gate
|
# probability. Sync the setup's headline target/rr_ratio so the chart, gate
|
||||||
# and outcome eval all agree with the table's starred row.
|
# and outcome eval all agree with the table's starred row.
|
||||||
primary = _select_primary_target(targets)
|
primary = _select_primary_target(targets, min_rr=primary_min_rr)
|
||||||
if primary is not None:
|
if primary is not None:
|
||||||
for target in targets:
|
for target in targets:
|
||||||
target["is_primary"] = target is primary
|
target["is_primary"] = target is primary
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ async def scan_ticker(
|
|||||||
momentum_percentile: float | None = None,
|
momentum_percentile: float | None = None,
|
||||||
strategy_rank: float | None = None,
|
strategy_rank: float | None = None,
|
||||||
volatility_percentile: float | None = None,
|
volatility_percentile: float | None = None,
|
||||||
|
primary_min_rr: float | None = None,
|
||||||
) -> list[TradeSetup]:
|
) -> list[TradeSetup]:
|
||||||
"""Scan a single ticker for trade setups meeting the R:R threshold.
|
"""Scan a single ticker for trade setups meeting the R:R threshold.
|
||||||
|
|
||||||
@@ -421,6 +422,14 @@ async def scan_ticker(
|
|||||||
production ordering score used for top-pick ranking."""
|
production ordering score used for top-pick ranking."""
|
||||||
ticker = await _get_ticker(db, symbol)
|
ticker = await _get_ticker(db, symbol)
|
||||||
|
|
||||||
|
if primary_min_rr is None:
|
||||||
|
# Direct single-ticker scans still use the same activation threshold as
|
||||||
|
# qualification. scan_all_tickers resolves this once for the universe.
|
||||||
|
from app.services.admin_service import get_activation_config
|
||||||
|
|
||||||
|
activation = await get_activation_config(db)
|
||||||
|
primary_min_rr = float(activation.get("min_rr", rr_threshold))
|
||||||
|
|
||||||
records = await query_ohlcv(db, symbol)
|
records = await query_ohlcv(db, symbol)
|
||||||
if not records or len(records) < 15:
|
if not records or len(records) < 15:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -558,6 +567,7 @@ async def scan_ticker(
|
|||||||
sr_levels=sr_levels,
|
sr_levels=sr_levels,
|
||||||
sentiment_classification=sentiment_classification,
|
sentiment_classification=sentiment_classification,
|
||||||
atr_value=atr_value,
|
atr_value=atr_value,
|
||||||
|
primary_min_rr=primary_min_rr,
|
||||||
available_directions=available_directions,
|
available_directions=available_directions,
|
||||||
)
|
)
|
||||||
enhanced_setups.append(enhanced)
|
enhanced_setups.append(enhanced)
|
||||||
@@ -610,6 +620,16 @@ async def scan_all_tickers(
|
|||||||
logger.exception("Activation ranking refresh failed")
|
logger.exception("Activation ranking refresh failed")
|
||||||
ranks = {}
|
ranks = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.admin_service import get_activation_config
|
||||||
|
|
||||||
|
activation = await get_activation_config(db)
|
||||||
|
primary_min_rr = float(activation.get("min_rr", rr_threshold))
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception("Activation config load failed; using scanner R:R floor")
|
||||||
|
primary_min_rr = rr_threshold
|
||||||
|
|
||||||
all_setups: list[TradeSetup] = []
|
all_setups: list[TradeSetup] = []
|
||||||
for index, symbol in enumerate(symbols):
|
for index, symbol in enumerate(symbols):
|
||||||
if progress_callback is not None:
|
if progress_callback is not None:
|
||||||
@@ -641,6 +661,7 @@ async def scan_all_tickers(
|
|||||||
momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"),
|
momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"),
|
||||||
strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"),
|
strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"),
|
||||||
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"),
|
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"),
|
||||||
|
primary_min_rr=primary_min_rr,
|
||||||
)
|
)
|
||||||
all_setups.extend(setups)
|
all_setups.extend(setups)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+558
-76
@@ -1,12 +1,15 @@
|
|||||||
"""S/R Detector service.
|
"""S/R Detector service.
|
||||||
|
|
||||||
Detects support/resistance levels from Volume Profile (HVN/LVN) and
|
Detects support/resistance levels from Volume Profile (POC/VA/HVN peaks)
|
||||||
Pivot Points (swing highs/lows), assigns strength scores, merges nearby
|
and Pivot Points (prominent swing highs/lows), plus light psychological
|
||||||
levels, tags as support/resistance, and persists to DB.
|
round numbers. Scores by rejection-weighted recent touches, merges nearby
|
||||||
|
levels with ATR-adaptive tolerance, tags support/resistance, caps count,
|
||||||
|
and persists to DB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
@@ -17,12 +20,43 @@ from app.models.sr_level import SRLevel
|
|||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.services.indicator_service import (
|
from app.services.indicator_service import (
|
||||||
_extract_ohlcv,
|
_extract_ohlcv,
|
||||||
|
compute_atr,
|
||||||
compute_pivot_points,
|
compute_pivot_points,
|
||||||
compute_volume_profile,
|
compute_volume_profile,
|
||||||
)
|
)
|
||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
|
|
||||||
DEFAULT_TOLERANCE = 0.005 # 0.5%
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tunable constants (keep detection pure / deterministic)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_TOLERANCE = 0.005 # fallback when ATR unavailable; also API legacy default
|
||||||
|
|
||||||
|
VP_LOOKBACK = 252
|
||||||
|
TOUCH_LOOKBACK = 252
|
||||||
|
PIVOT_LOOKBACK = 504
|
||||||
|
PIVOT_PROMINENCE_ATR = 0.75
|
||||||
|
PIVOT_PROMINENCE_PCT = 0.006
|
||||||
|
|
||||||
|
MERGE_TOL_ATR_MULT = 0.35
|
||||||
|
MERGE_TOL_MIN = 0.004 # 0.4%
|
||||||
|
MERGE_TOL_MAX = 0.015 # 1.5%
|
||||||
|
|
||||||
|
MAX_LEVELS = 16
|
||||||
|
STRENGTH_HALF_LIFE = 60 # bars
|
||||||
|
# Raw respect score is soft-mapped to 0–100 (see _raw_to_strength).
|
||||||
|
STRENGTH_SCALE = 8.0
|
||||||
|
STRENGTH_SOFT_K = 35.0 # higher → slower approach to 100
|
||||||
|
|
||||||
|
ROUND_NUMBER_RANGE = 0.15 # ±15% of spot
|
||||||
|
ROUND_NUMBER_MAX = 8
|
||||||
|
|
||||||
|
# Base strength seed before touch scoring (method priors)
|
||||||
|
_METHOD_BASE_STRENGTH = {
|
||||||
|
"volume_profile": 12,
|
||||||
|
"pivot_point": 8,
|
||||||
|
"round_number": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||||
@@ -35,36 +69,235 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
|||||||
return ticker
|
return ticker
|
||||||
|
|
||||||
|
|
||||||
def _count_price_touches(
|
def _slice_tail(
|
||||||
|
highs: list[float],
|
||||||
|
lows: list[float],
|
||||||
|
closes: list[float],
|
||||||
|
volumes: list[int],
|
||||||
|
lookback: int,
|
||||||
|
) -> tuple[list[float], list[float], list[float], list[int]]:
|
||||||
|
"""Return the last *lookback* bars (or all if shorter)."""
|
||||||
|
n = len(closes)
|
||||||
|
if lookback <= 0 or n <= lookback:
|
||||||
|
return highs, lows, closes, volumes
|
||||||
|
start = n - lookback
|
||||||
|
return highs[start:], lows[start:], closes[start:], volumes[start:]
|
||||||
|
|
||||||
|
|
||||||
|
def _atr_pct(
|
||||||
|
highs: list[float],
|
||||||
|
lows: list[float],
|
||||||
|
closes: list[float],
|
||||||
|
) -> float | None:
|
||||||
|
"""ATR as a fraction of last close, or None if insufficient data."""
|
||||||
|
try:
|
||||||
|
result = compute_atr(highs, lows, closes)
|
||||||
|
except ValidationError:
|
||||||
|
return None
|
||||||
|
atr = result["atr"]
|
||||||
|
last = closes[-1]
|
||||||
|
if last == 0:
|
||||||
|
return None
|
||||||
|
return atr / last
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tolerance(
|
||||||
|
highs: list[float],
|
||||||
|
lows: list[float],
|
||||||
|
closes: list[float],
|
||||||
|
tolerance: float | None,
|
||||||
|
) -> float:
|
||||||
|
"""Resolve merge tolerance: explicit value or ATR-adaptive clamp."""
|
||||||
|
if tolerance is not None:
|
||||||
|
return tolerance
|
||||||
|
atr_frac = _atr_pct(highs, lows, closes)
|
||||||
|
if atr_frac is None:
|
||||||
|
return DEFAULT_TOLERANCE
|
||||||
|
return max(MERGE_TOL_MIN, min(MERGE_TOL_MAX, MERGE_TOL_ATR_MULT * atr_frac))
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_respect_weight(
|
||||||
|
price_level: float,
|
||||||
|
high: float,
|
||||||
|
low: float,
|
||||||
|
close: float,
|
||||||
|
prev_close: float | None,
|
||||||
|
tolerance: float,
|
||||||
|
) -> float:
|
||||||
|
"""Weight for how much a bar *respects* a level (not mere occupancy).
|
||||||
|
|
||||||
|
Only bars whose high/low **probes near the level** and closes away from
|
||||||
|
that extreme count as rejections. Full-range pass-throughs score near zero.
|
||||||
|
"""
|
||||||
|
tol = price_level * tolerance if price_level != 0 else tolerance
|
||||||
|
if tol <= 0:
|
||||||
|
tol = abs(price_level) * DEFAULT_TOLERANCE if price_level else DEFAULT_TOLERANCE
|
||||||
|
# Tight probe band: ~0.4% of price (capped), not 2× merge tolerance
|
||||||
|
band = min(max(abs(price_level) * 0.004, tol * 0.35), abs(price_level) * 0.008)
|
||||||
|
if band <= 0:
|
||||||
|
band = abs(price_level) * 0.004 if price_level else 0.01
|
||||||
|
|
||||||
|
if high + band < price_level or low - band > price_level:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
bar_range = high - low
|
||||||
|
# Support test: low probes near level, close recovers above
|
||||||
|
support_test = abs(low - price_level) <= band and close > price_level
|
||||||
|
if support_test and bar_range > 0:
|
||||||
|
support_test = (close - low) >= 0.25 * bar_range
|
||||||
|
# Resistance test: high probes near level, close rejects below
|
||||||
|
resist_test = abs(high - price_level) <= band and close < price_level
|
||||||
|
if resist_test and bar_range > 0:
|
||||||
|
resist_test = (high - close) >= 0.25 * bar_range
|
||||||
|
|
||||||
|
if support_test or resist_test:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# Clear directional pass-through — barely counts
|
||||||
|
if (
|
||||||
|
prev_close is not None
|
||||||
|
and (prev_close - price_level) * (close - price_level) < 0
|
||||||
|
and low < price_level - tol
|
||||||
|
and high > price_level + tol
|
||||||
|
):
|
||||||
|
return 0.1
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_to_strength(raw: float) -> int:
|
||||||
|
"""Map unbounded raw score to 0–100 with soft saturation (no hard pin)."""
|
||||||
|
if raw <= 0:
|
||||||
|
return 0
|
||||||
|
# 1 - e^(-raw/k): raw=k → ~63, 2k → ~86, 3k → ~95
|
||||||
|
return max(0, min(100, int(round(100.0 * (1.0 - math.exp(-raw / STRENGTH_SOFT_K))))))
|
||||||
|
|
||||||
|
|
||||||
|
def _respect_evidence(
|
||||||
price_level: float,
|
price_level: float,
|
||||||
highs: list[float],
|
highs: list[float],
|
||||||
lows: list[float],
|
lows: list[float],
|
||||||
closes: list[float],
|
closes: list[float],
|
||||||
tolerance: float = DEFAULT_TOLERANCE,
|
tolerance: float = DEFAULT_TOLERANCE,
|
||||||
) -> int:
|
base: int = 0,
|
||||||
"""Count how many bars touched/respected a price level within tolerance."""
|
half_life: float = STRENGTH_HALF_LIFE,
|
||||||
count = 0
|
lookback: int = TOUCH_LOOKBACK,
|
||||||
tol = price_level * tolerance if price_level != 0 else tolerance
|
cooldown: int = 3,
|
||||||
for i in range(len(closes)):
|
) -> dict[str, float | int | None]:
|
||||||
# A bar "touches" the level if the level is within the bar's range
|
"""Return rejection evidence and its soft-mapped strength.
|
||||||
# (within tolerance)
|
|
||||||
if lows[i] - tol <= price_level <= highs[i] + tol:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
*cooldown* bars after a full rejection are ignored so multi-day chop at a
|
||||||
def _strength_from_touches(touches: int, total_bars: int) -> int:
|
level counts as one test cluster, not N identical rejections.
|
||||||
"""Convert touch count to a 0-100 strength score.
|
|
||||||
|
|
||||||
More touches relative to total bars = higher strength.
|
|
||||||
Cap at 100.
|
|
||||||
"""
|
"""
|
||||||
if total_bars == 0:
|
n = len(closes)
|
||||||
return 0
|
if n == 0:
|
||||||
# Scale: each touch contributes proportionally, with a multiplier
|
return {
|
||||||
# so that a level touched ~20% of bars gets score ~100
|
"strength": _raw_to_strength(float(base)),
|
||||||
raw = (touches / total_bars) * 500.0
|
"rejection_count": 0,
|
||||||
return max(0, min(100, int(round(raw))))
|
"last_rejection_age": None,
|
||||||
|
"weighted_respects": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
start = max(0, n - lookback) if lookback > 0 else 0
|
||||||
|
weighted = 0.0
|
||||||
|
rejection_count = 0
|
||||||
|
last_rejection_age: int | None = None
|
||||||
|
next_ok = start
|
||||||
|
for i in range(start, n):
|
||||||
|
age = n - 1 - i
|
||||||
|
decay = 0.5 ** (age / half_life) if half_life > 0 else 1.0
|
||||||
|
prev = closes[i - 1] if i > 0 else None
|
||||||
|
w = _bar_respect_weight(
|
||||||
|
price_level, highs[i], lows[i], closes[i], prev, tolerance
|
||||||
|
)
|
||||||
|
if w >= 0.9:
|
||||||
|
if i < next_ok:
|
||||||
|
continue
|
||||||
|
weighted += decay * w
|
||||||
|
rejection_count += 1
|
||||||
|
if last_rejection_age is None or age < last_rejection_age:
|
||||||
|
last_rejection_age = age
|
||||||
|
next_ok = i + max(cooldown, 1)
|
||||||
|
elif w > 0:
|
||||||
|
weighted += decay * w
|
||||||
|
|
||||||
|
raw = float(base) + weighted * STRENGTH_SCALE
|
||||||
|
return {
|
||||||
|
"strength": _raw_to_strength(raw),
|
||||||
|
"rejection_count": rejection_count,
|
||||||
|
"last_rejection_age": last_rejection_age,
|
||||||
|
"weighted_respects": round(weighted, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _strength_from_respects(
|
||||||
|
price_level: float,
|
||||||
|
highs: list[float],
|
||||||
|
lows: list[float],
|
||||||
|
closes: list[float],
|
||||||
|
tolerance: float = DEFAULT_TOLERANCE,
|
||||||
|
base: int = 0,
|
||||||
|
half_life: float = STRENGTH_HALF_LIFE,
|
||||||
|
lookback: int = TOUCH_LOOKBACK,
|
||||||
|
cooldown: int = 3,
|
||||||
|
) -> int:
|
||||||
|
"""Compatibility wrapper returning only the evidence-derived strength."""
|
||||||
|
return int(_respect_evidence(
|
||||||
|
price_level,
|
||||||
|
highs,
|
||||||
|
lows,
|
||||||
|
closes,
|
||||||
|
tolerance,
|
||||||
|
base,
|
||||||
|
half_life,
|
||||||
|
lookback,
|
||||||
|
cooldown,
|
||||||
|
)["strength"])
|
||||||
|
|
||||||
|
|
||||||
|
def _round_number_candidates(
|
||||||
|
current_price: float,
|
||||||
|
range_pct: float = ROUND_NUMBER_RANGE,
|
||||||
|
max_count: int = ROUND_NUMBER_MAX,
|
||||||
|
) -> list[float]:
|
||||||
|
"""Psychological round levels near spot (cheap order-magnet candidates)."""
|
||||||
|
if current_price <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if current_price < 5:
|
||||||
|
steps = [0.5, 1.0]
|
||||||
|
elif current_price < 20:
|
||||||
|
steps = [1.0, 5.0]
|
||||||
|
elif current_price < 100:
|
||||||
|
steps = [5.0, 10.0, 25.0]
|
||||||
|
elif current_price < 500:
|
||||||
|
steps = [10.0, 25.0, 50.0, 100.0]
|
||||||
|
else:
|
||||||
|
steps = [25.0, 50.0, 100.0, 250.0]
|
||||||
|
|
||||||
|
lo = current_price * (1.0 - range_pct)
|
||||||
|
hi = current_price * (1.0 + range_pct)
|
||||||
|
found: set[float] = set()
|
||||||
|
|
||||||
|
for step in steps:
|
||||||
|
if step <= 0:
|
||||||
|
continue
|
||||||
|
# Start at first multiple at or below lo
|
||||||
|
k = math.floor(lo / step)
|
||||||
|
while True:
|
||||||
|
level = round(k * step, 4)
|
||||||
|
if level > hi + step:
|
||||||
|
break
|
||||||
|
if lo <= level <= hi and level > 0:
|
||||||
|
# Skip levels that are essentially current price
|
||||||
|
if abs(level - current_price) / current_price > 0.001:
|
||||||
|
found.add(level)
|
||||||
|
k += 1
|
||||||
|
if k > 1_000_000: # safety
|
||||||
|
break
|
||||||
|
|
||||||
|
ordered = sorted(found, key=lambda p: abs(p - current_price))
|
||||||
|
return ordered[:max_count]
|
||||||
|
|
||||||
|
|
||||||
def _extract_candidate_levels(
|
def _extract_candidate_levels(
|
||||||
@@ -73,55 +306,194 @@ def _extract_candidate_levels(
|
|||||||
closes: list[float],
|
closes: list[float],
|
||||||
volumes: list[int],
|
volumes: list[int],
|
||||||
) -> list[tuple[float, str]]:
|
) -> list[tuple[float, str]]:
|
||||||
"""Extract candidate S/R levels from Volume Profile and Pivot Points.
|
"""Extract candidate S/R levels from VP nodes, prominent pivots, rounds.
|
||||||
|
|
||||||
Returns list of (price_level, detection_method) tuples.
|
Returns list of (price_level, detection_method) tuples.
|
||||||
"""
|
"""
|
||||||
candidates: list[tuple[float, str]] = []
|
candidates: list[tuple[float, str]] = []
|
||||||
|
if not closes:
|
||||||
|
return candidates
|
||||||
|
|
||||||
# Volume Profile: HVN and LVN as candidate levels
|
current_price = closes[-1]
|
||||||
|
|
||||||
|
# --- Volume profile on recent window ---
|
||||||
|
vp_h, vp_l, vp_c, vp_v = _slice_tail(
|
||||||
|
highs, lows, closes, volumes, VP_LOOKBACK
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
vp = compute_volume_profile(highs, lows, closes, volumes)
|
vp = compute_volume_profile(vp_h, vp_l, vp_c, vp_v)
|
||||||
|
# Structural VP levels: POC, value-area edges, local HVN peaks.
|
||||||
|
# LVN intentionally omitted (rejection voids ≠ support/resistance lines).
|
||||||
|
for key in ("poc", "value_area_low", "value_area_high"):
|
||||||
|
price = vp.get(key)
|
||||||
|
if price is not None and price > 0:
|
||||||
|
candidates.append((float(price), "volume_profile"))
|
||||||
for price in vp.get("hvn", []):
|
for price in vp.get("hvn", []):
|
||||||
candidates.append((price, "volume_profile"))
|
candidates.append((float(price), "volume_profile"))
|
||||||
for price in vp.get("lvn", []):
|
|
||||||
candidates.append((price, "volume_profile"))
|
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
pass # Not enough data for volume profile
|
pass
|
||||||
|
|
||||||
|
# --- Prominent pivots on pivot lookback ---
|
||||||
|
p_h, p_l, p_c, _ = _slice_tail(highs, lows, closes, volumes, PIVOT_LOOKBACK)
|
||||||
|
atr_frac = _atr_pct(p_h, p_l, p_c)
|
||||||
|
last = p_c[-1] if p_c else current_price
|
||||||
|
if atr_frac is not None and last > 0:
|
||||||
|
prominence = max(PIVOT_PROMINENCE_ATR * atr_frac * last, PIVOT_PROMINENCE_PCT * last)
|
||||||
|
else:
|
||||||
|
prominence = PIVOT_PROMINENCE_PCT * last if last > 0 else None
|
||||||
|
|
||||||
# Pivot Points: swing highs and lows
|
|
||||||
try:
|
try:
|
||||||
pp = compute_pivot_points(highs, lows, closes)
|
pp = compute_pivot_points(p_h, p_l, p_c, min_prominence=prominence)
|
||||||
for price in pp.get("swing_highs", []):
|
for price in pp.get("swing_highs", []):
|
||||||
candidates.append((price, "pivot_point"))
|
candidates.append((float(price), "pivot_point"))
|
||||||
for price in pp.get("swing_lows", []):
|
for price in pp.get("swing_lows", []):
|
||||||
candidates.append((price, "pivot_point"))
|
candidates.append((float(price), "pivot_point"))
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
pass # Not enough data for pivot points
|
pass
|
||||||
|
|
||||||
|
# --- Psychological round numbers near spot ---
|
||||||
|
for price in _round_number_candidates(current_price):
|
||||||
|
candidates.append((price, "round_number"))
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_volume_profile_nodes(
|
||||||
|
highs: list[float],
|
||||||
|
lows: list[float],
|
||||||
|
closes: list[float],
|
||||||
|
volumes: list[int],
|
||||||
|
num_bins: int = 20,
|
||||||
|
) -> list[float]:
|
||||||
|
"""Reproduce the deployed pre-rewrite HVN/LVN grid for a control arm.
|
||||||
|
|
||||||
|
This intentionally retains the old span-volume double counting. It exists
|
||||||
|
only so a local research report can prove that its control still reproduces
|
||||||
|
the frozen production baseline while the live detector is being rewritten.
|
||||||
|
"""
|
||||||
|
if len(closes) < 20:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Volume Profile requires at least 20 bars, got {len(closes)}"
|
||||||
|
)
|
||||||
|
price_min = min(lows)
|
||||||
|
price_max = max(highs)
|
||||||
|
if price_max == price_min:
|
||||||
|
price_max = price_min + 1.0
|
||||||
|
bin_width = (price_max - price_min) / num_bins
|
||||||
|
bins: list[float] = [0.0] * num_bins
|
||||||
|
prices = [price_min + (i + 0.5) * bin_width for i in range(num_bins)]
|
||||||
|
for i in range(len(closes)):
|
||||||
|
for b in range(num_bins):
|
||||||
|
low_edge = price_min + b * bin_width
|
||||||
|
high_edge = low_edge + bin_width
|
||||||
|
if highs[i] >= low_edge and lows[i] <= high_edge:
|
||||||
|
bins[b] += volumes[i]
|
||||||
|
average = sum(bins) / num_bins
|
||||||
|
hvn = [round(prices[i], 4) for i in range(num_bins) if bins[i] > average]
|
||||||
|
lvn = [round(prices[i], 4) for i in range(num_bins) if bins[i] < average]
|
||||||
|
return hvn + lvn
|
||||||
|
|
||||||
|
|
||||||
|
def detect_sr_levels_legacy(
|
||||||
|
highs: list[float],
|
||||||
|
lows: list[float],
|
||||||
|
closes: list[float],
|
||||||
|
volumes: list[int],
|
||||||
|
tolerance: float = DEFAULT_TOLERANCE,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Exact research control for the deployed pre-rewrite detector."""
|
||||||
|
if not closes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates: list[tuple[float, str]] = []
|
||||||
|
try:
|
||||||
|
for price in _legacy_volume_profile_nodes(highs, lows, closes, volumes):
|
||||||
|
candidates.append((float(price), "volume_profile"))
|
||||||
|
except ValidationError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
pivots = compute_pivot_points(highs, lows, closes)
|
||||||
|
candidates.extend(
|
||||||
|
(float(price), "pivot_point")
|
||||||
|
for price in pivots.get("swing_highs", []) + pivots.get("swing_lows", [])
|
||||||
|
)
|
||||||
|
except ValidationError:
|
||||||
|
pass
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
total_bars = len(closes)
|
||||||
|
raw: list[dict] = []
|
||||||
|
for price, method in candidates:
|
||||||
|
tol = price * tolerance if price != 0 else tolerance
|
||||||
|
touches = sum(
|
||||||
|
1 for low, high in zip(lows, highs, strict=False)
|
||||||
|
if low - tol <= price <= high + tol
|
||||||
|
)
|
||||||
|
strength = max(0, min(100, int(round((touches / total_bars) * 500.0))))
|
||||||
|
raw.append({
|
||||||
|
"price_level": price,
|
||||||
|
"strength": strength,
|
||||||
|
"detection_method": method,
|
||||||
|
"type": "",
|
||||||
|
"sources": [method],
|
||||||
|
"rejection_count": touches,
|
||||||
|
"last_rejection_age": None,
|
||||||
|
"weighted_respects": float(touches),
|
||||||
|
})
|
||||||
|
|
||||||
|
merged: list[dict] = []
|
||||||
|
for level in sorted(raw, key=lambda row: row["price_level"]):
|
||||||
|
if not merged:
|
||||||
|
merged.append(dict(level))
|
||||||
|
continue
|
||||||
|
last = merged[-1]
|
||||||
|
ref = last["price_level"]
|
||||||
|
tol = ref * tolerance if ref != 0 else tolerance
|
||||||
|
if abs(level["price_level"] - ref) > tol:
|
||||||
|
merged.append(dict(level))
|
||||||
|
continue
|
||||||
|
last["price_level"] = round(
|
||||||
|
(last["price_level"] + level["price_level"]) / 2.0, 4
|
||||||
|
)
|
||||||
|
last["strength"] = min(100, last["strength"] + level["strength"])
|
||||||
|
sources = set(last.get("sources") or [last["detection_method"]])
|
||||||
|
sources |= set(level.get("sources") or [level["detection_method"]])
|
||||||
|
last["sources"] = sorted(sources)
|
||||||
|
last["detection_method"] = (
|
||||||
|
next(iter(sources)) if len(sources) == 1 else "merged"
|
||||||
|
)
|
||||||
|
last["rejection_count"] = max(
|
||||||
|
int(last.get("rejection_count", 0)),
|
||||||
|
int(level.get("rejection_count", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
_tag_levels(merged, closes[-1])
|
||||||
|
merged.sort(key=lambda row: row["strength"], reverse=True)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _merge_levels(
|
def _merge_levels(
|
||||||
levels: list[dict],
|
levels: list[dict],
|
||||||
tolerance: float = DEFAULT_TOLERANCE,
|
tolerance: float = DEFAULT_TOLERANCE,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Merge levels within tolerance into consolidated levels.
|
"""Merge levels within tolerance into consolidated levels.
|
||||||
|
|
||||||
Levels from different methods within tolerance are merged.
|
Strength combines via max + partial min (avoids instant saturation) with
|
||||||
Merged levels combine strength scores (capped at 100) and get
|
a confluence bonus when detection methods differ. Price is strength-weighted.
|
||||||
detection_method = "merged".
|
|
||||||
"""
|
"""
|
||||||
if not levels:
|
if not levels:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Sort by price
|
|
||||||
sorted_levels = sorted(levels, key=lambda x: x["price_level"])
|
sorted_levels = sorted(levels, key=lambda x: x["price_level"])
|
||||||
merged: list[dict] = []
|
merged: list[dict] = []
|
||||||
|
|
||||||
for level in sorted_levels:
|
for level in sorted_levels:
|
||||||
if not merged:
|
if not merged:
|
||||||
merged.append(dict(level))
|
entry = dict(level)
|
||||||
|
sources = level.get("sources") or [level["detection_method"]]
|
||||||
|
entry["sources"] = sorted(set(sources))
|
||||||
|
merged.append(entry)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
last = merged[-1]
|
last = merged[-1]
|
||||||
@@ -129,19 +501,52 @@ def _merge_levels(
|
|||||||
tol = ref_price * tolerance if ref_price != 0 else tolerance
|
tol = ref_price * tolerance if ref_price != 0 else tolerance
|
||||||
|
|
||||||
if abs(level["price_level"] - ref_price) <= tol:
|
if abs(level["price_level"] - ref_price) <= tol:
|
||||||
# Merge: average price, combine strength, mark as merged
|
s1 = last["strength"]
|
||||||
combined_strength = min(100, last["strength"] + level["strength"])
|
s2 = level["strength"]
|
||||||
avg_price = (last["price_level"] + level["price_level"]) / 2.0
|
# Soft combine — avoid merge math pinning everything at 100
|
||||||
method = (
|
combined = int(round(0.85 * max(s1, s2) + 0.15 * min(s1, s2)))
|
||||||
"merged"
|
sources = set(last.get("sources") or [last["detection_method"]])
|
||||||
if last["detection_method"] != level["detection_method"]
|
sources |= set(level.get("sources") or [level["detection_method"]])
|
||||||
else last["detection_method"]
|
if len(sources) > 1:
|
||||||
)
|
combined = min(100, combined + 5)
|
||||||
last["price_level"] = round(avg_price, 4)
|
|
||||||
last["strength"] = combined_strength
|
|
||||||
last["detection_method"] = method
|
|
||||||
else:
|
else:
|
||||||
merged.append(dict(level))
|
combined = min(100, combined)
|
||||||
|
|
||||||
|
w1, w2 = max(s1, 1), max(s2, 1)
|
||||||
|
avg_price = (last["price_level"] * w1 + level["price_level"] * w2) / (w1 + w2)
|
||||||
|
|
||||||
|
if len(sources) == 1:
|
||||||
|
method = next(iter(sources))
|
||||||
|
else:
|
||||||
|
method = "merged"
|
||||||
|
|
||||||
|
last["price_level"] = round(avg_price, 4)
|
||||||
|
last["strength"] = combined
|
||||||
|
last["detection_method"] = method
|
||||||
|
last["sources"] = sorted(sources)
|
||||||
|
# Nearby candidates often describe the same price reaction, so do
|
||||||
|
# not add their rejection counts and double-count one market event.
|
||||||
|
last["rejection_count"] = max(
|
||||||
|
int(last.get("rejection_count", 0)),
|
||||||
|
int(level.get("rejection_count", 0)),
|
||||||
|
)
|
||||||
|
ages = [
|
||||||
|
age for age in (
|
||||||
|
last.get("last_rejection_age"),
|
||||||
|
level.get("last_rejection_age"),
|
||||||
|
)
|
||||||
|
if age is not None
|
||||||
|
]
|
||||||
|
last["last_rejection_age"] = min(ages) if ages else None
|
||||||
|
last["weighted_respects"] = max(
|
||||||
|
float(last.get("weighted_respects", 0.0)),
|
||||||
|
float(level.get("weighted_respects", 0.0)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entry = dict(level)
|
||||||
|
sources = level.get("sources") or [level["detection_method"]]
|
||||||
|
entry["sources"] = sorted(set(sources))
|
||||||
|
merged.append(entry)
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
@@ -159,14 +564,66 @@ def _tag_levels(
|
|||||||
return levels
|
return levels
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_levels(
|
||||||
|
levels: list[dict],
|
||||||
|
max_levels: int = MAX_LEVELS,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Keep up to *max_levels* levels, interleaving support/resistance by strength."""
|
||||||
|
if max_levels <= 0 or len(levels) <= max_levels:
|
||||||
|
return levels
|
||||||
|
|
||||||
|
support = sorted(
|
||||||
|
[lvl for lvl in levels if lvl.get("type") == "support"],
|
||||||
|
key=lambda x: x["strength"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
resistance = sorted(
|
||||||
|
[lvl for lvl in levels if lvl.get("type") != "support"],
|
||||||
|
key=lambda x: x["strength"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
selected: list[dict] = []
|
||||||
|
si, ri = 0, 0
|
||||||
|
pick_support = True
|
||||||
|
while len(selected) < max_levels and (si < len(support) or ri < len(resistance)):
|
||||||
|
if pick_support:
|
||||||
|
if si < len(support):
|
||||||
|
selected.append(support[si])
|
||||||
|
si += 1
|
||||||
|
elif ri < len(resistance):
|
||||||
|
selected.append(resistance[ri])
|
||||||
|
ri += 1
|
||||||
|
else:
|
||||||
|
if ri < len(resistance):
|
||||||
|
selected.append(resistance[ri])
|
||||||
|
ri += 1
|
||||||
|
elif si < len(support):
|
||||||
|
selected.append(support[si])
|
||||||
|
si += 1
|
||||||
|
pick_support = not pick_support
|
||||||
|
|
||||||
|
selected.sort(key=lambda x: x["strength"], reverse=True)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def detect_sr_levels(
|
def detect_sr_levels(
|
||||||
highs: list[float],
|
highs: list[float],
|
||||||
lows: list[float],
|
lows: list[float],
|
||||||
closes: list[float],
|
closes: list[float],
|
||||||
volumes: list[int],
|
volumes: list[int],
|
||||||
tolerance: float = DEFAULT_TOLERANCE,
|
tolerance: float | None = None,
|
||||||
|
max_levels: int = MAX_LEVELS,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Detect, score, merge, and tag S/R levels from OHLCV data.
|
"""Detect, score, merge, tag, and cap S/R levels from OHLCV data.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tolerance:
|
||||||
|
Relative merge tolerance. ``None`` (default) uses ATR-adaptive
|
||||||
|
tolerance clamped to [0.4%, 1.5%]. Pass an explicit fraction to override.
|
||||||
|
max_levels:
|
||||||
|
Hard cap after merge (balanced support/resistance). 0 = no cap.
|
||||||
|
|
||||||
Returns list of dicts with keys: price_level, type, strength,
|
Returns list of dicts with keys: price_level, type, strength,
|
||||||
detection_method — sorted by strength descending.
|
detection_method — sorted by strength descending.
|
||||||
@@ -178,37 +635,42 @@ def detect_sr_levels(
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
total_bars = len(closes)
|
|
||||||
current_price = closes[-1]
|
current_price = closes[-1]
|
||||||
|
merge_tol = _merge_tolerance(highs, lows, closes, tolerance)
|
||||||
|
# Touch tolerance for strength: use merge tol (same price scale)
|
||||||
|
touch_tol = merge_tol
|
||||||
|
|
||||||
# Build level dicts with strength scores
|
# Score each candidate on recent rejection-weighted touches
|
||||||
raw_levels: list[dict] = []
|
raw_levels: list[dict] = []
|
||||||
for price, method in candidates:
|
for price, method in candidates:
|
||||||
touches = _count_price_touches(price, highs, lows, closes, tolerance)
|
base = _METHOD_BASE_STRENGTH.get(method, 0)
|
||||||
strength = _strength_from_touches(touches, total_bars)
|
evidence = _respect_evidence(
|
||||||
|
price, highs, lows, closes, touch_tol, base=base
|
||||||
|
)
|
||||||
raw_levels.append({
|
raw_levels.append({
|
||||||
"price_level": price,
|
"price_level": price,
|
||||||
"strength": strength,
|
"strength": int(evidence["strength"]),
|
||||||
"detection_method": method,
|
"detection_method": method,
|
||||||
"type": "", # will be tagged after merge
|
"type": "",
|
||||||
|
"sources": [method],
|
||||||
|
"rejection_count": int(evidence["rejection_count"]),
|
||||||
|
"last_rejection_age": evidence["last_rejection_age"],
|
||||||
|
"weighted_respects": float(evidence["weighted_respects"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Merge nearby levels
|
merged = _merge_levels(raw_levels, merge_tol)
|
||||||
merged = _merge_levels(raw_levels, tolerance)
|
|
||||||
|
|
||||||
# Tag as support/resistance
|
|
||||||
tagged = _tag_levels(merged, current_price)
|
tagged = _tag_levels(merged, current_price)
|
||||||
|
capped = _cap_levels(tagged, max_levels=max_levels)
|
||||||
|
capped.sort(key=lambda x: x["strength"], reverse=True)
|
||||||
|
return capped
|
||||||
|
|
||||||
# Sort by strength descending
|
|
||||||
tagged.sort(key=lambda x: x["strength"], reverse=True)
|
|
||||||
|
|
||||||
return tagged
|
|
||||||
|
|
||||||
def cluster_sr_zones(
|
def cluster_sr_zones(
|
||||||
levels: list[dict],
|
levels: list[dict],
|
||||||
current_price: float,
|
current_price: float,
|
||||||
tolerance: float = 0.02,
|
tolerance: float = 0.02,
|
||||||
max_zones: int | None = None,
|
max_zones: int | None = None,
|
||||||
|
strength_mode: str = "sum",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Cluster nearby S/R levels into zones.
|
"""Cluster nearby S/R levels into zones.
|
||||||
|
|
||||||
@@ -263,8 +725,26 @@ def cluster_sr_zones(
|
|||||||
low = min(prices)
|
low = min(prices)
|
||||||
high = max(prices)
|
high = max(prices)
|
||||||
midpoint = (low + high) / 2.0
|
midpoint = (low + high) / 2.0
|
||||||
strength = min(100, sum(lvl["strength"] for lvl in cluster))
|
if strength_mode == "soft":
|
||||||
|
strongest = max(int(lvl["strength"]) for lvl in cluster)
|
||||||
|
all_sources = {
|
||||||
|
source
|
||||||
|
for lvl in cluster
|
||||||
|
for source in (lvl.get("sources") or [lvl.get("detection_method", "unknown")])
|
||||||
|
}
|
||||||
|
strength = min(100, strongest + (5 if len(all_sources) > 1 else 0))
|
||||||
|
elif strength_mode == "sum":
|
||||||
|
strength = min(100, sum(int(lvl["strength"]) for lvl in cluster))
|
||||||
|
all_sources = {
|
||||||
|
source
|
||||||
|
for lvl in cluster
|
||||||
|
for source in (lvl.get("sources") or [lvl.get("detection_method", "unknown")])
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported S/R zone strength mode: {strength_mode}")
|
||||||
level_count = len(cluster)
|
level_count = len(cluster)
|
||||||
|
rejection_count = max(int(lvl.get("rejection_count", 0)) for lvl in cluster)
|
||||||
|
ages = [lvl.get("last_rejection_age") for lvl in cluster if lvl.get("last_rejection_age") is not None]
|
||||||
|
|
||||||
# 4. Tag zone type
|
# 4. Tag zone type
|
||||||
zone_type = "support" if midpoint < current_price else "resistance"
|
zone_type = "support" if midpoint < current_price else "resistance"
|
||||||
@@ -276,6 +756,9 @@ def cluster_sr_zones(
|
|||||||
"strength": strength,
|
"strength": strength,
|
||||||
"type": zone_type,
|
"type": zone_type,
|
||||||
"level_count": level_count,
|
"level_count": level_count,
|
||||||
|
"sources": sorted(all_sources),
|
||||||
|
"rejection_count": rejection_count,
|
||||||
|
"last_rejection_age": min(ages) if ages else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 5. Split into support and resistance pools, each sorted by strength desc
|
# 5. Split into support and resistance pools, each sorted by strength desc
|
||||||
@@ -319,11 +802,10 @@ def cluster_sr_zones(
|
|||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def recalculate_sr_levels(
|
async def recalculate_sr_levels(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
tolerance: float = DEFAULT_TOLERANCE,
|
tolerance: float | None = None,
|
||||||
) -> list[SRLevel]:
|
) -> list[SRLevel]:
|
||||||
"""Recalculate S/R levels for a ticker and persist to DB.
|
"""Recalculate S/R levels for a ticker and persist to DB.
|
||||||
|
|
||||||
@@ -380,7 +862,7 @@ async def recalculate_sr_levels(
|
|||||||
async def get_sr_levels(
|
async def get_sr_levels(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
tolerance: float = DEFAULT_TOLERANCE,
|
tolerance: float | None = None,
|
||||||
) -> list[SRLevel]:
|
) -> list[SRLevel]:
|
||||||
"""Get S/R levels for a ticker, recalculating on every request (MVP).
|
"""Get S/R levels for a ticker, recalculating on every request (MVP).
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,24 @@ net-positive is the open question, tracked below.
|
|||||||
|
|
||||||
## 1. How the levels are built today
|
## 1. How the levels are built today
|
||||||
|
|
||||||
`app/services/sr_service.py::detect_sr_levels`, over **all stored history**
|
> **Update (2026-07-12 detector rewrite):** several gaps below were addressed in
|
||||||
(`query_ohlcv` with no date range — 5 years / ~1260 daily bars per ticker):
|
> `sr_service` / `indicator_service` — close-bin VP, local-peak HVN, POC/VAH/VAL
|
||||||
|
> as candidates, LVN dropped from S/R, pivot prominence + lookbacks, rejection-
|
||||||
|
> weighted recency strength, ATR-adaptive merge, hard cap, round numbers. The
|
||||||
|
> table documents the *pre-rewrite* failure modes measured on the snapshot; keep
|
||||||
|
> it for historical context. Re-measure density on prod after deploy if gate
|
||||||
|
> rates shift.
|
||||||
|
|
||||||
1. Candidates = volume-profile **HVN and LVN** bins + **pivot** swing highs/lows.
|
`app/services/sr_service.py::detect_sr_levels` (post-rewrite):
|
||||||
2. Strength = share of bars that "touched" the level, scaled so ~20% of bars → 100.
|
|
||||||
3. Nearby levels merged within 0.5%; tagged `support` if below spot, else `resistance`.
|
|
||||||
|
|
||||||
### Where that departs from best practice
|
1. Candidates = VP **POC / VAH / VAL / local HVN peaks** (lookback 252) +
|
||||||
|
**prominent** swing pivots (lookback 504) + nearby **round numbers**.
|
||||||
|
2. Strength = rejection-weighted touches on last 252 bars with recency decay
|
||||||
|
(pass-throughs down-weighted); method base + confluence on merge.
|
||||||
|
3. Nearby levels merged with **ATR-adaptive** tolerance (clamped ~0.4–1.5%);
|
||||||
|
capped (~16, interleaved S/R); tagged `support` if below spot, else `resistance`.
|
||||||
|
|
||||||
|
### Where the pre-rewrite detector departed from best practice
|
||||||
|
|
||||||
Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63):
|
Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63):
|
||||||
|
|
||||||
@@ -35,8 +45,8 @@ Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63):
|
|||||||
| **Round-number levels absent** — the mechanism with the best empirical support ([Osler 2000](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=888805)). | not implemented |
|
| **Round-number levels absent** — the mechanism with the best empirical support ([Osler 2000](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=888805)). | not implemented |
|
||||||
|
|
||||||
Not a defect: the pivot `window=2` is the standard 5-bar Williams fractal. What it
|
Not a defect: the pivot `window=2` is the standard 5-bar Williams fractal. What it
|
||||||
lacks is a **prominence filter** — AAPL yields 338 pivots over 1261 bars, one every
|
lacked pre-rewrite is a **prominence filter** — AAPL yielded 338 pivots over 1261
|
||||||
~3.7 bars.
|
bars, one every ~3.7 bars.
|
||||||
|
|
||||||
### The structural problem: resistance famine
|
### The structural problem: resistance famine
|
||||||
|
|
||||||
@@ -383,6 +393,35 @@ Note `--allow-spawn` is required on Windows: `_mp_context()` has no `fork`/
|
|||||||
large, consistent across five nested windows — and still didn't survive a holdout.
|
large, consistent across five nested windows — and still didn't survive a holdout.
|
||||||
Nested lookbacks are not out-of-sample. Split by entry date before believing anything.
|
Nested lookbacks are not out-of-sample. Split by entry date before believing anything.
|
||||||
|
|
||||||
|
## 7. S/R v2 research harness (implementation started 2026-07-12)
|
||||||
|
|
||||||
|
The detector rewrite is decomposed into causal, research-only arms. The live
|
||||||
|
scanner does not read `BACKTEST_SR_VARIANT`; these switches exist only in the
|
||||||
|
offline snapshot harness:
|
||||||
|
|
||||||
|
| arm | behavior |
|
||||||
|
|---|---|
|
||||||
|
| `production_control` | deployed detector plus legacy 1.5 primary selection |
|
||||||
|
| `rr_aligned_control` | deployed detector; primary selection uses activation `min_rr` |
|
||||||
|
| `rewrite` | rewritten detector with activation-aligned primary selection |
|
||||||
|
| `soft_zones` | rewrite plus max-strength/confluence zone aggregation |
|
||||||
|
| `confirmed_rounds` | soft zones; standalone rounds need two rejection clusters |
|
||||||
|
| `gate_v2` | confirmed rounds plus uncapped gate evidence |
|
||||||
|
|
||||||
|
Detector evidence (`sources`, rejection count, last rejection age) stays in the
|
||||||
|
pure backtest objects. It is deliberately not migrated into the production DB
|
||||||
|
schema until a variant passes validation.
|
||||||
|
|
||||||
|
Run the training matrix with `--entry-end 2024-06-30 --sr-audit`, choose one arm,
|
||||||
|
and record that lock before running exactly control and the locked arm with
|
||||||
|
`--entry-start 2024-07-01`. Use `scripts/compare_sr_variants.py` to produce the
|
||||||
|
paired cohort CSV and summary JSON.
|
||||||
|
|
||||||
|
The post-2024 interval has informed earlier research, so this is validation rather
|
||||||
|
than a pristine holdout; do not sweep variants on it. No deployment follows
|
||||||
|
automatically. A lower validation Sharpe or higher drawdown remains a no-ship
|
||||||
|
result even when CAGR rises.
|
||||||
|
|
||||||
**Next runs, if picked back up:**
|
**Next runs, if picked back up:**
|
||||||
|
|
||||||
- A **per-name target model** for clear-air setups instead of a constant k×ATR. This
|
- A **per-name target model** for clear-air setups instead of a constant k×ATR. This
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Compare two audited local S/R backtest reports by setup identity.
|
||||||
|
|
||||||
|
Reports must be generated with ``--sr-audit``. The comparison is read-only
|
||||||
|
apart from its explicit CSV/JSON outputs under the caller-selected paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("control")
|
||||||
|
parser.add_argument("variant")
|
||||||
|
parser.add_argument("--out-csv", required=True)
|
||||||
|
parser.add_argument("--out-json", required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: str) -> dict:
|
||||||
|
with Path(path).open(encoding="utf-8") as handle:
|
||||||
|
report = json.load(handle)
|
||||||
|
if report.get("sr_candidate_audit") is None:
|
||||||
|
raise SystemExit(f"Report lacks sr_candidate_audit; rerun with --sr-audit: {path}")
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _key(row: dict) -> tuple[str, str, str]:
|
||||||
|
return row["symbol"], row["date"], row["direction"]
|
||||||
|
|
||||||
|
|
||||||
|
def _cohort_stats(rows: list[dict]) -> dict:
|
||||||
|
net = [float(row.get("net_r", 0.0)) for row in rows]
|
||||||
|
hold = [float(row.get("hold30_r", 0.0)) for row in rows]
|
||||||
|
trimmed = sorted(net, reverse=True)[math.ceil(len(net) * 0.05):]
|
||||||
|
return {
|
||||||
|
"count": len(rows),
|
||||||
|
"net_avg_r": round(sum(net) / len(net), 4) if net else None,
|
||||||
|
"net_avg_r_ex_top5": round(sum(trimmed) / len(trimmed), 4) if trimmed else None,
|
||||||
|
"hold30_avg_r": round(sum(hold) / len(hold), 4) if hold else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _production_book(report: dict) -> dict | None:
|
||||||
|
runs = ((report.get("portfolio_monitor") or {}).get("runs") or [])
|
||||||
|
row = next(
|
||||||
|
(
|
||||||
|
run for run in runs
|
||||||
|
if run.get("is_production") and run.get("lookback") == "all"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
key: row.get(key)
|
||||||
|
for key in ("sharpe", "cagr_pct", "max_drawdown_pct", "trades", "skipped_book_full")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = _args()
|
||||||
|
control = _load(args.control)
|
||||||
|
variant = _load(args.variant)
|
||||||
|
control_rows = {_key(row): row for row in control["sr_candidate_audit"]}
|
||||||
|
variant_rows = {_key(row): row for row in variant["sr_candidate_audit"]}
|
||||||
|
control_q = {key for key, row in control_rows.items() if row.get("qualified")}
|
||||||
|
variant_q = {key for key, row in variant_rows.items() if row.get("qualified")}
|
||||||
|
|
||||||
|
retained = control_q & variant_q
|
||||||
|
added = variant_q - control_q
|
||||||
|
removed = control_q - variant_q
|
||||||
|
union = sorted(control_q | variant_q, key=lambda key: (key[1], key[0], key[2]))
|
||||||
|
|
||||||
|
csv_path = Path(args.out_csv)
|
||||||
|
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fields = [
|
||||||
|
"symbol", "date", "direction", "cohort",
|
||||||
|
"control_rr", "variant_rr", "control_prob", "variant_prob",
|
||||||
|
"control_sources", "variant_sources", "control_net_r", "variant_net_r",
|
||||||
|
"control_hold30_r", "variant_hold30_r",
|
||||||
|
]
|
||||||
|
with csv_path.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=fields)
|
||||||
|
writer.writeheader()
|
||||||
|
for key in union:
|
||||||
|
c = control_rows.get(key) or {}
|
||||||
|
v = variant_rows.get(key) or {}
|
||||||
|
cohort = "retained" if key in retained else "added" if key in added else "removed"
|
||||||
|
writer.writerow({
|
||||||
|
"symbol": key[0], "date": key[1], "direction": key[2], "cohort": cohort,
|
||||||
|
"control_rr": c.get("rr"), "variant_rr": v.get("rr"),
|
||||||
|
"control_prob": c.get("primary_prob"), "variant_prob": v.get("primary_prob"),
|
||||||
|
"control_sources": "+".join(c.get("primary_sources") or []),
|
||||||
|
"variant_sources": "+".join(v.get("primary_sources") or []),
|
||||||
|
"control_net_r": c.get("net_r"), "variant_net_r": v.get("net_r"),
|
||||||
|
"control_hold30_r": c.get("hold30_r"), "variant_hold30_r": v.get("hold30_r"),
|
||||||
|
})
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"control_report": str(Path(args.control)),
|
||||||
|
"variant_report": str(Path(args.variant)),
|
||||||
|
"control_variant": (control.get("params") or {}).get("sr_variant"),
|
||||||
|
"variant": (variant.get("params") or {}).get("sr_variant"),
|
||||||
|
"retained": _cohort_stats([variant_rows[key] for key in retained]),
|
||||||
|
"added": _cohort_stats([variant_rows[key] for key in added]),
|
||||||
|
"removed": _cohort_stats([control_rows[key] for key in removed]),
|
||||||
|
"control_book": _production_book(control),
|
||||||
|
"variant_book": _production_book(variant),
|
||||||
|
}
|
||||||
|
json_path = Path(args.out_json)
|
||||||
|
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with json_path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(summary, handle, indent=2)
|
||||||
|
handle.write("\n")
|
||||||
|
print(json.dumps(summary, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -46,6 +46,30 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
help="Allow spawn multiprocessing for offline CLI runs, useful on Windows.",
|
help="Allow spawn multiprocessing for offline CLI runs, useful on Windows.",
|
||||||
)
|
)
|
||||||
parser.add_argument("--quiet", action="store_true", help="Hide progress output.")
|
parser.add_argument("--quiet", action="store_true", help="Hide progress output.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--sr-variant",
|
||||||
|
choices=(
|
||||||
|
"production_control", "rr_aligned_control", "rewrite",
|
||||||
|
"soft_zones", "confirmed_rounds", "gate_v2",
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
help="Research-only S/R detector/gate arm.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--entry-start",
|
||||||
|
default=None,
|
||||||
|
help="Include entries on/after YYYY-MM-DD.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--entry-end",
|
||||||
|
default=None,
|
||||||
|
help="Include entries on/before YYYY-MM-DD.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sr-audit",
|
||||||
|
action="store_true",
|
||||||
|
help="Include candidate-level S/R audit rows for paired comparison.",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -138,6 +162,14 @@ async def _main() -> None:
|
|||||||
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||||
if args.allow_spawn:
|
if args.allow_spawn:
|
||||||
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||||
|
if args.sr_variant:
|
||||||
|
os.environ["BACKTEST_SR_VARIANT"] = args.sr_variant
|
||||||
|
if args.entry_start:
|
||||||
|
os.environ["BACKTEST_ENTRY_START"] = args.entry_start
|
||||||
|
if args.entry_end:
|
||||||
|
os.environ["BACKTEST_ENTRY_END"] = args.entry_end
|
||||||
|
if args.sr_audit:
|
||||||
|
os.environ["BACKTEST_SR_AUDIT"] = "1"
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.services.backtest_service import run_backtest
|
from app.services.backtest_service import run_backtest
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
param(
|
||||||
|
[string]$Snapshot = "backtest_snapshots\prod.sqlite",
|
||||||
|
[string]$Python = ".venv\Scripts\python.exe",
|
||||||
|
[int]$Workers = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$arms = @(
|
||||||
|
"production_control",
|
||||||
|
"rr_aligned_control",
|
||||||
|
"rewrite",
|
||||||
|
"soft_zones",
|
||||||
|
"confirmed_rounds",
|
||||||
|
"gate_v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($arm in $arms) {
|
||||||
|
$output = "reports\backtest-sr-v2-train-$arm.json"
|
||||||
|
Write-Host "Running S/R training arm: $arm"
|
||||||
|
& $Python scripts\run_backtest_snapshot.py $Snapshot `
|
||||||
|
--workers $Workers `
|
||||||
|
--allow-spawn `
|
||||||
|
--sr-variant $arm `
|
||||||
|
--entry-end 2024-06-30 `
|
||||||
|
--sr-audit `
|
||||||
|
--out $output
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "S/R training arm failed: $arm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Training matrix complete. Lock one arm before running validation."
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet("rr_aligned_control", "rewrite", "soft_zones", "confirmed_rounds", "gate_v2")]
|
||||||
|
[string]$LockedArm,
|
||||||
|
[string]$Snapshot = "backtest_snapshots\prod.sqlite",
|
||||||
|
[string]$Python = ".venv\Scripts\python.exe",
|
||||||
|
[int]$Workers = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$arms = @("production_control", $LockedArm)
|
||||||
|
foreach ($arm in $arms) {
|
||||||
|
$output = "reports\backtest-sr-v2-validation-$arm.json"
|
||||||
|
Write-Host "Running locked S/R validation arm: $arm"
|
||||||
|
& $Python scripts\run_backtest_snapshot.py $Snapshot `
|
||||||
|
--workers $Workers `
|
||||||
|
--allow-spawn `
|
||||||
|
--sr-variant $arm `
|
||||||
|
--entry-start 2024-07-01 `
|
||||||
|
--sr-audit `
|
||||||
|
--out $output
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "S/R validation arm failed: $arm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& $Python scripts\compare_sr_variants.py `
|
||||||
|
reports\backtest-sr-v2-validation-production_control.json `
|
||||||
|
"reports\backtest-sr-v2-validation-$LockedArm.json" `
|
||||||
|
--out-csv reports\sr-v2-validation-cohorts.csv `
|
||||||
|
--out-json reports\sr-v2-validation-comparison.json
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "S/R validation comparison failed"
|
||||||
|
}
|
||||||
+5
-1
@@ -213,7 +213,11 @@ def sr_levels(draw: st.DrawFn) -> dict[str, Any]:
|
|||||||
"price_level": draw(st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False)),
|
"price_level": draw(st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False)),
|
||||||
"type": draw(st.sampled_from(["support", "resistance"])),
|
"type": draw(st.sampled_from(["support", "resistance"])),
|
||||||
"strength": draw(st.integers(min_value=0, max_value=100)),
|
"strength": draw(st.integers(min_value=0, max_value=100)),
|
||||||
"detection_method": draw(st.sampled_from(["volume_profile", "pivot_point", "merged"])),
|
"detection_method": draw(
|
||||||
|
st.sampled_from(
|
||||||
|
["volume_profile", "pivot_point", "merged", "round_number"]
|
||||||
|
)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -730,6 +730,23 @@ def test_window_setups_too_short_returns_empty():
|
|||||||
assert bt._window_setups([], {}, {}) == []
|
assert bt._window_setups([], {}, {}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sr_research_variant_is_explicit_and_validated(monkeypatch):
|
||||||
|
monkeypatch.setenv("BACKTEST_SR_VARIANT", "production_control")
|
||||||
|
assert bt._sr_research_variant() == "production_control"
|
||||||
|
monkeypatch.setenv("BACKTEST_SR_VARIANT", "not-a-variant")
|
||||||
|
with pytest.raises(ValueError, match="Unknown BACKTEST_SR_VARIANT"):
|
||||||
|
bt._sr_research_variant()
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_entry_bounds_validate_dates(monkeypatch):
|
||||||
|
monkeypatch.setenv("BACKTEST_ENTRY_START", "2024-07-01")
|
||||||
|
monkeypatch.setenv("BACKTEST_ENTRY_END", "2024-12-31")
|
||||||
|
assert bt._backtest_entry_bounds() == (date(2024, 7, 1), date(2024, 12, 31))
|
||||||
|
monkeypatch.setenv("BACKTEST_ENTRY_START", "2025-01-01")
|
||||||
|
with pytest.raises(ValueError, match="on or before"):
|
||||||
|
bt._backtest_entry_bounds()
|
||||||
|
|
||||||
|
|
||||||
def test_replay_ticker_candidates_carry_gate_fields():
|
def test_replay_ticker_candidates_carry_gate_fields():
|
||||||
"""The ablation recomputes floors from candidate fields — a candidate missing
|
"""The ablation recomputes floors from candidate fields — a candidate missing
|
||||||
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
|
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
|
||||||
|
|||||||
@@ -85,6 +85,33 @@ class TestClusterSrZonesStrength:
|
|||||||
zones = cluster_sr_zones(levels, current_price=200.0, tolerance=0.02)
|
zones = cluster_sr_zones(levels, current_price=200.0, tolerance=0.02)
|
||||||
assert zones[0]["strength"] == 30
|
assert zones[0]["strength"] == 30
|
||||||
|
|
||||||
|
def test_soft_strength_uses_max_plus_confluence(self):
|
||||||
|
levels = [
|
||||||
|
{
|
||||||
|
"price_level": 100.0,
|
||||||
|
"strength": 60,
|
||||||
|
"detection_method": "pivot_point",
|
||||||
|
"sources": ["pivot_point"],
|
||||||
|
"rejection_count": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"price_level": 100.5,
|
||||||
|
"strength": 60,
|
||||||
|
"detection_method": "round_number",
|
||||||
|
"sources": ["round_number"],
|
||||||
|
"rejection_count": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
zones = cluster_sr_zones(
|
||||||
|
levels,
|
||||||
|
current_price=200.0,
|
||||||
|
tolerance=0.02,
|
||||||
|
strength_mode="soft",
|
||||||
|
)
|
||||||
|
assert zones[0]["strength"] == 65
|
||||||
|
assert set(zones[0]["sources"]) == {"pivot_point", "round_number"}
|
||||||
|
assert zones[0]["rejection_count"] == 3
|
||||||
|
|
||||||
|
|
||||||
class TestClusterSrZonesTypeTagging:
|
class TestClusterSrZonesTypeTagging:
|
||||||
"""Support vs resistance tagging."""
|
"""Support vs resistance tagging."""
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""Unit tests for detect_sr_levels and related pure helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.sr_service import (
|
||||||
|
MAX_LEVELS,
|
||||||
|
_bar_respect_weight,
|
||||||
|
_cap_levels,
|
||||||
|
_merge_levels,
|
||||||
|
_round_number_candidates,
|
||||||
|
_strength_from_respects,
|
||||||
|
detect_sr_levels,
|
||||||
|
detect_sr_levels_legacy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_series(
|
||||||
|
n: int = 300,
|
||||||
|
*,
|
||||||
|
base: float = 100.0,
|
||||||
|
support: float = 95.0,
|
||||||
|
resistance: float = 110.0,
|
||||||
|
) -> tuple[list[float], list[float], list[float], list[int]]:
|
||||||
|
"""Synthetic OHLCV that repeatedly tests support/resistance."""
|
||||||
|
highs: list[float] = []
|
||||||
|
lows: list[float] = []
|
||||||
|
closes: list[float] = []
|
||||||
|
volumes: list[int] = []
|
||||||
|
|
||||||
|
price = base
|
||||||
|
for i in range(n):
|
||||||
|
phase = i % 40
|
||||||
|
if phase < 15:
|
||||||
|
# Drift down toward support, bounce
|
||||||
|
target = support
|
||||||
|
price = price + (target - price) * 0.25
|
||||||
|
low = min(price, support) - 0.3
|
||||||
|
high = price + 1.0
|
||||||
|
close = max(price, support + 0.5) if phase > 12 else price
|
||||||
|
elif phase < 30:
|
||||||
|
# Drift up toward resistance, reject
|
||||||
|
target = resistance
|
||||||
|
price = price + (target - price) * 0.25
|
||||||
|
high = max(price, resistance) + 0.3
|
||||||
|
low = price - 1.0
|
||||||
|
close = min(price, resistance - 0.5) if phase > 27 else price
|
||||||
|
else:
|
||||||
|
price = base + (i % 7) * 0.2
|
||||||
|
high = price + 1.0
|
||||||
|
low = price - 1.0
|
||||||
|
close = price
|
||||||
|
|
||||||
|
# Occasional clear swing extremes
|
||||||
|
if i % 55 == 25:
|
||||||
|
high = resistance + 1.0
|
||||||
|
close = resistance - 1.0
|
||||||
|
low = close - 1.0
|
||||||
|
if i % 55 == 50:
|
||||||
|
low = support - 1.0
|
||||||
|
close = support + 1.0
|
||||||
|
high = close + 1.0
|
||||||
|
|
||||||
|
highs.append(high)
|
||||||
|
lows.append(low)
|
||||||
|
closes.append(close)
|
||||||
|
volumes.append(1000 + (i % 10) * 50)
|
||||||
|
price = close
|
||||||
|
|
||||||
|
return highs, lows, closes, volumes
|
||||||
|
|
||||||
|
|
||||||
|
class TestBarRespectWeight:
|
||||||
|
def test_no_interaction(self):
|
||||||
|
assert _bar_respect_weight(100.0, 90.0, 85.0, 88.0, 87.0, 0.005) == 0.0
|
||||||
|
|
||||||
|
def test_support_rejection(self):
|
||||||
|
# Low probes at 100, closes above with recovery wick
|
||||||
|
w = _bar_respect_weight(100.0, 103.0, 99.8, 102.0, 101.0, 0.005)
|
||||||
|
assert w >= 0.9
|
||||||
|
|
||||||
|
def test_resistance_rejection(self):
|
||||||
|
# High probes at 100, closes below
|
||||||
|
w = _bar_respect_weight(100.0, 100.2, 97.0, 98.0, 99.0, 0.005)
|
||||||
|
assert w >= 0.9
|
||||||
|
|
||||||
|
def test_pass_through_lower_weight(self):
|
||||||
|
# Prev below, close above, bar spans through without probing extremes at level
|
||||||
|
w = _bar_respect_weight(100.0, 105.0, 95.0, 104.0, 96.0, 0.005)
|
||||||
|
assert w < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrengthFromRespects:
|
||||||
|
def test_pass_through_not_maximal(self):
|
||||||
|
"""Central pass-through levels should not pin at strength 100."""
|
||||||
|
n = 200
|
||||||
|
# Trending series that passes through 100 many times
|
||||||
|
closes = [80.0 + i * 0.25 for i in range(n)]
|
||||||
|
highs = [c + 1.0 for c in closes]
|
||||||
|
lows = [c - 1.0 for c in closes]
|
||||||
|
strength = _strength_from_respects(100.0, highs, lows, closes, 0.005)
|
||||||
|
assert strength < 100
|
||||||
|
|
||||||
|
def test_repeated_rejection_stronger_than_no_touch(self):
|
||||||
|
n = 120
|
||||||
|
level = 100.0
|
||||||
|
# Bars that repeatedly probe support (low near level) and close above
|
||||||
|
highs = [103.0] * n
|
||||||
|
lows = [99.8] * n
|
||||||
|
closes = [102.0] * n
|
||||||
|
strong = _strength_from_respects(level, highs, lows, closes, 0.01, base=10)
|
||||||
|
|
||||||
|
far_highs = [120.0] * n
|
||||||
|
far_lows = [118.0] * n
|
||||||
|
far_closes = [119.0] * n
|
||||||
|
weak = _strength_from_respects(level, far_highs, far_lows, far_closes, 0.01, base=10)
|
||||||
|
assert strong > weak
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoundNumbers:
|
||||||
|
def test_near_spot(self):
|
||||||
|
levels = _round_number_candidates(103.0)
|
||||||
|
assert levels
|
||||||
|
assert all(abs(p - 103.0) / 103.0 <= 0.15 + 1e-9 for p in levels)
|
||||||
|
assert len(levels) <= 8
|
||||||
|
|
||||||
|
def test_non_positive_price(self):
|
||||||
|
assert _round_number_candidates(0.0) == []
|
||||||
|
assert _round_number_candidates(-5.0) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapLevels:
|
||||||
|
def test_interleaves_sides(self):
|
||||||
|
levels = [
|
||||||
|
{"price_level": 90.0, "type": "support", "strength": 80, "detection_method": "x"},
|
||||||
|
{"price_level": 91.0, "type": "support", "strength": 70, "detection_method": "x"},
|
||||||
|
{"price_level": 92.0, "type": "support", "strength": 60, "detection_method": "x"},
|
||||||
|
{"price_level": 110.0, "type": "resistance", "strength": 50, "detection_method": "x"},
|
||||||
|
{"price_level": 111.0, "type": "resistance", "strength": 40, "detection_method": "x"},
|
||||||
|
]
|
||||||
|
capped = _cap_levels(levels, max_levels=4)
|
||||||
|
assert len(capped) == 4
|
||||||
|
types = {lvl["type"] for lvl in capped}
|
||||||
|
assert "support" in types
|
||||||
|
assert "resistance" in types
|
||||||
|
|
||||||
|
|
||||||
|
class TestLevelEvidence:
|
||||||
|
def test_merge_preserves_sources_and_rejection_evidence(self):
|
||||||
|
levels = [
|
||||||
|
{
|
||||||
|
"price_level": 100.0,
|
||||||
|
"type": "",
|
||||||
|
"strength": 55,
|
||||||
|
"detection_method": "pivot_point",
|
||||||
|
"sources": ["pivot_point"],
|
||||||
|
"rejection_count": 3,
|
||||||
|
"last_rejection_age": 12,
|
||||||
|
"weighted_respects": 1.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"price_level": 100.3,
|
||||||
|
"type": "",
|
||||||
|
"strength": 40,
|
||||||
|
"detection_method": "round_number",
|
||||||
|
"sources": ["round_number"],
|
||||||
|
"rejection_count": 1,
|
||||||
|
"last_rejection_age": 4,
|
||||||
|
"weighted_respects": 0.5,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
merged = _merge_levels(levels, tolerance=0.005)
|
||||||
|
assert len(merged) == 1
|
||||||
|
assert set(merged[0]["sources"]) == {"pivot_point", "round_number"}
|
||||||
|
assert merged[0]["rejection_count"] == 3
|
||||||
|
assert merged[0]["last_rejection_age"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectSrLevels:
|
||||||
|
def test_returns_capped_tagged_levels(self):
|
||||||
|
highs, lows, closes, volumes = _make_series()
|
||||||
|
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||||
|
assert levels
|
||||||
|
assert len(levels) <= MAX_LEVELS
|
||||||
|
for lvl in levels:
|
||||||
|
assert lvl["type"] in ("support", "resistance")
|
||||||
|
assert 0 <= lvl["strength"] <= 100
|
||||||
|
assert lvl["detection_method"] in (
|
||||||
|
"volume_profile",
|
||||||
|
"pivot_point",
|
||||||
|
"merged",
|
||||||
|
"round_number",
|
||||||
|
)
|
||||||
|
assert lvl["price_level"] > 0
|
||||||
|
assert lvl["sources"]
|
||||||
|
assert lvl["rejection_count"] >= 0
|
||||||
|
# Sorted by strength desc
|
||||||
|
strengths = [lvl["strength"] for lvl in levels]
|
||||||
|
assert strengths == sorted(strengths, reverse=True)
|
||||||
|
|
||||||
|
def test_far_fewer_than_old_grid(self):
|
||||||
|
"""Should not produce a near-1%-spacing grid of ~70 levels."""
|
||||||
|
highs, lows, closes, volumes = _make_series(n=500)
|
||||||
|
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||||
|
assert len(levels) <= MAX_LEVELS
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
assert detect_sr_levels([], [], [], []) == []
|
||||||
|
|
||||||
|
def test_explicit_tolerance(self):
|
||||||
|
highs, lows, closes, volumes = _make_series()
|
||||||
|
tight = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.001)
|
||||||
|
wide = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.05)
|
||||||
|
# Wider merge should not produce more levels
|
||||||
|
assert len(wide) <= len(tight) + 2 # allow small jitter from scoring
|
||||||
|
|
||||||
|
def test_levels_near_structural_areas(self):
|
||||||
|
"""At least some levels should land near the synthetic S/R band."""
|
||||||
|
highs, lows, closes, volumes = _make_series(
|
||||||
|
n=400, support=95.0, resistance=110.0
|
||||||
|
)
|
||||||
|
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||||
|
prices = [lvl["price_level"] for lvl in levels]
|
||||||
|
near_support = any(abs(p - 95.0) / 95.0 < 0.05 for p in prices)
|
||||||
|
near_resist = any(abs(p - 110.0) / 110.0 < 0.05 for p in prices)
|
||||||
|
# Round numbers / VP may dominate; require at least one structural band hit
|
||||||
|
assert near_support or near_resist or any(
|
||||||
|
abs(p - 100.0) / 100.0 < 0.08 for p in prices
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strength_not_all_pinned_at_100(self):
|
||||||
|
highs, lows, closes, volumes = _make_series(n=400)
|
||||||
|
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||||
|
if len(levels) >= 3:
|
||||||
|
pinned = sum(1 for lvl in levels if lvl["strength"] == 100)
|
||||||
|
assert pinned < len(levels)
|
||||||
|
|
||||||
|
def test_legacy_control_retains_old_uncapped_grid(self):
|
||||||
|
highs, lows, closes, volumes = _make_series(n=500)
|
||||||
|
levels = detect_sr_levels_legacy(highs, lows, closes, volumes)
|
||||||
|
assert levels
|
||||||
|
assert all(level["sources"] for level in levels)
|
||||||
|
# The research control intentionally keeps the deployed detector's much
|
||||||
|
# denser output instead of borrowing the rewrite's presentation cap.
|
||||||
|
assert len(levels) > MAX_LEVELS
|
||||||
@@ -164,6 +164,34 @@ class TestComputeVolumeProfile:
|
|||||||
with pytest.raises(ValidationError, match="Volume Profile requires"):
|
with pytest.raises(ValidationError, match="Volume Profile requires"):
|
||||||
compute_volume_profile(highs, lows, closes, volumes)
|
compute_volume_profile(highs, lows, closes, volumes)
|
||||||
|
|
||||||
|
def test_close_bin_volume_no_double_count(self):
|
||||||
|
"""Each bar's volume is counted once (close bin), not per span."""
|
||||||
|
# Wide bars that would span many bins under the old algorithm
|
||||||
|
n = 25
|
||||||
|
closes = [100.0 + (i % 5) for i in range(n)]
|
||||||
|
highs = [c + 20 for c in closes] # wide range
|
||||||
|
lows = [c - 20 for c in closes]
|
||||||
|
volumes = [1000] * n
|
||||||
|
result = compute_volume_profile(highs, lows, closes, volumes, num_bins=20)
|
||||||
|
# Binned total equals true volume (close-bin assignment)
|
||||||
|
# We only expose poc/hvn; reconstruct by checking score fields exist
|
||||||
|
assert result["poc"] > 0
|
||||||
|
# With volume concentrated on a few close prices, HVNs should be few local peaks
|
||||||
|
assert len(result["hvn"]) < 20
|
||||||
|
|
||||||
|
def test_hvn_are_local_peaks_not_all_above_mean(self):
|
||||||
|
"""HVN should be local histogram peaks, not every above-mean bin."""
|
||||||
|
# Two clusters of closes → two volume peaks
|
||||||
|
closes = [80.0] * 10 + [120.0] * 10 + [100.0] * 5
|
||||||
|
highs = [c + 1 for c in closes]
|
||||||
|
lows = [c - 1 for c in closes]
|
||||||
|
volumes = [1000] * len(closes)
|
||||||
|
result = compute_volume_profile(highs, lows, closes, volumes, num_bins=20)
|
||||||
|
# At most a handful of local peaks (not ~half of 20 bins)
|
||||||
|
assert len(result["hvn"]) <= 6
|
||||||
|
# POC should land near one of the high-volume clusters
|
||||||
|
assert result["poc"] < 95 or result["poc"] > 105
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pivot Points
|
# Pivot Points
|
||||||
@@ -184,6 +212,26 @@ class TestComputePivotPoints:
|
|||||||
with pytest.raises(ValidationError, match="Pivot Points requires"):
|
with pytest.raises(ValidationError, match="Pivot Points requires"):
|
||||||
compute_pivot_points([1, 2], [0, 1], [0.5, 1.5])
|
compute_pivot_points([1, 2], [0, 1], [0.5, 1.5])
|
||||||
|
|
||||||
|
def test_prominence_filters_tiny_swings(self):
|
||||||
|
# Mix of a large swing (depth ~10) and tiny fractal noise (depth ~1)
|
||||||
|
closes = [
|
||||||
|
10, 10.2, 10.5, 10.2, 10, # tiny high around idx 2
|
||||||
|
10, 15, 20, 15, 10, # large high around idx 7
|
||||||
|
10, 10.3, 10.6, 10.3, 10, # tiny high around idx 12
|
||||||
|
]
|
||||||
|
highs = list(closes)
|
||||||
|
lows = [c - 0.5 for c in closes]
|
||||||
|
highs[2] = 10.8
|
||||||
|
highs[7] = 20.5
|
||||||
|
highs[12] = 10.9
|
||||||
|
lows[7] = 10.0 # large window range at major swing
|
||||||
|
unfiltered = compute_pivot_points(highs, lows, closes, min_prominence=None)
|
||||||
|
filtered = compute_pivot_points(highs, lows, closes, min_prominence=5.0)
|
||||||
|
assert unfiltered["pivot_count"] > 0
|
||||||
|
assert filtered["pivot_count"] < unfiltered["pivot_count"]
|
||||||
|
# Major swing high should survive
|
||||||
|
assert any(h >= 20.0 for h in filtered["swing_highs"])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# EMA Cross
|
# EMA Cross
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
|||||||
from app.services.recommendation_service import (
|
from app.services.recommendation_service import (
|
||||||
_build_reasoning,
|
_build_reasoning,
|
||||||
_choose_recommended_action,
|
_choose_recommended_action,
|
||||||
|
_gate_eligible_levels,
|
||||||
_prune_floor_pinned_targets,
|
_prune_floor_pinned_targets,
|
||||||
_select_primary_target,
|
_select_primary_target,
|
||||||
direction_analyzer,
|
direction_analyzer,
|
||||||
@@ -110,7 +111,7 @@ def test_primary_target_is_most_likely_worthwhile_not_lottery():
|
|||||||
{"price": 120.0, "rr_ratio": 3.5, "probability": 50.0},
|
{"price": 120.0, "rr_ratio": 3.5, "probability": 50.0},
|
||||||
{"price": 140.0, "rr_ratio": 6.0, "probability": 15.0}, # far lottery — not chosen
|
{"price": 140.0, "rr_ratio": 6.0, "probability": 15.0}, # far lottery — not chosen
|
||||||
]
|
]
|
||||||
primary = _select_primary_target(targets)
|
primary = _select_primary_target(targets, min_rr=1.5)
|
||||||
assert primary is not None
|
assert primary is not None
|
||||||
assert primary["price"] == 110.0
|
assert primary["price"] == 110.0
|
||||||
|
|
||||||
@@ -120,13 +121,13 @@ def test_primary_target_skips_sub_threshold_rr():
|
|||||||
{"price": 102.0, "rr_ratio": 1.0, "probability": 95.0}, # high prob but trivial R:R — skipped
|
{"price": 102.0, "rr_ratio": 1.0, "probability": 95.0}, # high prob but trivial R:R — skipped
|
||||||
{"price": 115.0, "rr_ratio": 2.5, "probability": 60.0}, # most likely above the R:R floor ← primary
|
{"price": 115.0, "rr_ratio": 2.5, "probability": 60.0}, # most likely above the R:R floor ← primary
|
||||||
]
|
]
|
||||||
primary = _select_primary_target(targets)
|
primary = _select_primary_target(targets, min_rr=1.5)
|
||||||
assert primary is not None
|
assert primary is not None
|
||||||
assert primary["price"] == 115.0
|
assert primary["price"] == 115.0
|
||||||
|
|
||||||
|
|
||||||
def test_primary_target_none_when_empty():
|
def test_primary_target_none_when_empty():
|
||||||
assert _select_primary_target([]) is None
|
assert _select_primary_target([], min_rr=1.5) is None
|
||||||
|
|
||||||
|
|
||||||
def test_primary_target_never_headlines_a_lottery():
|
def test_primary_target_never_headlines_a_lottery():
|
||||||
@@ -138,7 +139,7 @@ def test_primary_target_never_headlines_a_lottery():
|
|||||||
{"price": 101.0, "rr_ratio": 0.9, "probability": 55.0}, # likely, no asymmetry
|
{"price": 101.0, "rr_ratio": 0.9, "probability": 55.0}, # likely, no asymmetry
|
||||||
{"price": 140.0, "rr_ratio": 5.0, "probability": 3.0}, # asymmetric lottery
|
{"price": 140.0, "rr_ratio": 5.0, "probability": 3.0}, # asymmetric lottery
|
||||||
]
|
]
|
||||||
primary = _select_primary_target(targets)
|
primary = _select_primary_target(targets, min_rr=1.5)
|
||||||
assert primary is not None
|
assert primary is not None
|
||||||
assert primary["price"] == 101.0
|
assert primary["price"] == 101.0
|
||||||
|
|
||||||
@@ -150,7 +151,17 @@ def test_primary_target_requires_probability_floor():
|
|||||||
{"price": 130.0, "rr_ratio": 4.0, "probability": 12.0}, # asymmetric but unlikely
|
{"price": 130.0, "rr_ratio": 4.0, "probability": 12.0}, # asymmetric but unlikely
|
||||||
{"price": 112.0, "rr_ratio": 1.8, "probability": 38.0}, # clears both floors ← primary
|
{"price": 112.0, "rr_ratio": 1.8, "probability": 38.0}, # clears both floors ← primary
|
||||||
]
|
]
|
||||||
primary = _select_primary_target(targets)
|
primary = _select_primary_target(targets, min_rr=1.5)
|
||||||
|
assert primary is not None
|
||||||
|
assert primary["price"] == 112.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_primary_target_uses_activation_rr_not_scanner_floor():
|
||||||
|
targets = [
|
||||||
|
{"price": 108.0, "rr_ratio": 1.6, "probability": 60.0},
|
||||||
|
{"price": 112.0, "rr_ratio": 2.2, "probability": 35.0},
|
||||||
|
]
|
||||||
|
primary = _select_primary_target(targets, min_rr=2.0)
|
||||||
assert primary is not None
|
assert primary is not None
|
||||||
assert primary["price"] == 112.0
|
assert primary["price"] == 112.0
|
||||||
|
|
||||||
@@ -318,3 +329,48 @@ def test_zone_representative_levels_singletons_unchanged():
|
|||||||
reps = _zone_representative_levels(levels, entry_price=100.0)
|
reps = _zone_representative_levels(levels, entry_price=100.0)
|
||||||
assert len(reps) == 2
|
assert len(reps) == 2
|
||||||
assert {round(r.price_level) for r in reps} == {120, 150}
|
assert {round(r.price_level) for r in reps} == {120, 150}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_representative_levels_soft_strength_avoids_resaturation():
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from app.services.recommendation_service import _zone_representative_levels
|
||||||
|
|
||||||
|
levels = [
|
||||||
|
SimpleNamespace(
|
||||||
|
id=1, price_level=183.0, type="resistance", strength=60,
|
||||||
|
detection_method="pivot_point", sources=["pivot_point"],
|
||||||
|
rejection_count=3, last_rejection_age=5,
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
id=2, price_level=185.0, type="resistance", strength=60,
|
||||||
|
detection_method="round_number", sources=["round_number"],
|
||||||
|
rejection_count=1, last_rejection_age=10,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
reps = _zone_representative_levels(
|
||||||
|
levels, entry_price=180.0, strength_mode="soft"
|
||||||
|
)
|
||||||
|
assert len(reps) == 1
|
||||||
|
assert reps[0].strength == 65
|
||||||
|
assert set(reps[0].sources) == {"pivot_point", "round_number"}
|
||||||
|
assert reps[0].rejection_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_requires_confirmation_for_standalone_round_number():
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
untouched = SimpleNamespace(
|
||||||
|
detection_method="round_number", sources=["round_number"],
|
||||||
|
rejection_count=1,
|
||||||
|
)
|
||||||
|
confirmed = SimpleNamespace(
|
||||||
|
detection_method="round_number", sources=["round_number"],
|
||||||
|
rejection_count=2,
|
||||||
|
)
|
||||||
|
confluent = SimpleNamespace(
|
||||||
|
detection_method="merged", sources=["round_number", "pivot_point"],
|
||||||
|
rejection_count=0,
|
||||||
|
)
|
||||||
|
assert _gate_eligible_levels(
|
||||||
|
[untouched, confirmed, confluent], confirmed_rounds_only=True
|
||||||
|
) == [confirmed, confluent]
|
||||||
|
|||||||
Reference in New Issue
Block a user