Optimize signal read paths and enforce score invariants

This commit is contained in:
2026-07-11 16:04:13 +02:00
parent fdc49d0e28
commit 9450831ef3
13 changed files with 426 additions and 170 deletions
@@ -0,0 +1,61 @@
"""Enforce singleton score and fundamental snapshots.
Revision ID: 019
Revises: 018
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "019"
down_revision = "018"
branch_labels = None
depends_on = None
def _remove_duplicates(table: str, partition_by: str, order_by: str) -> None:
op.execute(
sa.text(
f"""
DELETE FROM {table}
WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY {partition_by}
ORDER BY {order_by} DESC, id DESC
) AS row_number
FROM {table}
) AS ranked
WHERE row_number > 1
)
"""
)
)
def upgrade() -> None:
_remove_duplicates("dimension_scores", "ticker_id, dimension", "computed_at")
_remove_duplicates("composite_scores", "ticker_id", "computed_at")
_remove_duplicates("fundamental_data", "ticker_id", "fetched_at")
op.create_unique_constraint(
"uq_dimension_score_ticker_dimension",
"dimension_scores",
["ticker_id", "dimension"],
)
op.create_unique_constraint("uq_composite_score_ticker", "composite_scores", ["ticker_id"])
op.create_unique_constraint("uq_fundamental_data_ticker", "fundamental_data", ["ticker_id"])
op.create_index("ix_sr_levels_ticker_id", "sr_levels", ["ticker_id"])
op.create_index("ix_trade_setups_ticker_rr", "trade_setups", ["ticker_id", "rr_ratio"])
def downgrade() -> None:
op.drop_index("ix_trade_setups_ticker_rr", table_name="trade_setups")
op.drop_index("ix_sr_levels_ticker_id", table_name="sr_levels")
op.drop_constraint("uq_fundamental_data_ticker", "fundamental_data", type_="unique")
op.drop_constraint("uq_composite_score_ticker", "composite_scores", type_="unique")
op.drop_constraint("uq_dimension_score_ticker_dimension", "dimension_scores", type_="unique")
+10
View File
@@ -1,5 +1,8 @@
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
@@ -28,6 +31,13 @@ class Base(DeclarativeBase):
pass pass
def insert_for_session(session: AsyncSession, table: Any) -> Any:
"""Build a dialect-native INSERT that supports conflict handling."""
if session.get_bind().dialect.name == "postgresql":
return postgresql_insert(table)
return sqlite_insert(table)
async def get_session() -> AsyncGenerator[AsyncSession, None]: async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session: async with async_session_factory() as session:
yield session yield session
+4 -1
View File
@@ -1,6 +1,6 @@
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, Text from sqlalchemy import Date, DateTime, Float, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -8,6 +8,9 @@ from app.database import Base
class FundamentalData(Base): class FundamentalData(Base):
__tablename__ = "fundamental_data" __tablename__ = "fundamental_data"
__table_args__ = (
UniqueConstraint("ticker_id", name="uq_fundamental_data_ticker"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
ticker_id: Mapped[int] = mapped_column( ticker_id: Mapped[int] = mapped_column(
+7 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, String, Text from sqlalchemy import Boolean, DateTime, Float, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -8,6 +8,9 @@ from app.database import Base
class DimensionScore(Base): class DimensionScore(Base):
__tablename__ = "dimension_scores" __tablename__ = "dimension_scores"
__table_args__ = (
UniqueConstraint("ticker_id", "dimension", name="uq_dimension_score_ticker_dimension"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
ticker_id: Mapped[int] = mapped_column( ticker_id: Mapped[int] = mapped_column(
@@ -25,6 +28,9 @@ class DimensionScore(Base):
class CompositeScore(Base): class CompositeScore(Base):
__tablename__ = "composite_scores" __tablename__ = "composite_scores"
__table_args__ = (
UniqueConstraint("ticker_id", name="uq_composite_score_ticker"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
ticker_id: Mapped[int] = mapped_column( ticker_id: Mapped[int] = mapped_column(
+2 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -8,6 +8,7 @@ from app.database import Base
class SRLevel(Base): class SRLevel(Base):
__tablename__ = "sr_levels" __tablename__ = "sr_levels"
__table_args__ = (Index("ix_sr_levels_ticker_id", "ticker_id"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
ticker_id: Mapped[int] = mapped_column( ticker_id: Mapped[int] = mapped_column(
+2 -1
View File
@@ -2,7 +2,7 @@ from datetime import date, datetime
import json import json
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text from sqlalchemy import Date, DateTime, Float, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -10,6 +10,7 @@ from app.database import Base
class TradeSetup(Base): class TradeSetup(Base):
__tablename__ = "trade_setups" __tablename__ = "trade_setups"
__table_args__ = (Index("ix_trade_setups_ticker_rr", "ticker_id", "rr_ratio"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
ticker_id: Mapped[int] = mapped_column( ticker_id: Mapped[int] = mapped_column(
+88 -29
View File
@@ -17,11 +17,12 @@ from __future__ import annotations
import logging import logging
import math import math
from collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
import httpx import httpx
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -257,17 +258,6 @@ def _log_alert(db: AsyncSession, alert_type: str, key: str, value: float | None
) )
async def _watermark(db: AsyncSession, symbol: str) -> float | None:
result = await db.execute(
select(AlertLog.value)
.where(AlertLog.alert_type == WATERMARK_TYPE, AlertLog.dedup_key == symbol)
.order_by(AlertLog.created_at.desc())
.limit(1)
)
row = result.first()
return row[0] if row else None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Trigger collectors # Trigger collectors
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -407,17 +397,49 @@ async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
single alert. Scoped to the watchlist only — qualified tickers already get single alert. Scoped to the watchlist only — qualified tickers already get
their own 'qualified setup' alert, so S/R on them would be redundant. their own 'qualified setup' alert, so S/R on them would be redundant.
""" """
watchlist = await _watchlist_tickers(db)
if not watchlist:
return []
ticker_ids = [ticker_id for ticker_id, _ in watchlist]
latest_dates = (
select(
OHLCVRecord.ticker_id,
func.max(OHLCVRecord.date).label("latest_date"),
)
.where(OHLCVRecord.ticker_id.in_(ticker_ids))
.group_by(OHLCVRecord.ticker_id)
.subquery()
)
prices_result = await db.execute(
select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
latest_dates,
(OHLCVRecord.ticker_id == latest_dates.c.ticker_id)
& (OHLCVRecord.date == latest_dates.c.latest_date),
)
)
prices = {ticker_id: float(close) for ticker_id, close in prices_result.all()}
levels_result = await db.execute(
select(SRLevel).where(SRLevel.ticker_id.in_(ticker_ids))
)
levels_by_ticker: dict[int, list[dict]] = defaultdict(list)
for level in levels_result.scalars():
levels_by_ticker[level.ticker_id].append(
{
"price_level": level.price_level,
"strength": level.strength,
"type": level.type,
}
)
out: list[tuple[str, str]] = [] out: list[tuple[str, str]] = []
for tid, symbol in await _watchlist_tickers(db): for tid, symbol in watchlist:
price = await _latest_close(db, tid) price = prices.get(tid)
if not price: if not price:
continue continue
levels_result = await db.execute(select(SRLevel).where(SRLevel.ticker_id == tid)) levels = levels_by_ticker[tid]
levels = [
{"price_level": lv.price_level, "strength": lv.strength, "type": lv.type}
for lv in levels_result.scalars().all()
]
if not levels: if not levels:
continue continue
@@ -445,17 +467,54 @@ async def _collect_score_drops(db: AsyncSession) -> list[tuple[str, str]]:
doesn't re-fire; let the watermark rise with the score so the next drop is doesn't re-fire; let the watermark rise with the score so the next drop is
measured from the new high. measured from the new high.
""" """
out: list[tuple[str, str]] = [] watchlist = await _watchlist_tickers(db)
for tid, symbol in await _watchlist_tickers(db): if not watchlist:
comp_result = await db.execute( return []
select(CompositeScore.score).where(CompositeScore.ticker_id == tid)
)
row = comp_result.first()
if row is None or row[0] is None:
continue
current = float(row[0])
base = await _watermark(db, symbol) ticker_ids = [ticker_id for ticker_id, _ in watchlist]
symbols = [symbol for _, symbol in watchlist]
scores_result = await db.execute(
select(CompositeScore.ticker_id, CompositeScore.score).where(
CompositeScore.ticker_id.in_(ticker_ids)
)
)
scores = {ticker_id: float(score) for ticker_id, score in scores_result.all()}
ranked_watermarks = (
select(
AlertLog.dedup_key,
AlertLog.value,
func.row_number()
.over(
partition_by=AlertLog.dedup_key,
order_by=(AlertLog.created_at.desc(), AlertLog.id.desc()),
)
.label("rank"),
)
.where(
AlertLog.alert_type == WATERMARK_TYPE,
AlertLog.dedup_key.in_(symbols),
)
.subquery()
)
watermarks_result = await db.execute(
select(ranked_watermarks.c.dedup_key, ranked_watermarks.c.value).where(
ranked_watermarks.c.rank == 1
)
)
watermarks = {
symbol: float(value)
for symbol, value in watermarks_result.all()
if value is not None
}
out: list[tuple[str, str]] = []
for tid, symbol in watchlist:
current = scores.get(tid)
if current is None:
continue
base = watermarks.get(symbol)
if base is None: if base is None:
_log_alert(db, WATERMARK_TYPE, symbol, value=current) # seed, no alert _log_alert(db, WATERMARK_TYPE, symbol, value=current) # seed, no alert
continue continue
+29 -34
View File
@@ -10,9 +10,10 @@ import json
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import insert_for_session
from app.exceptions import NotFoundError from app.exceptions import NotFoundError
from app.models.fundamental import FundamentalData from app.models.fundamental import FundamentalData
from app.models.score import DimensionScore from app.models.score import DimensionScore
@@ -48,51 +49,45 @@ async def store_fundamental(
""" """
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
# Check for existing record
result = await db.execute(
select(FundamentalData).where(FundamentalData.ticker_id == ticker.id)
)
existing = result.scalar_one_or_none()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
unavailable_fields_json = json.dumps(unavailable_fields or {}) unavailable_fields_json = json.dumps(unavailable_fields or {})
if existing is not None: stmt = insert_for_session(db, FundamentalData).values(
existing.pe_ratio = pe_ratio ticker_id=ticker.id,
existing.revenue_growth = revenue_growth pe_ratio=pe_ratio,
existing.earnings_surprise = earnings_surprise revenue_growth=revenue_growth,
existing.market_cap = market_cap earnings_surprise=earnings_surprise,
existing.next_earnings_date = next_earnings_date market_cap=market_cap,
existing.fetched_at = now next_earnings_date=next_earnings_date,
existing.unavailable_fields_json = unavailable_fields_json fetched_at=now,
record = existing unavailable_fields_json=unavailable_fields_json,
else: )
record = FundamentalData( stmt = stmt.on_conflict_do_update(
ticker_id=ticker.id, index_elements=["ticker_id"],
pe_ratio=pe_ratio, set_={
revenue_growth=revenue_growth, "pe_ratio": stmt.excluded.pe_ratio,
earnings_surprise=earnings_surprise, "revenue_growth": stmt.excluded.revenue_growth,
market_cap=market_cap, "earnings_surprise": stmt.excluded.earnings_surprise,
next_earnings_date=next_earnings_date, "market_cap": stmt.excluded.market_cap,
fetched_at=now, "next_earnings_date": stmt.excluded.next_earnings_date,
unavailable_fields_json=unavailable_fields_json, "fetched_at": stmt.excluded.fetched_at,
) "unavailable_fields_json": stmt.excluded.unavailable_fields_json,
db.add(record) },
).returning(FundamentalData)
record = (await db.execute(stmt)).scalar_one()
# Mark fundamental dimension score as stale if it exists # Mark fundamental dimension score as stale if it exists
# TODO: Use DimensionScore service when built # TODO: Use DimensionScore service when built
dim_result = await db.execute( await db.execute(
select(DimensionScore).where( update(DimensionScore)
.where(
DimensionScore.ticker_id == ticker.id, DimensionScore.ticker_id == ticker.id,
DimensionScore.dimension == "fundamental", DimensionScore.dimension == "fundamental",
) )
.values(is_stale=True)
) )
dim_score = dim_result.scalar_one_or_none()
if dim_score is not None:
dim_score.is_stale = True
await db.commit() await db.commit()
await db.refresh(record)
return record return record
+3 -3
View File
@@ -3,9 +3,9 @@
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import insert_for_session
from app.exceptions import NotFoundError, ValidationError from app.exceptions import NotFoundError, ValidationError
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker from app.models.ticker import Ticker
@@ -53,7 +53,7 @@ async def upsert_ohlcv(
_validate_ohlcv(high, low, open_, close, volume, record_date) _validate_ohlcv(high, low, open_, close, volume, record_date)
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
stmt = pg_insert(OHLCVRecord).values( stmt = insert_for_session(db, OHLCVRecord).values(
ticker_id=ticker.id, ticker_id=ticker.id,
date=record_date, date=record_date,
open=open_, open=open_,
@@ -64,7 +64,7 @@ async def upsert_ohlcv(
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
) )
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
constraint="uq_ohlcv_ticker_date", index_elements=["ticker_id", "date"],
set_={ set_={
"open": stmt.excluded.open, "open": stmt.excluded.open,
"high": stmt.excluded.high, "high": stmt.excluded.high,
+14 -10
View File
@@ -566,6 +566,7 @@ async def scan_all_tickers(
ranks = await momentum_service.compute_activation_ranks(db) ranks = await momentum_service.compute_activation_ranks(db)
except Exception: except Exception:
await db.rollback()
logger.exception("Activation ranking refresh failed") logger.exception("Activation ranking refresh failed")
ranks = {} ranks = {}
@@ -573,19 +574,21 @@ async def scan_all_tickers(
for index, ticker in enumerate(tickers): for index, ticker in enumerate(tickers):
if progress_callback is not None: if progress_callback is not None:
progress_callback(index, total, ticker.symbol) progress_callback(index, total, ticker.symbol)
# Refresh scores first so the scheduled scan works off current data.
# Nothing else marks scores stale, so without this they'd never update
# for tickers the user doesn't manually fetch.
try: try:
# Refresh scores first so the scheduled scan works off current data. from app.services import scoring_service
# Nothing else marks scores stale, so without this they'd never
# update for tickers the user doesn't manually fetch.
try:
from app.services import scoring_service
await scoring_service.compute_all_dimensions(db, ticker.symbol) await scoring_service.compute_all_dimensions(db, ticker.symbol)
await scoring_service.compute_composite_score(db, ticker.symbol) await scoring_service.compute_composite_score(db, ticker.symbol)
await db.commit() await db.commit()
except Exception: except Exception:
logger.exception("Error refreshing scores for %s", ticker.symbol) await db.rollback()
logger.exception("Error refreshing scores for %s", ticker.symbol)
continue
try:
setups = await scan_ticker( setups = await scan_ticker(
db, ticker.symbol, rr_threshold, atr_multiplier, db, ticker.symbol, rr_threshold, atr_multiplier,
momentum_percentile=(ranks.get(ticker.symbol) or {}).get("momentum_percentile"), momentum_percentile=(ranks.get(ticker.symbol) or {}).get("momentum_percentile"),
@@ -594,6 +597,7 @@ async def scan_all_tickers(
) )
all_setups.extend(setups) all_setups.extend(setups)
except Exception: except Exception:
await db.rollback()
logger.exception("Error scanning ticker %s", ticker.symbol) logger.exception("Error scanning ticker %s", ticker.symbol)
if progress_callback is not None and total: if progress_callback is not None and total:
+28 -19
View File
@@ -16,6 +16,7 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import insert_for_session
from app.exceptions import NotFoundError, ValidationError from app.exceptions import NotFoundError, ValidationError
from app.models.score import CompositeScore, DimensionScore from app.models.score import CompositeScore, DimensionScore
from app.models.ticker import Ticker from app.models.ticker import Ticker
@@ -661,14 +662,23 @@ async def compute_dimension_score(
# Can't compute — mark stale # Can't compute — mark stale
existing.is_stale = True existing.is_stale = True
elif score_val is not None: elif score_val is not None:
dim = DimensionScore( stmt = insert_for_session(db, DimensionScore).values(
ticker_id=ticker.id, ticker_id=ticker.id,
dimension=dimension, dimension=dimension,
score=score_val, score=score_val,
is_stale=False, is_stale=False,
computed_at=now, computed_at=now,
) )
db.add(dim) await db.execute(
stmt.on_conflict_do_update(
index_elements=["ticker_id", "dimension"],
set_={
"score": stmt.excluded.score,
"is_stale": False,
"computed_at": stmt.excluded.computed_at,
},
)
)
return score_val return score_val
@@ -738,25 +748,24 @@ async def compute_composite_score(
# Persist composite score # Persist composite score
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
comp_result = await db.execute( stmt = insert_for_session(db, CompositeScore).values(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id) ticker_id=ticker.id,
score=composite,
is_stale=False,
weights_json=json.dumps(weights),
computed_at=now,
) )
existing = comp_result.scalar_one_or_none() await db.execute(
stmt.on_conflict_do_update(
if existing is not None: index_elements=["ticker_id"],
existing.score = composite set_={
existing.is_stale = False "score": stmt.excluded.score,
existing.weights_json = json.dumps(weights) "is_stale": False,
existing.computed_at = now "weights_json": stmt.excluded.weights_json,
else: "computed_at": stmt.excluded.computed_at,
comp = CompositeScore( },
ticker_id=ticker.id,
score=composite,
is_stale=False,
weights_json=json.dumps(weights),
computed_at=now,
) )
db.add(comp) )
return composite, missing return composite, missing
+102 -69
View File
@@ -8,6 +8,7 @@ best trade setup, active S/R levels, and latest price + day-over-day move.
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -102,87 +103,122 @@ async def remove_entry(
await db.commit() await db.commit()
async def _enrich_entry( async def _enrich_entries(
db: AsyncSession, db: AsyncSession,
entry: WatchlistEntry, rows: list[tuple[WatchlistEntry, str]],
symbol: str, ) -> list[dict]:
) -> dict: """Build watchlist rows from a fixed set of bulk lookups."""
"""Build enriched watchlist entry dict with scores, R:R, SR levels, price.""" if not rows:
ticker_id = entry.ticker_id return []
# Composite score ticker_ids = [entry.ticker_id for entry, _ in rows]
comp_result = await db.execute( comps_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker_id) select(CompositeScore).where(CompositeScore.ticker_id.in_(ticker_ids))
) )
comp = comp_result.scalar_one_or_none() comps = {score.ticker_id: score for score in comps_result.scalars()}
# Dimension scores dims_result = await db.execute(
dim_result = await db.execute( select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
select(DimensionScore).where(DimensionScore.ticker_id == ticker_id)
) )
dims = [ dims_by_ticker: dict[int, list[dict]] = defaultdict(list)
{"dimension": ds.dimension, "score": ds.score} for score in dims_result.scalars():
for ds in dim_result.scalars().all() dims_by_ticker[score.ticker_id].append(
] {"dimension": score.dimension, "score": score.score}
)
# Best trade setup (highest R:R) for this ticker ranked_setups = (
select(
TradeSetup.id,
func.row_number()
.over(
partition_by=TradeSetup.ticker_id,
order_by=TradeSetup.rr_ratio.desc(),
)
.label("rank"),
)
.where(TradeSetup.ticker_id.in_(ticker_ids))
.subquery()
)
setup_result = await db.execute( setup_result = await db.execute(
select(TradeSetup) select(TradeSetup)
.where(TradeSetup.ticker_id == ticker_id) .join(ranked_setups, TradeSetup.id == ranked_setups.c.id)
.order_by(TradeSetup.rr_ratio.desc()) .where(ranked_setups.c.rank == 1)
.limit(1)
) )
setup = setup_result.scalar_one_or_none() best_setups = {setup.ticker_id: setup for setup in setup_result.scalars()}
# Active SR levels levels_result = await db.execute(
sr_result = await db.execute(
select(SRLevel) select(SRLevel)
.where(SRLevel.ticker_id == ticker_id) .where(SRLevel.ticker_id.in_(ticker_ids))
.order_by(SRLevel.strength.desc()) .order_by(SRLevel.ticker_id, SRLevel.strength.desc())
) )
sr_levels = [ levels_by_ticker: dict[int, list[dict]] = defaultdict(list)
{ for level in levels_result.scalars():
"price_level": lv.price_level, levels_by_ticker[level.ticker_id].append(
"type": lv.type, {
"strength": lv.strength, "price_level": level.price_level,
} "type": level.type,
for lv in sr_result.scalars().all() "strength": level.strength,
] }
)
# Latest two daily closes → current price + day-over-day move ranked_prices = (
price_result = await db.execute( select(
select(OHLCVRecord.close, OHLCVRecord.date) OHLCVRecord.ticker_id,
.where(OHLCVRecord.ticker_id == ticker_id) OHLCVRecord.close,
.order_by(OHLCVRecord.date.desc()) OHLCVRecord.date,
.limit(2) func.row_number()
.over(
partition_by=OHLCVRecord.ticker_id,
order_by=OHLCVRecord.date.desc(),
)
.label("rank"),
)
.where(OHLCVRecord.ticker_id.in_(ticker_ids))
.subquery()
) )
bars = price_result.all() prices_result = await db.execute(
last_close = bars[0].close if bars else None select(
prev_close = bars[1].close if len(bars) > 1 else None ranked_prices.c.ticker_id,
change_pct = ( ranked_prices.c.close,
(last_close - prev_close) / prev_close * 100 ranked_prices.c.date,
if last_close is not None and prev_close )
else None .where(ranked_prices.c.rank <= 2)
.order_by(ranked_prices.c.ticker_id, ranked_prices.c.rank)
) )
price_date = bars[0].date if bars else None prices_by_ticker: dict[int, list[tuple[float, datetime]]] = defaultdict(list)
for ticker_id, close, price_date in prices_result.all():
prices_by_ticker[ticker_id].append((close, price_date))
return { entries: list[dict] = []
"symbol": symbol, for entry, symbol in rows:
"entry_type": entry.entry_type, ticker_id = entry.ticker_id
"composite_score": comp.score if comp else None, comp = comps.get(ticker_id)
"dimensions": dims, setup = best_setups.get(ticker_id)
"rr_ratio": setup.rr_ratio if setup else None, bars = prices_by_ticker[ticker_id]
"rr_direction": setup.direction if setup else None, last_close = bars[0][0] if bars else None
# Residual 12-1 activation percentile gates qualification; strategy_rank prev_close = bars[1][0] if len(bars) > 1 else None
# is the promoted top-pick ordering score. entries.append(
"momentum_percentile": setup.momentum_percentile if setup else None, {
"strategy_rank": setup.strategy_rank if setup else None, "symbol": symbol,
"sr_levels": sr_levels, "entry_type": entry.entry_type,
"last_close": last_close, "composite_score": comp.score if comp else None,
"change_pct": change_pct, "dimensions": dims_by_ticker[ticker_id],
"price_date": price_date, "rr_ratio": setup.rr_ratio if setup else None,
"added_at": entry.added_at, "rr_direction": setup.direction if setup else None,
} "momentum_percentile": setup.momentum_percentile if setup else None,
"strategy_rank": setup.strategy_rank if setup else None,
"sr_levels": levels_by_ticker[ticker_id],
"last_close": last_close,
"change_pct": (
(last_close - prev_close) / prev_close * 100
if last_close is not None and prev_close
else None
),
"price_date": bars[0][1] if bars else None,
"added_at": entry.added_at,
}
)
return entries
async def get_watchlist( async def get_watchlist(
@@ -203,10 +239,7 @@ async def get_watchlist(
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() rows = result.all()
entries: list[dict] = [] entries = await _enrich_entries(db, rows)
for entry, symbol in rows:
enriched = await _enrich_entry(db, entry, symbol)
entries.append(enriched)
# Sort # Sort
if sort_by == "composite": if sort_by == "composite":
+76 -2
View File
@@ -95,7 +95,17 @@ async def test_score_drop_seeds_then_alerts(session):
msgs = await svc._collect_score_drops(session) msgs = await svc._collect_score_drops(session)
await session.commit() await session.commit()
assert msgs == [] assert msgs == []
assert await svc._watermark(session, "AAA") == 80.0 watermarks = (
await session.execute(
select(AlertLog.value)
.where(
AlertLog.alert_type == svc.WATERMARK_TYPE,
AlertLog.dedup_key == "AAA",
)
.order_by(AlertLog.created_at.desc(), AlertLog.id.desc())
)
).scalars().all()
assert watermarks == [80.0]
# Drop the composite well past the threshold # Drop the composite well past the threshold
row = (await session.execute( row = (await session.execute(
@@ -111,7 +121,71 @@ async def test_score_drop_seeds_then_alerts(session):
assert key == "scoredrop:AAA" assert key == "scoredrop:AAA"
assert "AAA" in text assert "AAA" in text
# rebaselined to the new (lower) level # rebaselined to the new (lower) level
assert await svc._watermark(session, "AAA") == 60.0 watermarks = (
await session.execute(
select(AlertLog.value)
.where(
AlertLog.alert_type == svc.WATERMARK_TYPE,
AlertLog.dedup_key == "AAA",
)
.order_by(AlertLog.created_at.desc(), AlertLog.id.desc())
)
).scalars().all()
assert watermarks[0] == 60.0
async def test_score_drop_uses_latest_watermark_when_timestamps_tie(session):
await _seed_watchlisted_ticker(session, "AAA", 50.0)
timestamp = datetime.now(timezone.utc)
session.add_all([
AlertLog(
alert_type=svc.WATERMARK_TYPE,
dedup_key="AAA",
value=90.0,
created_at=timestamp,
),
AlertLog(
alert_type=svc.WATERMARK_TYPE,
dedup_key="AAA",
value=70.0,
created_at=timestamp,
),
])
await session.commit()
msgs = await svc._collect_score_drops(session)
assert len(msgs) == 1
assert "from 70" in msgs[0][1]
async def test_score_drop_seeds_when_latest_watermark_has_no_value(session):
await _seed_watchlisted_ticker(session, "AAA", 80.0)
session.add(
AlertLog(
alert_type=svc.WATERMARK_TYPE,
dedup_key="AAA",
value=None,
created_at=datetime.now(timezone.utc),
)
)
await session.commit()
msgs = await svc._collect_score_drops(session)
await session.commit()
assert msgs == []
values = (
await session.execute(
select(AlertLog.value)
.where(
AlertLog.alert_type == svc.WATERMARK_TYPE,
AlertLog.dedup_key == "AAA",
)
.order_by(AlertLog.created_at.desc(), AlertLog.id.desc())
)
).scalars().all()
assert values[0] == 80.0
def test_format_qualified_includes_current_price_and_target_move(): def test_format_qualified_includes_current_price_and_target_move():