Add S/R v2 research and validation harness
This commit is contained in:
@@ -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 "
|
||||
|
||||
@@ -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 high–low span).
|
||||
|
||||
HVN = local peaks in the volume histogram (not every bin above mean).
|
||||
LVN = local valleys in the histogram.
|
||||
|
||||
Score: proximity of latest close to POC (closer = higher).
|
||||
"""
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 0–100 (see _raw_to_strength).
|
||||
STRENGTH_SCALE = 8.0
|
||||
STRENGTH_SOFT_K = 35.0 # higher → slower approach to 100
|
||||
|
||||
ROUND_NUMBER_RANGE = 0.15 # ±15% of spot
|
||||
ROUND_NUMBER_MAX = 8
|
||||
|
||||
# Base strength seed before touch scoring (method priors)
|
||||
_METHOD_BASE_STRENGTH = {
|
||||
"volume_profile": 12,
|
||||
"pivot_point": 8,
|
||||
"round_number": 4,
|
||||
}
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
@@ -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 0–100 with soft saturation (no hard pin)."""
|
||||
if raw <= 0:
|
||||
return 0
|
||||
# 1 - e^(-raw/k): raw=k → ~63, 2k → ~86, 3k → ~95
|
||||
return max(0, min(100, int(round(100.0 * (1.0 - math.exp(-raw / STRENGTH_SOFT_K))))))
|
||||
|
||||
|
||||
def _respect_evidence(
|
||||
price_level: float,
|
||||
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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user