Spread trade targets across distance bands
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s

MKC showed 5 targets all far/Aggressive: target selection was top-5 by
quality (0.35*R:R + ...), and R:R grows with distance, so far levels
crowded out every nearby one.

generate_targets now selects for spread: always include the nearest
level, plus the best-quality representative from each distance band
(Conservative <=2.9 ATR, Moderate <=4.6 ATR, Aggressive beyond), then
fill remaining slots by quality. Restores a Conservative/Moderate/
Aggressive mix with the nearest target always present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 12:44:59 +02:00
parent 8c89396987
commit 3aebfd72d3
2 changed files with 64 additions and 5 deletions
+38 -5
View File
@@ -33,6 +33,12 @@ DEFAULT_RECOMMENDATION_CONFIG: dict[str, float] = {
# metric we actually measure.
_TARGET_HORIZON_DAYS = 30.0
# Distance bands (in ATR) used to spread targets across Conservative / Moderate
# / Aggressive. Aligned with where the touch-probability crosses 60% / 40% over
# the horizon, so a target from each band tends to land in the matching label.
_CONSERVATIVE_MAX_ATR = 2.9
_MODERATE_MAX_ATR = 4.6
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
@@ -255,12 +261,39 @@ class TargetGenerator:
}
)
candidates.sort(key=lambda row: row["quality"], reverse=True)
selected = candidates[:5]
selected.sort(key=lambda row: row["distance_from_entry"])
if not candidates:
return []
# Classification is assigned later from the computed reach-probability
# (see _classify_by_probability), not from distance rank.
# Select up to 5 targets that SPAN the distance range, instead of the
# top-5 by quality (which biases toward far, high-R:R levels and buries
# every nearby target). Guarantees the nearest level plus a
# representative from each distance band when they exist, so the table
# offers a real mix of Conservative / Moderate / Aggressive.
conservative = [c for c in candidates if c["distance_atr_multiple"] <= _CONSERVATIVE_MAX_ATR]
moderate = [c for c in candidates if _CONSERVATIVE_MAX_ATR < c["distance_atr_multiple"] <= _MODERATE_MAX_ATR]
aggressive = [c for c in candidates if c["distance_atr_multiple"] > _MODERATE_MAX_ATR]
selected: list[dict[str, Any]] = []
selected_ids: set[int] = set()
def _add(candidate: dict[str, Any] | None) -> None:
if candidate is not None and candidate["sr_level_id"] not in selected_ids:
selected.append(candidate)
selected_ids.add(candidate["sr_level_id"])
# Nearest overall (the most likely / Conservative anchor)
_add(min(candidates, key=lambda c: c["distance_atr_multiple"]))
# Best-quality representative from each band → spread across labels
for bucket in (conservative, moderate, aggressive):
if bucket:
_add(max(bucket, key=lambda c: c["quality"]))
# Fill remaining slots with the next-best by quality
for candidate in sorted(candidates, key=lambda c: c["quality"], reverse=True):
if len(selected) >= 5:
break
_add(candidate)
selected.sort(key=lambda row: row["distance_from_entry"])
for target in selected:
target.pop("quality", None)
+26
View File
@@ -159,6 +159,32 @@ def test_generate_targets_respects_direction_and_order():
assert distances == sorted(distances)
def test_generate_targets_spreads_across_distance_bands():
"""Near levels must not be crowded out by far high-R:R ones — expect a mix
of distance bands, including the nearest, not just the 5 farthest."""
# entry 100, atr 2 → ATR multiples: 5→2.5, 8→4.0, 14→7.0, 24→12, 34→17, 44→22
sr_levels = [
_SRLevelStub(id=1, price_level=105.0, type="resistance", strength=60), # 2.5 ATR (conservative)
_SRLevelStub(id=2, price_level=108.0, type="resistance", strength=55), # 4.0 ATR (moderate)
_SRLevelStub(id=3, price_level=114.0, type="resistance", strength=90), # 7.0 ATR (aggressive, strong)
_SRLevelStub(id=4, price_level=124.0, type="resistance", strength=95), # 12 ATR (aggressive, strong)
_SRLevelStub(id=5, price_level=134.0, type="resistance", strength=92), # 17 ATR (aggressive, strong)
_SRLevelStub(id=6, price_level=144.0, type="resistance", strength=88), # 22 ATR (aggressive, strong)
]
targets = target_generator.generate_targets(
direction="long", entry_price=100.0, stop_loss=96.0,
sr_levels=sr_levels, atr_value=2.0, # type: ignore[arg-type]
)
multiples = [t["distance_atr_multiple"] for t in targets]
# The nearest (conservative) and a moderate target survive despite the
# strong far levels that would dominate a pure top-5-by-quality pick.
assert any(m <= 2.9 for m in multiples), "expected a conservative (near) target"
assert any(2.9 < m <= 4.6 for m in multiples), "expected a moderate target"
assert any(m > 4.6 for m in multiples), "expected an aggressive (far) target"
def test_probability_decreases_with_distance():
"""A far target must be far less likely than a near one — no 90% at +39%."""
config = {