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}
)}