fix: align production defaults and close review parity gaps
Ship greenfield min_rr=2.0 and conf=0, read-only Structural S/R, indicator cache invalidation, and UI/gate language that treats GTL as screening not exit. Align strategy_rank missing-vol fallback live vs backtest, single-source PRIMARY_TARGET_MIN_RR, expand prod parity tests, and drop dead FE clients.
This commit is contained in:
@@ -76,13 +76,13 @@ async def get_trade_performance(
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Aggregate outcome statistics over evaluated trade setups.
|
||||
"""Aggregate setup-outcome statistics (gate barrier diagnostic).
|
||||
|
||||
Outcomes are written by the nightly outcome_evaluator job (win = target
|
||||
hit first, loss = stop hit first, expired = neither within the window).
|
||||
With qualified_only, the overall/direction/action breakdowns cover only
|
||||
setups clearing the activation gate; the confidence breakdown always
|
||||
covers all setups so the gate can be validated against it.
|
||||
Outcomes come from the nightly outcome_evaluator: win = gate target first,
|
||||
loss = stop first, expired = neither in the window. This is **not** the
|
||||
production ATR-trail book; it checks setup grading plumbing only.
|
||||
With qualified_only, overall/direction/action cover only gate-clearing
|
||||
setups; the confidence breakdown always covers all setups.
|
||||
"""
|
||||
config = await admin_service.get_activation_config(db) if qualified_only else None
|
||||
stats = await get_performance_stats(db, config=config)
|
||||
|
||||
@@ -54,7 +54,9 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
|
||||
}
|
||||
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
||||
"min_momentum_percentile": 80.0,
|
||||
"min_rr": 1.2,
|
||||
# Production floor from the 2026-07-12 min_rr sweep (in-sample and OOS peak).
|
||||
# 1.2 was the old code default and the trough next to the spike — do not restore.
|
||||
"min_rr": 2.0,
|
||||
# 0 = off. The July 2026 gate ablation showed the confidence floor added
|
||||
# nothing (identical net/trade with it removed, under both exit models)
|
||||
# while cutting ~25% of qualified trades.
|
||||
|
||||
@@ -59,6 +59,7 @@ from app.services.admin_service import get_activation_config, update_setting
|
||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||
from app.services.momentum_service import (
|
||||
STRATEGY_RANK_MOMENTUM_WEIGHT,
|
||||
blend_strategy_rank,
|
||||
compute_realized_vol_6m,
|
||||
)
|
||||
from app.services.outcome_service import (
|
||||
@@ -77,6 +78,7 @@ from app.services.qualification import (
|
||||
setup_qualifies,
|
||||
)
|
||||
from app.services.recommendation_service import (
|
||||
PRIMARY_TARGET_MIN_RR,
|
||||
_choose_recommended_action,
|
||||
_classify_by_probability,
|
||||
_prune_floor_pinned_targets,
|
||||
@@ -337,11 +339,11 @@ def _window_setups(
|
||||
targets = _prune_floor_pinned_targets(targets)
|
||||
primary = _select_primary_target(
|
||||
targets,
|
||||
min_rr=1.5,
|
||||
min_rr=PRIMARY_TARGET_MIN_RR,
|
||||
)
|
||||
if primary is None:
|
||||
continue
|
||||
# Flag the primary so qualification's EV uses the primary target's
|
||||
# Flag the primary so qualification uses the primary target's
|
||||
# probability (matching production's enhance_trade_setup).
|
||||
for t in targets:
|
||||
t["is_primary"] = t is primary
|
||||
@@ -1265,15 +1267,27 @@ def _assign_weighted_blend(
|
||||
primary_weight: float,
|
||||
secondary_key: str,
|
||||
) -> None:
|
||||
secondary_weight = 1.0 - primary_weight
|
||||
"""Blend ranks; fall back to primary when secondary is missing.
|
||||
|
||||
Matches live ``blend_strategy_rank`` for the production 80/20 key: a name
|
||||
with residual momentum but no vol history keeps its mom percentile instead
|
||||
of ranking as 0 / None at the bottom of the book.
|
||||
"""
|
||||
for c in candidates:
|
||||
primary = c.get(primary_key)
|
||||
secondary = c.get(secondary_key)
|
||||
c[output_key] = (
|
||||
primary * primary_weight + secondary * secondary_weight
|
||||
if primary is not None and secondary is not None
|
||||
else None
|
||||
# Production weight path: reuse the shared helper so live/sim cannot drift.
|
||||
if primary_weight == STRATEGY_RANK_MOMENTUM_WEIGHT:
|
||||
c[output_key] = blend_strategy_rank(
|
||||
None if primary is None else float(primary),
|
||||
None if secondary is None else float(secondary),
|
||||
momentum_weight=primary_weight,
|
||||
)
|
||||
continue
|
||||
if primary is not None and secondary is not None:
|
||||
c[output_key] = primary * primary_weight + secondary * (1.0 - primary_weight)
|
||||
else:
|
||||
c[output_key] = primary
|
||||
|
||||
|
||||
def _assign_residual_low_vol_blend(candidates: list[dict]) -> None:
|
||||
|
||||
@@ -34,6 +34,28 @@ STRATEGY_RANK_MOMENTUM_WEIGHT = 0.8
|
||||
STRATEGY_RANK_VOL_WEIGHT = 1.0 - STRATEGY_RANK_MOMENTUM_WEIGHT
|
||||
|
||||
|
||||
def blend_strategy_rank(
|
||||
momentum_percentile: float | None,
|
||||
volatility_percentile: float | None,
|
||||
*,
|
||||
momentum_weight: float = STRATEGY_RANK_MOMENTUM_WEIGHT,
|
||||
) -> float | None:
|
||||
"""80/20 production rank with mom-only fallback when vol is missing.
|
||||
|
||||
Live and backtest must share this policy: missing vol must not send a
|
||||
residual-qualified name to the bottom of the book (that was the old
|
||||
backtest behaviour when either leg was None).
|
||||
"""
|
||||
if momentum_percentile is not None and volatility_percentile is not None:
|
||||
vol_weight = 1.0 - momentum_weight
|
||||
return round(
|
||||
float(momentum_percentile) * momentum_weight
|
||||
+ float(volatility_percentile) * vol_weight,
|
||||
2,
|
||||
)
|
||||
return float(momentum_percentile) if momentum_percentile is not None else None
|
||||
|
||||
|
||||
def compute_12_1_momentum(closes: list[float]) -> float | None:
|
||||
"""Return over the window ending ~1 month ago, starting ~12 months ago.
|
||||
None when there isn't a full year of history."""
|
||||
@@ -100,41 +122,17 @@ async def _load_activation_benchmark(db: AsyncSession) -> dict[date, float]:
|
||||
|
||||
|
||||
async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
|
||||
"""Compute each ticker's activation momentum rank.
|
||||
"""Momentum leg only — thin view of ``compute_activation_ranks``.
|
||||
|
||||
Production uses residual 12-1 momentum when benchmark data is available. If
|
||||
SPY data is absent, fall back to raw 12-1 momentum rather than disabling the
|
||||
scanner. Tickers without enough stock/benchmark history are absent.
|
||||
Prefer ``compute_activation_ranks`` in new code (includes vol + strategy_rank).
|
||||
Kept so tests/helpers that only need the residual/raw percentile map stay simple.
|
||||
"""
|
||||
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
||||
tickers = list(result.scalars().all())
|
||||
|
||||
benchmark_closes = await _load_activation_benchmark(db)
|
||||
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK
|
||||
|
||||
values: dict[str, float] = {}
|
||||
for ticker in tickers:
|
||||
try:
|
||||
records = await query_ohlcv(db, ticker.symbol)
|
||||
except Exception:
|
||||
logger.exception("Momentum fetch failed for %s", ticker.symbol)
|
||||
continue
|
||||
closes = [float(r.close) for r in records]
|
||||
value = (
|
||||
compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes)
|
||||
if using_residual
|
||||
else compute_12_1_momentum(closes)
|
||||
)
|
||||
if value is not None:
|
||||
values[ticker.symbol] = value
|
||||
|
||||
percentiles = _percentiles(values)
|
||||
logger.info(json.dumps({
|
||||
"event": "momentum_ranked",
|
||||
"signal": "residual_12_1" if using_residual else "raw_12_1_fallback",
|
||||
"tickers": len(percentiles),
|
||||
}))
|
||||
return percentiles
|
||||
ranks = await compute_activation_ranks(db)
|
||||
return {
|
||||
sym: float(row["momentum_percentile"])
|
||||
for sym, row in ranks.items()
|
||||
if row.get("momentum_percentile") is not None
|
||||
}
|
||||
|
||||
|
||||
def compute_realized_vol_6m(closes: list[float]) -> float | None:
|
||||
@@ -204,19 +202,10 @@ async def compute_activation_ranks(db: AsyncSession) -> dict[str, dict[str, floa
|
||||
for sym in symbols:
|
||||
momentum_pct = momentum_percentiles.get(sym)
|
||||
vol_pct = vol_percentiles.get(sym)
|
||||
strategy_rank = (
|
||||
round(
|
||||
momentum_pct * STRATEGY_RANK_MOMENTUM_WEIGHT
|
||||
+ vol_pct * STRATEGY_RANK_VOL_WEIGHT,
|
||||
2,
|
||||
)
|
||||
if momentum_pct is not None and vol_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[sym] = {
|
||||
"momentum_percentile": momentum_pct,
|
||||
"volatility_percentile": vol_pct,
|
||||
"strategy_rank": strategy_rank,
|
||||
"strategy_rank": blend_strategy_rank(momentum_pct, vol_pct),
|
||||
}
|
||||
|
||||
logger.info(json.dumps({
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Trade setup outcome evaluation service.
|
||||
|
||||
Closes the feedback loop on R:R scanner setups: walks daily OHLCV bars
|
||||
after detection and records whether the stop or the target was hit first.
|
||||
Diagnostic barrier resolution for scanner setups: walks daily OHLCV bars
|
||||
after detection and records whether the gate target or the stop was hit first.
|
||||
|
||||
This is **not** the production exit model. Live paper trades and the portfolio
|
||||
monitor use ATR trail / max hold and never exit at the gate target. Track-record
|
||||
stats from this path measure gate-level plumbing, not ATR-trail book expectancy.
|
||||
|
||||
Outcome semantics (entry is the close at detection time, i.e. market entry):
|
||||
- target_hit: target reached before the stop
|
||||
- stop_hit: stop reached before the target
|
||||
- target_hit: gate target reached before the stop
|
||||
- stop_hit: stop reached before the gate target
|
||||
- ambiguous: stop AND target both within the same daily bar — with daily
|
||||
granularity the order is unknowable, counted as a loss in stats
|
||||
- expired: neither level hit within ``max_bars`` trading days
|
||||
|
||||
@@ -80,8 +80,9 @@ async def upsert_ohlcv(
|
||||
|
||||
record = result.scalar_one()
|
||||
|
||||
# TODO: Invalidate LRU cache entries for this ticker (Task 7.1)
|
||||
# TODO: Mark composite score as stale for this ticker (Task 10.1)
|
||||
from app.cache import indicator_cache
|
||||
|
||||
indicator_cache.invalidate_ticker(ticker.symbol)
|
||||
|
||||
return record
|
||||
|
||||
|
||||
@@ -618,6 +618,10 @@ def build_recommendation_snapshot(
|
||||
# agree on what counts as a probability-backed target.
|
||||
PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY
|
||||
|
||||
# Primary-target selector floor (independent of the live activation min_rr).
|
||||
# Live scanner and backtest setup replay must share this constant.
|
||||
PRIMARY_TARGET_MIN_RR = 1.5
|
||||
|
||||
|
||||
def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]:
|
||||
"""Keep only the nearest target pinned at the probability clamp floor.
|
||||
|
||||
@@ -35,6 +35,7 @@ from app.services.trade_policy import (
|
||||
observe_reentry_gate_transitions,
|
||||
)
|
||||
from app.services.recommendation_service import (
|
||||
PRIMARY_TARGET_MIN_RR,
|
||||
_risk_level_from_conflicts,
|
||||
build_recommendation_snapshot,
|
||||
enhance_trade_setup,
|
||||
@@ -44,7 +45,6 @@ from app.services.recommendation_service import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
|
||||
PRIMARY_TARGET_MIN_RR = 1.5
|
||||
|
||||
# A setup counts as live only while the daily scan keeps re-emitting it. The
|
||||
# scan runs every day (07:00 UTC cron), so anything older than this was NOT
|
||||
@@ -743,14 +743,15 @@ async def scan_all_tickers(
|
||||
for index, (ticker_id, symbol) in enumerate(ticker_rows):
|
||||
if progress_callback is not None:
|
||||
progress_callback(index, total, symbol)
|
||||
# Refresh scores first so the scheduled scan works off current data.
|
||||
# Nothing else marks scores stale, so without this they'd never update
|
||||
# for tickers the user doesn't manually fetch. A refresh failure still
|
||||
# scans the ticker: qualification re-gates on live scores at alert
|
||||
# time, so a stale score is recoverable but a skipped scan is not.
|
||||
# Refresh Structural S/R once, then scores. get_sr_levels is read-only;
|
||||
# without this recalculate the score path would see yesterday's zones.
|
||||
# A refresh failure still scans the ticker: qualification re-gates on
|
||||
# live scores at alert time, so a stale score is recoverable but a
|
||||
# skipped scan is not.
|
||||
try:
|
||||
from app.services import scoring_service
|
||||
from app.services import scoring_service, sr_service
|
||||
|
||||
await sr_service.recalculate_sr_levels(db, symbol)
|
||||
await scoring_service.compute_all_dimensions(db, symbol)
|
||||
await scoring_service.compute_composite_score(db, symbol)
|
||||
await db.commit()
|
||||
|
||||
@@ -857,8 +857,17 @@ async def get_sr_levels(
|
||||
symbol: str,
|
||||
tolerance: float | None = None,
|
||||
) -> list[SRLevel]:
|
||||
"""Get S/R levels for a ticker, recalculating on every request (MVP).
|
||||
"""Return persisted Structural S/R levels, strength descending.
|
||||
|
||||
Returns levels sorted by strength descending.
|
||||
Read-only: does not recompute or rewrite. Pipeline/ingestion call
|
||||
``recalculate_sr_levels`` to refresh. ``tolerance`` is kept for API
|
||||
compatibility and ignored on read (it only applies at recalculation).
|
||||
"""
|
||||
return await recalculate_sr_levels(db, symbol, tolerance)
|
||||
del tolerance # API compat only; levels were stored at last recalculation
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
result = await db.execute(
|
||||
select(SRLevel)
|
||||
.where(SRLevel.ticker_id == ticker.id)
|
||||
.order_by(SRLevel.strength.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -56,12 +56,6 @@ export function updateSetting(key: string, value: string) {
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updateRegistration(enabled: boolean) {
|
||||
return apiClient
|
||||
.put<{ message: string }>('admin/settings/registration', { enabled })
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getRecommendationSettings() {
|
||||
return apiClient
|
||||
.get<RecommendationConfig>('admin/settings/recommendations')
|
||||
|
||||
@@ -14,7 +14,3 @@ export function list(params?: TradeListParams) {
|
||||
export function bySymbol(symbol: string) {
|
||||
return apiClient.get<TradeSetup[]>(`trades/${symbol.toUpperCase()}`).then((r) => r.data);
|
||||
}
|
||||
|
||||
export function history(symbol: string) {
|
||||
return apiClient.get<TradeSetup[]>(`trades/${symbol.toUpperCase()}/history`).then((r) => r.data);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import type { ActivationConfig } from '../../lib/types';
|
||||
import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
|
||||
/** Mirrors app.services.admin_service.ACTIVATION_DEFAULTS — keep in sync. */
|
||||
const DEFAULTS: ActivationConfig = {
|
||||
min_momentum_percentile: 80,
|
||||
min_rr: 1.2,
|
||||
min_confidence: 55,
|
||||
min_rr: 2.0,
|
||||
min_confidence: 0,
|
||||
require_high_conviction: false,
|
||||
exclude_conflicts: false,
|
||||
exclude_neutral: true,
|
||||
|
||||
@@ -39,7 +39,7 @@ export function RBar({ r, max = 1.6 }: { r: number | null; max?: number }) {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PriceRail — stop → entry → now → target laid out spatially */
|
||||
/* PriceRail — stop → entry → now → gate level laid out spatially */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function PriceRail({
|
||||
@@ -69,7 +69,7 @@ export function PriceRail({
|
||||
const progressWidth = current != null ? Math.abs(pct(current) - pct(entry)) : 0;
|
||||
return (
|
||||
<div className="hz-rail" role="img" aria-label={
|
||||
`Stop ${fmt(stop)}, entry ${fmt(entry)}, now ${current != null ? fmt(current) : 'unknown'}, target ${fmt(target)}`
|
||||
`Stop ${fmt(stop)}, entry ${fmt(entry)}, now ${current != null ? fmt(current) : 'unknown'}, gate ${fmt(target)}`
|
||||
}>
|
||||
<div className="hz-rail-track" />
|
||||
<div
|
||||
@@ -111,10 +111,10 @@ export function PriceRail({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(target)}%` }}>
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(target)}%` }} title="Gate level — screening only, not a take-profit">
|
||||
<span className="hz-rail-ring" />
|
||||
<span className="hz-rail-label">
|
||||
<em>target</em>
|
||||
<em>gate</em>
|
||||
<b>{fmt(target)}</b>
|
||||
{rTarget != null && <i>+{fmt(rTarget, 1)}R</i>}
|
||||
</span>
|
||||
|
||||
@@ -4,7 +4,25 @@ import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
|
||||
import { primaryTarget } from '../../lib/qualification';
|
||||
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
|
||||
|
||||
export type SortColumn = 'symbol' | 'direction' | 'recommended_action' | 'confidence_score' | 'entry_price' | 'stop_loss' | 'target' | 'primary_target_probability' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'risk_level' | 'composite_score' | 'detected_at';
|
||||
export type SortColumn =
|
||||
| 'symbol'
|
||||
| 'direction'
|
||||
| 'recommended_action'
|
||||
| 'confidence_score'
|
||||
| 'entry_price'
|
||||
| 'stop_loss'
|
||||
| 'target'
|
||||
| 'primary_target_probability'
|
||||
| 'risk_amount'
|
||||
| 'reward_amount'
|
||||
| 'rr_ratio'
|
||||
| 'stop_pct'
|
||||
| 'target_pct'
|
||||
| 'risk_level'
|
||||
| 'composite_score'
|
||||
| 'strategy_rank'
|
||||
| 'momentum_percentile'
|
||||
| 'detected_at';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface TradeTableProps {
|
||||
@@ -16,20 +34,22 @@ interface TradeTableProps {
|
||||
|
||||
const columns: { key: SortColumn; label: string }[] = [
|
||||
{ key: 'symbol', label: 'Symbol' },
|
||||
{ key: 'strategy_rank', label: 'Prod rank' },
|
||||
{ key: 'momentum_percentile', label: 'Mom %ile' },
|
||||
{ key: 'recommended_action', label: 'Recommended Action' },
|
||||
{ key: 'confidence_score', label: 'Confidence' },
|
||||
{ key: 'direction', label: 'Direction' },
|
||||
{ key: 'entry_price', label: 'Entry' },
|
||||
{ key: 'stop_loss', label: 'Stop Loss' },
|
||||
{ key: 'target', label: 'Target' },
|
||||
{ key: 'primary_target_probability', label: 'Primary Target' },
|
||||
{ key: 'target', label: 'Gate level' },
|
||||
{ key: 'primary_target_probability', label: 'Gate reach' },
|
||||
{ key: 'risk_amount', label: 'Risk $' },
|
||||
{ key: 'reward_amount', label: 'Reward $' },
|
||||
{ key: 'rr_ratio', label: 'R:R' },
|
||||
{ key: 'rr_ratio', label: 'Gate R:R' },
|
||||
{ key: 'stop_pct', label: '% to Stop' },
|
||||
{ key: 'target_pct', label: '% to Target' },
|
||||
{ key: 'target_pct', label: '% to gate' },
|
||||
{ key: 'risk_level', label: 'Risk' },
|
||||
{ key: 'composite_score', label: 'Score' },
|
||||
{ key: 'composite_score', label: 'Composite' },
|
||||
{ key: 'detected_at', label: 'Detected' },
|
||||
];
|
||||
|
||||
@@ -105,6 +125,12 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
|
||||
{trade.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200" title="80% residual momentum + 20% vol — production book order">
|
||||
{trade.strategy_rank != null ? trade.strategy_rank.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200" title="Residual 12-1 momentum percentile (activation gate)">
|
||||
{trade.momentum_percentile != null ? trade.momentum_percentile.toFixed(0) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-xs font-semibold text-blue-300">{recommendationActionLabel(trade.recommended_action)}</span>
|
||||
@@ -123,15 +149,21 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{primaryTargetText(trade)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200" title="Gate Target Ladder level — screening only, not an exit">
|
||||
{formatPrice(trade.target)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200" title="Reach probability for the gate level before stop">
|
||||
{primaryTargetText(trade)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.risk_amount)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.reward_amount)}</td>
|
||||
<td className={`px-4 py-3.5 font-mono font-semibold ${rrColorClass(trade.rr_ratio)}`}>{trade.rr_ratio.toFixed(2)}</td>
|
||||
<td className={`px-4 py-3.5 font-mono font-semibold ${rrColorClass(trade.rr_ratio)}`} title="Gate R:R — not the live trail exit">
|
||||
{trade.rr_ratio.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPercent(analysis.stop_pct)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPercent(analysis.target_pct)}</td>
|
||||
<td className={`px-4 py-3.5 font-semibold ${riskLevelClass(trade.risk_level)}`}>{trade.risk_level ?? '—'}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<td className="px-4 py-3.5" title="Display quality only — does not select trades">
|
||||
<span className={`font-semibold ${trade.composite_score > 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{Math.round(trade.composite_score)}
|
||||
</span>
|
||||
|
||||
@@ -44,6 +44,10 @@ function getComputedValue(trade: TradeSetup, column: SortColumn): number {
|
||||
case 'confidence_score': return trade.confidence_score ?? -1;
|
||||
case 'primary_target_probability':
|
||||
return primaryTargetProbability(trade) ?? -1;
|
||||
case 'strategy_rank':
|
||||
return trade.strategy_rank ?? trade.momentum_percentile ?? -1;
|
||||
case 'momentum_percentile':
|
||||
return trade.momentum_percentile ?? -1;
|
||||
case 'risk_level':
|
||||
if (trade.risk_level === 'Low') return 1;
|
||||
if (trade.risk_level === 'Medium') return 2;
|
||||
@@ -79,6 +83,8 @@ function sortTrades(
|
||||
case 'target_pct':
|
||||
case 'confidence_score':
|
||||
case 'primary_target_probability':
|
||||
case 'strategy_rank':
|
||||
case 'momentum_percentile':
|
||||
case 'risk_level':
|
||||
cmp = getComputedValue(a, column) - getComputedValue(b, column);
|
||||
break;
|
||||
@@ -108,7 +114,8 @@ export function SetupsPanel() {
|
||||
const [minConfidence, setMinConfidence] = useState(0);
|
||||
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
|
||||
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
|
||||
// Production book orders by 80/20 strategy_rank, not raw R:R.
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('strategy_rank');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
// Keep the Min R:R / Min Confidence inputs showing the *effective* floor: when
|
||||
@@ -244,10 +251,11 @@ export function SetupsPanel() {
|
||||
|
||||
<Disclosure summary="How the scanner works & action glossary">
|
||||
<p className="mb-3 text-xs text-gray-400">
|
||||
The scanner identifies asymmetric risk-reward trade setups by analyzing S/R levels as
|
||||
price targets and using ATR-based stops to define risk. Click{' '}
|
||||
<span className="font-medium text-gray-300">Run Scanner</span> to scan all tickers now,
|
||||
or wait for the scheduled run.
|
||||
The scanner builds long setups with a 1.5× ATR stop and a Gate Target Ladder proposal used
|
||||
only for R:R / reach-probability screening — not as a take-profit. Structural chart S/R is
|
||||
separate. Live exit is the ATR trail / max hold. Click{' '}
|
||||
<span className="font-medium text-gray-300">Run Scanner</span> to scan all tickers now, or
|
||||
wait for the scheduled run.
|
||||
</p>
|
||||
<div className="grid gap-1 md:grid-cols-2">
|
||||
{RECOMMENDATION_ACTION_GLOSSARY.map((item) => (
|
||||
|
||||
@@ -109,19 +109,19 @@ export function TrackRecordPanel() {
|
||||
<Disclosure summary="Track-record maintenance">
|
||||
<div className="space-y-4 pt-1">
|
||||
<p className="max-w-2xl text-xs text-gray-500">
|
||||
The live check replays every setup against the daily bars after detection: target before stop =
|
||||
win, stop first = loss (both in one bar counts conservatively as a loss), neither within 30
|
||||
trading days = expired at 0R. Only setups whose full window has elapsed count; younger ones are
|
||||
still maturing (near stops resolve fast, far targets need time, so early numbers skew negative).
|
||||
The evaluator scores <span className="text-gray-300">all</span> setups — qualified or not, so
|
||||
unqualified ones stay a control group — and runs nightly.
|
||||
<span className="text-amber-300/90">Diagnostic only — not production P&L.</span>{' '}
|
||||
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
|
||||
initial stop / ATR trail / max hold — see paper trades and the portfolio monitor above.
|
||||
Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading
|
||||
days = expired at 0R. Only matured windows count. Scores{' '}
|
||||
<span className="text-gray-300">all</span> setups as a control group; runs nightly.
|
||||
</p>
|
||||
|
||||
{/* Diagnostic, not strategy validation: live target/stop outcomes vs the backtest's target/stop model. */}
|
||||
<div className="glass-sm space-y-2 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
|
||||
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-1">
|
||||
<span className="text-sm text-gray-300">Setup-outcome pipeline check</span>
|
||||
<span className="text-sm text-gray-300">Gate barrier pipeline check</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
|
||||
</span>
|
||||
@@ -129,7 +129,7 @@ export function TrackRecordPanel() {
|
||||
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · qualified target/stop
|
||||
{liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · not ATR-trail book
|
||||
</span>
|
||||
</div>
|
||||
<StatusChip status={status} />
|
||||
|
||||
@@ -203,6 +203,31 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
||||
selectedPrice?: number | null;
|
||||
onSelectPrice?: (price: number) => void;
|
||||
}) {
|
||||
// Hooks must run unconditionally (Rules of Hooks) even when setup is missing.
|
||||
const createTrade = useCreatePaperTrade();
|
||||
const [taking, setTaking] = useState(false);
|
||||
const [takeShares, setTakeShares] = useState(0);
|
||||
const [takeEntry, setTakeEntry] = useState(0);
|
||||
const [takeTarget, setTakeTarget] = useState(0);
|
||||
const [internalSel, setInternalSel] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setup) return;
|
||||
const next = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
|
||||
setTakeShares(next?.shares ?? 0);
|
||||
setTakeEntry(currentPrice ?? setup.entry_price);
|
||||
setTakeTarget(setup.target);
|
||||
}, [setup, currentPrice, risk.accountSize, risk.riskPct]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taking) return;
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setTaking(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [taking]);
|
||||
|
||||
if (!setup) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.07] p-4 text-xs text-gray-500">
|
||||
@@ -222,16 +247,9 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
||||
const exitPlan = deriveExitPlan(setup, exitPolicy);
|
||||
const honorsTarget = exitPlan?.honorsTarget ?? false;
|
||||
|
||||
const createTrade = useCreatePaperTrade();
|
||||
const [taking, setTaking] = useState(false);
|
||||
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
|
||||
const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price);
|
||||
const [takeTarget, setTakeTarget] = useState<number>(setup.target);
|
||||
|
||||
// Target choice from the ladder drives the rail, the chips, and the take
|
||||
// flow — the scanner's primary is just the default. Controlled by the page
|
||||
// when provided (so the candlestick overlay follows), else local.
|
||||
const [internalSel, setInternalSel] = useState<number | null>(null);
|
||||
const selPrice = selectedPrice !== undefined ? selectedPrice : internalSel;
|
||||
const selectTargetPrice = (p: number) => {
|
||||
if (onSelectPrice) onSelectPrice(p);
|
||||
@@ -242,16 +260,6 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
||||
const activeRR = selected?.rr_ratio ?? setup.rr_ratio;
|
||||
const activeProb = selected?.probability ?? prob;
|
||||
|
||||
// Close the take dialog on Escape.
|
||||
useEffect(() => {
|
||||
if (!taking) return;
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setTaking(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [taking]);
|
||||
|
||||
const confirmTake = () => {
|
||||
createTrade.mutate(
|
||||
{
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ReferenceLine } from 'recharts';
|
||||
import type { SRLevel } from '../../lib/types';
|
||||
import { formatPrice } from '../../lib/format';
|
||||
|
||||
interface SROverlayProps {
|
||||
levels: SRLevel[];
|
||||
}
|
||||
|
||||
export function SROverlay({ levels }: SROverlayProps) {
|
||||
return (
|
||||
<>
|
||||
{levels.map((level) => {
|
||||
const isSupport = level.type === 'support';
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={level.id}
|
||||
y={level.price_level}
|
||||
stroke={isSupport ? '#22c55e' : '#ef4444'}
|
||||
strokeDasharray="6 3"
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: formatPrice(level.price_level),
|
||||
position: 'right',
|
||||
fill: isSupport ? '#22c55e' : '#ef4444',
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -118,9 +118,9 @@ export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): s
|
||||
|
||||
/**
|
||||
* Symbol of the current single 'top pick' — the #1 row the dashboard highlights:
|
||||
* the highest residual 12-1 momentum percentile among qualified setups. Returns
|
||||
* null when there are no actionable setups. Keep in step with the Top Setups
|
||||
* ranking in DashboardPage.
|
||||
* highest production strategy_rank (80/20 mom/vol) among qualified setups,
|
||||
* falling back to residual momentum percentile. Returns null when there are no
|
||||
* actionable setups. Keep in step with the Top Setups ranking in DashboardPage.
|
||||
*/
|
||||
export function topPickSymbol(
|
||||
trades: TradeSetup[] | undefined,
|
||||
|
||||
@@ -26,7 +26,7 @@ class TestActivationConfig:
|
||||
config = await get_activation_config(session)
|
||||
assert config == {
|
||||
"min_momentum_percentile": 80.0,
|
||||
"min_rr": 1.2,
|
||||
"min_rr": 2.0,
|
||||
"min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing
|
||||
"require_high_conviction": False,
|
||||
"exclude_conflicts": False,
|
||||
@@ -47,7 +47,7 @@ class TestActivationConfig:
|
||||
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
|
||||
await update_activation_config(session, {"min_confidence": 80.0})
|
||||
config = await get_activation_config(session)
|
||||
assert config["min_rr"] == 1.2 # default untouched
|
||||
assert config["min_rr"] == 2.0 # default untouched
|
||||
assert config["min_confidence"] == 80.0
|
||||
|
||||
async def test_rejects_out_of_range_momentum_percentile(self, session: AsyncSession):
|
||||
|
||||
@@ -71,10 +71,13 @@ async def test_ranks_universe_into_raw_percentiles_when_benchmark_missing(sessio
|
||||
await _seed(session, "MID", rate=1.002)
|
||||
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum
|
||||
|
||||
ranks = await ms.compute_activation_ranks(session)
|
||||
assert ranks["HIGH"]["momentum_percentile"] == 100.0
|
||||
assert ranks["MID"]["momentum_percentile"] == 50.0
|
||||
assert ranks["LOW"]["momentum_percentile"] == 0.0
|
||||
# Thin momentum-only view stays aligned with the production ranker.
|
||||
pct = await ms.compute_momentum_percentiles(session)
|
||||
assert pct["HIGH"] == 100.0
|
||||
assert pct["MID"] == 50.0
|
||||
assert pct["LOW"] == 0.0
|
||||
assert pct == {s: ranks[s]["momentum_percentile"] for s in pct}
|
||||
|
||||
|
||||
async def test_ranks_universe_into_residual_percentiles_when_benchmark_available(session, monkeypatch):
|
||||
@@ -91,6 +94,10 @@ async def test_ranks_universe_into_residual_percentiles_when_benchmark_available
|
||||
await _seed_closes(session, "BETA", market)
|
||||
await _seed_closes(session, "LAG", [market[i] * (0.9992 ** i) for i in range(n)])
|
||||
|
||||
ranks = await ms.compute_activation_ranks(session)
|
||||
assert ranks["DRIFT"]["momentum_percentile"] == 100.0
|
||||
assert ranks["BETA"]["momentum_percentile"] == 50.0
|
||||
assert ranks["LAG"]["momentum_percentile"] == 0.0
|
||||
pct = await ms.compute_momentum_percentiles(session)
|
||||
assert pct["DRIFT"] == 100.0
|
||||
assert pct["BETA"] == 50.0
|
||||
@@ -105,6 +112,9 @@ async def test_short_history_ticker_is_unranked(session, monkeypatch):
|
||||
await _seed(session, "LONG", rate=1.005)
|
||||
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum
|
||||
|
||||
ranks = await ms.compute_activation_ranks(session)
|
||||
assert "LONG" in ranks and ranks["LONG"]["momentum_percentile"] is not None
|
||||
assert "SHORTHX" not in ranks or ranks["SHORTHX"]["momentum_percentile"] is None
|
||||
pct = await ms.compute_momentum_percentiles(session)
|
||||
assert "LONG" in pct
|
||||
assert "SHORTHX" not in pct
|
||||
@@ -115,4 +125,5 @@ async def test_empty_universe_returns_empty(session, monkeypatch):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||
assert await ms.compute_activation_ranks(session) == {}
|
||||
assert await ms.compute_momentum_percentiles(session) == {}
|
||||
|
||||
@@ -26,7 +26,11 @@ from app.services.backtest_service import (
|
||||
from app.services.momentum_service import (
|
||||
STRATEGY_RANK_MOMENTUM_WEIGHT,
|
||||
STRATEGY_RANK_VOL_WEIGHT,
|
||||
blend_strategy_rank,
|
||||
)
|
||||
from app.services.qualification import MIN_TARGET_PROBABILITY
|
||||
from app.services.recommendation_service import PRIMARY_TARGET_MIN_RR
|
||||
from app.services import rr_scanner_service
|
||||
|
||||
|
||||
def _production_monitor_row() -> dict:
|
||||
@@ -103,3 +107,149 @@ def test_live_gate_equals_the_production_variant_gate() -> None:
|
||||
assert _momentum_qualifies(cand, cutoff) == _qualifies_strategy_variant(
|
||||
cand, entry_cfg
|
||||
), cand
|
||||
|
||||
|
||||
def test_activation_defaults_match_promoted_production_gate() -> None:
|
||||
"""Greenfield Admin must ship the researched gate, not the old trough defaults."""
|
||||
assert float(ACTIVATION_DEFAULTS["min_rr"]) == 2.0
|
||||
assert float(ACTIVATION_DEFAULTS["min_confidence"]) == 0.0
|
||||
assert float(ACTIVATION_DEFAULTS["min_momentum_percentile"]) == 80.0
|
||||
assert ACTIVATION_DEFAULTS["exclude_neutral"] is True
|
||||
|
||||
|
||||
def test_primary_target_rr_floor_is_single_sourced() -> None:
|
||||
assert rr_scanner_service.PRIMARY_TARGET_MIN_RR == PRIMARY_TARGET_MIN_RR
|
||||
assert PRIMARY_TARGET_MIN_RR == 1.5
|
||||
assert MIN_TARGET_PROBABILITY == 20.0
|
||||
|
||||
|
||||
def test_strategy_rank_falls_back_to_momentum_when_vol_missing() -> None:
|
||||
"""Live and backtest must not bury a name solely because vol history is short."""
|
||||
assert blend_strategy_rank(80.0, 60.0) == 76.0
|
||||
assert blend_strategy_rank(80.0, None) == 80.0
|
||||
assert blend_strategy_rank(None, 60.0) is None
|
||||
assert blend_strategy_rank(None, None) is None
|
||||
|
||||
from app.services import backtest_service as bt
|
||||
|
||||
cands = [
|
||||
{bt.PRODUCTION_PERCENTILE_KEY: 80.0, bt.VOL_PERCENTILE_KEY: None},
|
||||
{bt.PRODUCTION_PERCENTILE_KEY: 70.0, bt.VOL_PERCENTILE_KEY: 50.0},
|
||||
]
|
||||
bt._assign_residual_high_vol_blend(cands)
|
||||
assert cands[0][bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] == 80.0
|
||||
assert cands[1][bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] == 66.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_scan_and_backtest_window_share_gtl_primary() -> None:
|
||||
"""Same OHLCV + dims: live scan_ticker primary ≡ backtest _window_setups.
|
||||
|
||||
No gate_levels_override — both paths build the production GTL from bars.
|
||||
Dimension scores are seeded to the values the backtest window computes so
|
||||
probability ranking cannot diverge for that reason alone.
|
||||
"""
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.score import DimensionScore
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import backtest_service as bt
|
||||
from app.services.recommendation_service import DEFAULT_RECOMMENDATION_CONFIG
|
||||
from app.services.rr_scanner_service import scan_ticker
|
||||
from app.services.scoring_service import (
|
||||
compute_momentum_from_closes,
|
||||
compute_technical_from_arrays,
|
||||
)
|
||||
from tests.conftest import _test_session_factory
|
||||
|
||||
n = 120
|
||||
base = date(2024, 1, 1)
|
||||
# Oscillating range so GTL finds traffic-backed proposals above/below spot.
|
||||
closes: list[float] = []
|
||||
highs: list[float] = []
|
||||
lows: list[float] = []
|
||||
volumes: list[int] = []
|
||||
price = 100.0
|
||||
for i in range(n):
|
||||
phase = i % 30
|
||||
if phase < 12:
|
||||
price = price + (94.0 - price) * 0.2
|
||||
elif phase < 24:
|
||||
price = price + (108.0 - price) * 0.2
|
||||
else:
|
||||
price = 100.0 + (i % 5) * 0.3
|
||||
high = price + 1.2
|
||||
low = price - 1.2
|
||||
close = price
|
||||
closes.append(close)
|
||||
highs.append(high)
|
||||
lows.append(low)
|
||||
volumes.append(100_000 + i * 10)
|
||||
|
||||
tech = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0
|
||||
mom = (compute_momentum_from_closes(closes)[0]) or 50.0
|
||||
|
||||
async with _test_session_factory() as session:
|
||||
ticker = Ticker(symbol="GTLPAR")
|
||||
session.add(ticker)
|
||||
await session.flush()
|
||||
bars = [
|
||||
OHLCVRecord(
|
||||
ticker_id=ticker.id,
|
||||
date=base + timedelta(days=i),
|
||||
open=closes[i] - 0.2,
|
||||
high=highs[i],
|
||||
low=lows[i],
|
||||
close=closes[i],
|
||||
volume=volumes[i],
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
session.add_all(bars)
|
||||
now = datetime.now(timezone.utc)
|
||||
session.add_all([
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id, dimension="technical", score=float(tech),
|
||||
is_stale=False, computed_at=now,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id, dimension="momentum", score=float(mom),
|
||||
is_stale=False, computed_at=now,
|
||||
),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
live = await scan_ticker(session, "GTLPAR", rr_threshold=1.5, atr_multiplier=1.5)
|
||||
# Re-load bars as plain ORM list for the pure backtest window path.
|
||||
from sqlalchemy import select
|
||||
|
||||
records = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(OHLCVRecord)
|
||||
.where(OHLCVRecord.ticker_id == ticker.id)
|
||||
.order_by(OHLCVRecord.date.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
config = dict(DEFAULT_RECOMMENDATION_CONFIG)
|
||||
activation = dict(ACTIVATION_DEFAULTS)
|
||||
sim = bt._window_setups(records, config, activation)
|
||||
|
||||
live_by_dir = {s.direction: s for s in live}
|
||||
sim_by_dir = {s["direction"]: s for s in sim}
|
||||
assert set(live_by_dir) == set(sim_by_dir), (
|
||||
f"direction mismatch live={set(live_by_dir)} sim={set(sim_by_dir)}"
|
||||
)
|
||||
assert live_by_dir, "expected at least one directional setup from GTL"
|
||||
|
||||
for direction, live_setup in live_by_dir.items():
|
||||
sim_setup = sim_by_dir[direction]
|
||||
assert live_setup.target == pytest.approx(float(sim_setup["target"]), abs=0.05), (
|
||||
f"{direction}: live target {live_setup.target} != sim {sim_setup['target']}"
|
||||
)
|
||||
assert live_setup.rr_ratio == pytest.approx(float(sim_setup["rr"]), abs=0.05), (
|
||||
f"{direction}: live rr {live_setup.rr_ratio} != sim {sim_setup['rr']}"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Bug-condition exploration tests for R:R scanner target quality.
|
||||
"""Regression: scanner must not headline the most distant (max raw R:R) level.
|
||||
|
||||
These tests confirm the bug described in bugfix.md: the old code always selected
|
||||
the most distant S/R level (highest raw R:R) regardless of strength or proximity.
|
||||
The fix replaces max-R:R selection with quality-score selection.
|
||||
|
||||
Since the code is already fixed, these tests PASS on the current codebase.
|
||||
On the unfixed code they would FAIL, confirming the bug.
|
||||
Historical bug: provisional candidate pick used max R:R / quality only. Production
|
||||
headline is probability-based primary after enhance_trade_setup — near levels
|
||||
with real reach-probability beat far lotteries.
|
||||
|
||||
**Validates: Requirements 1.1, 1.3, 1.4, 2.1, 2.3, 2.4**
|
||||
"""
|
||||
@@ -76,10 +73,7 @@ def _make_ohlcv_bars(
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession):
|
||||
"""With a strong nearby resistance and a weak distant resistance, the
|
||||
scanner should pick the strong nearby one — NOT the most distant.
|
||||
|
||||
On unfixed code this would fail because max-R:R always picks the
|
||||
farthest level.
|
||||
probability primary should be the nearby level — NOT the far lottery.
|
||||
"""
|
||||
ticker = Ticker(symbol="EXPLR")
|
||||
scan_session.add(ticker)
|
||||
@@ -126,8 +120,11 @@ async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession
|
||||
"Bug: scanner picked the weak distant level (130) instead of the "
|
||||
"strong nearby level (105)"
|
||||
)
|
||||
# It should pick the strong nearby level
|
||||
# Probability primary should pick the strong nearby level
|
||||
assert selected_target == pytest.approx(105.0, abs=0.01)
|
||||
primaries = [t for t in long_setups[0].targets if t.get("is_primary")]
|
||||
assert len(primaries) == 1
|
||||
assert primaries[0]["price"] == pytest.approx(105.0, abs=0.01)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Fix-checking tests for R:R scanner quality-score selection.
|
||||
"""Fix-checking tests for R:R scanner probability-based primary selection.
|
||||
|
||||
Verify that the fixed scan_ticker selects the candidate with the highest
|
||||
quality score among all candidates meeting the R:R threshold, for both
|
||||
long and short setups.
|
||||
Verify that after enhance_trade_setup the headline target is the most likely
|
||||
worthwhile primary (R:R + probability floors), for both long and short setups.
|
||||
The pre-enhance quality loop only seeds a provisional target.
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3, 2.4**
|
||||
"""
|
||||
@@ -22,9 +22,7 @@ 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)."""
|
||||
"""Headline = starred primary = max(probability, rr) among floor-clearing targets."""
|
||||
targets = setup.targets
|
||||
assert targets, "expected generated targets"
|
||||
primaries = [t for t in targets if t.get("is_primary")]
|
||||
@@ -32,7 +30,11 @@ def _assert_primary_is_most_likely_worthwhile(setup) -> None:
|
||||
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]
|
||||
# Mirrors recommendation_service._select_primary_target floors.
|
||||
worthwhile = [
|
||||
t for t in targets
|
||||
if float(t["rr_ratio"]) >= 1.5 and float(t["probability"]) >= 20.0
|
||||
]
|
||||
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)
|
||||
@@ -122,7 +124,7 @@ def short_candidate_levels(draw: st.DrawFn) -> list[dict]:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property test: long setup selects highest quality score candidate
|
||||
# Property test: long setup selects probability-based primary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -132,14 +134,14 @@ def short_candidate_levels(draw: st.DrawFn) -> list[dict]:
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
async def test_property_long_selects_highest_quality(
|
||||
async def test_property_long_selects_probability_primary(
|
||||
levels: list[dict],
|
||||
scan_session: AsyncSession,
|
||||
):
|
||||
"""**Validates: Requirements 2.1, 2.3, 2.4**
|
||||
|
||||
Property: when multiple resistance levels meet the R:R threshold,
|
||||
the fixed scan_ticker selects the one with the highest quality score.
|
||||
the headline after enhance is the probability-based primary.
|
||||
"""
|
||||
from tests.conftest import _test_engine, _test_session_factory
|
||||
from app.database import Base
|
||||
@@ -183,7 +185,7 @@ async def test_property_long_selects_highest_quality(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property test: short setup selects highest quality score candidate
|
||||
# Property test: short setup selects probability-based primary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -193,14 +195,14 @@ async def test_property_long_selects_highest_quality(
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
async def test_property_short_selects_highest_quality(
|
||||
async def test_property_short_selects_probability_primary(
|
||||
levels: list[dict],
|
||||
scan_session: AsyncSession,
|
||||
):
|
||||
"""**Validates: Requirements 2.2, 2.3, 2.4**
|
||||
|
||||
Property: when multiple support levels meet the R:R threshold,
|
||||
the fixed scan_ticker selects the one with the highest quality score.
|
||||
the headline after enhance is the probability-based primary.
|
||||
"""
|
||||
from tests.conftest import _test_engine, _test_session_factory
|
||||
from app.database import Base
|
||||
@@ -303,9 +305,10 @@ async def test_deterministic_long_three_levels(scan_session: AsyncSession):
|
||||
long_setups = [s for s in setups if s.direction == "long"]
|
||||
assert len(long_setups) == 1, "Expected exactly one long setup"
|
||||
|
||||
# Level A (105, strength=90) should win with highest quality
|
||||
_assert_primary_is_most_likely_worthwhile(long_setups[0])
|
||||
# Near/strong level A wins on reach-probability over far lottery C.
|
||||
assert long_setups[0].target == pytest.approx(105.0, abs=0.01), (
|
||||
f"Expected target=105.0 (highest quality), got {long_setups[0].target}"
|
||||
f"Expected primary=105.0 (near, high reach-prob), got {long_setups[0].target}"
|
||||
)
|
||||
|
||||
|
||||
@@ -366,7 +369,7 @@ async def test_deterministic_short_three_levels(scan_session: AsyncSession):
|
||||
short_setups = [s for s in setups if s.direction == "short"]
|
||||
assert len(short_setups) == 1, "Expected exactly one short setup"
|
||||
|
||||
# Level A (95, strength=85) should win with highest quality
|
||||
_assert_primary_is_most_likely_worthwhile(short_setups[0])
|
||||
assert short_setups[0].target == pytest.approx(95.0, abs=0.01), (
|
||||
f"Expected target=95.0 (highest quality), got {short_setups[0].target}"
|
||||
f"Expected primary=95.0 (near, high reach-prob), got {short_setups[0].target}"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Integration tests for R:R scanner full flow with quality-based target selection.
|
||||
"""Integration tests for R:R scanner full flow with probability-based primary.
|
||||
|
||||
Verifies the complete scan_ticker pipeline: quality-based S/R level selection,
|
||||
correct TradeSetup field population, and database persistence.
|
||||
Verifies scan_ticker → enhance_trade_setup: headline target is the primary
|
||||
selected by probability floors (not the pre-enhance quality candidate loop),
|
||||
TradeSetup fields, and persistence.
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 3.4**
|
||||
"""
|
||||
@@ -63,35 +64,52 @@ def _make_ohlcv_bars(
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 8.1 Integration test: full scan_ticker flow with quality-based selection,
|
||||
# 8.1 Integration test: full scan_ticker flow with probability primary,
|
||||
# correct TradeSetup fields, and database persistence
|
||||
# ===========================================================================
|
||||
|
||||
def _assert_headline_is_probability_primary(setup: TradeSetup) -> None:
|
||||
"""Headline target/rr must match the starred primary from _select_primary_target."""
|
||||
targets = setup.targets or []
|
||||
assert targets, "expected generated targets after enhance"
|
||||
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(float(primary["price"]), abs=0.01)
|
||||
assert setup.rr_ratio == pytest.approx(float(primary["rr_ratio"]), abs=0.01)
|
||||
worthwhile = [
|
||||
t for t in targets
|
||||
if float(t.get("rr_ratio", 0.0)) >= 1.5 and float(t.get("probability", 0.0)) >= 20.0
|
||||
]
|
||||
pool = worthwhile or targets
|
||||
best = max(pool, key=lambda t: (float(t["probability"]), float(t["rr_ratio"])))
|
||||
assert primary["price"] == pytest.approx(float(best["price"]), abs=0.01)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_ticker_full_flow_quality_selection_and_persistence(
|
||||
async def test_scan_ticker_full_flow_probability_primary_and_persistence(
|
||||
scan_session: AsyncSession,
|
||||
):
|
||||
"""Integration test for the complete scan_ticker pipeline.
|
||||
"""Integration test for the complete scan_ticker → enhance pipeline.
|
||||
|
||||
Scenario:
|
||||
- Entry ≈ 100, ATR ≈ 2.0, risk ≈ 3.0 (atr_multiplier=1.5)
|
||||
- 3 resistance levels above (long candidates):
|
||||
A: price=105, strength=90 (strong, near) → highest quality
|
||||
A: price=105, strength=90 (strong, near) → typically highest reach-prob
|
||||
B: price=115, strength=40 (medium, mid)
|
||||
C: price=135, strength=5 (weak, far)
|
||||
C: price=135, strength=5 (weak, far / lottery)
|
||||
- 3 support levels below (short candidates):
|
||||
D: price=95, strength=85 (strong, near) → highest quality
|
||||
D: price=95, strength=85 (strong, near)
|
||||
E: price=85, strength=35 (medium, mid)
|
||||
F: price=65, strength=8 (weak, far)
|
||||
- CompositeScore: 72.5
|
||||
|
||||
Verifies:
|
||||
1. Both long and short setups are produced
|
||||
2. Long target = Level A (highest quality, not most distant)
|
||||
3. Short target = Level D (highest quality, not most distant)
|
||||
4. All TradeSetup fields are correct and rounded to 4 decimals
|
||||
5. rr_ratio is the actual R:R of the selected level
|
||||
6. Old setups are deleted, new ones persisted
|
||||
2. Headline is the probability-based primary (not a distant lottery)
|
||||
3. Near/strong levels win over far/weak when they clear floors
|
||||
4. rr_ratio matches the selected primary's R:R
|
||||
5. Old setups are deleted, new ones persisted
|
||||
"""
|
||||
# -- Setup: create ticker --
|
||||
ticker = Ticker(symbol="INTEG")
|
||||
@@ -172,16 +190,14 @@ async def test_scan_ticker_full_flow_quality_selection_and_persistence(
|
||||
long_setup = long_setups[0]
|
||||
short_setup = short_setups[0]
|
||||
|
||||
# -- Assert: long target is Level A (highest quality, not most distant) --
|
||||
# Level A: price=105 (strong, near) should beat Level C: price=135 (weak, far)
|
||||
# -- Assert: headline is probability primary; near/strong beats far lottery --
|
||||
_assert_headline_is_probability_primary(long_setup)
|
||||
_assert_headline_is_probability_primary(short_setup)
|
||||
assert long_setup.target == pytest.approx(105.0, abs=0.01), (
|
||||
f"Long target should be 105.0 (highest quality), got {long_setup.target}"
|
||||
f"Long primary should be 105.0 (near, high reach-prob), got {long_setup.target}"
|
||||
)
|
||||
|
||||
# -- Assert: short target is Level D (highest quality, not most distant) --
|
||||
# Level D: price=95 (strong, near) should beat Level F: price=65 (weak, far)
|
||||
assert short_setup.target == pytest.approx(95.0, abs=0.01), (
|
||||
f"Short target should be 95.0 (highest quality), got {short_setup.target}"
|
||||
f"Short primary should be 95.0 (near, high reach-prob), got {short_setup.target}"
|
||||
)
|
||||
|
||||
# -- Assert: entry_price is the last close (≈ 100) --
|
||||
|
||||
Reference in New Issue
Block a user