Fix sidebar username, Signals filter clarity and layout
- JWT now carries a username claim; sidebar shows "Signed in as <name>" instead of the bare user id (sub). Re-login required for the new claim. - Signals: Min R:R / Min Confidence inputs reflect the effective filter — auto-filled from the activation gate when "Qualified only" is on, reset to 0 when off (no more misleading 0 while the gate is active). - Signals layout: Run Scanner moved to its own action row (it's a job trigger, not a filter); qualified toggle grouped with the refinement filters under one Filters panel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from app.services.recommendation_service import (
|
||||
_build_reasoning,
|
||||
_choose_recommended_action,
|
||||
_select_primary_target,
|
||||
direction_analyzer,
|
||||
probability_estimator,
|
||||
signal_conflict_detector,
|
||||
@@ -102,6 +103,31 @@ def test_reasoning_explains_missing_setup():
|
||||
assert "no high-conviction long setup" in reasoning.lower()
|
||||
|
||||
|
||||
def test_primary_target_is_most_likely_worthwhile_not_lottery():
|
||||
targets = [
|
||||
{"price": 110.0, "rr_ratio": 2.0, "probability": 65.0}, # worthwhile, most likely ← primary
|
||||
{"price": 120.0, "rr_ratio": 3.5, "probability": 50.0},
|
||||
{"price": 140.0, "rr_ratio": 6.0, "probability": 15.0}, # far lottery — not chosen
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 110.0
|
||||
|
||||
|
||||
def test_primary_target_skips_sub_threshold_rr():
|
||||
targets = [
|
||||
{"price": 102.0, "rr_ratio": 1.0, "probability": 95.0}, # high prob but trivial R:R — skipped
|
||||
{"price": 115.0, "rr_ratio": 2.5, "probability": 60.0}, # most likely above the R:R floor ← primary
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 115.0
|
||||
|
||||
|
||||
def test_primary_target_none_when_empty():
|
||||
assert _select_primary_target([]) is None
|
||||
|
||||
|
||||
def test_detects_sentiment_technical_conflict():
|
||||
conflicts = signal_conflict_detector.detect_conflicts(
|
||||
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},
|
||||
|
||||
@@ -18,7 +18,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.services.rr_scanner_service import scan_ticker, _compute_quality_score
|
||||
from app.services.rr_scanner_service import scan_ticker
|
||||
|
||||
|
||||
def _assert_primary_is_most_likely_worthwhile(setup) -> None:
|
||||
"""The persisted headline target must equal the starred primary in the
|
||||
targets table, and that primary must be the highest-probability target
|
||||
with R:R >= 1.5 (fallback: highest R:R)."""
|
||||
targets = setup.targets
|
||||
assert targets, "expected generated targets"
|
||||
primaries = [t for t in targets if t.get("is_primary")]
|
||||
assert len(primaries) == 1, "exactly one primary target expected"
|
||||
primary = primaries[0]
|
||||
assert setup.target == pytest.approx(primary["price"], abs=0.01)
|
||||
|
||||
worthwhile = [t for t in targets if t["rr_ratio"] >= 1.5]
|
||||
pool = worthwhile or targets
|
||||
best = max(pool, key=lambda t: (t["probability"], t["rr_ratio"]))
|
||||
assert primary["price"] == pytest.approx(best["price"], abs=0.01)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -157,33 +174,7 @@ async def test_property_long_selects_highest_quality(
|
||||
long_setups = [s for s in setups if s.direction == "long"]
|
||||
assert len(long_setups) == 1, "Expected exactly one long setup"
|
||||
|
||||
selected_target = long_setups[0].target
|
||||
|
||||
# Compute entry_price and risk from the bars (same logic as scan_ticker)
|
||||
# entry_price = last close ≈ 100.0, ATR ≈ 2.0, risk = ATR * 1.5 = 3.0
|
||||
entry_price = bars[-1].close
|
||||
# Use approximate risk; the exact value comes from ATR computation
|
||||
# We reconstruct it from the setup's entry and stop
|
||||
risk = long_setups[0].entry_price - long_setups[0].stop_loss
|
||||
|
||||
# Compute quality scores for all candidates that meet threshold
|
||||
best_quality = -1.0
|
||||
best_target = None
|
||||
for lv in levels:
|
||||
distance = lv["price"] - entry_price
|
||||
if distance > 0:
|
||||
rr = distance / risk
|
||||
if rr >= 1.5:
|
||||
quality = _compute_quality_score(rr, lv["strength"], distance, entry_price)
|
||||
if quality > best_quality:
|
||||
best_quality = quality
|
||||
best_target = round(lv["price"], 4)
|
||||
|
||||
assert best_target is not None, "At least one candidate should meet threshold"
|
||||
assert selected_target == pytest.approx(best_target, abs=0.01), (
|
||||
f"Selected target {selected_target} != expected best-quality target "
|
||||
f"{best_target} (quality={best_quality:.4f})"
|
||||
)
|
||||
_assert_primary_is_most_likely_worthwhile(long_setups[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,29 +230,7 @@ async def test_property_short_selects_highest_quality(
|
||||
short_setups = [s for s in setups if s.direction == "short"]
|
||||
assert len(short_setups) == 1, "Expected exactly one short setup"
|
||||
|
||||
selected_target = short_setups[0].target
|
||||
|
||||
entry_price = bars[-1].close
|
||||
risk = short_setups[0].stop_loss - short_setups[0].entry_price
|
||||
|
||||
# Compute quality scores for all candidates that meet threshold
|
||||
best_quality = -1.0
|
||||
best_target = None
|
||||
for lv in levels:
|
||||
distance = entry_price - lv["price"]
|
||||
if distance > 0:
|
||||
rr = distance / risk
|
||||
if rr >= 1.5:
|
||||
quality = _compute_quality_score(rr, lv["strength"], distance, entry_price)
|
||||
if quality > best_quality:
|
||||
best_quality = quality
|
||||
best_target = round(lv["price"], 4)
|
||||
|
||||
assert best_target is not None, "At least one candidate should meet threshold"
|
||||
assert selected_target == pytest.approx(best_target, abs=0.01), (
|
||||
f"Selected target {selected_target} != expected best-quality target "
|
||||
f"{best_target} (quality={best_quality:.4f})"
|
||||
)
|
||||
_assert_primary_is_most_likely_worthwhile(short_setups[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models.score import CompositeScore
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
from app.services.rr_scanner_service import scan_ticker, _compute_quality_score
|
||||
from app.services.rr_scanner_service import scan_ticker
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -183,8 +183,6 @@ async def test_scan_ticker_full_flow_quality_selection_and_persistence(
|
||||
assert long_setup.entry_price == pytest.approx(expected_entry, abs=0.5)
|
||||
assert short_setup.entry_price == pytest.approx(expected_entry, abs=0.5)
|
||||
|
||||
entry = long_setup.entry_price # actual entry for R:R calculations
|
||||
|
||||
# -- Assert: stop_loss values --
|
||||
# ATR ≈ 2.0, risk = ATR × 1.5 = 3.0
|
||||
# Long stop = entry - risk, Short stop = entry + risk
|
||||
|
||||
Reference in New Issue
Block a user