605f95098c
Part 1 — long-only. The momentum edge is long top-momentum; the gate was
qualifying shorts on high-momentum names (fighting the trend), which showed as
the -0.13R Short(qual.) drag. While the gate is active, shorts no longer qualify
(backend qualification, backtest _momentum_qualifies, and the frontend mirror).
Part 2 — production wiring. Live setups now carry a real momentum rank, so the
dashboard, the Track Record's qualified stats, and outcome evaluation all gate on
the same value instead of deferring to floors:
- new momentum_service.compute_momentum_percentiles: 12-1 momentum per ticker,
ranked across the universe into a {symbol: percentile} map.
- the daily R:R scan ranks the universe up front and stores each setup's
percentile (new trade_setups.momentum_percentile column, migration 010).
- enhance_trade_setup mutates the same row, so the percentile is preserved;
_trade_setup_to_dict + TradeSetupResponse expose it to the API.
Until a fresh scan runs, pre-existing setups have a null percentile and the gate
falls back to floors for them (longs) / excludes them (shorts) — they fill in on
the next scan. 341 backend tests pass; frontend build clean.
Needs the alembic upgrade (migration 010) on deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
66 lines
2.6 KiB
Python
66 lines
2.6 KiB
Python
from datetime import date, datetime
|
||
|
||
import json
|
||
|
||
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
||
from app.database import Base
|
||
|
||
|
||
class TradeSetup(Base):
|
||
__tablename__ = "trade_setups"
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
ticker_id: Mapped[int] = mapped_column(
|
||
ForeignKey("tickers.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
direction: Mapped[str] = mapped_column(String(10), nullable=False)
|
||
entry_price: Mapped[float] = mapped_column(Float, nullable=False)
|
||
stop_loss: Mapped[float] = mapped_column(Float, nullable=False)
|
||
target: Mapped[float] = mapped_column(Float, nullable=False)
|
||
rr_ratio: Mapped[float] = mapped_column(Float, nullable=False)
|
||
composite_score: Mapped[float] = mapped_column(Float, nullable=False)
|
||
detected_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), nullable=False
|
||
)
|
||
|
||
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||
# Ticker's 12-1 momentum percentile across the universe at detection time
|
||
# (0–100, 100 = strongest). Drives the activation gate's core selection.
|
||
momentum_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)
|
||
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
risk_level: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||
actual_outcome: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||
evaluated_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True
|
||
)
|
||
outcome_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
|
||
ticker = relationship("Ticker", back_populates="trade_setups")
|
||
|
||
@property
|
||
def targets(self) -> list[dict]:
|
||
if not self.targets_json:
|
||
return []
|
||
try:
|
||
parsed = json.loads(self.targets_json)
|
||
except (TypeError, ValueError):
|
||
return []
|
||
return parsed if isinstance(parsed, list) else []
|
||
|
||
@property
|
||
def conflict_flags(self) -> list[str]:
|
||
if not self.conflict_flags_json:
|
||
return []
|
||
try:
|
||
parsed = json.loads(self.conflict_flags_json)
|
||
except (TypeError, ValueError):
|
||
return []
|
||
if not isinstance(parsed, list):
|
||
return []
|
||
return [str(item) for item in parsed]
|