Add S/R v2 research and validation harness
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user