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
+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 "
+47 -14
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,14 +281,18 @@ 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:
bins[b] += volumes[i]
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)
if total_vol == 0:
@@ -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,12 +370,24 @@ 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)):
swing_highs.append(round(highs[i], 4))
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)):
swing_lows.append(round(lows[i], 4))
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
latest = closes[-1]
+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:
+556 -74
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"]
)
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:
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_strength
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:
merged.append(dict(level))
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).