Big refactoring
Some checks failed
Deploy / lint (push) Failing after 21s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-03-03 15:20:18 +01:00
parent 181cfe6588
commit 0a011d4ce9
55 changed files with 6898 additions and 544 deletions

View File

@@ -3,16 +3,34 @@
from datetime import datetime, timedelta, timezone
from passlib.hash import bcrypt
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import DuplicateError, NotFoundError, ValidationError
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.sr_level import SRLevel
from app.models.settings import SystemSetting
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.models.user import User
RECOMMENDATION_CONFIG_DEFAULTS: dict[str, float] = {
"recommendation_high_confidence_threshold": 70.0,
"recommendation_moderate_confidence_threshold": 50.0,
"recommendation_confidence_diff_threshold": 20.0,
"recommendation_signal_alignment_weight": 0.15,
"recommendation_sr_strength_weight": 0.20,
"recommendation_distance_penalty_factor": 0.10,
"recommendation_momentum_technical_divergence_threshold": 30.0,
"recommendation_fundamental_technical_divergence_threshold": 40.0,
}
DEFAULT_TICKER_UNIVERSE = "sp500"
SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
# ---------------------------------------------------------------------------
# User management
@@ -125,6 +143,67 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
return setting
def _recommendation_public_to_storage_key(key: str) -> str:
return f"recommendation_{key}"
async def get_recommendation_config(db: AsyncSession) -> dict[str, float]:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key.like("recommendation_%"))
)
rows = result.scalars().all()
config = dict(RECOMMENDATION_CONFIG_DEFAULTS)
for row in rows:
try:
config[row.key] = float(row.value)
except (TypeError, ValueError):
continue
return {
"high_confidence_threshold": config["recommendation_high_confidence_threshold"],
"moderate_confidence_threshold": config["recommendation_moderate_confidence_threshold"],
"confidence_diff_threshold": config["recommendation_confidence_diff_threshold"],
"signal_alignment_weight": config["recommendation_signal_alignment_weight"],
"sr_strength_weight": config["recommendation_sr_strength_weight"],
"distance_penalty_factor": config["recommendation_distance_penalty_factor"],
"momentum_technical_divergence_threshold": config["recommendation_momentum_technical_divergence_threshold"],
"fundamental_technical_divergence_threshold": config["recommendation_fundamental_technical_divergence_threshold"],
}
async def update_recommendation_config(
db: AsyncSession,
payload: dict[str, float],
) -> dict[str, float]:
for public_key, public_value in payload.items():
storage_key = _recommendation_public_to_storage_key(public_key)
await update_setting(db, storage_key, str(public_value))
return await get_recommendation_config(db)
async def get_ticker_universe_default(db: AsyncSession) -> dict[str, str]:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == "ticker_universe_default")
)
setting = result.scalar_one_or_none()
universe = setting.value if setting else DEFAULT_TICKER_UNIVERSE
if universe not in SUPPORTED_TICKER_UNIVERSES:
universe = DEFAULT_TICKER_UNIVERSE
return {"universe": universe}
async def update_ticker_universe_default(db: AsyncSession, universe: str) -> dict[str, str]:
normalised = universe.strip().lower()
if normalised not in SUPPORTED_TICKER_UNIVERSES:
supported = ", ".join(sorted(SUPPORTED_TICKER_UNIVERSES))
raise ValidationError(f"Unsupported ticker universe '{universe}'. Supported: {supported}")
await update_setting(db, "ticker_universe_default", normalised)
return {"universe": normalised}
# ---------------------------------------------------------------------------
# Data cleanup
# ---------------------------------------------------------------------------
@@ -160,23 +239,181 @@ async def cleanup_data(db: AsyncSession, older_than_days: int) -> dict[str, int]
return counts
async def get_pipeline_readiness(db: AsyncSession) -> list[dict]:
"""Return per-ticker readiness snapshot for ingestion/scoring/scanner pipeline."""
tickers_result = await db.execute(select(Ticker).order_by(Ticker.symbol.asc()))
tickers = list(tickers_result.scalars().all())
if not tickers:
return []
ticker_ids = [ticker.id for ticker in tickers]
ohlcv_stats_result = await db.execute(
select(
OHLCVRecord.ticker_id,
func.count(OHLCVRecord.id),
func.max(OHLCVRecord.date),
)
.where(OHLCVRecord.ticker_id.in_(ticker_ids))
.group_by(OHLCVRecord.ticker_id)
)
ohlcv_stats = {
ticker_id: {
"bars": int(count or 0),
"last_date": max_date.isoformat() if max_date else None,
}
for ticker_id, count, max_date in ohlcv_stats_result.all()
}
dim_rows_result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
)
dim_map_by_ticker: dict[int, dict[str, tuple[float | None, bool]]] = {}
for row in dim_rows_result.scalars().all():
dim_map_by_ticker.setdefault(row.ticker_id, {})[row.dimension] = (row.score, row.is_stale)
sr_counts_result = await db.execute(
select(SRLevel.ticker_id, func.count(SRLevel.id))
.where(SRLevel.ticker_id.in_(ticker_ids))
.group_by(SRLevel.ticker_id)
)
sr_counts = {ticker_id: int(count or 0) for ticker_id, count in sr_counts_result.all()}
sentiment_stats_result = await db.execute(
select(
SentimentScore.ticker_id,
func.count(SentimentScore.id),
func.max(SentimentScore.timestamp),
)
.where(SentimentScore.ticker_id.in_(ticker_ids))
.group_by(SentimentScore.ticker_id)
)
sentiment_stats = {
ticker_id: {
"count": int(count or 0),
"last_at": max_ts.isoformat() if max_ts else None,
}
for ticker_id, count, max_ts in sentiment_stats_result.all()
}
fundamentals_result = await db.execute(
select(FundamentalData.ticker_id, FundamentalData.fetched_at)
.where(FundamentalData.ticker_id.in_(ticker_ids))
)
fundamentals_map = {
ticker_id: fetched_at.isoformat() if fetched_at else None
for ticker_id, fetched_at in fundamentals_result.all()
}
composites_result = await db.execute(
select(CompositeScore.ticker_id, CompositeScore.is_stale)
.where(CompositeScore.ticker_id.in_(ticker_ids))
)
composites_map = {
ticker_id: is_stale
for ticker_id, is_stale in composites_result.all()
}
setup_counts_result = await db.execute(
select(TradeSetup.ticker_id, func.count(TradeSetup.id))
.where(TradeSetup.ticker_id.in_(ticker_ids))
.group_by(TradeSetup.ticker_id)
)
setup_counts = {ticker_id: int(count or 0) for ticker_id, count in setup_counts_result.all()}
readiness: list[dict] = []
for ticker in tickers:
ohlcv = ohlcv_stats.get(ticker.id, {"bars": 0, "last_date": None})
ohlcv_bars = int(ohlcv["bars"])
ohlcv_last_date = ohlcv["last_date"]
dim_map = dim_map_by_ticker.get(ticker.id, {})
sr_count = int(sr_counts.get(ticker.id, 0))
sentiment = sentiment_stats.get(ticker.id, {"count": 0, "last_at": None})
sentiment_count = int(sentiment["count"])
sentiment_last_at = sentiment["last_at"]
fundamentals_fetched_at = fundamentals_map.get(ticker.id)
has_fundamentals = ticker.id in fundamentals_map
has_composite = ticker.id in composites_map
composite_stale = composites_map.get(ticker.id)
setup_count = int(setup_counts.get(ticker.id, 0))
missing_reasons: list[str] = []
if ohlcv_bars < 30:
missing_reasons.append("insufficient_ohlcv_bars(<30)")
if "technical" not in dim_map or dim_map["technical"][0] is None:
missing_reasons.append("missing_technical")
if "momentum" not in dim_map or dim_map["momentum"][0] is None:
missing_reasons.append("missing_momentum")
if "sr_quality" not in dim_map or dim_map["sr_quality"][0] is None:
missing_reasons.append("missing_sr_quality")
if sentiment_count == 0:
missing_reasons.append("missing_sentiment")
if not has_fundamentals:
missing_reasons.append("missing_fundamentals")
if not has_composite:
missing_reasons.append("missing_composite")
if setup_count == 0:
missing_reasons.append("missing_trade_setup")
readiness.append(
{
"symbol": ticker.symbol,
"ohlcv_bars": ohlcv_bars,
"ohlcv_last_date": ohlcv_last_date,
"dimensions": {
"technical": dim_map.get("technical", (None, True))[0],
"sr_quality": dim_map.get("sr_quality", (None, True))[0],
"sentiment": dim_map.get("sentiment", (None, True))[0],
"fundamental": dim_map.get("fundamental", (None, True))[0],
"momentum": dim_map.get("momentum", (None, True))[0],
},
"sentiment_count": sentiment_count,
"sentiment_last_at": sentiment_last_at,
"has_fundamentals": has_fundamentals,
"fundamentals_fetched_at": fundamentals_fetched_at,
"sr_level_count": sr_count,
"has_composite": has_composite,
"composite_stale": composite_stale,
"trade_setup_count": setup_count,
"missing_reasons": missing_reasons,
"ready_for_scanner": ohlcv_bars >= 15 and sr_count > 0,
}
)
return readiness
# ---------------------------------------------------------------------------
# Job control (placeholder — scheduler is Task 12.1)
# ---------------------------------------------------------------------------
VALID_JOB_NAMES = {"data_collector", "sentiment_collector", "fundamental_collector", "rr_scanner"}
VALID_JOB_NAMES = {
"data_collector",
"sentiment_collector",
"fundamental_collector",
"rr_scanner",
"ticker_universe_sync",
}
JOB_LABELS = {
"data_collector": "Data Collector (OHLCV)",
"sentiment_collector": "Sentiment Collector",
"fundamental_collector": "Fundamental Collector",
"rr_scanner": "R:R Scanner",
"ticker_universe_sync": "Ticker Universe Sync",
}
async def list_jobs(db: AsyncSession) -> list[dict]:
"""Return status of all scheduled jobs."""
from app.scheduler import scheduler
from app.scheduler import get_job_runtime_snapshot, scheduler
jobs_out = []
for name in sorted(VALID_JOB_NAMES):
@@ -194,12 +431,23 @@ async def list_jobs(db: AsyncSession) -> list[dict]:
if job and job.next_run_time:
next_run = job.next_run_time.isoformat()
runtime = get_job_runtime_snapshot(name)
jobs_out.append({
"name": name,
"label": JOB_LABELS.get(name, name),
"enabled": enabled,
"next_run_at": next_run,
"registered": job is not None,
"running": bool(runtime.get("running", False)),
"runtime_status": runtime.get("status"),
"runtime_processed": runtime.get("processed"),
"runtime_total": runtime.get("total"),
"runtime_progress_pct": runtime.get("progress_pct"),
"runtime_current_ticker": runtime.get("current_ticker"),
"runtime_started_at": runtime.get("started_at"),
"runtime_finished_at": runtime.get("finished_at"),
"runtime_message": runtime.get("message"),
})
return jobs_out
@@ -213,7 +461,26 @@ async def trigger_job(db: AsyncSession, job_name: str) -> dict[str, str]:
if job_name not in VALID_JOB_NAMES:
raise ValidationError(f"Unknown job: {job_name}. Valid jobs: {', '.join(sorted(VALID_JOB_NAMES))}")
from app.scheduler import scheduler
from app.scheduler import get_job_runtime_snapshot, scheduler
runtime_target = get_job_runtime_snapshot(job_name)
if runtime_target.get("running"):
return {
"job": job_name,
"status": "busy",
"message": f"Job '{job_name}' is already running",
}
all_runtime = get_job_runtime_snapshot()
for running_name, runtime in all_runtime.items():
if running_name == job_name:
continue
if runtime.get("running"):
return {
"job": job_name,
"status": "blocked",
"message": f"Cannot trigger '{job_name}' while '{running_name}' is running",
}
job = scheduler.get_job(job_name)
if job is None:

