feat: add strategy variant lab and signal context snapshots
Backtest report now includes research-only hold-to-horizon portfolio variants comparing raw vs residual 12-1 momentum, cutoff 80 vs 90, max 10 vs 15 positions, and SPY-200 risk scaling. A dynamic research recommendation panel flags residual momentum, cutoff 90, or regime scaling only when transparent promotion rules pass. Adds signal_context_snapshots with migration 016 and captures one point-in-time context row per newly generated TradeSetup: setup fields, composite/dimensions, latest sentiment, latest fundamentals, and strategy_version=momentum_12_1_rr_time_v1. This is forward-only; no historical sentiment/fundamental backfill is attempted. No live gate, paper-trade exit, or production ranking behavior changes. Verification: 458 backend tests pass, ruff check app/ clean, frontend npm run build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,15 +11,17 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.exceptions import NotFoundError
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.sentiment import SentimentScore
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
@@ -29,6 +31,8 @@ from app.services.recommendation_service import enhance_trade_setup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STRATEGY_VERSION = "momentum_12_1_rr_time_v1"
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
normalised = symbol.strip().upper()
|
||||
@@ -76,6 +80,136 @@ async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None:
|
||||
return row.classification if row else None
|
||||
|
||||
|
||||
def _json_default(value):
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
async def _create_signal_context_snapshots(
|
||||
db: AsyncSession,
|
||||
setups: list[TradeSetup],
|
||||
*,
|
||||
strategy_version: str = STRATEGY_VERSION,
|
||||
) -> None:
|
||||
"""Capture point-in-time discretionary context for freshly generated setups.
|
||||
|
||||
The scanner stores the setup itself first so each snapshot can be keyed by
|
||||
``trade_setup_id``. This is intentionally forward-only: old sentiment,
|
||||
fundamentals and composite scores are not reconstructed from today's data.
|
||||
"""
|
||||
if not setups:
|
||||
return
|
||||
|
||||
ticker_ids = {s.ticker_id for s in setups}
|
||||
|
||||
dims: dict[int, dict[str, dict]] = {}
|
||||
dim_rows = (
|
||||
await db.execute(select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids)))
|
||||
).scalars().all()
|
||||
for row in dim_rows:
|
||||
dims.setdefault(row.ticker_id, {})[row.dimension] = {
|
||||
"score": float(row.score),
|
||||
"is_stale": bool(row.is_stale),
|
||||
"computed_at": row.computed_at,
|
||||
}
|
||||
|
||||
composites: dict[int, CompositeScore] = {}
|
||||
comp_rows = (
|
||||
await db.execute(
|
||||
select(CompositeScore)
|
||||
.where(CompositeScore.ticker_id.in_(ticker_ids))
|
||||
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
for row in comp_rows:
|
||||
composites.setdefault(row.ticker_id, row)
|
||||
|
||||
sentiments: dict[int, SentimentScore] = {}
|
||||
sent_rows = (
|
||||
await db.execute(
|
||||
select(SentimentScore)
|
||||
.where(SentimentScore.ticker_id.in_(ticker_ids))
|
||||
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
for row in sent_rows:
|
||||
sentiments.setdefault(row.ticker_id, row)
|
||||
|
||||
fundamentals: dict[int, FundamentalData] = {}
|
||||
fund_rows = (
|
||||
await db.execute(
|
||||
select(FundamentalData)
|
||||
.where(FundamentalData.ticker_id.in_(ticker_ids))
|
||||
.order_by(FundamentalData.ticker_id, FundamentalData.fetched_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
for row in fund_rows:
|
||||
fundamentals.setdefault(row.ticker_id, row)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for setup in setups:
|
||||
comp = composites.get(setup.ticker_id)
|
||||
sent = sentiments.get(setup.ticker_id)
|
||||
fund = fundamentals.get(setup.ticker_id)
|
||||
score_context = {
|
||||
"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,
|
||||
"dimensions": dims.get(setup.ticker_id, {}),
|
||||
}
|
||||
sentiment_context = (
|
||||
{
|
||||
"classification": sent.classification,
|
||||
"confidence": int(sent.confidence),
|
||||
"recommendation": sent.recommendation,
|
||||
"timestamp": sent.timestamp,
|
||||
"source": sent.source,
|
||||
}
|
||||
if sent
|
||||
else {}
|
||||
)
|
||||
fundamental_context = (
|
||||
{
|
||||
"pe_ratio": fund.pe_ratio,
|
||||
"revenue_growth": fund.revenue_growth,
|
||||
"earnings_surprise": fund.earnings_surprise,
|
||||
"market_cap": fund.market_cap,
|
||||
"next_earnings_date": fund.next_earnings_date,
|
||||
"fetched_at": fund.fetched_at,
|
||||
}
|
||||
if fund
|
||||
else {}
|
||||
)
|
||||
db.add(
|
||||
SignalContextSnapshot(
|
||||
trade_setup_id=setup.id,
|
||||
ticker_id=setup.ticker_id,
|
||||
detected_at=setup.detected_at,
|
||||
created_at=now,
|
||||
strategy_version=strategy_version,
|
||||
direction=setup.direction,
|
||||
entry_price=float(setup.entry_price),
|
||||
stop_loss=float(setup.stop_loss),
|
||||
target=float(setup.target),
|
||||
rr_ratio=float(setup.rr_ratio),
|
||||
confidence_score=(
|
||||
float(setup.confidence_score) if setup.confidence_score is not None else None
|
||||
),
|
||||
recommended_action=setup.recommended_action,
|
||||
risk_level=setup.risk_level,
|
||||
momentum_percentile=(
|
||||
float(setup.momentum_percentile)
|
||||
if setup.momentum_percentile is not None
|
||||
else None
|
||||
),
|
||||
score_context_json=json.dumps(score_context, default=_json_default),
|
||||
sentiment_context_json=json.dumps(sentiment_context, default=_json_default),
|
||||
fundamental_context_json=json.dumps(fundamental_context, default=_json_default),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def scan_ticker(
|
||||
db: AsyncSession,
|
||||
symbol: str,
|
||||
@@ -238,6 +372,9 @@ async def scan_ticker(
|
||||
for s in enhanced_setups:
|
||||
await db.refresh(s)
|
||||
|
||||
await _create_signal_context_snapshots(db, enhanced_setups)
|
||||
await db.commit()
|
||||
|
||||
return enhanced_setups
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user