Big refactoring
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
499
app/services/recommendation_service.py
Normal file
499
app/services/recommendation_service.py
Normal 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
|
||||
@@ -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 0–1 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,
|
||||
}
|
||||
|
||||
405
app/services/ticker_universe_service.py
Normal file
405
app/services/ticker_universe_service.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user