Fix sidebar username, Signals filter clarity and layout
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s

- 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:
2026-06-14 12:11:39 +02:00
parent 33f6baca6b
commit 5a0e8c8258
11 changed files with 178 additions and 125 deletions
+1
View File
@@ -16,6 +16,7 @@ class TradeTargetResponse(BaseModel):
classification: str classification: str
sr_level_id: int sr_level_id: int
sr_strength: float sr_strength: float
is_primary: bool = False
class RecommendationSummaryResponse(BaseModel): class RecommendationSummaryResponse(BaseModel):
+1
View File
@@ -59,6 +59,7 @@ async def login(db: AsyncSession, username: str, password: str) -> str:
payload = { payload = {
"sub": str(user.id), "sub": str(user.id),
"username": user.username,
"role": user.role, "role": user.role,
"exp": datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expiry_minutes), "exp": datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expiry_minutes),
} }
+34
View File
@@ -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( async def enhance_trade_setup(
db: AsyncSession, db: AsyncSession,
ticker: Ticker, ticker: Ticker,
@@ -494,6 +517,17 @@ async def enhance_trade_setup(
config=config, 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) # Per-setup conflicts (target availability is specific to this setup)
setup_conflicts = list(conflicts) setup_conflicts = list(conflicts)
if len(targets) < 3: if len(targets) < 3:
+1 -1
View File
@@ -70,7 +70,7 @@ export default function Sidebar() {
</span> </span>
</div> </div>
{username && ( {username && (
<p className="text-xs text-gray-500 truncate px-1">{username}</p> <p className="text-xs text-gray-500 truncate px-1">Signed in as {username}</p>
)} )}
<button <button
onClick={logout} onClick={logout}
+43 -27
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation'; import { useActivation } from '../../hooks/useActivation';
import { useTrades } from '../../hooks/useTrades'; import { useTrades } from '../../hooks/useTrades';
@@ -101,8 +101,8 @@ export function SetupsPanel() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const toast = useToast(); const toast = useToast();
// "Qualified only" applies the admin activation gate; the manual filters // "Qualified only" applies the admin activation gate; the refinement filters
// below refine within whatever is shown. // can raise the bar further.
const [qualifiedOnly, setQualifiedOnly] = useState(true); const [qualifiedOnly, setQualifiedOnly] = useState(true);
const [minRR, setMinRR] = useState(0); const [minRR, setMinRR] = useState(0);
const [minConfidence, setMinConfidence] = useState(0); const [minConfidence, setMinConfidence] = useState(0);
@@ -111,6 +111,19 @@ export function SetupsPanel() {
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio'); const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc'); const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
// Keep the Min R:R / Min Confidence inputs showing the *effective* floor: when
// qualified-only is on they reflect the activation gate (so they're never a
// misleading 0); off, they reset to 0 (no minimum).
useEffect(() => {
if (qualifiedOnly && activation.data) {
setMinRR(activation.data.min_rr);
setMinConfidence(activation.data.min_confidence);
} else if (!qualifiedOnly) {
setMinRR(0);
setMinConfidence(0);
}
}, [qualifiedOnly, activation.data]);
const scanMutation = useMutation({ const scanMutation = useMutation({
mutationFn: () => triggerJob('rr_scanner'), mutationFn: () => triggerJob('rr_scanner'),
onSuccess: () => { onSuccess: () => {
@@ -142,9 +155,19 @@ export function SetupsPanel() {
}, [trades, qualifiedOnly, activation.data, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]); }, [trades, qualifiedOnly, activation.data, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* Qualified gate toggle */} {/* Action row — Run Scanner is a job trigger, kept apart from the filters */}
<div className="glass-sm flex flex-wrap items-center justify-between gap-3 px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-gray-500">
Setups from the latest scan. Re-run to refresh against current prices.
</p>
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
{scanMutation.isPending ? 'Scanning…' : 'Run Scanner'}
</Button>
</div>
{/* Filters — qualified gate on top, refinements below */}
<div className="glass-sm space-y-4 p-4">
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-gray-300"> <label className="flex cursor-pointer items-center gap-2.5 text-sm text-gray-300">
<input <input
type="checkbox" type="checkbox"
@@ -159,11 +182,21 @@ export function SetupsPanel() {
)} )}
</span> </span>
</label> </label>
<span className="text-xs text-gray-500">Manual filters below refine within this.</span>
</div>
{/* Filter toolbar */} <div className="flex flex-wrap items-end gap-4 border-t border-white/[0.06] pt-4">
<div className="glass-sm flex flex-wrap items-end gap-4 p-4"> <Field label="Direction" htmlFor="direction">
<Dropdown
id="direction"
value={directionFilter}
onChange={(v) => setDirectionFilter(v as DirectionFilter)}
className="w-32"
options={[
{ value: 'both', label: 'Both' },
{ value: 'long', label: 'Long' },
{ value: 'short', label: 'Short' },
]}
/>
</Field>
<Field label="Min Risk:Reward" htmlFor="min-rr"> <Field label="Min Risk:Reward" htmlFor="min-rr">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-sm text-gray-400">1 :</span> <span className="text-sm text-gray-400">1 :</span>
@@ -178,19 +211,6 @@ export function SetupsPanel() {
/> />
</div> </div>
</Field> </Field>
<Field label="Direction" htmlFor="direction">
<Dropdown
id="direction"
value={directionFilter}
onChange={(v) => setDirectionFilter(v as DirectionFilter)}
className="w-32"
options={[
{ value: 'both', label: 'Both' },
{ value: 'long', label: 'Long' },
{ value: 'short', label: 'Short' },
]}
/>
</Field>
<Field label="Min Confidence" htmlFor="min-confidence"> <Field label="Min Confidence" htmlFor="min-confidence">
<Input <Input
id="min-confidence" id="min-confidence"
@@ -219,10 +239,6 @@ export function SetupsPanel() {
]} ]}
/> />
</Field> </Field>
<div className="ml-auto">
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
{scanMutation.isPending ? 'Scanning…' : 'Run Scanner'}
</Button>
</div> </div>
</div> </div>
@@ -60,8 +60,14 @@ function TargetTable({ setup }: { setup: TradeSetup }) {
</thead> </thead>
<tbody> <tbody>
{setup.targets.map((target) => ( {setup.targets.map((target) => (
<tr key={`${setup.id}-${target.sr_level_id}-${target.price}`} className="border-b border-white/[0.04]"> <tr
<td className="py-2 pr-3 text-gray-300">{target.classification}</td> key={`${setup.id}-${target.sr_level_id}-${target.price}`}
className={`border-b border-white/[0.04] ${target.is_primary ? 'bg-blue-400/10' : ''}`}
>
<td className="py-2 pr-3 text-gray-300">
{target.is_primary && <span className="mr-1 text-blue-300"></span>}
{target.classification}
</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td> <td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td> <td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{target.rr_ratio.toFixed(2)}</td> <td className="py-2 pr-3 font-mono text-gray-200">{target.rr_ratio.toFixed(2)}</td>
+1
View File
@@ -195,6 +195,7 @@ export interface TradeTarget {
classification: 'Conservative' | 'Moderate' | 'Aggressive'; classification: 'Conservative' | 'Moderate' | 'Aggressive';
sr_level_id: number; sr_level_id: number;
sr_strength: number; sr_strength: number;
is_primary?: boolean;
} }
export interface RecommendationSummary { export interface RecommendationSummary {
+4 -3
View File
@@ -8,7 +8,7 @@ export interface AuthState {
logout: () => void; logout: () => void;
} }
function decodeJwtPayload(token: string): { sub?: string; role?: string } { function decodeJwtPayload(token: string): { sub?: string; username?: string; role?: string } {
try { try {
const base64 = token.split('.')[1]; const base64 = token.split('.')[1];
const json = atob(base64); const json = atob(base64);
@@ -23,7 +23,8 @@ export const useAuthStore = create<AuthState>()((set) => ({
username: (() => { username: (() => {
const t = localStorage.getItem('token'); const t = localStorage.getItem('token');
if (!t) return null; if (!t) return null;
return decodeJwtPayload(t).sub ?? null; const p = decodeJwtPayload(t);
return p.username ?? p.sub ?? null;
})(), })(),
role: (() => { role: (() => {
const t = localStorage.getItem('token'); const t = localStorage.getItem('token');
@@ -37,7 +38,7 @@ export const useAuthStore = create<AuthState>()((set) => ({
localStorage.setItem('token', token); localStorage.setItem('token', token);
set({ set({
token, token,
username: payload.sub ?? null, username: payload.username ?? payload.sub ?? null,
role: payload.role === 'admin' ? 'admin' : 'user', role: payload.role === 'admin' ? 'admin' : 'user',
}); });
}, },
+26
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from app.services.recommendation_service import ( from app.services.recommendation_service import (
_build_reasoning, _build_reasoning,
_choose_recommended_action, _choose_recommended_action,
_select_primary_target,
direction_analyzer, direction_analyzer,
probability_estimator, probability_estimator,
signal_conflict_detector, signal_conflict_detector,
@@ -102,6 +103,31 @@ def test_reasoning_explains_missing_setup():
assert "no high-conviction long setup" in reasoning.lower() 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(): def test_detects_sentiment_technical_conflict():
conflicts = signal_conflict_detector.detect_conflicts( conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0}, dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},
+20 -51
View File
@@ -18,7 +18,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker 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"] long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup" assert len(long_setups) == 1, "Expected exactly one long setup"
selected_target = long_setups[0].target _assert_primary_is_most_likely_worthwhile(long_setups[0])
# 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})"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -239,29 +230,7 @@ async def test_property_short_selects_highest_quality(
short_setups = [s for s in setups if s.direction == "short"] short_setups = [s for s in setups if s.direction == "short"]
assert len(short_setups) == 1, "Expected exactly one short setup" assert len(short_setups) == 1, "Expected exactly one short setup"
selected_target = short_setups[0].target _assert_primary_is_most_likely_worthwhile(short_setups[0])
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})"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -3
View File
@@ -19,7 +19,7 @@ from app.models.score import CompositeScore
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup 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 long_setup.entry_price == pytest.approx(expected_entry, abs=0.5)
assert short_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 -- # -- Assert: stop_loss values --
# ATR ≈ 2.0, risk = ATR × 1.5 = 3.0 # ATR ≈ 2.0, risk = ATR × 1.5 = 3.0
# Long stop = entry - risk, Short stop = entry + risk # Long stop = entry - risk, Short stop = entry + risk