Add S/R v2 research and validation harness

This commit is contained in:
2026-07-12 21:15:18 +02:00
parent 57ac1d2cdd
commit 19b81c169d
19 changed files with 1575 additions and 117 deletions
+4
View File
@@ -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_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_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 |
+6 -1
View File
@@ -15,7 +15,12 @@ router = APIRouter(tags=["sr-levels"])
@router.get("/sr-levels/{symbol}", response_model=APIEnvelope)
async def read_sr_levels(
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)"),
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
+3 -1
View File
@@ -15,7 +15,9 @@ class SRLevelResult(BaseModel):
price_level: float
type: Literal["support", "resistance"]
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
+197 -4
View File
@@ -67,6 +67,7 @@ from app.services.qualification import (
from app.services.recommendation_service import (
_choose_recommended_action,
_classify_by_probability,
_gate_eligible_levels,
_prune_floor_pinned_targets,
_risk_level_from_conflicts,
_select_primary_target,
@@ -81,7 +82,7 @@ from app.services.scoring_service import (
compute_momentum_from_closes,
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__)
@@ -120,11 +121,58 @@ def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
price_level=float(d["price_level"]),
type=d["type"],
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)
]
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:
"""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 —
@@ -219,10 +267,28 @@ def _window_setups(
if atr <= 0:
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:
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
momentum = (compute_momentum_from_closes(closes)[0]) or 50.0
dim_scores = {"technical": technical, "momentum": momentum}
@@ -237,7 +303,11 @@ def _window_setups(
per_dir: dict[str, dict] = {}
for direction in ("long", "short"):
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)
if not targets:
fallback_k = _atr_target_fallback_k()
@@ -254,7 +324,15 @@ def _window_setups(
# Collapse duplicate floor-pinned lottery targets (parity with
# enhance_trade_setup).
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:
continue
# Flag the primary so qualification's EV uses the primary target's
@@ -309,6 +387,18 @@ def _window_setups(
"meets_core": meets_core,
"action": action,
"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
@@ -399,7 +489,13 @@ def _replay_ticker(
if n < MIN_LOOKBACK + HORIZON:
return candidates
entry_start, entry_end = _backtest_entry_bounds()
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]
forward = records[i + 1 :]
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.
"action": s["action"],
"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,
"target_hit": target_hit,
"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
# 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
@@ -2851,6 +3033,15 @@ async def run_backtest(
"horizon_days": HORIZON,
"min_lookback": MIN_LOOKBACK,
"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,
"overall_qualified": _bucket_stats(qualified),
@@ -2914,6 +3105,8 @@ async def run_backtest(
"portfolio_monitor": portfolio_monitor_report,
"holdout": holdout_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_note": (
"Cross-sectional rank-IC of price-only signals vs the forward "
+44 -11
View File
@@ -256,6 +256,12 @@ def compute_volume_profile(
) -> dict[str, Any]:
"""Compute Volume Profile: POC, Value Area, HVN, LVN.
Volume is assigned to the bin containing each bar's **close** (no
double-counting across the highlow 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).
"""
n = len(closes)
@@ -275,13 +281,17 @@ def compute_volume_profile(
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):
# Distribute volume across bins the bar spans
bar_low, bar_high = lows[i], highs[i]
for b in range(num_bins):
bl = price_min + b * bin_width
bh = bl + bin_width
if bar_high >= bl and bar_low <= bh:
c = closes[i]
if c <= price_min:
b = 0
elif c >= price_max:
b = num_bins - 1
else:
b = int((c - price_min) / bin_width)
if b >= num_bins:
b = num_bins - 1
bins[b] += volumes[i]
total_vol = sum(bins)
@@ -304,10 +314,17 @@ def compute_volume_profile(
va_low = round(price_min + min(va_indices) * 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
hvn = [round(bin_prices[i], 4) for i in range(num_bins) if bins[i] > avg_vol]
lvn = [round(bin_prices[i], 4) for i in range(num_bins) if bins[i] < avg_vol]
hvn: list[float] = []
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
latest = closes[-1]
@@ -333,10 +350,14 @@ def compute_pivot_points(
lows: list[float],
closes: list[float],
window: int = 2,
min_prominence: float | None = None,
) -> dict[str, Any]:
"""Detect swing highs/lows as pivot points.
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.
"""
n = len(closes)
@@ -349,11 +370,23 @@ def compute_pivot_points(
swing_lows: list[float] = []
for i in range(window, n - window):
lo = i - window
hi = i + window + 1
# 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 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))
all_pivots = swing_highs + swing_lows
+69 -9
View File
@@ -56,7 +56,40 @@ def _clamp(value: float, low: float, high: float) -> float:
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.
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:
return list(sr_levels)
level_dicts = [
{"price_level": float(lv.price_level), "strength": int(lv.strength), "type": lv.type}
for lv in sr_levels
]
zones = cluster_sr_zones(level_dicts, entry_price, tolerance=_SR_ZONE_TOLERANCE)
level_dicts = []
for lv in sr_levels:
level_dicts.append({
"price_level": float(lv.price_level),
"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] = []
for zone in zones:
@@ -94,6 +141,10 @@ def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) ->
price_level=float(near_edge),
type=zone["type"],
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
@@ -312,6 +363,15 @@ class TargetGenerator:
"classification": "Moderate",
"sr_level_id": int(level.id),
"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),
}
)
@@ -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
# (qualification.MIN_TARGET_PROBABILITY) so the primary selection and the gate
# 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(
targets: list[dict],
min_rr: float = PRIMARY_TARGET_MIN_RR,
min_rr: float,
min_probability: float = PRIMARY_TARGET_MIN_PROBABILITY,
) -> dict | None:
"""Primary = the most LIKELY target that still offers real asymmetry.
@@ -651,6 +710,7 @@ async def enhance_trade_setup(
sr_levels: list[SRLevel],
sentiment_classification: str | None,
atr_value: float,
primary_min_rr: float,
available_directions: set[str] | None = None,
) -> TradeSetup:
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
# probability. Sync the setup's headline target/rr_ratio so the chart, gate
# 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:
for target in targets:
target["is_primary"] = target is primary
+21
View File
@@ -412,6 +412,7 @@ async def scan_ticker(
momentum_percentile: float | None = None,
strategy_rank: float | None = None,
volatility_percentile: float | None = None,
primary_min_rr: float | None = None,
) -> list[TradeSetup]:
"""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."""
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)
if not records or len(records) < 15:
logger.info(
@@ -558,6 +567,7 @@ async def scan_ticker(
sr_levels=sr_levels,
sentiment_classification=sentiment_classification,
atr_value=atr_value,
primary_min_rr=primary_min_rr,
available_directions=available_directions,
)
enhanced_setups.append(enhanced)
@@ -610,6 +620,16 @@ async def scan_all_tickers(
logger.exception("Activation ranking refresh failed")
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] = []
for index, symbol in enumerate(symbols):
if progress_callback is not None:
@@ -641,6 +661,7 @@ async def scan_all_tickers(
momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"),
strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"),
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"),
primary_min_rr=primary_min_rr,
)
all_setups.extend(setups)
except Exception:
+558 -76
View File
@@ -1,12 +1,15 @@
"""S/R Detector service.
Detects support/resistance levels from Volume Profile (HVN/LVN) and
Pivot Points (swing highs/lows), assigns strength scores, merges nearby
levels, tags as support/resistance, and persists to DB.
Detects support/resistance levels from Volume Profile (POC/VA/HVN peaks)
and Pivot Points (prominent swing highs/lows), plus light psychological
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
import math
from datetime import datetime
from sqlalchemy import delete, select
@@ -17,12 +20,43 @@ from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.services.indicator_service import (
_extract_ohlcv,
compute_atr,
compute_pivot_points,
compute_volume_profile,
)
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 0100 (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:
@@ -35,36 +69,235 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> 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 0100 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,
highs: list[float],
lows: list[float],
closes: list[float],
tolerance: float = DEFAULT_TOLERANCE,
) -> int:
"""Count how many bars touched/respected a price level within tolerance."""
count = 0
tol = price_level * tolerance if price_level != 0 else tolerance
for i in range(len(closes)):
# A bar "touches" the level if the level is within the bar's range
# (within tolerance)
if lows[i] - tol <= price_level <= highs[i] + tol:
count += 1
return count
base: int = 0,
half_life: float = STRENGTH_HALF_LIFE,
lookback: int = TOUCH_LOOKBACK,
cooldown: int = 3,
) -> dict[str, float | int | None]:
"""Return rejection evidence and its soft-mapped strength.
def _strength_from_touches(touches: int, total_bars: int) -> int:
"""Convert touch count to a 0-100 strength score.
More touches relative to total bars = higher strength.
Cap at 100.
*cooldown* bars after a full rejection are ignored so multi-day chop at a
level counts as one test cluster, not N identical rejections.
"""
if total_bars == 0:
return 0
# Scale: each touch contributes proportionally, with a multiplier
# so that a level touched ~20% of bars gets score ~100
raw = (touches / total_bars) * 500.0
return max(0, min(100, int(round(raw))))
n = len(closes)
if n == 0:
return {
"strength": _raw_to_strength(float(base)),
"rejection_count": 0,
"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(
@@ -73,55 +306,194 @@ def _extract_candidate_levels(
closes: list[float],
volumes: list[int],
) -> 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.
"""
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:
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", []):
candidates.append((price, "volume_profile"))
for price in vp.get("lvn", []):
candidates.append((price, "volume_profile"))
candidates.append((float(price), "volume_profile"))
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:
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", []):
candidates.append((price, "pivot_point"))
candidates.append((float(price), "pivot_point"))
for price in pp.get("swing_lows", []):
candidates.append((price, "pivot_point"))
candidates.append((float(price), "pivot_point"))
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
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(
levels: list[dict],
tolerance: float = DEFAULT_TOLERANCE,
) -> list[dict]:
"""Merge levels within tolerance into consolidated levels.
Levels from different methods within tolerance are merged.
Merged levels combine strength scores (capped at 100) and get
detection_method = "merged".
Strength combines via max + partial min (avoids instant saturation) with
a confluence bonus when detection methods differ. Price is strength-weighted.
"""
if not levels:
return []
# Sort by price
sorted_levels = sorted(levels, key=lambda x: x["price_level"])
merged: list[dict] = []
for level in sorted_levels:
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
last = merged[-1]
@@ -129,19 +501,52 @@ def _merge_levels(
tol = ref_price * tolerance if ref_price != 0 else tolerance
if abs(level["price_level"] - ref_price) <= tol:
# Merge: average price, combine strength, mark as merged
combined_strength = min(100, last["strength"] + level["strength"])
avg_price = (last["price_level"] + level["price_level"]) / 2.0
method = (
"merged"
if last["detection_method"] != level["detection_method"]
else last["detection_method"]
)
last["price_level"] = round(avg_price, 4)
last["strength"] = combined_strength
last["detection_method"] = method
s1 = last["strength"]
s2 = level["strength"]
# Soft combine — avoid merge math pinning everything at 100
combined = int(round(0.85 * max(s1, s2) + 0.15 * min(s1, s2)))
sources = set(last.get("sources") or [last["detection_method"]])
sources |= set(level.get("sources") or [level["detection_method"]])
if len(sources) > 1:
combined = min(100, combined + 5)
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
@@ -159,14 +564,66 @@ def _tag_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(
highs: list[float],
lows: list[float],
closes: list[float],
volumes: list[int],
tolerance: float = DEFAULT_TOLERANCE,
tolerance: float | None = None,
max_levels: int = MAX_LEVELS,
) -> 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,
detection_method — sorted by strength descending.
@@ -178,37 +635,42 @@ def detect_sr_levels(
if not candidates:
return []
total_bars = len(closes)
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] = []
for price, method in candidates:
touches = _count_price_touches(price, highs, lows, closes, tolerance)
strength = _strength_from_touches(touches, total_bars)
base = _METHOD_BASE_STRENGTH.get(method, 0)
evidence = _respect_evidence(
price, highs, lows, closes, touch_tol, base=base
)
raw_levels.append({
"price_level": price,
"strength": strength,
"strength": int(evidence["strength"]),
"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, tolerance)
# Tag as support/resistance
merged = _merge_levels(raw_levels, merge_tol)
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(
levels: list[dict],
current_price: float,
tolerance: float = 0.02,
max_zones: int | None = None,
strength_mode: str = "sum",
) -> list[dict]:
"""Cluster nearby S/R levels into zones.
@@ -263,8 +725,26 @@ def cluster_sr_zones(
low = min(prices)
high = max(prices)
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)
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
zone_type = "support" if midpoint < current_price else "resistance"
@@ -276,6 +756,9 @@ def cluster_sr_zones(
"strength": strength,
"type": zone_type,
"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
@@ -319,11 +802,10 @@ def cluster_sr_zones(
return selected
async def recalculate_sr_levels(
db: AsyncSession,
symbol: str,
tolerance: float = DEFAULT_TOLERANCE,
tolerance: float | None = None,
) -> list[SRLevel]:
"""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(
db: AsyncSession,
symbol: str,
tolerance: float = DEFAULT_TOLERANCE,
tolerance: float | None = None,
) -> list[SRLevel]:
"""Get S/R levels for a ticker, recalculating on every request (MVP).
+47 -8
View File
@@ -13,14 +13,24 @@ net-positive is the open question, tracked below.
## 1. How the levels are built today
`app/services/sr_service.py::detect_sr_levels`, over **all stored history**
(`query_ohlcv` with no date range — 5 years / ~1260 daily bars per ticker):
> **Update (2026-07-12 detector rewrite):** several gaps below were addressed in
> `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.
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`.
`app/services/sr_service.py::detect_sr_levels` (post-rewrite):
### 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.41.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):
@@ -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 |
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
~3.7 bars.
lacked pre-rewrite is a **prominence filter** — AAPL yielded 338 pivots over 1261
bars, one every ~3.7 bars.
### 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.
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:**
- A **per-name target model** for clear-air setups instead of a constant k×ATR. This
+125
View File
@@ -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()
+32
View File
@@ -46,6 +46,30 @@ def _parse_args() -> argparse.Namespace:
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(
"--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()
@@ -138,6 +162,14 @@ async def _main() -> None:
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
if args.allow_spawn:
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.services.backtest_service import run_backtest
+32
View File
@@ -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."
+34
View File
@@ -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
View File
@@ -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)),
"type": draw(st.sampled_from(["support", "resistance"])),
"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"]
)
),
}
+17
View File
@@ -730,6 +730,23 @@ def test_window_setups_too_short_returns_empty():
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():
"""The ablation recomputes floors from candidate fields — a candidate missing
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
+27
View File
@@ -85,6 +85,33 @@ class TestClusterSrZonesStrength:
zones = cluster_sr_zones(levels, current_price=200.0, tolerance=0.02)
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:
"""Support vs resistance tagging."""
+244
View File
@@ -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
+48
View File
@@ -164,6 +164,34 @@ class TestComputeVolumeProfile:
with pytest.raises(ValidationError, match="Volume Profile requires"):
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
@@ -184,6 +212,26 @@ class TestComputePivotPoints:
with pytest.raises(ValidationError, match="Pivot Points requires"):
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
+61 -5
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from app.services.recommendation_service import (
_build_reasoning,
_choose_recommended_action,
_gate_eligible_levels,
_prune_floor_pinned_targets,
_select_primary_target,
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": 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["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": 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["price"] == 115.0
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():
@@ -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": 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["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": 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["price"] == 112.0
@@ -318,3 +329,48 @@ def test_zone_representative_levels_singletons_unchanged():
reps = _zone_representative_levels(levels, entry_price=100.0)
assert len(reps) == 2
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]