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:
2026-07-18 13:03:22 +02:00
parent e07da0f8f0
commit b0e33e1606
25 changed files with 429 additions and 221 deletions
+6 -6
View File
@@ -76,13 +76,13 @@ async def get_trade_performance(
_user=Depends(require_access), _user=Depends(require_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> 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 Outcomes come from the nightly outcome_evaluator: win = gate target first,
hit first, loss = stop hit first, expired = neither within the window). loss = stop first, expired = neither in the window. This is **not** the
With qualified_only, the overall/direction/action breakdowns cover only production ATR-trail book; it checks setup grading plumbing only.
setups clearing the activation gate; the confidence breakdown always With qualified_only, overall/direction/action cover only gate-clearing
covers all setups so the gate can be validated against it. setups; the confidence breakdown always covers all setups.
""" """
config = await admin_service.get_activation_config(db) if qualified_only else None config = await admin_service.get_activation_config(db) if qualified_only else None
stats = await get_performance_stats(db, config=config) stats = await get_performance_stats(db, config=config)
+3 -1
View File
@@ -54,7 +54,9 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
} }
ACTIVATION_DEFAULTS: dict[str, float | bool] = { ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_momentum_percentile": 80.0, "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 # 0 = off. The July 2026 gate ablation showed the confidence floor added
# nothing (identical net/trade with it removed, under both exit models) # nothing (identical net/trade with it removed, under both exit models)
# while cutting ~25% of qualified trades. # while cutting ~25% of qualified trades.
+21 -7
View File
@@ -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.indicator_service import _extract_ohlcv, compute_atr
from app.services.momentum_service import ( from app.services.momentum_service import (
STRATEGY_RANK_MOMENTUM_WEIGHT, STRATEGY_RANK_MOMENTUM_WEIGHT,
blend_strategy_rank,
compute_realized_vol_6m, compute_realized_vol_6m,
) )
from app.services.outcome_service import ( from app.services.outcome_service import (
@@ -77,6 +78,7 @@ from app.services.qualification import (
setup_qualifies, setup_qualifies,
) )
from app.services.recommendation_service import ( from app.services.recommendation_service import (
PRIMARY_TARGET_MIN_RR,
_choose_recommended_action, _choose_recommended_action,
_classify_by_probability, _classify_by_probability,
_prune_floor_pinned_targets, _prune_floor_pinned_targets,
@@ -337,11 +339,11 @@ def _window_setups(
targets = _prune_floor_pinned_targets(targets) targets = _prune_floor_pinned_targets(targets)
primary = _select_primary_target( primary = _select_primary_target(
targets, targets,
min_rr=1.5, min_rr=PRIMARY_TARGET_MIN_RR,
) )
if primary is None: if primary is None:
continue 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). # probability (matching production's enhance_trade_setup).
for t in targets: for t in targets:
t["is_primary"] = t is primary t["is_primary"] = t is primary
@@ -1265,15 +1267,27 @@ def _assign_weighted_blend(
primary_weight: float, primary_weight: float,
secondary_key: str, secondary_key: str,
) -> None: ) -> 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: for c in candidates:
primary = c.get(primary_key) primary = c.get(primary_key)
secondary = c.get(secondary_key) secondary = c.get(secondary_key)
c[output_key] = ( # Production weight path: reuse the shared helper so live/sim cannot drift.
primary * primary_weight + secondary * secondary_weight if primary_weight == STRATEGY_RANK_MOMENTUM_WEIGHT:
if primary is not None and secondary is not None c[output_key] = blend_strategy_rank(
else None 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: def _assign_residual_low_vol_blend(candidates: list[dict]) -> None:
+32 -43
View File
@@ -34,6 +34,28 @@ STRATEGY_RANK_MOMENTUM_WEIGHT = 0.8
STRATEGY_RANK_VOL_WEIGHT = 1.0 - STRATEGY_RANK_MOMENTUM_WEIGHT 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: def compute_12_1_momentum(closes: list[float]) -> float | None:
"""Return over the window ending ~1 month ago, starting ~12 months ago. """Return over the window ending ~1 month ago, starting ~12 months ago.
None when there isn't a full year of history.""" 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]: 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 Prefer ``compute_activation_ranks`` in new code (includes vol + strategy_rank).
SPY data is absent, fall back to raw 12-1 momentum rather than disabling the Kept so tests/helpers that only need the residual/raw percentile map stay simple.
scanner. Tickers without enough stock/benchmark history are absent.
""" """
result = await db.execute(select(Ticker).order_by(Ticker.symbol)) ranks = await compute_activation_ranks(db)
tickers = list(result.scalars().all()) return {
sym: float(row["momentum_percentile"])
benchmark_closes = await _load_activation_benchmark(db) for sym, row in ranks.items()
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK if row.get("momentum_percentile") is not None
}
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
def compute_realized_vol_6m(closes: list[float]) -> float | 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: for sym in symbols:
momentum_pct = momentum_percentiles.get(sym) momentum_pct = momentum_percentiles.get(sym)
vol_pct = vol_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] = { ranks[sym] = {
"momentum_percentile": momentum_pct, "momentum_percentile": momentum_pct,
"volatility_percentile": vol_pct, "volatility_percentile": vol_pct,
"strategy_rank": strategy_rank, "strategy_rank": blend_strategy_rank(momentum_pct, vol_pct),
} }
logger.info(json.dumps({ logger.info(json.dumps({
+8 -4
View File
@@ -1,11 +1,15 @@
"""Trade setup outcome evaluation service. """Trade setup outcome evaluation service.
Closes the feedback loop on R:R scanner setups: walks daily OHLCV bars Diagnostic barrier resolution for scanner setups: walks daily OHLCV bars
after detection and records whether the stop or the target was hit first. 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): Outcome semantics (entry is the close at detection time, i.e. market entry):
- target_hit: target reached before the stop - target_hit: gate target reached before the stop
- stop_hit: stop reached before the target - stop_hit: stop reached before the gate target
- ambiguous: stop AND target both within the same daily bar — with daily - ambiguous: stop AND target both within the same daily bar — with daily
granularity the order is unknowable, counted as a loss in stats granularity the order is unknowable, counted as a loss in stats
- expired: neither level hit within ``max_bars`` trading days - expired: neither level hit within ``max_bars`` trading days
+3 -2
View File
@@ -80,8 +80,9 @@ async def upsert_ohlcv(
record = result.scalar_one() record = result.scalar_one()
# TODO: Invalidate LRU cache entries for this ticker (Task 7.1) from app.cache import indicator_cache
# TODO: Mark composite score as stale for this ticker (Task 10.1)
indicator_cache.invalidate_ticker(ticker.symbol)
return record return record
+4
View File
@@ -618,6 +618,10 @@ def build_recommendation_snapshot(
# agree on what counts as a probability-backed target. # agree on what counts as a probability-backed target.
PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY 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]: def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]:
"""Keep only the nearest target pinned at the probability clamp floor. """Keep only the nearest target pinned at the probability clamp floor.
+8 -7
View File
@@ -35,6 +35,7 @@ from app.services.trade_policy import (
observe_reentry_gate_transitions, observe_reentry_gate_transitions,
) )
from app.services.recommendation_service import ( from app.services.recommendation_service import (
PRIMARY_TARGET_MIN_RR,
_risk_level_from_conflicts, _risk_level_from_conflicts,
build_recommendation_snapshot, build_recommendation_snapshot,
enhance_trade_setup, enhance_trade_setup,
@@ -44,7 +45,6 @@ from app.services.recommendation_service import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1" 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 # 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 # 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): for index, (ticker_id, symbol) in enumerate(ticker_rows):
if progress_callback is not None: if progress_callback is not None:
progress_callback(index, total, symbol) progress_callback(index, total, symbol)
# Refresh scores first so the scheduled scan works off current data. # Refresh Structural S/R once, then scores. get_sr_levels is read-only;
# Nothing else marks scores stale, so without this they'd never update # without this recalculate the score path would see yesterday's zones.
# for tickers the user doesn't manually fetch. A refresh failure still # A refresh failure still scans the ticker: qualification re-gates on
# scans the ticker: qualification re-gates on live scores at alert # live scores at alert time, so a stale score is recoverable but a
# time, so a stale score is recoverable but a skipped scan is not. # skipped scan is not.
try: 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_all_dimensions(db, symbol)
await scoring_service.compute_composite_score(db, symbol) await scoring_service.compute_composite_score(db, symbol)
await db.commit() await db.commit()
+12 -3
View File
@@ -857,8 +857,17 @@ async def get_sr_levels(
symbol: str, symbol: str,
tolerance: float | None = None, tolerance: float | None = None,
) -> list[SRLevel]: ) -> 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())
-6
View File
@@ -56,12 +56,6 @@ export function updateSetting(key: string, value: string) {
.then((r) => r.data); .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() { export function getRecommendationSettings() {
return apiClient return apiClient
.get<RecommendationConfig>('admin/settings/recommendations') .get<RecommendationConfig>('admin/settings/recommendations')
-4
View File
@@ -14,7 +14,3 @@ export function list(params?: TradeListParams) {
export function bySymbol(symbol: string) { export function bySymbol(symbol: string) {
return apiClient.get<TradeSetup[]>(`trades/${symbol.toUpperCase()}`).then((r) => r.data); 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 { useActivationSettings, useUpdateActivationSettings } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton'; import { SkeletonTable } from '../ui/Skeleton';
/** Mirrors app.services.admin_service.ACTIVATION_DEFAULTS — keep in sync. */
const DEFAULTS: ActivationConfig = { const DEFAULTS: ActivationConfig = {
min_momentum_percentile: 80, min_momentum_percentile: 80,
min_rr: 1.2, min_rr: 2.0,
min_confidence: 55, min_confidence: 0,
require_high_conviction: false, require_high_conviction: false,
exclude_conflicts: false, exclude_conflicts: false,
exclude_neutral: true, exclude_neutral: true,
+4 -4
View File
@@ -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({ export function PriceRail({
@@ -69,7 +69,7 @@ export function PriceRail({
const progressWidth = current != null ? Math.abs(pct(current) - pct(entry)) : 0; const progressWidth = current != null ? Math.abs(pct(current) - pct(entry)) : 0;
return ( return (
<div className="hz-rail" role="img" aria-label={ <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 className="hz-rail-track" />
<div <div
@@ -111,10 +111,10 @@ export function PriceRail({
</span> </span>
</div> </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-ring" />
<span className="hz-rail-label"> <span className="hz-rail-label">
<em>target</em> <em>gate</em>
<b>{fmt(target)}</b> <b>{fmt(target)}</b>
{rTarget != null && <i>+{fmt(rTarget, 1)}R</i>} {rTarget != null && <i>+{fmt(rTarget, 1)}R</i>}
</span> </span>
+42 -10
View File
@@ -4,7 +4,25 @@ import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
import { primaryTarget } from '../../lib/qualification'; import { primaryTarget } from '../../lib/qualification';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation'; 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'; export type SortDirection = 'asc' | 'desc';
interface TradeTableProps { interface TradeTableProps {
@@ -16,20 +34,22 @@ interface TradeTableProps {
const columns: { key: SortColumn; label: string }[] = [ const columns: { key: SortColumn; label: string }[] = [
{ key: 'symbol', label: 'Symbol' }, { key: 'symbol', label: 'Symbol' },
{ key: 'strategy_rank', label: 'Prod rank' },
{ key: 'momentum_percentile', label: 'Mom %ile' },
{ key: 'recommended_action', label: 'Recommended Action' }, { key: 'recommended_action', label: 'Recommended Action' },
{ key: 'confidence_score', label: 'Confidence' }, { key: 'confidence_score', label: 'Confidence' },
{ key: 'direction', label: 'Direction' }, { key: 'direction', label: 'Direction' },
{ key: 'entry_price', label: 'Entry' }, { key: 'entry_price', label: 'Entry' },
{ key: 'stop_loss', label: 'Stop Loss' }, { key: 'stop_loss', label: 'Stop Loss' },
{ key: 'target', label: 'Target' }, { key: 'target', label: 'Gate level' },
{ key: 'primary_target_probability', label: 'Primary Target' }, { key: 'primary_target_probability', label: 'Gate reach' },
{ key: 'risk_amount', label: 'Risk $' }, { key: 'risk_amount', label: 'Risk $' },
{ key: 'reward_amount', label: 'Reward $' }, { 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: 'stop_pct', label: '% to Stop' },
{ key: 'target_pct', label: '% to Target' }, { key: 'target_pct', label: '% to gate' },
{ key: 'risk_level', label: 'Risk' }, { key: 'risk_level', label: 'Risk' },
{ key: 'composite_score', label: 'Score' }, { key: 'composite_score', label: 'Composite' },
{ key: 'detected_at', label: 'Detected' }, { key: 'detected_at', label: 'Detected' },
]; ];
@@ -105,6 +125,12 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
{trade.symbol} {trade.symbol}
</Link> </Link>
</td> </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"> <td className="px-4 py-3.5">
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-xs font-semibold text-blue-300">{recommendationActionLabel(trade.recommended_action)}</span> <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>
<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.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.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" title="Gate Target Ladder level — screening only, not an exit">
<td className="px-4 py-3.5 font-mono text-gray-200">{primaryTargetText(trade)}</td> {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.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 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.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-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 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'}`}> <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)} {Math.round(trade.composite_score)}
</span> </span>
@@ -44,6 +44,10 @@ function getComputedValue(trade: TradeSetup, column: SortColumn): number {
case 'confidence_score': return trade.confidence_score ?? -1; case 'confidence_score': return trade.confidence_score ?? -1;
case 'primary_target_probability': case 'primary_target_probability':
return primaryTargetProbability(trade) ?? -1; 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': case 'risk_level':
if (trade.risk_level === 'Low') return 1; if (trade.risk_level === 'Low') return 1;
if (trade.risk_level === 'Medium') return 2; if (trade.risk_level === 'Medium') return 2;
@@ -79,6 +83,8 @@ function sortTrades(
case 'target_pct': case 'target_pct':
case 'confidence_score': case 'confidence_score':
case 'primary_target_probability': case 'primary_target_probability':
case 'strategy_rank':
case 'momentum_percentile':
case 'risk_level': case 'risk_level':
cmp = getComputedValue(a, column) - getComputedValue(b, column); cmp = getComputedValue(a, column) - getComputedValue(b, column);
break; break;
@@ -108,7 +114,8 @@ export function SetupsPanel() {
const [minConfidence, setMinConfidence] = useState(0); const [minConfidence, setMinConfidence] = useState(0);
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both'); const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
const [actionFilter, setActionFilter] = useState<ActionFilter>('all'); 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'); const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
// Keep the Min R:R / Min Confidence inputs showing the *effective* floor: when // 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"> <Disclosure summary="How the scanner works & action glossary">
<p className="mb-3 text-xs text-gray-400"> <p className="mb-3 text-xs text-gray-400">
The scanner identifies asymmetric risk-reward trade setups by analyzing S/R levels as The scanner builds long setups with a 1.5× ATR stop and a Gate Target Ladder proposal used
price targets and using ATR-based stops to define risk. Click{' '} only for R:R / reach-probability screening not as a take-profit. Structural chart S/R is
<span className="font-medium text-gray-300">Run Scanner</span> to scan all tickers now, separate. Live exit is the ATR trail / max hold. Click{' '}
or wait for the scheduled run. <span className="font-medium text-gray-300">Run Scanner</span> to scan all tickers now, or
wait for the scheduled run.
</p> </p>
<div className="grid gap-1 md:grid-cols-2"> <div className="grid gap-1 md:grid-cols-2">
{RECOMMENDATION_ACTION_GLOSSARY.map((item) => ( {RECOMMENDATION_ACTION_GLOSSARY.map((item) => (
@@ -109,19 +109,19 @@ export function TrackRecordPanel() {
<Disclosure summary="Track-record maintenance"> <Disclosure summary="Track-record maintenance">
<div className="space-y-4 pt-1"> <div className="space-y-4 pt-1">
<p className="max-w-2xl text-xs text-gray-500"> <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 = <span className="text-amber-300/90">Diagnostic only not production P&amp;L.</span>{' '}
win, stop first = loss (both in one bar counts conservatively as a loss), neither within 30 Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
trading days = expired at 0R. Only setups whose full window has elapsed count; younger ones are initial stop / ATR trail / max hold see paper trades and the portfolio monitor above.
still maturing (near stops resolve fast, far targets need time, so early numbers skew negative). Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading
The evaluator scores <span className="text-gray-300">all</span> setups qualified or not, so days = expired at 0R. Only matured windows count. Scores{' '}
unqualified ones stay a control group and runs nightly. <span className="text-gray-300">all</span> setups as a control group; runs nightly.
</p> </p>
{/* Diagnostic, not strategy validation: live target/stop outcomes vs the backtest's target/stop model. */} {/* 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="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-center justify-between gap-x-6 gap-y-2">
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-1"> <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"> <span className="text-sm text-gray-400">
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span> Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
</span> </span>
@@ -129,7 +129,7 @@ export function TrackRecordPanel() {
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span> Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
</span> </span>
<span className="text-xs text-gray-500"> <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> </span>
</div> </div>
<StatusChip status={status} /> <StatusChip status={status} />
@@ -203,6 +203,31 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
selectedPrice?: number | null; selectedPrice?: number | null;
onSelectPrice?: (price: number) => void; 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) { if (!setup) {
return ( return (
<div className="rounded-xl border border-white/[0.07] p-4 text-xs text-gray-500"> <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 exitPlan = deriveExitPlan(setup, exitPolicy);
const honorsTarget = exitPlan?.honorsTarget ?? false; 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 // 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 // flow — the scanner's primary is just the default. Controlled by the page
// when provided (so the candlestick overlay follows), else local. // when provided (so the candlestick overlay follows), else local.
const [internalSel, setInternalSel] = useState<number | null>(null);
const selPrice = selectedPrice !== undefined ? selectedPrice : internalSel; const selPrice = selectedPrice !== undefined ? selectedPrice : internalSel;
const selectTargetPrice = (p: number) => { const selectTargetPrice = (p: number) => {
if (onSelectPrice) onSelectPrice(p); 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 activeRR = selected?.rr_ratio ?? setup.rr_ratio;
const activeProb = selected?.probability ?? prob; 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 = () => { const confirmTake = () => {
createTrade.mutate( 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,
}}
/>
);
})}
</>
);
}
+3 -3
View File
@@ -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: * Symbol of the current single 'top pick' the #1 row the dashboard highlights:
* the highest residual 12-1 momentum percentile among qualified setups. Returns * highest production strategy_rank (80/20 mom/vol) among qualified setups,
* null when there are no actionable setups. Keep in step with the Top Setups * falling back to residual momentum percentile. Returns null when there are no
* ranking in DashboardPage. * actionable setups. Keep in step with the Top Setups ranking in DashboardPage.
*/ */
export function topPickSymbol( export function topPickSymbol(
trades: TradeSetup[] | undefined, trades: TradeSetup[] | undefined,
+2 -2
View File
@@ -26,7 +26,7 @@ class TestActivationConfig:
config = await get_activation_config(session) config = await get_activation_config(session)
assert config == { assert config == {
"min_momentum_percentile": 80.0, "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 "min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing
"require_high_conviction": False, "require_high_conviction": False,
"exclude_conflicts": False, "exclude_conflicts": False,
@@ -47,7 +47,7 @@ class TestActivationConfig:
async def test_partial_update_keeps_other_value(self, session: AsyncSession): async def test_partial_update_keeps_other_value(self, session: AsyncSession):
await update_activation_config(session, {"min_confidence": 80.0}) await update_activation_config(session, {"min_confidence": 80.0})
config = await get_activation_config(session) 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 assert config["min_confidence"] == 80.0
async def test_rejects_out_of_range_momentum_percentile(self, session: AsyncSession): async def test_rejects_out_of_range_momentum_percentile(self, session: AsyncSession):
+14 -3
View File
@@ -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, "MID", rate=1.002)
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum 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) pct = await ms.compute_momentum_percentiles(session)
assert pct["HIGH"] == 100.0 assert pct == {s: ranks[s]["momentum_percentile"] for s in pct}
assert pct["MID"] == 50.0
assert pct["LOW"] == 0.0
async def test_ranks_universe_into_residual_percentiles_when_benchmark_available(session, monkeypatch): 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, "BETA", market)
await _seed_closes(session, "LAG", [market[i] * (0.9992 ** i) for i in range(n)]) 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) pct = await ms.compute_momentum_percentiles(session)
assert pct["DRIFT"] == 100.0 assert pct["DRIFT"] == 100.0
assert pct["BETA"] == 50.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, "LONG", rate=1.005)
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum 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) pct = await ms.compute_momentum_percentiles(session)
assert "LONG" in pct assert "LONG" in pct
assert "SHORTHX" not in pct assert "SHORTHX" not in pct
@@ -115,4 +125,5 @@ async def test_empty_universe_returns_empty(session, monkeypatch):
return {} return {}
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark) monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
assert await ms.compute_activation_ranks(session) == {}
assert await ms.compute_momentum_percentiles(session) == {} assert await ms.compute_momentum_percentiles(session) == {}
+150
View File
@@ -26,7 +26,11 @@ from app.services.backtest_service import (
from app.services.momentum_service import ( from app.services.momentum_service import (
STRATEGY_RANK_MOMENTUM_WEIGHT, STRATEGY_RANK_MOMENTUM_WEIGHT,
STRATEGY_RANK_VOL_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: 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( assert _momentum_qualifies(cand, cutoff) == _qualifies_strategy_variant(
cand, entry_cfg cand, entry_cfg
), cand ), 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']}"
)
+9 -12
View File
@@ -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 Historical bug: provisional candidate pick used max R:R / quality only. Production
the most distant S/R level (highest raw R:R) regardless of strength or proximity. headline is probability-based primary after enhance_trade_setup near levels
The fix replaces max-R:R selection with quality-score selection. with real reach-probability beat far lotteries.
Since the code is already fixed, these tests PASS on the current codebase.
On the unfixed code they would FAIL, confirming the bug.
**Validates: Requirements 1.1, 1.3, 1.4, 2.1, 2.3, 2.4** **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 @pytest.mark.asyncio
async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession): async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession):
"""With a strong nearby resistance and a weak distant resistance, the """With a strong nearby resistance and a weak distant resistance, the
scanner should pick the strong nearby one NOT the most distant. probability primary should be the nearby level NOT the far lottery.
On unfixed code this would fail because max-R:R always picks the
farthest level.
""" """
ticker = Ticker(symbol="EXPLR") ticker = Ticker(symbol="EXPLR")
scan_session.add(ticker) 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 " "Bug: scanner picked the weak distant level (130) instead of the "
"strong nearby level (105)" "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) 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+21 -18
View File
@@ -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 Verify that after enhance_trade_setup the headline target is the most likely
quality score among all candidates meeting the R:R threshold, for both worthwhile primary (R:R + probability floors), for both long and short setups.
long and short setups. The pre-enhance quality loop only seeds a provisional target.
**Validates: Requirements 2.1, 2.2, 2.3, 2.4** **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: def _assert_primary_is_most_likely_worthwhile(setup) -> None:
"""The persisted headline target must equal the starred primary in the """Headline = starred primary = max(probability, rr) among floor-clearing targets."""
targets table, and that primary must be the highest-probability target
with R:R >= 1.5 (fallback: highest R:R)."""
targets = setup.targets targets = setup.targets
assert targets, "expected generated targets" assert targets, "expected generated targets"
primaries = [t for t in targets if t.get("is_primary")] 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] primary = primaries[0]
assert setup.target == pytest.approx(primary["price"], abs=0.01) 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 pool = worthwhile or targets
best = max(pool, key=lambda t: (t["probability"], t["rr_ratio"])) best = max(pool, key=lambda t: (t["probability"], t["rr_ratio"]))
assert primary["price"] == pytest.approx(best["price"], abs=0.01) 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 @pytest.mark.asyncio
@@ -132,14 +134,14 @@ def short_candidate_levels(draw: st.DrawFn) -> list[dict]:
deadline=None, deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture], 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], levels: list[dict],
scan_session: AsyncSession, scan_session: AsyncSession,
): ):
"""**Validates: Requirements 2.1, 2.3, 2.4** """**Validates: Requirements 2.1, 2.3, 2.4**
Property: when multiple resistance levels meet the R:R threshold, 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 tests.conftest import _test_engine, _test_session_factory
from app.database import Base 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 @pytest.mark.asyncio
@@ -193,14 +195,14 @@ async def test_property_long_selects_highest_quality(
deadline=None, deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture], 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], levels: list[dict],
scan_session: AsyncSession, scan_session: AsyncSession,
): ):
"""**Validates: Requirements 2.2, 2.3, 2.4** """**Validates: Requirements 2.2, 2.3, 2.4**
Property: when multiple support levels meet the R:R threshold, 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 tests.conftest import _test_engine, _test_session_factory
from app.database import Base 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"] 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"
# 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), ( 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"] 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"
# 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), ( 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}"
) )
+37 -21
View File
@@ -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, Verifies scan_ticker enhance_trade_setup: headline target is the primary
correct TradeSetup field population, and database persistence. 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** **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 # 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 @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, scan_session: AsyncSession,
): ):
"""Integration test for the complete scan_ticker pipeline. """Integration test for the complete scan_ticker → enhance pipeline.
Scenario: Scenario:
- Entry 100, ATR 2.0, risk 3.0 (atr_multiplier=1.5) - Entry 100, ATR 2.0, risk 3.0 (atr_multiplier=1.5)
- 3 resistance levels above (long candidates): - 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) 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): - 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) E: price=85, strength=35 (medium, mid)
F: price=65, strength=8 (weak, far) F: price=65, strength=8 (weak, far)
- CompositeScore: 72.5 - CompositeScore: 72.5
Verifies: Verifies:
1. Both long and short setups are produced 1. Both long and short setups are produced
2. Long target = Level A (highest quality, not most distant) 2. Headline is the probability-based primary (not a distant lottery)
3. Short target = Level D (highest quality, not most distant) 3. Near/strong levels win over far/weak when they clear floors
4. All TradeSetup fields are correct and rounded to 4 decimals 4. rr_ratio matches the selected primary's R:R
5. rr_ratio is the actual R:R of the selected level 5. Old setups are deleted, new ones persisted
6. Old setups are deleted, new ones persisted
""" """
# -- Setup: create ticker -- # -- Setup: create ticker --
ticker = Ticker(symbol="INTEG") ticker = Ticker(symbol="INTEG")
@@ -172,16 +190,14 @@ async def test_scan_ticker_full_flow_quality_selection_and_persistence(
long_setup = long_setups[0] long_setup = long_setups[0]
short_setup = short_setups[0] short_setup = short_setups[0]
# -- Assert: long target is Level A (highest quality, not most distant) -- # -- Assert: headline is probability primary; near/strong beats far lottery --
# Level A: price=105 (strong, near) should beat Level C: price=135 (weak, far) _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), ( 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), ( 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) -- # -- Assert: entry_price is the last close (≈ 100) --