View File

@@ -9,10 +9,11 @@ import logging
from dataclasses import dataclass
from datetime import date, timedelta
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError, ProviderError, RateLimitError
from app.models.ohlcv import OHLCVRecord
from app.models.settings import IngestionProgress
from app.models.ticker import Ticker
from app.providers.protocol import MarketDataProvider
@@ -50,6 +51,13 @@ async def _get_progress(db: AsyncSession, ticker_id: int) -> IngestionProgress |
return result.scalar_one_or_none()
async def _get_ohlcv_bar_count(db: AsyncSession, ticker_id: int) -> int:
result = await db.execute(
select(func.count()).select_from(OHLCVRecord).where(OHLCVRecord.ticker_id == ticker_id)
)
return int(result.scalar() or 0)
async def _update_progress(
db: AsyncSession, ticker_id: int, last_date: date
) -> None:
@@ -84,10 +92,17 @@ async def fetch_and_ingest(
if end_date is None:
end_date = date.today()
# Resolve start_date: use progress resume or default to 1 year ago
# Resolve start_date: use progress resume or default to 1 year ago.
# If we have too little history, force a one-year backfill even if
# ingestion progress exists (upsert makes this safe and idempotent).
if start_date is None:
progress = await _get_progress(db, ticker.id)
if progress is not None:
bar_count = await _get_ohlcv_bar_count(db, ticker.id)
minimum_backfill_bars = 200
if bar_count < minimum_backfill_bars:
start_date = end_date - timedelta(days=365)
elif progress is not None:
start_date = progress.last_ingested_date + timedelta(days=1)
else:
start_date = end_date - timedelta(days=365)

View File

@@ -0,0 +1,499 @@
from __future__ import annotations
import json
import logging
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.settings import SystemSetting
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
logger = logging.getLogger(__name__)
DEFAULT_RECOMMENDATION_CONFIG: dict[str, float] = {
"recommendation_high_confidence_threshold": 70.0,
"recommendation_moderate_confidence_threshold": 50.0,
"recommendation_confidence_diff_threshold": 20.0,
"recommendation_signal_alignment_weight": 0.15,
"recommendation_sr_strength_weight": 0.20,
"recommendation_distance_penalty_factor": 0.10,
"recommendation_momentum_technical_divergence_threshold": 30.0,
"recommendation_fundamental_technical_divergence_threshold": 40.0,
}
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def _sentiment_value(sentiment_classification: str | None) -> str | None:
if sentiment_classification is None:
return None
return sentiment_classification.strip().lower()
def check_signal_alignment(
direction: str,
dimension_scores: dict[str, float],
sentiment_classification: str | None,
) -> tuple[bool, str]:
technical = float(dimension_scores.get("technical", 50.0))
momentum = float(dimension_scores.get("momentum", 50.0))
sentiment = _sentiment_value(sentiment_classification)
if direction == "long":
aligned_count = sum([
technical > 60,
momentum > 60,
sentiment == "bullish",
])
if aligned_count >= 2:
return True, "Technical, momentum, and/or sentiment align with LONG direction."
return False, "Signals are mixed for LONG direction."
aligned_count = sum([
technical < 40,
momentum < 40,
sentiment == "bearish",
])
if aligned_count >= 2:
return True, "Technical, momentum, and/or sentiment align with SHORT direction."
return False, "Signals are mixed for SHORT direction."
class SignalConflictDetector:
def detect_conflicts(
self,
dimension_scores: dict[str, float],
sentiment_classification: str | None,
config: dict[str, float] | None = None,
) -> list[str]:
cfg = config or DEFAULT_RECOMMENDATION_CONFIG
technical = float(dimension_scores.get("technical", 50.0))
momentum = float(dimension_scores.get("momentum", 50.0))
fundamental = float(dimension_scores.get("fundamental", 50.0))
sentiment = _sentiment_value(sentiment_classification)
mt_threshold = float(cfg.get("recommendation_momentum_technical_divergence_threshold", 30.0))
ft_threshold = float(cfg.get("recommendation_fundamental_technical_divergence_threshold", 40.0))
conflicts: list[str] = []
if sentiment == "bearish" and technical > 60:
conflicts.append(
f"sentiment-technical: Bearish sentiment conflicts with bullish technical ({technical:.0f})"
)
if sentiment == "bullish" and technical < 40:
conflicts.append(
f"sentiment-technical: Bullish sentiment conflicts with bearish technical ({technical:.0f})"
)
mt_diff = abs(momentum - technical)
if mt_diff > mt_threshold:
conflicts.append(
"momentum-technical: "
f"Momentum ({momentum:.0f}) diverges from technical ({technical:.0f}) by {mt_diff:.0f} points"
)
if sentiment == "bearish" and momentum > 60:
conflicts.append(
f"sentiment-momentum: Bearish sentiment conflicts with momentum ({momentum:.0f})"
)
if sentiment == "bullish" and momentum < 40:
conflicts.append(
f"sentiment-momentum: Bullish sentiment conflicts with momentum ({momentum:.0f})"
)
ft_diff = abs(fundamental - technical)
if ft_diff > ft_threshold:
conflicts.append(
"fundamental-technical: "
f"Fundamental ({fundamental:.0f}) diverges significantly from technical ({technical:.0f})"
)
return conflicts
class DirectionAnalyzer:
def calculate_confidence(
self,
direction: str,
dimension_scores: dict[str, float],
sentiment_classification: str | None,
conflicts: list[str] | None = None,
) -> float:
confidence = 50.0
technical = float(dimension_scores.get("technical", 50.0))
momentum = float(dimension_scores.get("momentum", 50.0))
fundamental = float(dimension_scores.get("fundamental", 50.0))
sentiment = _sentiment_value(sentiment_classification)
if direction == "long":
if technical > 70:
confidence += 25.0
elif technical > 60:
confidence += 15.0
if momentum > 70:
confidence += 20.0
elif momentum > 60:
confidence += 15.0
if sentiment == "bullish":
confidence += 15.0
elif sentiment == "neutral":
confidence += 5.0
if fundamental > 60:
confidence += 10.0
else:
if technical < 30:
confidence += 25.0
elif technical < 40:
confidence += 15.0
if momentum < 30:
confidence += 20.0
elif momentum < 40:
confidence += 15.0
if sentiment == "bearish":
confidence += 15.0
elif sentiment == "neutral":
confidence += 5.0
if fundamental < 40:
confidence += 10.0
for conflict in conflicts or []:
if "sentiment-technical" in conflict:
confidence -= 20.0
elif "momentum-technical" in conflict:
confidence -= 15.0
elif "sentiment-momentum" in conflict:
confidence -= 20.0
elif "fundamental-technical" in conflict:
confidence -= 10.0
return _clamp(confidence, 0.0, 100.0)
class TargetGenerator:
def generate_targets(
self,
direction: str,
entry_price: float,
stop_loss: float,
sr_levels: list[SRLevel],
atr_value: float,
) -> list[dict[str, Any]]:
if atr_value <= 0:
return []
risk = abs(entry_price - stop_loss)
if risk <= 0:
return []
candidates: list[dict[str, Any]] = []
atr_pct = atr_value / entry_price if entry_price > 0 else 0.0
max_atr_multiple: float | None = None
if atr_pct > 0.05:
max_atr_multiple = 10.0
elif atr_pct < 0.02:
max_atr_multiple = 3.0
for level in sr_levels:
is_candidate = False
if direction == "long":
is_candidate = level.type == "resistance" and level.price_level > entry_price
else:
is_candidate = level.type == "support" and level.price_level < entry_price
if not is_candidate:
continue
distance = abs(level.price_level - entry_price)
distance_atr_multiple = distance / atr_value
if distance_atr_multiple < 1.0:
continue
if max_atr_multiple is not None and distance_atr_multiple > max_atr_multiple:
continue
reward = abs(level.price_level - entry_price)
rr_ratio = reward / risk
norm_rr = min(rr_ratio / 10.0, 1.0)
norm_strength = _clamp(level.strength, 0, 100) / 100.0
norm_proximity = 1.0 - min(distance / entry_price, 1.0)
quality = 0.35 * norm_rr + 0.35 * norm_strength + 0.30 * norm_proximity
candidates.append(
{
"price": float(level.price_level),
"distance_from_entry": float(distance),
"distance_atr_multiple": float(distance_atr_multiple),
"rr_ratio": float(rr_ratio),
"classification": "Moderate",
"sr_level_id": int(level.id),
"sr_strength": float(level.strength),
"quality": float(quality),
}
)
candidates.sort(key=lambda row: row["quality"], reverse=True)
selected = candidates[:5]
selected.sort(key=lambda row: row["distance_from_entry"])
if not selected:
return []
n = len(selected)
for idx, target in enumerate(selected):
if n <= 2:
target["classification"] = "Conservative" if idx == 0 else "Aggressive"
elif idx <= 1:
target["classification"] = "Conservative"
elif idx >= n - 2:
target["classification"] = "Aggressive"
else:
target["classification"] = "Moderate"
target.pop("quality", None)
return selected
class ProbabilityEstimator:
def estimate_probability(
self,
target: dict[str, Any],
dimension_scores: dict[str, float],
sentiment_classification: str | None,
direction: str,
config: dict[str, float],
) -> float:
classification = str(target.get("classification", "Moderate"))
strength = float(target.get("sr_strength", 50.0))
atr_multiple = float(target.get("distance_atr_multiple", 1.0))
if classification == "Conservative":
base_prob = 70.0
elif classification == "Aggressive":
base_prob = 40.0
else:
base_prob = 55.0
if strength >= 80:
strength_adj = 15.0
elif strength >= 60:
strength_adj = 10.0
elif strength >= 40:
strength_adj = 5.0
else:
strength_adj = -10.0
technical = float(dimension_scores.get("technical", 50.0))
momentum = float(dimension_scores.get("momentum", 50.0))
sentiment = _sentiment_value(sentiment_classification)
alignment_adj = 0.0
if direction == "long":
if technical > 60 and (sentiment == "bullish" or momentum > 60):
alignment_adj = 15.0
elif technical < 40 or (sentiment == "bearish" and momentum < 40):
alignment_adj = -15.0
else:
if technical < 40 and (sentiment == "bearish" or momentum < 40):
alignment_adj = 15.0
elif technical > 60 or (sentiment == "bullish" and momentum > 60):
alignment_adj = -15.0
volatility_adj = 0.0
if atr_multiple > 5:
volatility_adj = 5.0
elif atr_multiple < 2:
volatility_adj = 5.0
signal_weight = float(config.get("recommendation_signal_alignment_weight", 0.15))
sr_weight = float(config.get("recommendation_sr_strength_weight", 0.20))
distance_penalty = float(config.get("recommendation_distance_penalty_factor", 0.10))
scaled_alignment_adj = alignment_adj * (signal_weight / 0.15)
scaled_strength_adj = strength_adj * (sr_weight / 0.20)
distance_adj = -distance_penalty * max(atr_multiple - 1.0, 0.0) * 2.0
probability = base_prob + scaled_strength_adj + scaled_alignment_adj + volatility_adj + distance_adj
probability = _clamp(probability, 10.0, 90.0)
if classification == "Conservative":
probability = max(probability, 61.0)
elif classification == "Moderate":
probability = _clamp(probability, 40.0, 70.0)
elif classification == "Aggressive":
probability = min(probability, 49.0)
return round(probability, 2)
signal_conflict_detector = SignalConflictDetector()
direction_analyzer = DirectionAnalyzer()
target_generator = TargetGenerator()
probability_estimator = ProbabilityEstimator()
async def get_recommendation_config(db: AsyncSession) -> dict[str, float]:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key.like("recommendation_%"))
)
rows = result.scalars().all()
config: dict[str, float] = dict(DEFAULT_RECOMMENDATION_CONFIG)
for setting in rows:
try:
config[setting.key] = float(setting.value)
except (TypeError, ValueError):
logger.warning("Invalid recommendation setting value for %s: %s", setting.key, setting.value)
return config
def _risk_level_from_conflicts(conflicts: list[str]) -> str:
if not conflicts:
return "Low"
severe = [c for c in conflicts if "sentiment-technical" in c or "sentiment-momentum" in c]
if len(severe) >= 2 or len(conflicts) >= 3:
return "High"
return "Medium"
def _choose_recommended_action(
long_confidence: float,
short_confidence: float,
config: dict[str, float],
) -> str:
high = float(config.get("recommendation_high_confidence_threshold", 70.0))
moderate = float(config.get("recommendation_moderate_confidence_threshold", 50.0))
diff = float(config.get("recommendation_confidence_diff_threshold", 20.0))
if long_confidence >= high and (long_confidence - short_confidence) >= diff:
return "LONG_HIGH"
if short_confidence >= high and (short_confidence - long_confidence) >= diff:
return "SHORT_HIGH"
if long_confidence >= moderate and (long_confidence - short_confidence) >= diff:
return "LONG_MODERATE"
if short_confidence >= moderate and (short_confidence - long_confidence) >= diff:
return "SHORT_MODERATE"
return "NEUTRAL"
def _build_reasoning(
direction: str,
confidence: float,
conflicts: list[str],
dimension_scores: dict[str, float],
sentiment_classification: str | None,
action: str,
) -> str:
aligned, alignment_text = check_signal_alignment(
direction,
dimension_scores,
sentiment_classification,
)
sentiment = _sentiment_value(sentiment_classification) or "unknown"
technical = float(dimension_scores.get("technical", 50.0))
momentum = float(dimension_scores.get("momentum", 50.0))
direction_text = direction.upper()
alignment_summary = "aligned" if aligned else "mixed"
base = (
f"{direction_text} confidence {confidence:.1f}% with {alignment_summary} signals "
f"(technical={technical:.0f}, momentum={momentum:.0f}, sentiment={sentiment})."
)
if conflicts:
return (
f"{base} {alignment_text} Detected {len(conflicts)} conflict(s), "
f"so recommendation is risk-adjusted. Action={action}."
)
return f"{base} {alignment_text} No major conflicts detected. Action={action}."
async def enhance_trade_setup(
db: AsyncSession,
ticker: Ticker,
setup: TradeSetup,
dimension_scores: dict[str, float],
sr_levels: list[SRLevel],
sentiment_classification: str | None,
atr_value: float,
) -> TradeSetup:
config = await get_recommendation_config(db)
conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
)
long_confidence = direction_analyzer.calculate_confidence(
direction="long",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
short_confidence = direction_analyzer.calculate_confidence(
direction="short",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
direction = setup.direction.lower()
confidence = long_confidence if direction == "long" else short_confidence
targets = target_generator.generate_targets(
direction=direction,
entry_price=setup.entry_price,
stop_loss=setup.stop_loss,
sr_levels=sr_levels,
atr_value=atr_value,
)
for target in targets:
target["probability"] = probability_estimator.estimate_probability(
target=target,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
direction=direction,
config=config,
)
if len(targets) < 3:
conflicts = [*conflicts, "target-availability: Fewer than 3 valid S/R targets available"]
action = _choose_recommended_action(long_confidence, short_confidence, config)
risk_level = _risk_level_from_conflicts(conflicts)
setup.confidence_score = round(confidence, 2)
setup.targets_json = json.dumps(targets)
setup.conflict_flags_json = json.dumps(conflicts)
setup.recommended_action = action
setup.reasoning = _build_reasoning(
direction=direction,
confidence=confidence,
conflicts=conflicts,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
action=action,
)
setup.risk_level = risk_level
return setup

View File

@@ -3,24 +3,27 @@
Scans tracked tickers for asymmetric risk-reward trade setups.
Long: target = nearest SR above, stop = entry - ATR × multiplier.
Short: target = nearest SR below, stop = entry + ATR × multiplier.
Filters by configurable R:R threshold (default 3:1).
Filters by configurable R:R threshold (default 1.5).
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from sqlalchemy import delete, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError
from app.models.score import CompositeScore
from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv
from app.services.recommendation_service import enhance_trade_setup
logger = logging.getLogger(__name__)
@@ -45,70 +48,63 @@ def _compute_quality_score(
w_proximity: float = 0.30,
rr_cap: float = 10.0,
) -> float:
"""Compute a quality score for a candidate S/R level.
Combines normalized R:R ratio, level strength, and proximity to entry
into a single 01 score using configurable weights.
"""
"""Compute a quality score for a candidate S/R level."""
norm_rr = min(rr / rr_cap, 1.0)
norm_strength = strength / 100.0
norm_proximity = 1.0 - min(distance / entry_price, 1.0)
return w_rr * norm_rr + w_strength * norm_strength + w_proximity * norm_proximity
async def _get_dimension_scores(db: AsyncSession, ticker_id: int) -> dict[str, float]:
result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id == ticker_id)
)
rows = result.scalars().all()
return {row.dimension: float(row.score) for row in rows}
async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None:
result = await db.execute(
select(SentimentScore)
.where(SentimentScore.ticker_id == ticker_id)
.order_by(SentimentScore.timestamp.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return row.classification if row else None
async def scan_ticker(
db: AsyncSession,
symbol: str,
rr_threshold: float = 1.5,
atr_multiplier: float = 1.5,
) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold.
1. Fetch OHLCV data and compute ATR.
2. Fetch SR levels.
3. Compute long and short setups.
4. Filter by R:R threshold.
5. Delete old setups for this ticker and persist new ones.
Returns list of persisted TradeSetup models.
"""
"""Scan a single ticker for trade setups meeting the R:R threshold."""
ticker = await _get_ticker(db, symbol)
# Fetch OHLCV
records = await query_ohlcv(db, symbol)
if not records or len(records) < 15:
logger.info(
"Skipping %s: insufficient OHLCV data (%d bars, need 15+)",
symbol, len(records),
)
# Clear any stale setups
await db.execute(
delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id)
)
return []
_, highs, lows, closes, _ = _extract_ohlcv(records)
entry_price = closes[-1]
# Compute ATR
try:
atr_result = compute_atr(highs, lows, closes)
atr_value = atr_result["atr"]
except Exception:
logger.info("Skipping %s: cannot compute ATR", symbol)
await db.execute(
delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id)
)
return []
if atr_value <= 0:
logger.info("Skipping %s: ATR is zero or negative", symbol)
await db.execute(
delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id)
)
return []
# Fetch SR levels from DB (already computed by sr_service)
sr_result = await db.execute(
select(SRLevel).where(SRLevel.ticker_id == ticker.id)
)
@@ -116,9 +112,6 @@ async def scan_ticker(
if not sr_levels:
logger.info("Skipping %s: no SR levels available", symbol)
await db.execute(
delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id)
)
return []
levels_above = sorted(
@@ -131,18 +124,18 @@ async def scan_ticker(
reverse=True,
)
# Get composite score for this ticker
comp_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
)
comp = comp_result.scalar_one_or_none()
composite_score = comp.score if comp else 0.0
dimension_scores = await _get_dimension_scores(db, ticker.id)
sentiment_classification = await _get_latest_sentiment(db, ticker.id)
now = datetime.now(timezone.utc)
setups: list[TradeSetup] = []
# Long setup: target = nearest SR above, stop = entry - ATR × multiplier
# Check all resistance levels above and pick the one with the best quality score
if levels_above:
stop = entry_price - (atr_value * atr_multiplier)
risk = entry_price - stop
@@ -152,15 +145,18 @@ async def scan_ticker(
best_candidate_target = 0.0
for lv in levels_above:
reward = lv.price_level - entry_price
if reward > 0:
rr = reward / risk
if rr >= rr_threshold:
distance = lv.price_level - entry_price
quality = _compute_quality_score(rr, lv.strength, distance, entry_price)
if quality > best_quality:
best_quality = quality
best_candidate_rr = rr
best_candidate_target = lv.price_level
if reward <= 0:
continue
rr = reward / risk
if rr < rr_threshold:
continue
distance = lv.price_level - entry_price
quality = _compute_quality_score(rr, lv.strength, distance, entry_price)
if quality > best_quality:
best_quality = quality
best_candidate_rr = rr
best_candidate_target = lv.price_level
if best_candidate_rr > 0:
setups.append(TradeSetup(
ticker_id=ticker.id,
@@ -173,8 +169,6 @@ async def scan_ticker(
detected_at=now,
))
# Short setup: target = nearest SR below, stop = entry + ATR × multiplier
# Check all support levels below and pick the one with the best quality score
if levels_below:
stop = entry_price + (atr_value * atr_multiplier)
risk = stop - entry_price
@@ -184,15 +178,18 @@ async def scan_ticker(
best_candidate_target = 0.0
for lv in levels_below:
reward = entry_price - lv.price_level
if reward > 0:
rr = reward / risk
if rr >= rr_threshold:
distance = entry_price - lv.price_level
quality = _compute_quality_score(rr, lv.strength, distance, entry_price)
if quality > best_quality:
best_quality = quality
best_candidate_rr = rr
best_candidate_target = lv.price_level
if reward <= 0:
continue
rr = reward / risk
if rr < rr_threshold:
continue
distance = entry_price - lv.price_level
quality = _compute_quality_score(rr, lv.strength, distance, entry_price)
if quality > best_quality:
best_quality = quality
best_candidate_rr = rr
best_candidate_target = lv.price_level
if best_candidate_rr > 0:
setups.append(TradeSetup(
ticker_id=ticker.id,
@@ -205,20 +202,32 @@ async def scan_ticker(
detected_at=now,
))
# Delete old setups for this ticker, persist new ones
await db.execute(
delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id)
)
enhanced_setups: list[TradeSetup] = []
for setup in setups:
try:
enhanced = await enhance_trade_setup(
db=db,
ticker=ticker,
setup=setup,
dimension_scores=dimension_scores,
sr_levels=sr_levels,
sentiment_classification=sentiment_classification,
atr_value=atr_value,
)
enhanced_setups.append(enhanced)
except Exception:
logger.exception("Error enhancing setup for %s (%s)", ticker.symbol, setup.direction)
enhanced_setups.append(setup)
for setup in enhanced_setups:
db.add(setup)
await db.commit()
# Refresh to get IDs
for s in setups:
for s in enhanced_setups:
await db.refresh(s)
return setups
return enhanced_setups
async def scan_all_tickers(
@@ -226,11 +235,7 @@ async def scan_all_tickers(
rr_threshold: float = 1.5,
atr_multiplier: float = 1.5,
) -> list[TradeSetup]:
"""Scan all tracked tickers for trade setups.
Processes each ticker independently — one failure doesn't stop others.
Returns all setups found across all tickers.
"""
"""Scan all tracked tickers for trade setups."""
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
tickers = list(result.scalars().all())
@@ -250,38 +255,100 @@ async def scan_all_tickers(
async def get_trade_setups(
db: AsyncSession,
direction: str | None = None,
min_confidence: float | None = None,
recommended_action: str | None = None,
symbol: str | None = None,
) -> list[dict]:
"""Get all stored trade setups, optionally filtered by direction.
Returns dicts sorted by R:R desc, secondary composite desc.
Each dict includes the ticker symbol.
"""
"""Get latest stored trade setups, optionally filtered."""
stmt = (
select(TradeSetup, Ticker.symbol)
.join(Ticker, TradeSetup.ticker_id == Ticker.id)
)
if direction is not None:
stmt = stmt.where(TradeSetup.direction == direction.lower())
if symbol is not None:
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
if min_confidence is not None:
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
if recommended_action is not None:
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
stmt = stmt.order_by(
TradeSetup.rr_ratio.desc(),
TradeSetup.composite_score.desc(),
)
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
result = await db.execute(stmt)
rows = result.all()
return [
{
"id": setup.id,
"symbol": symbol,
"direction": setup.direction,
"entry_price": setup.entry_price,
"stop_loss": setup.stop_loss,
"target": setup.target,
"rr_ratio": setup.rr_ratio,
"composite_score": setup.composite_score,
"detected_at": setup.detected_at,
}
for setup, symbol in rows
]
latest_by_key: dict[tuple[str, str], tuple[TradeSetup, str]] = {}
for setup, ticker_symbol in rows:
dedupe_key = (ticker_symbol, setup.direction)
if dedupe_key not in latest_by_key:
latest_by_key[dedupe_key] = (setup, ticker_symbol)
latest_rows = list(latest_by_key.values())
latest_rows.sort(
key=lambda row: (
row[0].confidence_score if row[0].confidence_score is not None else -1.0,
row[0].rr_ratio,
row[0].composite_score,
),
reverse=True,
)
return [_trade_setup_to_dict(setup, ticker_symbol) for setup, ticker_symbol in latest_rows]
async def get_trade_setup_history(
db: AsyncSession,
symbol: str,
) -> list[dict]:
"""Get full recommendation history for a symbol (newest first)."""
stmt = (
select(TradeSetup, Ticker.symbol)
.join(Ticker, TradeSetup.ticker_id == Ticker.id)
.where(Ticker.symbol == symbol.strip().upper())
.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
)
result = await db.execute(stmt)
rows = result.all()
return [_trade_setup_to_dict(setup, ticker_symbol) for setup, ticker_symbol in rows]
def _trade_setup_to_dict(setup: TradeSetup, symbol: str) -> dict:
targets: list[dict] = []
conflicts: list[str] = []
if setup.targets_json:
try:
parsed_targets = json.loads(setup.targets_json)
if isinstance(parsed_targets, list):
targets = parsed_targets
except (TypeError, ValueError):
targets = []
if setup.conflict_flags_json:
try:
parsed_conflicts = json.loads(setup.conflict_flags_json)
if isinstance(parsed_conflicts, list):
conflicts = [str(item) for item in parsed_conflicts]
except (TypeError, ValueError):
conflicts = []
return {
"id": setup.id,
"symbol": symbol,
"direction": setup.direction,
"entry_price": setup.entry_price,
"stop_loss": setup.stop_loss,
"target": setup.target,
"rr_ratio": setup.rr_ratio,
"composite_score": setup.composite_score,
"detected_at": setup.detected_at,
"confidence_score": setup.confidence_score,
"targets": targets,
"conflict_flags": conflicts,
"recommended_action": setup.recommended_action,
"reasoning": setup.reasoning,
"risk_level": setup.risk_level,
"actual_outcome": setup.actual_outcome,
}

View File

@@ -0,0 +1,405 @@
"""Ticker universe discovery and bootstrap service.
Provides a minimal, provider-backed way to populate tracked tickers from
well-known universes (S&P 500, NASDAQ-100, NASDAQ All).
"""
from __future__ import annotations
import json
import logging
import os
import re
from collections.abc import Iterable
from datetime import datetime, timezone
from pathlib import Path
import httpx
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.exceptions import ProviderError, ValidationError
from app.models.settings import SystemSetting
from app.models.ticker import Ticker
logger = logging.getLogger(__name__)
SUPPORTED_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
_SYMBOL_PATTERN = re.compile(r"^[A-Z0-9-]{1,10}$")
_SEED_UNIVERSES: dict[str, list[str]] = {
"sp500": [
"AAPL", "MSFT", "NVDA", "AMZN", "META", "GOOGL", "GOOG", "BRK-B", "TSLA", "JPM",
"V", "MA", "UNH", "XOM", "LLY", "AVGO", "COST", "PG", "JNJ", "HD", "MRK", "BAC",
"ABBV", "PEP", "KO", "ADBE", "NFLX", "CRM", "CSCO", "WMT", "AMD", "TMO", "MCD",
"ORCL", "ACN", "CVX", "LIN", "DHR", "ABT", "QCOM", "TXN", "PM", "DIS", "INTU",
],
"nasdaq100": [
"AAPL", "MSFT", "NVDA", "AMZN", "META", "GOOGL", "GOOG", "TSLA", "AVGO", "COST",
"NFLX", "ADBE", "CSCO", "AMD", "INTU", "QCOM", "AMGN", "TXN", "INTC", "BKNG", "GILD",
"ISRG", "MDLZ", "ADP", "LRCX", "ADI", "PANW", "SNPS", "CDNS", "KLAC", "MELI", "MU",
"SBUX", "CSX", "REGN", "VRTX", "MAR", "MNST", "CTAS", "ASML", "PYPL", "AMAT", "NXPI",
],
"nasdaq_all": [
"AAPL", "MSFT", "NVDA", "AMZN", "META", "GOOGL", "TSLA", "AMD", "INTC", "QCOM", "CSCO",
"ADBE", "NFLX", "PYPL", "AMAT", "MU", "SBUX", "GILD", "INTU", "BKNG", "ADP", "CTAS",
"PANW", "SNPS", "CDNS", "LRCX", "KLAC", "MELI", "ASML", "REGN", "VRTX", "MDLZ", "AMGN",
],
}
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
if not _CA_BUNDLE or not Path(_CA_BUNDLE).exists():
_CA_BUNDLE_PATH: str | bool = True
else:
_CA_BUNDLE_PATH = _CA_BUNDLE
def _validate_universe(universe: str) -> str:
normalised = universe.strip().lower()
if normalised not in SUPPORTED_UNIVERSES:
supported = ", ".join(sorted(SUPPORTED_UNIVERSES))
raise ValidationError(f"Unsupported universe '{universe}'. Supported: {supported}")
return normalised
def _normalise_symbols(symbols: Iterable[str]) -> list[str]:
deduped: set[str] = set()
for raw_symbol in symbols:
symbol = raw_symbol.strip().upper().replace(".", "-")
if not symbol:
continue
if _SYMBOL_PATTERN.fullmatch(symbol) is None:
continue
deduped.add(symbol)
return sorted(deduped)
def _extract_symbols_from_fmp_payload(payload: object) -> list[str]:
if not isinstance(payload, list):
return []
symbols: list[str] = []
for item in payload:
if not isinstance(item, dict):
continue
candidate = item.get("symbol") or item.get("ticker")
if isinstance(candidate, str):
symbols.append(candidate)
return symbols
async def _try_fmp_urls(
client: httpx.AsyncClient,
urls: list[str],
) -> tuple[list[str], list[str]]:
failures: list[str] = []
for url in urls:
endpoint = url.split("?")[0]
try:
response = await client.get(url)
except httpx.HTTPError as exc:
failures.append(f"{endpoint}: network error ({type(exc).__name__}: {exc})")
continue
if response.status_code != 200:
failures.append(f"{endpoint}: HTTP {response.status_code}")
continue
try:
payload = response.json()
except ValueError:
failures.append(f"{endpoint}: invalid JSON payload")
continue
symbols = _extract_symbols_from_fmp_payload(payload)
if symbols:
return symbols, failures
failures.append(f"{endpoint}: empty/unsupported payload")
return [], failures
async def _fetch_universe_symbols_from_fmp(universe: str) -> list[str]:
if not settings.fmp_api_key:
raise ValidationError(
"FMP API key is required for universe bootstrap (set FMP_API_KEY)"
)
api_key = settings.fmp_api_key
stable_base = "https://financialmodelingprep.com/stable"
legacy_base = "https://financialmodelingprep.com/api/v3"
stable_candidates: dict[str, list[str]] = {
"sp500": [
f"{stable_base}/sp500-constituent?apikey={api_key}",
f"{stable_base}/sp500-constituents?apikey={api_key}",
],
"nasdaq100": [
f"{stable_base}/nasdaq-100-constituent?apikey={api_key}",
f"{stable_base}/nasdaq100-constituent?apikey={api_key}",
f"{stable_base}/nasdaq-100-constituents?apikey={api_key}",
],
"nasdaq_all": [
f"{stable_base}/stock-screener?exchange=NASDAQ&isEtf=false&limit=10000&apikey={api_key}",
f"{stable_base}/available-traded/list?apikey={api_key}",
],
}
legacy_candidates: dict[str, list[str]] = {
"sp500": [
f"{legacy_base}/sp500_constituent?apikey={api_key}",
f"{legacy_base}/sp500_constituent",
],
"nasdaq100": [
f"{legacy_base}/nasdaq_constituent?apikey={api_key}",
f"{legacy_base}/nasdaq_constituent",
],
"nasdaq_all": [
f"{legacy_base}/stock-screener?exchange=NASDAQ&isEtf=false&limit=10000&apikey={api_key}",
],
}
failures: list[str] = []
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
stable_symbols, stable_failures = await _try_fmp_urls(client, stable_candidates[universe])
failures.extend(stable_failures)
if stable_symbols:
return stable_symbols
legacy_symbols, legacy_failures = await _try_fmp_urls(client, legacy_candidates[universe])
failures.extend(legacy_failures)
if legacy_symbols:
return legacy_symbols
if failures:
reason = "; ".join(failures[:6])
logger.warning("FMP universe fetch failed for %s: %s", universe, reason)
raise ProviderError(
f"Failed to fetch universe symbols from FMP for '{universe}'. Attempts: {reason}"
)
raise ProviderError(f"Failed to fetch universe symbols from FMP for '{universe}'")
async def _fetch_html_symbols(
client: httpx.AsyncClient,
url: str,
pattern: str,
) -> tuple[list[str], str | None]:
try:
response = await client.get(url)
except httpx.HTTPError as exc:
return [], f"{url}: network error ({type(exc).__name__}: {exc})"
if response.status_code != 200:
return [], f"{url}: HTTP {response.status_code}"
matches = re.findall(pattern, response.text, flags=re.IGNORECASE)
if not matches:
return [], f"{url}: no symbols parsed"
return list(matches), None
async def _fetch_nasdaq_trader_symbols(
client: httpx.AsyncClient,
) -> tuple[list[str], str | None]:
url = "https://www.nasdaqtrader.com/dynamic/SymDir/nasdaqlisted.txt"
try:
response = await client.get(url)
except httpx.HTTPError as exc:
return [], f"{url}: network error ({type(exc).__name__}: {exc})"
if response.status_code != 200:
return [], f"{url}: HTTP {response.status_code}"
symbols: list[str] = []
for line in response.text.splitlines():
if not line or line.startswith("Symbol|") or line.startswith("File Creation Time"):
continue
parts = line.split("|")
if not parts:
continue
symbol = parts[0].strip()
test_issue = parts[6].strip() if len(parts) > 6 else "N"
if test_issue == "Y":
continue
symbols.append(symbol)
if not symbols:
return [], f"{url}: no symbols parsed"
return symbols, None
async def _fetch_universe_symbols_from_public(universe: str) -> tuple[list[str], list[str], str | None]:
failures: list[str] = []
sp500_url = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
nasdaq100_url = "https://en.wikipedia.org/wiki/Nasdaq-100"
wiki_symbol_pattern = r"<td>\s*<a[^>]*>([A-Z.]{1,10})</a>\s*</td>"
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
if universe == "sp500":
symbols, error = await _fetch_html_symbols(client, sp500_url, wiki_symbol_pattern)
if error:
failures.append(error)
else:
return symbols, failures, "wikipedia_sp500"
if universe == "nasdaq100":
symbols, error = await _fetch_html_symbols(client, nasdaq100_url, wiki_symbol_pattern)
if error:
failures.append(error)
else:
return symbols, failures, "wikipedia_nasdaq100"
if universe == "nasdaq_all":
symbols, error = await _fetch_nasdaq_trader_symbols(client)
if error:
failures.append(error)
else:
return symbols, failures, "nasdaq_trader"
return [], failures, None
async def _read_cached_symbols(db: AsyncSession, universe: str) -> list[str]:
key = f"ticker_universe_cache_{universe}"
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting is None:
return []
try:
payload = json.loads(setting.value)
except (TypeError, ValueError):
return []
if isinstance(payload, dict):
symbols = payload.get("symbols", [])
elif isinstance(payload, list):
symbols = payload
else:
symbols = []
if not isinstance(symbols, list):
return []
return _normalise_symbols([str(symbol) for symbol in symbols])
async def _write_cached_symbols(
db: AsyncSession,
universe: str,
symbols: list[str],
source: str,
) -> None:
key = f"ticker_universe_cache_{universe}"
payload = {
"symbols": symbols,
"source": source,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
value = json.dumps(payload)
if setting is None:
db.add(SystemSetting(key=key, value=value))
else:
setting.value = value
await db.commit()
async def fetch_universe_symbols(db: AsyncSession, universe: str) -> list[str]:
"""Fetch and normalise symbols for a supported universe with fallbacks.
Fallback order:
1) Free public sources (Wikipedia/NASDAQ trader)
2) FMP endpoints (if available)
3) Cached snapshot in SystemSetting
4) Built-in seed symbols
"""
normalised_universe = _validate_universe(universe)
failures: list[str] = []
public_symbols, public_failures, public_source = await _fetch_universe_symbols_from_public(normalised_universe)
failures.extend(public_failures)
cleaned_public = _normalise_symbols(public_symbols)
if cleaned_public:
await _write_cached_symbols(db, normalised_universe, cleaned_public, public_source or "public")
return cleaned_public
try:
fmp_symbols = await _fetch_universe_symbols_from_fmp(normalised_universe)
cleaned_fmp = _normalise_symbols(fmp_symbols)
if cleaned_fmp:
await _write_cached_symbols(db, normalised_universe, cleaned_fmp, "fmp")
return cleaned_fmp
except (ProviderError, ValidationError) as exc:
failures.append(str(exc))
cached_symbols = await _read_cached_symbols(db, normalised_universe)
if cached_symbols:
logger.warning(
"Using cached universe symbols for %s because live fetch failed: %s",
normalised_universe,
"; ".join(failures[:3]),
)
return cached_symbols
seed_symbols = _normalise_symbols(_SEED_UNIVERSES.get(normalised_universe, []))
if seed_symbols:
logger.warning(
"Using built-in seed symbols for %s because live/cache fetch failed: %s",
normalised_universe,
"; ".join(failures[:3]),
)
return seed_symbols
reason = "; ".join(failures[:6]) if failures else "no provider returned symbols"
raise ProviderError(f"Universe '{normalised_universe}' returned no valid symbols. Attempts: {reason}")
async def bootstrap_universe(
db: AsyncSession,
universe: str,
*,
prune_missing: bool = False,
) -> dict[str, int | str]:
"""Upsert ticker universe into tracked tickers.
Returns summary counts for added/existing/deleted symbols.
"""
normalised_universe = _validate_universe(universe)
symbols = await fetch_universe_symbols(db, normalised_universe)
existing_rows = await db.execute(select(Ticker.symbol))
existing_symbols = set(existing_rows.scalars().all())
target_symbols = set(symbols)
symbols_to_add = sorted(target_symbols - existing_symbols)
symbols_to_delete = sorted(existing_symbols - target_symbols) if prune_missing else []
for symbol in symbols_to_add:
db.add(Ticker(symbol=symbol))
deleted_count = 0
if symbols_to_delete:
result = await db.execute(delete(Ticker).where(Ticker.symbol.in_(symbols_to_delete)))
deleted_count = int(result.rowcount or 0)
await db.commit()
return {
"universe": normalised_universe,
"total_universe_symbols": len(symbols),
"added": len(symbols_to_add),
"already_tracked": len(target_symbols & existing_symbols),
"deleted": deleted_count,
}