Setup views: primary-target column, floor-target prune, liveness cutoff
Three follow-ups to the gate probability floor (8f41143):
- Signals table shows the starred primary target (shared primaryTarget
helper) instead of an independently computed max-probability best,
so Overview, Signals and ticker details agree by construction.
- Targets pinned at the 3% probability clamp floor collapse to the
nearest one (enhance_trade_setup + backtest candidates in parity):
floor-pinned levels are indistinguishable to the model, so farther
ones were duplicate 3% rows inviting lottery headlines.
- get_trade_setups only returns setups re-emitted within
LIVE_SETUP_MAX_AGE_DAYS (3): an older latest row means the daily
scan no longer confirms the setup, and such rows otherwise surface
forever on Overview/Signals/ticker/alerts. History endpoints keep
full history.
Backtest on the Jul-3 snapshot is metric-identical to the gate-floor
run on all qualified stats (1089 qualified, Sharpe 2.02, CAGR +49.6%,
DD -15.8%): the prune only removes noise the gate already rejected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,7 @@ from app.services.qualification import (
|
|||||||
from app.services.recommendation_service import (
|
from app.services.recommendation_service import (
|
||||||
_choose_recommended_action,
|
_choose_recommended_action,
|
||||||
_classify_by_probability,
|
_classify_by_probability,
|
||||||
|
_prune_floor_pinned_targets,
|
||||||
_risk_level_from_conflicts,
|
_risk_level_from_conflicts,
|
||||||
_select_primary_target,
|
_select_primary_target,
|
||||||
_zone_representative_levels,
|
_zone_representative_levels,
|
||||||
@@ -179,6 +180,9 @@ def _window_setups(
|
|||||||
t, dim_scores, None, direction, config
|
t, dim_scores, None, direction, config
|
||||||
)
|
)
|
||||||
t["classification"] = _classify_by_probability(t["probability"])
|
t["classification"] = _classify_by_probability(t["probability"])
|
||||||
|
# Collapse duplicate floor-pinned lottery targets (parity with
|
||||||
|
# enhance_trade_setup).
|
||||||
|
targets = _prune_floor_pinned_targets(targets)
|
||||||
primary = _select_primary_target(targets)
|
primary = _select_primary_target(targets)
|
||||||
if primary is None:
|
if primary is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ _MODERATE_MAX_ATR = 4.6
|
|||||||
# the same tolerance the chart and alerts use, so S/R is one model app-wide.
|
# the same tolerance the chart and alerts use, so S/R is one model app-wide.
|
||||||
_SR_ZONE_TOLERANCE = 0.02
|
_SR_ZONE_TOLERANCE = 0.02
|
||||||
|
|
||||||
|
# Reach-probability estimates are clamped to this band; a target at the floor
|
||||||
|
# means "the model considers it essentially unreachable" and floor-pinned
|
||||||
|
# targets are mutually indistinguishable.
|
||||||
|
_PROBABILITY_CLAMP_LOW = 3.0
|
||||||
|
_PROBABILITY_CLAMP_HIGH = 95.0
|
||||||
|
|
||||||
|
|
||||||
def _clamp(value: float, low: float, high: float) -> float:
|
def _clamp(value: float, low: float, high: float) -> float:
|
||||||
return max(low, min(high, value))
|
return max(low, min(high, value))
|
||||||
@@ -408,7 +414,7 @@ class ProbabilityEstimator:
|
|||||||
elif opposed:
|
elif opposed:
|
||||||
probability -= signal_weight * 100.0
|
probability -= signal_weight * 100.0
|
||||||
|
|
||||||
return round(_clamp(probability, 3.0, 95.0), 2)
|
return round(_clamp(probability, _PROBABILITY_CLAMP_LOW, _PROBABILITY_CLAMP_HIGH), 2)
|
||||||
|
|
||||||
|
|
||||||
signal_conflict_detector = SignalConflictDetector()
|
signal_conflict_detector = SignalConflictDetector()
|
||||||
@@ -582,6 +588,26 @@ PRIMARY_TARGET_MIN_RR = 1.5
|
|||||||
PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY
|
PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]:
|
||||||
|
"""Keep only the nearest target pinned at the probability clamp floor.
|
||||||
|
|
||||||
|
Floor-pinned targets are indistinguishable to the model (true probability
|
||||||
|
at/below the clamp), so farther ones add no information — they just fill
|
||||||
|
the table with duplicate "3%" rows whose inflated R:R invites lottery
|
||||||
|
picks. ``targets`` is distance-sorted by the generator, so the first
|
||||||
|
floor-pinned entry is the nearest (most reachable) representative.
|
||||||
|
"""
|
||||||
|
pruned: list[dict] = []
|
||||||
|
seen_floor = False
|
||||||
|
for target in targets:
|
||||||
|
if float(target.get("probability", 0.0)) <= _PROBABILITY_CLAMP_LOW:
|
||||||
|
if seen_floor:
|
||||||
|
continue
|
||||||
|
seen_floor = True
|
||||||
|
pruned.append(target)
|
||||||
|
return pruned
|
||||||
|
|
||||||
|
|
||||||
def _select_primary_target(
|
def _select_primary_target(
|
||||||
targets: list[dict],
|
targets: list[dict],
|
||||||
min_rr: float = PRIMARY_TARGET_MIN_RR,
|
min_rr: float = PRIMARY_TARGET_MIN_RR,
|
||||||
@@ -665,6 +691,9 @@ async def enhance_trade_setup(
|
|||||||
# Label follows from the reach-probability: high prob = Conservative.
|
# Label follows from the reach-probability: high prob = Conservative.
|
||||||
target["classification"] = _classify_by_probability(target["probability"])
|
target["classification"] = _classify_by_probability(target["probability"])
|
||||||
|
|
||||||
|
# Collapse duplicate floor-pinned lottery targets to the nearest one.
|
||||||
|
targets = _prune_floor_pinned_targets(targets)
|
||||||
|
|
||||||
# Primary target = most-likely target with real asymmetry (see
|
# Primary target = most-likely target with real asymmetry (see
|
||||||
# _select_primary_target), not the old quality-score pick that ignored
|
# _select_primary_target), not the old quality-score pick that ignored
|
||||||
# probability. Sync the setup's headline target/rr_ratio so the chart, gate
|
# probability. Sync the setup's headline target/rr_ratio so the chart, gate
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -39,6 +39,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
|
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# re-confirmed — typically because no level clears the R:R threshold from the
|
||||||
|
# current price anymore. Without this cutoff such rows stay "latest" forever
|
||||||
|
# (the scanner never writes a replacement) and keep surfacing on the live
|
||||||
|
# views. 3 days buffers a missed pipeline run or two; history endpoints are
|
||||||
|
# unaffected.
|
||||||
|
LIVE_SETUP_MAX_AGE_DAYS = 3
|
||||||
|
|
||||||
|
|
||||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||||
normalised = symbol.strip().upper()
|
normalised = symbol.strip().upper()
|
||||||
@@ -602,10 +611,17 @@ async def get_trade_setups(
|
|||||||
live_recommendation: bool = False,
|
live_recommendation: bool = False,
|
||||||
exclude_open_trade_tickers: bool = False,
|
exclude_open_trade_tickers: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get latest stored trade setups, optionally filtered."""
|
"""Get latest stored trade setups, optionally filtered.
|
||||||
|
|
||||||
|
Only setups the daily scan re-emitted within ``LIVE_SETUP_MAX_AGE_DAYS``
|
||||||
|
are returned — an older "latest" row means the scanner no longer finds a
|
||||||
|
valid setup for that ticker, so it must not surface as current.
|
||||||
|
"""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=LIVE_SETUP_MAX_AGE_DAYS)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(TradeSetup, Ticker.symbol)
|
select(TradeSetup, Ticker.symbol)
|
||||||
.join(Ticker, TradeSetup.ticker_id == Ticker.id)
|
.join(Ticker, TradeSetup.ticker_id == Ticker.id)
|
||||||
|
.where(TradeSetup.detected_at >= cutoff)
|
||||||
)
|
)
|
||||||
if direction is not None:
|
if direction is not None:
|
||||||
stmt = stmt.where(TradeSetup.direction == direction.lower())
|
stmt = stmt.where(TradeSetup.direction == direction.lower())
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { TradeSetup } from '../../lib/types';
|
import type { TradeSetup } from '../../lib/types';
|
||||||
import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
|
import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
|
||||||
|
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' | 'best_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' | 'detected_at';
|
||||||
export type SortDirection = 'asc' | 'desc';
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
interface TradeTableProps {
|
interface TradeTableProps {
|
||||||
@@ -21,7 +22,7 @@ const columns: { key: SortColumn; label: string }[] = [
|
|||||||
{ 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: 'Target' },
|
||||||
{ key: 'best_target_probability', label: 'Best Target' },
|
{ key: 'primary_target_probability', label: 'Primary Target' },
|
||||||
{ 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: 'R:R' },
|
||||||
@@ -65,10 +66,12 @@ function riskLevelClass(riskLevel: TradeSetup['risk_level']) {
|
|||||||
return 'text-gray-400';
|
return 'text-gray-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
function bestTargetText(trade: TradeSetup) {
|
// The starred primary — the same target the Overview and ticker details
|
||||||
if (!trade.targets || trade.targets.length === 0) return '—';
|
// headline, so every view agrees on which target a setup is "about".
|
||||||
const best = [...trade.targets].sort((a, b) => b.probability - a.probability)[0];
|
function primaryTargetText(trade: TradeSetup) {
|
||||||
return `${formatPrice(best.price)} (${best.probability.toFixed(0)}%)`;
|
const primary = primaryTarget(trade);
|
||||||
|
if (!primary) return '—';
|
||||||
|
return `${formatPrice(primary.price)} (${primary.probability.toFixed(0)}%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) {
|
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) {
|
||||||
@@ -121,7 +124,7 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
|
|||||||
<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">{formatPrice(trade.target)}</td>
|
||||||
<td className="px-4 py-3.5 font-mono text-gray-200">{bestTargetText(trade)}</td>
|
<td className="px-4 py-3.5 font-mono text-gray-200">{primaryTargetText(trade)}</td>
|
||||||
<td className="px-4 py-3.5 font-mono text-gray-200">{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)}`}>{trade.rr_ratio.toFixed(2)}</td>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useActivation } from '../../hooks/useActivation';
|
import { useActivation } from '../../hooks/useActivation';
|
||||||
import { useTrades } from '../../hooks/useTrades';
|
import { useTrades } from '../../hooks/useTrades';
|
||||||
import { qualifiesSetup, activationSummary } from '../../lib/qualification';
|
import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../../lib/qualification';
|
||||||
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
|
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
|
||||||
import { SkeletonTable } from '../ui/Skeleton';
|
import { SkeletonTable } from '../ui/Skeleton';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
@@ -42,8 +42,8 @@ function getComputedValue(trade: TradeSetup, column: SortColumn): number {
|
|||||||
case 'stop_pct': return analysis.stop_pct;
|
case 'stop_pct': return analysis.stop_pct;
|
||||||
case 'target_pct': return analysis.target_pct;
|
case 'target_pct': return analysis.target_pct;
|
||||||
case 'confidence_score': return trade.confidence_score ?? -1;
|
case 'confidence_score': return trade.confidence_score ?? -1;
|
||||||
case 'best_target_probability':
|
case 'primary_target_probability':
|
||||||
return trade.targets?.length ? Math.max(...trade.targets.map((t) => t.probability)) : -1;
|
return primaryTargetProbability(trade) ?? -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;
|
||||||
@@ -78,7 +78,7 @@ function sortTrades(
|
|||||||
case 'stop_pct':
|
case 'stop_pct':
|
||||||
case 'target_pct':
|
case 'target_pct':
|
||||||
case 'confidence_score':
|
case 'confidence_score':
|
||||||
case 'best_target_probability':
|
case 'primary_target_probability':
|
||||||
case 'risk_level':
|
case 'risk_level':
|
||||||
cmp = getComputedValue(a, column) - getComputedValue(b, column);
|
cmp = getComputedValue(a, column) - getComputedValue(b, column);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ActivationConfig, TradeSetup } from './types';
|
import type { ActivationConfig, TradeSetup, TradeTarget } from './types';
|
||||||
|
|
||||||
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
|
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
|
||||||
|
|
||||||
@@ -16,15 +16,18 @@ function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'sh
|
|||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bestTargetProbability(setup: TradeSetup): number {
|
/** The starred primary target (the one the headline R:R refers to), falling
|
||||||
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
|
* back to the most likely target when no star is stored. */
|
||||||
|
export function primaryTarget(setup: TradeSetup): TradeTarget | null {
|
||||||
|
const starred = setup.targets?.find((t) => t.is_primary);
|
||||||
|
if (starred) return starred;
|
||||||
|
if (!setup.targets?.length) return null;
|
||||||
|
return [...setup.targets].sort((a, b) => b.probability - a.probability)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Probability of the starred primary target (the one the headline R:R refers to). */
|
/** Probability of the starred primary target (the one the headline R:R refers to). */
|
||||||
export function primaryTargetProbability(setup: TradeSetup): number | null {
|
export function primaryTargetProbability(setup: TradeSetup): number | null {
|
||||||
const primary = setup.targets?.find((t) => t.is_primary);
|
return primaryTarget(setup)?.probability ?? null;
|
||||||
if (primary) return primary.probability;
|
|
||||||
return setup.targets?.length ? bestTargetProbability(setup) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** R:R recomputed from the current price (0 if no reward/risk left). */
|
/** R:R recomputed from the current price (0 if no reward/risk left). */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
|||||||
from app.services.recommendation_service import (
|
from app.services.recommendation_service import (
|
||||||
_build_reasoning,
|
_build_reasoning,
|
||||||
_choose_recommended_action,
|
_choose_recommended_action,
|
||||||
|
_prune_floor_pinned_targets,
|
||||||
_select_primary_target,
|
_select_primary_target,
|
||||||
direction_analyzer,
|
direction_analyzer,
|
||||||
probability_estimator,
|
probability_estimator,
|
||||||
@@ -154,6 +155,35 @@ def test_primary_target_requires_probability_floor():
|
|||||||
assert primary["price"] == 112.0
|
assert primary["price"] == 112.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_keeps_only_nearest_floor_pinned_target():
|
||||||
|
# Two targets pinned at the 3% clamp floor are indistinguishable to the
|
||||||
|
# model — only the nearest survives; farther ones are duplicate noise.
|
||||||
|
targets = [
|
||||||
|
{"price": 204.0, "rr_ratio": 0.7, "probability": 25.6},
|
||||||
|
{"price": 241.0, "rr_ratio": 2.0, "probability": 3.0},
|
||||||
|
{"price": 272.0, "rr_ratio": 3.1, "probability": 3.0},
|
||||||
|
]
|
||||||
|
pruned = _prune_floor_pinned_targets(targets)
|
||||||
|
assert [t["price"] for t in pruned] == [204.0, 241.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_leaves_targets_above_floor_untouched():
|
||||||
|
targets = [
|
||||||
|
{"price": 110.0, "rr_ratio": 2.0, "probability": 65.0},
|
||||||
|
{"price": 120.0, "rr_ratio": 3.5, "probability": 20.0},
|
||||||
|
]
|
||||||
|
assert _prune_floor_pinned_targets(targets) == targets
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_all_floor_pinned_keeps_nearest_only():
|
||||||
|
targets = [
|
||||||
|
{"price": 241.0, "rr_ratio": 2.0, "probability": 3.0},
|
||||||
|
{"price": 272.0, "rr_ratio": 3.1, "probability": 3.0},
|
||||||
|
]
|
||||||
|
pruned = _prune_floor_pinned_targets(targets)
|
||||||
|
assert [t["price"] for t in pruned] == [241.0]
|
||||||
|
|
||||||
|
|
||||||
def test_detects_sentiment_technical_conflict():
|
def test_detects_sentiment_technical_conflict():
|
||||||
conflicts = signal_conflict_detector.detect_conflicts(
|
conflicts = signal_conflict_detector.detect_conflicts(
|
||||||
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},
|
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ from app.models.trade_setup import TradeSetup
|
|||||||
from app.models.score import CompositeScore, DimensionScore
|
from app.models.score import CompositeScore, DimensionScore
|
||||||
from app.models.sentiment import SentimentScore
|
from app.models.sentiment import SentimentScore
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
|
from app.services.rr_scanner_service import (
|
||||||
|
LIVE_SETUP_MAX_AGE_DAYS,
|
||||||
|
get_trade_setups,
|
||||||
|
scan_ticker,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _as_utc(value: datetime) -> datetime:
|
def _as_utc(value: datetime) -> datetime:
|
||||||
@@ -69,11 +73,11 @@ def _make_ohlcv_bars(
|
|||||||
num_bars: int = 20,
|
num_bars: int = 20,
|
||||||
base_close: float = 100.0,
|
base_close: float = 100.0,
|
||||||
) -> list[OHLCVRecord]:
|
) -> list[OHLCVRecord]:
|
||||||
"""Generate OHLCV bars closing around base_close with ATR ≈ 2.0."""
|
"""Generate OHLCV bars closing around base_close with ATR ≈ 2.0."""
|
||||||
bars: list[OHLCVRecord] = []
|
bars: list[OHLCVRecord] = []
|
||||||
start = date(2024, 1, 1)
|
start = date(2024, 1, 1)
|
||||||
for i in range(num_bars):
|
for i in range(num_bars):
|
||||||
close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5
|
close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5
|
||||||
bars.append(OHLCVRecord(
|
bars.append(OHLCVRecord(
|
||||||
ticker_id=ticker_id,
|
ticker_id=ticker_id,
|
||||||
date=start + timedelta(days=i),
|
date=start + timedelta(days=i),
|
||||||
@@ -101,7 +105,7 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
|
|||||||
but all below the R:R threshold for their respective directions
|
but all below the R:R threshold for their respective directions
|
||||||
- Levels in the right direction but below R:R threshold
|
- Levels in the right direction but below R:R threshold
|
||||||
|
|
||||||
Note: scan_ticker does NOT filter by SR level type — it only checks whether
|
Note: scan_ticker does NOT filter by SR level type — it only checks whether
|
||||||
the price_level is above or below entry. So "wrong side" means all levels
|
the price_level is above or below entry. So "wrong side" means all levels
|
||||||
are clustered near entry and below threshold in both directions.
|
are clustered near entry and below threshold in both directions.
|
||||||
"""
|
"""
|
||||||
@@ -111,10 +115,10 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
|
|||||||
return {"variant": variant, "levels": []}
|
return {"variant": variant, "levels": []}
|
||||||
|
|
||||||
else: # below_threshold
|
else: # below_threshold
|
||||||
# All levels close to entry so R:R < 1.5 with risk ≈ 3
|
# All levels close to entry so R:R < 1.5 with risk ≈ 3
|
||||||
# For longs: reward < 4.5 → price < 104.5
|
# For longs: reward < 4.5 → price < 104.5
|
||||||
# For shorts: reward < 4.5 → price > 95.5
|
# For shorts: reward < 4.5 → price > 95.5
|
||||||
# Place all levels in the 96–104 band (below threshold both ways)
|
# Place all levels in the 96–104 band (below threshold both ways)
|
||||||
num = draw(st.integers(min_value=1, max_value=3))
|
num = draw(st.integers(min_value=1, max_value=3))
|
||||||
levels = []
|
levels = []
|
||||||
for _ in range(num):
|
for _ in range(num):
|
||||||
@@ -139,7 +143,7 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
|
|||||||
def single_candidate_scenario(draw: st.DrawFn) -> dict:
|
def single_candidate_scenario(draw: st.DrawFn) -> dict:
|
||||||
"""Generate a scenario with exactly one S/R level that meets the R:R threshold.
|
"""Generate a scenario with exactly one S/R level that meets the R:R threshold.
|
||||||
|
|
||||||
For longs: one resistance above entry with R:R >= 1.5 (price >= 104.5 with risk ≈ 3).
|
For longs: one resistance above entry with R:R >= 1.5 (price >= 104.5 with risk ≈ 3).
|
||||||
"""
|
"""
|
||||||
direction = draw(st.sampled_from(["long", "short"]))
|
direction = draw(st.sampled_from(["long", "short"]))
|
||||||
|
|
||||||
@@ -175,7 +179,7 @@ async def test_property_zero_candidates_produce_no_setup(
|
|||||||
"""**Validates: Requirements 3.1, 3.2**
|
"""**Validates: Requirements 3.1, 3.2**
|
||||||
|
|
||||||
Property: when zero candidate S/R levels exist (no levels, wrong side,
|
Property: when zero candidate S/R levels exist (no levels, wrong side,
|
||||||
or below threshold), scan_ticker produces no setup — unchanged from
|
or below threshold), scan_ticker produces no setup — unchanged from
|
||||||
original behavior.
|
original behavior.
|
||||||
"""
|
"""
|
||||||
from tests.conftest import _test_engine, _test_session_factory
|
from tests.conftest import _test_engine, _test_session_factory
|
||||||
@@ -225,7 +229,7 @@ async def test_property_single_candidate_selected_unchanged(
|
|||||||
"""**Validates: Requirements 3.3**
|
"""**Validates: Requirements 3.3**
|
||||||
|
|
||||||
Property: when exactly one candidate S/R level meets the R:R threshold,
|
Property: when exactly one candidate S/R level meets the R:R threshold,
|
||||||
scan_ticker selects it — same as the original code would.
|
scan_ticker selects it — same as the original code would.
|
||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -270,7 +274,7 @@ async def test_property_single_candidate_selected_unchanged(
|
|||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# 7.2 Unit test: no S/R levels → no setup produced
|
# 7.2 Unit test: no S/R levels → no setup produced
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -296,7 +300,7 @@ async def test_no_sr_levels_produces_no_setup(scan_session: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# 7.3 Unit test: single candidate meets threshold → selected
|
# 7.3 Unit test: single candidate meets threshold → selected
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -306,7 +310,7 @@ async def test_single_resistance_above_threshold_selected(scan_session: AsyncSes
|
|||||||
When exactly one resistance level above entry meets the R:R threshold,
|
When exactly one resistance level above entry meets the R:R threshold,
|
||||||
it should be selected as the long setup target.
|
it should be selected as the long setup target.
|
||||||
|
|
||||||
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Resistance at 110 → R:R ≈ 3.33 (>= 1.5).
|
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Resistance at 110 → R:R ≈ 3.33 (>= 1.5).
|
||||||
"""
|
"""
|
||||||
ticker = Ticker(symbol="SINGL")
|
ticker = Ticker(symbol="SINGL")
|
||||||
scan_session.add(ticker)
|
scan_session.add(ticker)
|
||||||
@@ -343,7 +347,7 @@ async def test_single_support_below_threshold_selected(scan_session: AsyncSessio
|
|||||||
When exactly one support level below entry meets the R:R threshold,
|
When exactly one support level below entry meets the R:R threshold,
|
||||||
it should be selected as the short setup target.
|
it should be selected as the short setup target.
|
||||||
|
|
||||||
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Support at 90 → R:R ≈ 3.33 (>= 1.5).
|
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Support at 90 → R:R ≈ 3.33 (>= 1.5).
|
||||||
"""
|
"""
|
||||||
ticker = Ticker(symbol="SINGS")
|
ticker = Ticker(symbol="SINGS")
|
||||||
scan_session.add(ticker)
|
scan_session.add(ticker)
|
||||||
@@ -444,6 +448,42 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_trade_setups_excludes_stale_rows(db_session: AsyncSession):
|
||||||
|
"""A "latest" row older than LIVE_SETUP_MAX_AGE_DAYS means the daily scan
|
||||||
|
stopped re-emitting the setup (nothing clears the R:R threshold from the
|
||||||
|
current price) — it must not surface on the live views."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
ticker_fresh = Ticker(symbol="FRESH")
|
||||||
|
ticker_stale = Ticker(symbol="STALE")
|
||||||
|
db_session.add_all([ticker_fresh, ticker_stale])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
db_session.add_all([
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker_fresh.id, direction="long",
|
||||||
|
entry_price=100.0, stop_loss=97.0, target=109.0,
|
||||||
|
rr_ratio=3.0, composite_score=50.0,
|
||||||
|
detected_at=now - timedelta(days=1),
|
||||||
|
),
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker_stale.id, direction="long",
|
||||||
|
entry_price=100.0, stop_loss=97.0, target=109.0,
|
||||||
|
rr_ratio=3.0, composite_score=50.0,
|
||||||
|
detected_at=now - timedelta(days=LIVE_SETUP_MAX_AGE_DAYS, hours=1),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
results = await get_trade_setups(db_session)
|
||||||
|
symbols = [r["symbol"] for r in results]
|
||||||
|
assert symbols == ["FRESH"], f"Stale setup must be excluded, got {symbols}"
|
||||||
|
|
||||||
|
# The per-symbol view applies the same liveness rule.
|
||||||
|
stale_rows = await get_trade_setups(db_session, symbol="STALE")
|
||||||
|
assert stale_rows == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
|
async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
@@ -540,9 +580,12 @@ async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
|
|||||||
|
|
||||||
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
||||||
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
||||||
(bullish sentiment, composite 96) that yields live confidence 97."""
|
(bullish sentiment, composite 96) that yields live confidence 97.
|
||||||
old_scan = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
|
||||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
The scan date stays inside the LIVE_SETUP_MAX_AGE_DAYS liveness window —
|
||||||
|
these tests exercise the live overlay on a still-live row, not staleness."""
|
||||||
|
current = datetime.now(timezone.utc)
|
||||||
|
old_scan = current - timedelta(days=2)
|
||||||
old_reasoning = (
|
old_reasoning = (
|
||||||
"LONG (high confidence): 82% with aligned signals "
|
"LONG (high confidence): 82% with aligned signals "
|
||||||
"(technical=88, momentum=60, sentiment=neutral)."
|
"(technical=88, momentum=60, sentiment=neutral)."
|
||||||
@@ -653,7 +696,7 @@ async def test_live_recommendation_filters_apply_to_live_values(
|
|||||||
"""min_confidence must judge the overlaid live confidence, not the stored one."""
|
"""min_confidence must judge the overlaid live confidence, not the stored one."""
|
||||||
await _seed_stale_setup_with_current_scores(db_session)
|
await _seed_stale_setup_with_current_scores(db_session)
|
||||||
|
|
||||||
# Stored confidence is 82 — a stored-column filter would drop this row.
|
# Stored confidence is 82 — a stored-column filter would drop this row.
|
||||||
# Live confidence is 97, so it must pass.
|
# Live confidence is 97, so it must pass.
|
||||||
rows = await get_trade_setups(
|
rows = await get_trade_setups(
|
||||||
db_session,
|
db_session,
|
||||||
@@ -675,7 +718,7 @@ async def test_live_recommendation_filters_apply_to_live_values(
|
|||||||
|
|
||||||
|
|
||||||
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
|
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
|
||||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
current = datetime.now(timezone.utc)
|
||||||
ticker = Ticker(symbol="BOTH")
|
ticker = Ticker(symbol="BOTH")
|
||||||
db_session.add(ticker)
|
db_session.add(ticker)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
@@ -776,7 +819,7 @@ async def test_live_recommendation_action_independent_of_direction_filter(
|
|||||||
async def test_live_overlay_preserves_setup_specific_risk_and_context(
|
async def test_live_overlay_preserves_setup_specific_risk_and_context(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
):
|
):
|
||||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
current = datetime.now(timezone.utc)
|
||||||
ticker = Ticker(symbol="RISK")
|
ticker = Ticker(symbol="RISK")
|
||||||
db_session.add(ticker)
|
db_session.add(ticker)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
@@ -883,7 +926,7 @@ async def test_live_trade_setup_read_does_not_recompute_scores(db_session: Async
|
|||||||
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
|
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
):
|
):
|
||||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
current = datetime.now(timezone.utc)
|
||||||
ticker = Ticker(symbol="LIVEP")
|
ticker = Ticker(symbol="LIVEP")
|
||||||
db_session.add(ticker)
|
db_session.add(ticker)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
|
|||||||
Reference in New Issue
Block a user