diff --git a/app/schemas/trade_setup.py b/app/schemas/trade_setup.py index f7881c1..9ac1ccb 100644 --- a/app/schemas/trade_setup.py +++ b/app/schemas/trade_setup.py @@ -16,6 +16,7 @@ class TradeTargetResponse(BaseModel): classification: str sr_level_id: int sr_strength: float + is_primary: bool = False class RecommendationSummaryResponse(BaseModel): diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 648e9d4..d0b0090 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -59,6 +59,7 @@ async def login(db: AsyncSession, username: str, password: str) -> str: payload = { "sub": str(user.id), + "username": user.username, "role": user.role, "exp": datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expiry_minutes), } diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index 7d4bfad..4b9b95b 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -443,6 +443,29 @@ def _build_reasoning( ) +PRIMARY_TARGET_MIN_RR = 1.5 + + +def _select_primary_target(targets: list[dict], min_rr: float = PRIMARY_TARGET_MIN_RR) -> dict | None: + """Primary = the most LIKELY target that still offers real asymmetry. + + Among targets clearing a minimal R:R floor, pick the highest probability + (tie-break by R:R). This fixes the old pick, which ignored probability and + could land on the furthest, least-likely 'lottery' level. Stronger-reward + levels remain in the table as stretch targets. Falls back to the highest-R:R + target if nothing clears the floor. + """ + if not targets: + return None + + worthwhile = [t for t in targets if float(t.get("rr_ratio", 0.0)) >= min_rr] + pool = worthwhile or targets + return max( + pool, + key=lambda t: (float(t.get("probability", 0.0)), float(t.get("rr_ratio", 0.0))), + ) + + async def enhance_trade_setup( db: AsyncSession, ticker: Ticker, @@ -494,6 +517,17 @@ async def enhance_trade_setup( config=config, ) + # Primary target = most-likely target with real asymmetry (see + # _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) + if primary is not None: + for target in targets: + target["is_primary"] = target is primary + setup.target = round(float(primary["price"]), 4) + setup.rr_ratio = round(float(primary["rr_ratio"]), 4) + # Per-setup conflicts (target availability is specific to this setup) setup_conflicts = list(conflicts) if len(targets) < 3: diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 4efdae1..858afab 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -70,7 +70,7 @@ export default function Sidebar() { {username && ( -

{username}

+

Signed in as {username}

)} + + + {/* Filters — qualified gate on top, refinements below */} +
- Manual filters below refine within this. -
- {/* Filter toolbar */} -
- -
- 1 : +
+ + setDirectionFilter(v as DirectionFilter)} + className="w-32" + options={[ + { value: 'both', label: 'Both' }, + { value: 'long', label: 'Long' }, + { value: 'short', label: 'Short' }, + ]} + /> + + +
+ 1 : + setMinRR(Number(e.target.value) || 0)} + className="w-20" + /> +
+
+ setMinRR(Number(e.target.value) || 0)} - className="w-20" + max={100} + step={1} + value={minConfidence} + onChange={(e) => setMinConfidence(Number(e.target.value) || 0)} + className="w-24" /> -
- - - setDirectionFilter(v as DirectionFilter)} - className="w-32" - options={[ - { value: 'both', label: 'Both' }, - { value: 'long', label: 'Long' }, - { value: 'short', label: 'Short' }, - ]} - /> - - - setMinConfidence(Number(e.target.value) || 0)} - className="w-24" - /> - - - setActionFilter(v as ActionFilter)} - className="w-56" - options={[ - { value: 'all', label: 'All' }, - { value: 'LONG_HIGH', label: RECOMMENDATION_ACTION_LABELS.LONG_HIGH }, - { value: 'LONG_MODERATE', label: RECOMMENDATION_ACTION_LABELS.LONG_MODERATE }, - { value: 'SHORT_HIGH', label: RECOMMENDATION_ACTION_LABELS.SHORT_HIGH }, - { value: 'SHORT_MODERATE', label: RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE }, - { value: 'NEUTRAL', label: RECOMMENDATION_ACTION_LABELS.NEUTRAL }, - ]} - /> - -
- + + + setActionFilter(v as ActionFilter)} + className="w-56" + options={[ + { value: 'all', label: 'All' }, + { value: 'LONG_HIGH', label: RECOMMENDATION_ACTION_LABELS.LONG_HIGH }, + { value: 'LONG_MODERATE', label: RECOMMENDATION_ACTION_LABELS.LONG_MODERATE }, + { value: 'SHORT_HIGH', label: RECOMMENDATION_ACTION_LABELS.SHORT_HIGH }, + { value: 'SHORT_MODERATE', label: RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE }, + { value: 'NEUTRAL', label: RECOMMENDATION_ACTION_LABELS.NEUTRAL }, + ]} + /> +
diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 1fc8d53..87123b0 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -60,8 +60,14 @@ function TargetTable({ setup }: { setup: TradeSetup }) { {setup.targets.map((target) => ( - - {target.classification} + + + {target.is_primary && } + {target.classification} + {formatPrice(target.price)} {formatPercent((target.distance_from_entry / setup.entry_price) * 100)} {target.rr_ratio.toFixed(2)} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index cfab29c..d57519b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -195,6 +195,7 @@ export interface TradeTarget { classification: 'Conservative' | 'Moderate' | 'Aggressive'; sr_level_id: number; sr_strength: number; + is_primary?: boolean; } export interface RecommendationSummary { diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 1b61196..763d601 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -8,7 +8,7 @@ export interface AuthState { logout: () => void; } -function decodeJwtPayload(token: string): { sub?: string; role?: string } { +function decodeJwtPayload(token: string): { sub?: string; username?: string; role?: string } { try { const base64 = token.split('.')[1]; const json = atob(base64); @@ -23,7 +23,8 @@ export const useAuthStore = create()((set) => ({ username: (() => { const t = localStorage.getItem('token'); if (!t) return null; - return decodeJwtPayload(t).sub ?? null; + const p = decodeJwtPayload(t); + return p.username ?? p.sub ?? null; })(), role: (() => { const t = localStorage.getItem('token'); @@ -37,7 +38,7 @@ export const useAuthStore = create()((set) => ({ localStorage.setItem('token', token); set({ token, - username: payload.sub ?? null, + username: payload.username ?? payload.sub ?? null, role: payload.role === 'admin' ? 'admin' : 'user', }); }, diff --git a/tests/unit/test_recommendation_service.py b/tests/unit/test_recommendation_service.py index 38fad98..71033bb 100644 --- a/tests/unit/test_recommendation_service.py +++ b/tests/unit/test_recommendation_service.py @@ -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}, diff --git a/tests/unit/test_rr_scanner_fix_check.py b/tests/unit/test_rr_scanner_fix_check.py index 6810b0a..72a83b1 100644 --- a/tests/unit/test_rr_scanner_fix_check.py +++ b/tests/unit/test_rr_scanner_fix_check.py @@ -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]) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_rr_scanner_integration.py b/tests/unit/test_rr_scanner_integration.py index 94e1327..cfb983e 100644 --- a/tests/unit/test_rr_scanner_integration.py +++ b/tests/unit/test_rr_scanner_integration.py @@ -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