Promote production portfolio strategy

This commit is contained in:
2026-07-04 07:48:38 +02:00
parent 66ef0564c1
commit 5f2d108227
22 changed files with 1677 additions and 132 deletions
File diff suppressed because it is too large Load Diff
+88 -7
View File
@@ -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
+188 -18
View File
@@ -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(
+40 -9
View File
@@ -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,
}
+3 -2
View File
@@ -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,