Promote production portfolio strategy
This commit is contained in:
@@ -34,5 +34,5 @@ class PaperTrade(Base):
|
||||
)
|
||||
close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# How the trade was closed: "trailing" | "stop" | "target" | "manual".
|
||||
# How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual".
|
||||
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
|
||||
@@ -30,6 +30,10 @@ class TradeSetup(Base):
|
||||
# time. Since July 2026 this is residual 12-1 momentum when benchmark data is
|
||||
# available, with raw 12-1 as a fallback.
|
||||
momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# Production ordering score. July 2026 promotion: residual momentum remains
|
||||
# the gate, while this rank blends residual momentum with realized volatility.
|
||||
strategy_rank: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
volatility_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conflict_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
recommended_action: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
@@ -62,7 +62,11 @@ async def write_exit_policy(
|
||||
) -> APIEnvelope:
|
||||
"""Change the auto-exit policy (admin)."""
|
||||
data = await paper_trade_service.set_exit_policy(
|
||||
db, mode=body.mode, trailing_pct=body.trailing_pct, hold_days=body.hold_days
|
||||
db,
|
||||
mode=body.mode,
|
||||
trailing_pct=body.trailing_pct,
|
||||
atr_multiplier=body.atr_multiplier,
|
||||
hold_days=body.hold_days,
|
||||
)
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ class PaperTradeClose(BaseModel):
|
||||
|
||||
class ExitPolicyUpdate(BaseModel):
|
||||
"""Auto-exit policy for open paper trades."""
|
||||
mode: str | None = Field(default=None, pattern=r"^(time|trailing|target)$")
|
||||
mode: str | None = Field(default=None, pattern=r"^(time|trailing|atr_trailing|target)$")
|
||||
trailing_pct: float | None = Field(default=None, ge=0.5, le=90)
|
||||
atr_multiplier: float | None = Field(default=None, ge=0.5, le=10)
|
||||
hold_days: int | None = Field(default=None, ge=2, le=250)
|
||||
|
||||
|
||||
|
||||
@@ -57,5 +57,7 @@ class TradeSetupResponse(BaseModel):
|
||||
evaluated_at: datetime | None = None
|
||||
current_price: float | None = None
|
||||
momentum_percentile: float | None = None
|
||||
strategy_rank: float | None = None
|
||||
volatility_percentile: float | None = None
|
||||
context_as_of: TradeSetupContextAsOfResponse | None = None
|
||||
recommendation_summary: RecommendationSummaryResponse | None = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,15 +122,96 @@ async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
|
||||
if value is not None:
|
||||
values[ticker.symbol] = value
|
||||
|
||||
ranked = sorted(values, key=lambda s: values[s])
|
||||
n = len(ranked)
|
||||
percentiles = {
|
||||
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
|
||||
for rank, sym in enumerate(ranked)
|
||||
}
|
||||
percentiles = _percentiles(values)
|
||||
logger.info(json.dumps({
|
||||
"event": "momentum_ranked",
|
||||
"signal": "residual_12_1" if using_residual else "raw_12_1_fallback",
|
||||
"tickers": n,
|
||||
"tickers": len(percentiles),
|
||||
}))
|
||||
return percentiles
|
||||
|
||||
|
||||
def compute_realized_vol_6m(closes: list[float]) -> float | None:
|
||||
"""126-trading-day realized daily volatility. Higher = more volatile."""
|
||||
if len(closes) < 127:
|
||||
return None
|
||||
rets = [
|
||||
closes[k] / closes[k - 1] - 1.0
|
||||
for k in range(len(closes) - 126, len(closes))
|
||||
if closes[k - 1] > 0
|
||||
]
|
||||
if len(rets) < 2:
|
||||
return None
|
||||
mean = sum(rets) / len(rets)
|
||||
var = sum((x - mean) ** 2 for x in rets) / (len(rets) - 1)
|
||||
return var ** 0.5
|
||||
|
||||
|
||||
def _percentiles(values: dict[str, float]) -> dict[str, float]:
|
||||
ranked = sorted(values, key=lambda s: values[s])
|
||||
n = len(ranked)
|
||||
return {
|
||||
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
|
||||
for rank, sym in enumerate(ranked)
|
||||
}
|
||||
|
||||
|
||||
async def compute_activation_ranks(db: AsyncSession) -> dict[str, dict[str, float | None]]:
|
||||
"""Compute production activation ranks for the live scanner.
|
||||
|
||||
``momentum_percentile`` remains the residual/raw 12-1 gate. ``strategy_rank``
|
||||
is the promoted production ordering score: 80% activation momentum rank plus
|
||||
20% 6-month realized-volatility percentile. Live ranks are universe-wide
|
||||
before scanning; the research backtest ranked each weekly setup-candidate
|
||||
cross-section, so this is the deliberate production approximation.
|
||||
"""
|
||||
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
||||
tickers = list(result.scalars().all())
|
||||
|
||||
benchmark_closes = await _load_activation_benchmark(db)
|
||||
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK
|
||||
|
||||
momentum_values: dict[str, float] = {}
|
||||
vol_values: dict[str, float] = {}
|
||||
for ticker in tickers:
|
||||
try:
|
||||
records = await query_ohlcv(db, ticker.symbol)
|
||||
except Exception:
|
||||
logger.exception("Activation rank fetch failed for %s", ticker.symbol)
|
||||
continue
|
||||
closes = [float(r.close) for r in records]
|
||||
momentum = (
|
||||
compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes)
|
||||
if using_residual
|
||||
else compute_12_1_momentum(closes)
|
||||
)
|
||||
if momentum is not None:
|
||||
momentum_values[ticker.symbol] = momentum
|
||||
vol = compute_realized_vol_6m(closes)
|
||||
if vol is not None:
|
||||
vol_values[ticker.symbol] = vol
|
||||
|
||||
momentum_percentiles = _percentiles(momentum_values)
|
||||
vol_percentiles = _percentiles(vol_values)
|
||||
symbols = set(momentum_percentiles) | set(vol_percentiles)
|
||||
ranks: dict[str, dict[str, float | None]] = {}
|
||||
for sym in symbols:
|
||||
momentum_pct = momentum_percentiles.get(sym)
|
||||
vol_pct = vol_percentiles.get(sym)
|
||||
strategy_rank = (
|
||||
round(momentum_pct * 0.8 + vol_pct * 0.2, 2)
|
||||
if momentum_pct is not None and vol_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[sym] = {
|
||||
"momentum_percentile": momentum_pct,
|
||||
"volatility_percentile": vol_pct,
|
||||
"strategy_rank": strategy_rank,
|
||||
}
|
||||
|
||||
logger.info(json.dumps({
|
||||
"event": "activation_ranked",
|
||||
"signal": "residual_12_1_plus_vol_80_20" if using_residual else "raw_12_1_plus_vol_80_20",
|
||||
"tickers": len(ranks),
|
||||
}))
|
||||
return ranks
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import benchmark_service, settings_store
|
||||
from app.services.indicator_service import compute_atr
|
||||
from app.services.outcome_service import (
|
||||
OUTCOME_AMBIGUOUS,
|
||||
OUTCOME_STOP_HIT,
|
||||
@@ -20,24 +21,25 @@ from app.services.outcome_service import (
|
||||
evaluate_setup_against_bars,
|
||||
)
|
||||
|
||||
# Exit policy for OPEN paper trades (auto-close). "time" holds a fixed number of
|
||||
# trading days with the initial stop and exits at that day's close — the exit the
|
||||
# July 2026 backtest validated (the classic momentum hold-and-re-rank); "trailing"
|
||||
# rides a trailing stop; "target" closes at the setup's stop/target. Stored in
|
||||
# SystemSetting so it's tunable + transparent in the UI.
|
||||
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
|
||||
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
|
||||
# 30-trading-day hold. The older percent trail and target/stop modes remain
|
||||
# selectable for comparison. Stored in SystemSetting so it's tunable and visible.
|
||||
KEY_EXIT_MODE = "paper_exit_mode"
|
||||
KEY_TRAILING_PCT = "paper_trailing_pct"
|
||||
KEY_ATR_MULTIPLIER = "paper_atr_multiplier"
|
||||
KEY_HOLD_DAYS = "paper_hold_days"
|
||||
DEFAULT_EXIT_MODE = "time"
|
||||
DEFAULT_EXIT_MODE = "atr_trailing"
|
||||
DEFAULT_TRAILING_PCT = 12.0
|
||||
DEFAULT_ATR_MULTIPLIER = 3.0
|
||||
DEFAULT_HOLD_DAYS = 30
|
||||
|
||||
_VALID_EXIT_MODES = ("time", "trailing", "target")
|
||||
_VALID_EXIT_MODES = ("time", "trailing", "atr_trailing", "target")
|
||||
|
||||
|
||||
async def get_exit_policy(db: AsyncSession) -> dict:
|
||||
"""Active auto-exit policy:
|
||||
{'mode': 'time'|'trailing'|'target', 'trailing_pct': float, 'hold_days': int}."""
|
||||
{'mode': 'time'|'trailing'|'atr_trailing'|'target', ...}."""
|
||||
mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
|
||||
if mode not in _VALID_EXIT_MODES:
|
||||
mode = DEFAULT_EXIT_MODE
|
||||
@@ -47,13 +49,24 @@ async def get_exit_policy(db: AsyncSession) -> dict:
|
||||
except (TypeError, ValueError):
|
||||
pct = DEFAULT_TRAILING_PCT
|
||||
pct = max(0.5, min(90.0, pct))
|
||||
raw_atr = await settings_store.get_value(db, KEY_ATR_MULTIPLIER, str(DEFAULT_ATR_MULTIPLIER))
|
||||
try:
|
||||
atr_multiplier = float(raw_atr)
|
||||
except (TypeError, ValueError):
|
||||
atr_multiplier = DEFAULT_ATR_MULTIPLIER
|
||||
atr_multiplier = max(0.5, min(10.0, atr_multiplier))
|
||||
raw_days = await settings_store.get_value(db, KEY_HOLD_DAYS, str(DEFAULT_HOLD_DAYS))
|
||||
try:
|
||||
hold_days = int(float(raw_days))
|
||||
except (TypeError, ValueError):
|
||||
hold_days = DEFAULT_HOLD_DAYS
|
||||
hold_days = max(2, min(250, hold_days))
|
||||
return {"mode": mode, "trailing_pct": pct, "hold_days": hold_days}
|
||||
return {
|
||||
"mode": mode,
|
||||
"trailing_pct": pct,
|
||||
"atr_multiplier": atr_multiplier,
|
||||
"hold_days": hold_days,
|
||||
}
|
||||
|
||||
|
||||
async def set_exit_policy(
|
||||
@@ -61,18 +74,23 @@ async def set_exit_policy(
|
||||
*,
|
||||
mode: str | None = None,
|
||||
trailing_pct: float | None = None,
|
||||
atr_multiplier: float | None = None,
|
||||
hold_days: int | None = None,
|
||||
) -> dict:
|
||||
"""Persist the auto-exit policy (admin). Validates inputs."""
|
||||
if mode is not None:
|
||||
mode = mode.strip().lower()
|
||||
if mode not in _VALID_EXIT_MODES:
|
||||
raise ValidationError("mode must be 'time', 'trailing' or 'target'")
|
||||
raise ValidationError("mode must be 'time', 'trailing', 'atr_trailing' or 'target'")
|
||||
await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
|
||||
if trailing_pct is not None:
|
||||
if not 0.5 <= float(trailing_pct) <= 90.0:
|
||||
raise ValidationError("trailing_pct must be between 0.5 and 90")
|
||||
await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct)))
|
||||
if atr_multiplier is not None:
|
||||
if not 0.5 <= float(atr_multiplier) <= 10.0:
|
||||
raise ValidationError("atr_multiplier must be between 0.5 and 10")
|
||||
await settings_store.upsert_setting(db, KEY_ATR_MULTIPLIER, str(float(atr_multiplier)))
|
||||
if hold_days is not None:
|
||||
if not 2 <= int(hold_days) <= 250:
|
||||
raise ValidationError("hold_days must be between 2 and 250")
|
||||
@@ -163,6 +181,107 @@ def _trailing_close(
|
||||
return None
|
||||
|
||||
|
||||
def _atr_from_rows(rows: list[tuple], idx: int) -> float | None:
|
||||
try:
|
||||
result = compute_atr(
|
||||
[float(r[2]) for r in rows[: idx + 1]],
|
||||
[float(r[3]) for r in rows[: idx + 1]],
|
||||
[float(r[4]) for r in rows[: idx + 1]],
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
atr = result.get("atr")
|
||||
return float(atr) if atr and atr > 0 else None
|
||||
|
||||
|
||||
def _atr_trailing_level(
|
||||
direction: str,
|
||||
entry: float,
|
||||
init_stop: float,
|
||||
atr_multiplier: float,
|
||||
rows: list[tuple],
|
||||
opened_on: date,
|
||||
) -> float:
|
||||
"""Current ATR trailing stop level after all available post-entry closes."""
|
||||
long = direction == "long"
|
||||
stop = float(init_stop)
|
||||
anchor = float(entry)
|
||||
for idx, (d, _, _, _, close) in enumerate(rows):
|
||||
if d <= opened_on:
|
||||
continue
|
||||
close = float(close)
|
||||
atr = _atr_from_rows(rows, idx)
|
||||
if long:
|
||||
anchor = max(anchor, close)
|
||||
if atr is not None:
|
||||
next_stop = anchor - atr_multiplier * atr
|
||||
if next_stop < close:
|
||||
stop = max(stop, next_stop)
|
||||
else:
|
||||
anchor = min(anchor, close)
|
||||
if atr is not None:
|
||||
next_stop = anchor + atr_multiplier * atr
|
||||
if next_stop > close:
|
||||
stop = min(stop, next_stop)
|
||||
return stop
|
||||
|
||||
|
||||
def _atr_trailing_close(
|
||||
direction: str,
|
||||
entry: float,
|
||||
init_stop: float,
|
||||
atr_multiplier: float,
|
||||
hold_days: int,
|
||||
rows: list[tuple],
|
||||
opened_on: date,
|
||||
) -> tuple[float, date, str] | None:
|
||||
"""Initial stop + ATR trailing stop + max hold, matching the portfolio sim.
|
||||
|
||||
Stop checks happen before the same day's trailing update, so a newly ratcheted
|
||||
stop becomes active on the next bar. Gaps through the stop fill at the open.
|
||||
"""
|
||||
long = direction == "long"
|
||||
stop = float(init_stop)
|
||||
anchor = float(entry)
|
||||
bars_held = 0
|
||||
for idx, (d, open_, high, low, close) in enumerate(rows):
|
||||
if d <= opened_on:
|
||||
continue
|
||||
open_ = float(open_)
|
||||
high = float(high)
|
||||
low = float(low)
|
||||
close = float(close)
|
||||
bars_held += 1
|
||||
|
||||
if long:
|
||||
if low <= stop:
|
||||
reason = "trailing" if stop > init_stop + 1e-9 else "stop"
|
||||
return min(stop, open_), d, reason
|
||||
else:
|
||||
if high >= stop:
|
||||
reason = "trailing" if stop < init_stop - 1e-9 else "stop"
|
||||
return max(stop, open_), d, reason
|
||||
|
||||
if bars_held >= hold_days:
|
||||
return close, d, "time"
|
||||
|
||||
atr = _atr_from_rows(rows, idx)
|
||||
if long:
|
||||
anchor = max(anchor, close)
|
||||
if atr is not None:
|
||||
next_stop = anchor - atr_multiplier * atr
|
||||
if next_stop < close:
|
||||
stop = max(stop, next_stop)
|
||||
else:
|
||||
anchor = min(anchor, close)
|
||||
if atr is not None:
|
||||
next_stop = anchor + atr_multiplier * atr
|
||||
if next_stop > close:
|
||||
stop = min(stop, next_stop)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def create_trade(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
@@ -273,7 +392,8 @@ async def list_trades(
|
||||
# makes a provider call).
|
||||
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
|
||||
|
||||
# Current trailing-stop level + distance for open trades (when trailing is active).
|
||||
# Current trailing-stop level + distance for open trades (when a trailing
|
||||
# policy is active).
|
||||
policy = await get_exit_policy(db)
|
||||
trailing_info: dict[int, tuple[float, float | None]] = {}
|
||||
if policy["mode"] == "trailing":
|
||||
@@ -294,6 +414,33 @@ async def list_trades(
|
||||
if cur:
|
||||
dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
|
||||
trailing_info[t.id] = (level, dist)
|
||||
elif policy["mode"] == "atr_trailing":
|
||||
atr_multiplier = float(policy["atr_multiplier"])
|
||||
for t, _ in rows:
|
||||
if t.status != "open":
|
||||
continue
|
||||
bars_result = await db.execute(
|
||||
select(
|
||||
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
|
||||
OHLCVRecord.low, OHLCVRecord.close,
|
||||
)
|
||||
.where(OHLCVRecord.ticker_id == t.ticker_id)
|
||||
.order_by(OHLCVRecord.date.asc())
|
||||
)
|
||||
level = _atr_trailing_level(
|
||||
t.direction,
|
||||
t.entry_price,
|
||||
t.stop_loss,
|
||||
atr_multiplier,
|
||||
bars_result.all(),
|
||||
t.opened_at.date(),
|
||||
)
|
||||
cur = prices.get(t.ticker_id)
|
||||
dist = None
|
||||
if cur:
|
||||
long = t.direction == "long"
|
||||
dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
|
||||
trailing_info[t.id] = (level, dist)
|
||||
|
||||
return [
|
||||
_to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes, trailing_info.get(t.id))
|
||||
@@ -337,10 +484,11 @@ async def close_trade(
|
||||
async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
"""Auto-close open trades per the active exit policy, from the daily bars.
|
||||
|
||||
Walks the bars after each trade's open. 'time' closes at the initial stop or
|
||||
the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at
|
||||
the setup's target or stop (same logic as the outcome evaluator). Trades that
|
||||
have hit nothing stay open. Returns the count closed.
|
||||
Walks the bars after each trade's open. 'atr_trailing' closes at the initial
|
||||
stop, a 3x-ATR-style trailing stop, or the hold_days-th close; 'time' closes
|
||||
at the initial stop or the hold_days-th close; 'trailing' uses the legacy
|
||||
percent trail; 'target' uses the setup's target or stop. Trades that have hit
|
||||
nothing stay open. Returns the count closed.
|
||||
"""
|
||||
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
|
||||
open_trades = list(result.scalars().all())
|
||||
@@ -350,6 +498,7 @@ async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
policy = await get_exit_policy(db)
|
||||
mode = policy["mode"]
|
||||
trail_frac = policy["trailing_pct"] / 100.0
|
||||
atr_multiplier = float(policy["atr_multiplier"])
|
||||
hold_days = policy["hold_days"]
|
||||
|
||||
closed = 0
|
||||
@@ -365,13 +514,13 @@ async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
)
|
||||
.order_by(OHLCVRecord.date.asc())
|
||||
)
|
||||
rows = bars_result.all()
|
||||
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows]
|
||||
post_rows = bars_result.all()
|
||||
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in post_rows]
|
||||
if not bars:
|
||||
continue
|
||||
|
||||
if mode == "time":
|
||||
hit = _time_close(trade.direction, trade.stop_loss, hold_days, rows)
|
||||
hit = _time_close(trade.direction, trade.stop_loss, hold_days, post_rows)
|
||||
if hit is None:
|
||||
continue # neither the stop nor the hold horizon reached yet
|
||||
close_price, close_date, reason = hit
|
||||
@@ -380,6 +529,27 @@ async def resolve_open_trades(db: AsyncSession) -> int:
|
||||
if hit is None:
|
||||
continue # neither the trailing nor the initial stop reached yet
|
||||
close_price, close_date, reason = hit
|
||||
elif mode == "atr_trailing":
|
||||
all_bars_result = await db.execute(
|
||||
select(
|
||||
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
|
||||
OHLCVRecord.low, OHLCVRecord.close,
|
||||
)
|
||||
.where(OHLCVRecord.ticker_id == trade.ticker_id)
|
||||
.order_by(OHLCVRecord.date.asc())
|
||||
)
|
||||
hit = _atr_trailing_close(
|
||||
trade.direction,
|
||||
trade.entry_price,
|
||||
trade.stop_loss,
|
||||
atr_multiplier,
|
||||
hold_days,
|
||||
all_bars_result.all(),
|
||||
trade.opened_at.date(),
|
||||
)
|
||||
if hit is None:
|
||||
continue
|
||||
close_price, close_date, reason = hit
|
||||
else:
|
||||
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
|
||||
outcome, outcome_date = evaluate_setup_against_bars(
|
||||
|
||||
@@ -36,7 +36,7 @@ from app.services.recommendation_service import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STRATEGY_VERSION = "residual_momentum_12_1_rr_time_v2"
|
||||
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
@@ -288,6 +288,21 @@ async def _create_signal_context_snapshots(
|
||||
"composite_score": float(comp.score) if comp else float(setup.composite_score),
|
||||
"composite_is_stale": bool(comp.is_stale) if comp else None,
|
||||
"composite_computed_at": comp.computed_at if comp else None,
|
||||
"momentum_percentile": (
|
||||
float(setup.momentum_percentile)
|
||||
if setup.momentum_percentile is not None
|
||||
else None
|
||||
),
|
||||
"volatility_percentile": (
|
||||
float(setup.volatility_percentile)
|
||||
if setup.volatility_percentile is not None
|
||||
else None
|
||||
),
|
||||
"strategy_rank": (
|
||||
float(setup.strategy_rank)
|
||||
if setup.strategy_rank is not None
|
||||
else None
|
||||
),
|
||||
"dimensions": dims.get(setup.ticker_id, {}),
|
||||
}
|
||||
sentiment_context = (
|
||||
@@ -348,12 +363,15 @@ async def scan_ticker(
|
||||
rr_threshold: float = 1.5,
|
||||
atr_multiplier: float = 1.5,
|
||||
momentum_percentile: float | None = None,
|
||||
strategy_rank: float | None = None,
|
||||
volatility_percentile: float | None = None,
|
||||
) -> list[TradeSetup]:
|
||||
"""Scan a single ticker for trade setups meeting the R:R threshold.
|
||||
|
||||
``momentum_percentile`` is the ticker's residual 12-1 momentum activation
|
||||
rank across the universe (computed by the caller), stored on each setup so
|
||||
the activation gate can select the top slice."""
|
||||
the activation gate can select the top slice. ``strategy_rank`` is the
|
||||
production ordering score used for top-pick ranking."""
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
|
||||
records = await query_ohlcv(db, symbol)
|
||||
@@ -441,6 +459,8 @@ async def scan_ticker(
|
||||
composite_score=round(composite_score, 4),
|
||||
detected_at=now,
|
||||
momentum_percentile=momentum_percentile,
|
||||
strategy_rank=strategy_rank,
|
||||
volatility_percentile=volatility_percentile,
|
||||
))
|
||||
|
||||
if levels_below:
|
||||
@@ -475,6 +495,8 @@ async def scan_ticker(
|
||||
composite_score=round(composite_score, 4),
|
||||
detected_at=now,
|
||||
momentum_percentile=momentum_percentile,
|
||||
strategy_rank=strategy_rank,
|
||||
volatility_percentile=volatility_percentile,
|
||||
))
|
||||
|
||||
available_directions = {s.direction for s in setups}
|
||||
@@ -525,16 +547,17 @@ async def scan_all_tickers(
|
||||
tickers = list(result.scalars().all())
|
||||
total = len(tickers)
|
||||
|
||||
# Rank the universe by residual 12-1 momentum up front so each new setup
|
||||
# carries its activation percentile. Best-effort; the ranker falls back to
|
||||
# raw 12-1 momentum only if benchmark data is unavailable.
|
||||
# Rank the universe up front so each new setup carries both the residual
|
||||
# activation gate percentile and the promoted production ordering score.
|
||||
# Best-effort; the ranker falls back to raw 12-1 momentum only if benchmark
|
||||
# data is unavailable.
|
||||
try:
|
||||
from app.services import momentum_service
|
||||
|
||||
percentiles = await momentum_service.compute_momentum_percentiles(db)
|
||||
ranks = await momentum_service.compute_activation_ranks(db)
|
||||
except Exception:
|
||||
logger.exception("Momentum ranking refresh failed")
|
||||
percentiles = {}
|
||||
logger.exception("Activation ranking refresh failed")
|
||||
ranks = {}
|
||||
|
||||
all_setups: list[TradeSetup] = []
|
||||
for index, ticker in enumerate(tickers):
|
||||
@@ -555,7 +578,9 @@ async def scan_all_tickers(
|
||||
|
||||
setups = await scan_ticker(
|
||||
db, ticker.symbol, rr_threshold, atr_multiplier,
|
||||
momentum_percentile=percentiles.get(ticker.symbol),
|
||||
momentum_percentile=(ranks.get(ticker.symbol) or {}).get("momentum_percentile"),
|
||||
strategy_rank=(ranks.get(ticker.symbol) or {}).get("strategy_rank"),
|
||||
volatility_percentile=(ranks.get(ticker.symbol) or {}).get("volatility_percentile"),
|
||||
)
|
||||
all_setups.extend(setups)
|
||||
except Exception:
|
||||
@@ -605,6 +630,8 @@ async def get_trade_setups(
|
||||
latest_rows = list(latest_by_key.values())
|
||||
latest_rows.sort(
|
||||
key=lambda row: (
|
||||
row[0].strategy_rank if row[0].strategy_rank is not None else -1.0,
|
||||
row[0].momentum_percentile if row[0].momentum_percentile is not None else -1.0,
|
||||
row[0].confidence_score if row[0].confidence_score is not None else -1.0,
|
||||
row[0].rr_ratio,
|
||||
row[0].composite_score,
|
||||
@@ -632,6 +659,8 @@ async def get_trade_setups(
|
||||
]
|
||||
rows_out.sort(
|
||||
key=lambda row: (
|
||||
row["strategy_rank"] if row["strategy_rank"] is not None else -1.0,
|
||||
row["momentum_percentile"] if row["momentum_percentile"] is not None else -1.0,
|
||||
row["confidence_score"] if row["confidence_score"] is not None else -1.0,
|
||||
row["rr_ratio"],
|
||||
row["composite_score"],
|
||||
@@ -757,5 +786,7 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, price_context: dict | N
|
||||
"evaluated_at": setup.evaluated_at,
|
||||
"current_price": current_price,
|
||||
"momentum_percentile": setup.momentum_percentile,
|
||||
"strategy_rank": setup.strategy_rank,
|
||||
"volatility_percentile": setup.volatility_percentile,
|
||||
"context_as_of": context_as_of,
|
||||
}
|
||||
|
||||
@@ -173,9 +173,10 @@ async def _enrich_entry(
|
||||
"dimensions": dims,
|
||||
"rr_ratio": setup.rr_ratio if setup else None,
|
||||
"rr_direction": setup.direction if setup else None,
|
||||
# Residual 12-1 activation percentile (the top-pick selector); ticker-level,
|
||||
# so any of the ticker's setups carries the same value.
|
||||
# Residual 12-1 activation percentile gates qualification; strategy_rank
|
||||
# is the promoted top-pick ordering score.
|
||||
"momentum_percentile": setup.momentum_percentile if setup else None,
|
||||
"strategy_rank": setup.strategy_rank if setup else None,
|
||||
"sr_levels": sr_levels,
|
||||
"last_close": last_close,
|
||||
"change_pct": change_pct,
|
||||
|
||||
Reference in New Issue
Block a user