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),
|
_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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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&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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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) == {}
|
||||||
|
|||||||
@@ -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']}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) --
|
||||||
|
|||||||
Reference in New Issue
Block a user