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
+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