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:
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,70 +182,63 @@ 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">
|
||||||
<Field label="Min Risk:Reward" htmlFor="min-rr">
|
<Dropdown
|
||||||
<div className="flex items-center gap-1">
|
id="direction"
|
||||||
<span className="text-sm text-gray-400">1 :</span>
|
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">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-gray-400">1 :</span>
|
||||||
|
<Input
|
||||||
|
id="min-rr"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
value={minRR}
|
||||||
|
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Min Confidence" htmlFor="min-confidence">
|
||||||
<Input
|
<Input
|
||||||
id="min-rr"
|
id="min-confidence"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={0.1}
|
max={100}
|
||||||
value={minRR}
|
step={1}
|
||||||
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
|
value={minConfidence}
|
||||||
className="w-20"
|
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
|
||||||
|
className="w-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</Field>
|
<Field label="Recommended Action" htmlFor="action">
|
||||||
<Field label="Direction" htmlFor="direction">
|
<Dropdown
|
||||||
<Dropdown
|
id="action"
|
||||||
id="direction"
|
value={actionFilter}
|
||||||
value={directionFilter}
|
onChange={(v) => setActionFilter(v as ActionFilter)}
|
||||||
onChange={(v) => setDirectionFilter(v as DirectionFilter)}
|
className="w-56"
|
||||||
className="w-32"
|
options={[
|
||||||
options={[
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'both', label: 'Both' },
|
{ value: 'LONG_HIGH', label: RECOMMENDATION_ACTION_LABELS.LONG_HIGH },
|
||||||
{ value: 'long', label: 'Long' },
|
{ value: 'LONG_MODERATE', label: RECOMMENDATION_ACTION_LABELS.LONG_MODERATE },
|
||||||
{ value: 'short', label: 'Short' },
|
{ 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 },
|
||||||
</Field>
|
]}
|
||||||
<Field label="Min Confidence" htmlFor="min-confidence">
|
/>
|
||||||
<Input
|
</Field>
|
||||||
id="min-confidence"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={minConfidence}
|
|
||||||
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
|
|
||||||
className="w-24"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Recommended Action" htmlFor="action">
|
|
||||||
<Dropdown
|
|
||||||
id="action"
|
|
||||||
value={actionFilter}
|
|
||||||
onChange={(v) => 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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user