major update
Some checks failed
Deploy / lint (push) Failing after 8s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-27 16:08:09 +01:00
parent 61ab24490d
commit 181cfe6588
71 changed files with 7647 additions and 281 deletions

View File

@@ -15,10 +15,14 @@ class Settings(BaseSettings):
alpaca_api_key: str = ""
alpaca_api_secret: str = ""
# Sentiment Provider — Gemini with Search Grounding
# Sentiment Provider — Gemini with Search Grounding (legacy)
gemini_api_key: str = ""
gemini_model: str = "gemini-2.0-flash"
# Sentiment Provider — OpenAI
openai_api_key: str = ""
openai_model: str = "gpt-4o-mini"
# Fundamentals Provider — Financial Modeling Prep
fmp_api_key: str = ""
@@ -30,7 +34,7 @@ class Settings(BaseSettings):
# Scoring Defaults
default_watchlist_auto_size: int = 10
default_rr_threshold: float = 3.0
default_rr_threshold: float = 1.5
# Database Pool
db_pool_size: int = 5

View File

@@ -1,5 +1,57 @@
"""FastAPI application entry point with lifespan management."""
# ---------------------------------------------------------------------------
# SSL + proxy injection — MUST happen before any HTTP client imports
# ---------------------------------------------------------------------------
import os as _os
import ssl as _ssl
from pathlib import Path as _Path
_COMBINED_CERT = _Path(__file__).resolve().parent.parent / "combined-ca-bundle.pem"
if _COMBINED_CERT.exists():
_cert_path = str(_COMBINED_CERT)
# Env vars for libraries that respect them (requests, urllib3)
_os.environ["SSL_CERT_FILE"] = _cert_path
_os.environ["REQUESTS_CA_BUNDLE"] = _cert_path
_os.environ["CURL_CA_BUNDLE"] = _cert_path
# Monkey-patch ssl.create_default_context so that ALL libraries
# (aiohttp, httpx, google-genai, alpaca-py, etc.) automatically
# use our combined CA bundle that includes the corporate root cert.
_original_create_default_context = _ssl.create_default_context
def _patched_create_default_context(
purpose=_ssl.Purpose.SERVER_AUTH, *, cafile=None, capath=None, cadata=None
):
ctx = _original_create_default_context(
purpose, cafile=cafile, capath=capath, cadata=cadata
)
# Always load our combined bundle on top of whatever was loaded
ctx.load_verify_locations(cafile=_cert_path)
return ctx
_ssl.create_default_context = _patched_create_default_context
# Also patch aiohttp's cached SSL context objects directly, since
# aiohttp creates them at import time and may have already cached
# a context without our corporate CA bundle.
try:
import aiohttp.connector as _aio_conn
if hasattr(_aio_conn, '_SSL_CONTEXT_VERIFIED') and _aio_conn._SSL_CONTEXT_VERIFIED is not None:
_aio_conn._SSL_CONTEXT_VERIFIED.load_verify_locations(cafile=_cert_path)
if hasattr(_aio_conn, '_SSL_CONTEXT_UNVERIFIED') and _aio_conn._SSL_CONTEXT_UNVERIFIED is not None:
_aio_conn._SSL_CONTEXT_UNVERIFIED.load_verify_locations(cafile=_cert_path)
except ImportError:
pass
# Corporate proxy — needed when Kiro spawns the process (no .zshrc sourced)
_PROXY = "http://aproxy.corproot.net:8080"
_NO_PROXY = "corproot.net,sharedtcs.net,127.0.0.1,localhost,bix.swisscom.com,swisscom.com"
_os.environ.setdefault("HTTP_PROXY", _PROXY)
_os.environ.setdefault("HTTPS_PROXY", _PROXY)
_os.environ.setdefault("NO_PROXY", _NO_PROXY)
import logging
import sys
from contextlib import asynccontextmanager

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey
from sqlalchemy import DateTime, Float, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -20,5 +20,8 @@ class FundamentalData(Base):
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
unavailable_fields_json: Mapped[str] = mapped_column(
Text, nullable=False, default="{}"
)
ticker = relationship("Ticker", back_populates="fundamental_data")

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -20,4 +20,7 @@ class SentimentScore(Base):
DateTime(timezone=True), nullable=False
)
reasoning: Mapped[str] = mapped_column(Text, nullable=False, default="")
citations_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
ticker = relationship("Ticker", back_populates="sentiment_scores")

View File

@@ -1,9 +1,15 @@
"""Financial Modeling Prep (FMP) fundamentals provider using httpx."""
"""Financial Modeling Prep (FMP) fundamentals provider using httpx.
Uses the stable API endpoints (https://financialmodelingprep.com/stable/)
which replaced the legacy /api/v3/ endpoints deprecated in Aug 2025.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
import httpx
@@ -12,7 +18,14 @@ from app.providers.protocol import FundamentalData
logger = logging.getLogger(__name__)
_FMP_BASE_URL = "https://financialmodelingprep.com/api/v3"
_FMP_STABLE_URL = "https://financialmodelingprep.com/stable"
# Resolve CA bundle for explicit httpx verify
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
if not _CA_BUNDLE or not Path(_CA_BUNDLE).exists():
_CA_BUNDLE_PATH: str | bool = True # use system default
else:
_CA_BUNDLE_PATH = _CA_BUNDLE
class FMPFundamentalProvider:
@@ -23,17 +36,54 @@ class FMPFundamentalProvider:
raise ProviderError("FMP API key is required")
self._api_key = api_key
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
"""Fetch P/E, revenue growth, earnings surprise, and market cap."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
profile = await self._fetch_profile(client, ticker)
earnings = await self._fetch_earnings_surprise(client, ticker)
# Mapping from FMP endpoint name to the FundamentalData field it populates
_ENDPOINT_FIELD_MAP: dict[str, str] = {
"ratios-ttm": "pe_ratio",
"financial-growth": "revenue_growth",
"earnings": "earnings_surprise",
}
pe_ratio = self._safe_float(profile.get("pe"))
revenue_growth = self._safe_float(profile.get("revenueGrowth"))
market_cap = self._safe_float(profile.get("mktCap"))
earnings_surprise = self._safe_float(earnings)
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
"""Fetch P/E, revenue growth, earnings surprise, and market cap.
Fetches from multiple stable endpoints. If a supplementary endpoint
(ratios, growth, earnings) returns 402 (paid tier), we gracefully
degrade and return partial data rather than failing entirely, and
record the affected field in ``unavailable_fields``.
"""
try:
endpoints_402: set[str] = set()
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
params = {"symbol": ticker, "apikey": self._api_key}
# Profile is the primary source — must succeed
profile = await self._fetch_json(client, "profile", params, ticker)
# Supplementary sources — degrade gracefully on 402
ratios, was_402 = await self._fetch_json_optional(client, "ratios-ttm", params, ticker)
if was_402:
endpoints_402.add("ratios-ttm")
growth, was_402 = await self._fetch_json_optional(client, "financial-growth", params, ticker)
if was_402:
endpoints_402.add("financial-growth")
earnings, was_402 = await self._fetch_json_optional(client, "earnings", params, ticker)
if was_402:
endpoints_402.add("earnings")
pe_ratio = self._safe_float(ratios.get("priceToEarningsRatioTTM"))
revenue_growth = self._safe_float(growth.get("revenueGrowth"))
market_cap = self._safe_float(profile.get("marketCap"))
earnings_surprise = self._compute_earnings_surprise(earnings)
# Build unavailable_fields from 402 endpoints
unavailable_fields: dict[str, str] = {
self._ENDPOINT_FIELD_MAP[ep]: "requires paid plan"
for ep in endpoints_402
if ep in self._ENDPOINT_FIELD_MAP
}
return FundamentalData(
ticker=ticker,
@@ -42,6 +92,7 @@ class FMPFundamentalProvider:
earnings_surprise=earnings_surprise,
market_cap=market_cap,
fetched_at=datetime.now(timezone.utc),
unavailable_fields=unavailable_fields,
)
except (ProviderError, RateLimitError):
@@ -50,27 +101,52 @@ class FMPFundamentalProvider:
logger.error("FMP provider error for %s: %s", ticker, exc)
raise ProviderError(f"FMP provider error for {ticker}: {exc}") from exc
async def _fetch_profile(self, client: httpx.AsyncClient, ticker: str) -> dict:
"""Fetch company profile (P/E, revenue growth, market cap)."""
url = f"{_FMP_BASE_URL}/profile/{ticker}"
resp = await client.get(url, params={"apikey": self._api_key})
self._check_response(resp, ticker, "profile")
async def _fetch_json(
self,
client: httpx.AsyncClient,
endpoint: str,
params: dict,
ticker: str,
) -> dict:
"""Fetch a stable endpoint and return the first item (or empty dict)."""
url = f"{_FMP_STABLE_URL}/{endpoint}"
resp = await client.get(url, params=params)
self._check_response(resp, ticker, endpoint)
data = resp.json()
if isinstance(data, list) and data:
return data[0]
if isinstance(data, list):
return data[0] if data else {}
return data if isinstance(data, dict) else {}
async def _fetch_earnings_surprise(
self, client: httpx.AsyncClient, ticker: str
) -> float | None:
"""Fetch the most recent earnings surprise percentage."""
url = f"{_FMP_BASE_URL}/earnings-surprises/{ticker}"
resp = await client.get(url, params={"apikey": self._api_key})
self._check_response(resp, ticker, "earnings-surprises")
async def _fetch_json_optional(
self,
client: httpx.AsyncClient,
endpoint: str,
params: dict,
ticker: str,
) -> tuple[dict, bool]:
"""Fetch a stable endpoint, returning ``({}, True)`` on 402 (paid tier).
Returns a tuple of (data_dict, was_402) so callers can track which
endpoints required a paid plan.
"""
url = f"{_FMP_STABLE_URL}/{endpoint}"
resp = await client.get(url, params=params)
if resp.status_code == 402:
logger.warning("FMP %s requires paid plan — skipping for %s", endpoint, ticker)
return {}, True
self._check_response(resp, ticker, endpoint)
data = resp.json()
if isinstance(data, list) and data:
return self._safe_float(data[0].get("actualEarningResult"))
return None
if isinstance(data, list):
return (data[0] if data else {}, False)
return (data if isinstance(data, dict) else {}, False)
def _compute_earnings_surprise(self, earnings_data: dict) -> float | None:
"""Compute earnings surprise % from the most recent actual vs estimated EPS."""
actual = self._safe_float(earnings_data.get("epsActual"))
estimated = self._safe_float(earnings_data.get("epsEstimated"))
if actual is None or estimated is None or estimated == 0:
return None
return ((actual - estimated) / abs(estimated)) * 100
def _check_response(
self, resp: httpx.Response, ticker: str, endpoint: str
@@ -78,6 +154,10 @@ class FMPFundamentalProvider:
"""Raise appropriate errors for non-200 responses."""
if resp.status_code == 429:
raise RateLimitError(f"FMP rate limit hit for {ticker} ({endpoint})")
if resp.status_code == 403:
raise ProviderError(
f"FMP {endpoint} access denied for {ticker}: HTTP 403 — check API key validity and plan tier"
)
if resp.status_code != 200:
raise ProviderError(
f"FMP {endpoint} error for {ticker}: HTTP {resp.status_code}"

View File

@@ -4,7 +4,10 @@ from __future__ import annotations
import json
import logging
import os
import ssl
from datetime import datetime, timezone
from pathlib import Path
from google import genai
from google.genai import types
@@ -14,6 +17,19 @@ from app.providers.protocol import SentimentData
logger = logging.getLogger(__name__)
# Ensure aiohttp's cached SSL context includes our corporate CA bundle.
# aiohttp creates _SSL_CONTEXT_VERIFIED at import time; we must patch it
# after import so that google-genai's aiohttp session trusts our proxy CA.
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
if _CA_BUNDLE and Path(_CA_BUNDLE).exists():
try:
import aiohttp.connector as _aio_conn
if hasattr(_aio_conn, "_SSL_CONTEXT_VERIFIED") and _aio_conn._SSL_CONTEXT_VERIFIED is not None:
_aio_conn._SSL_CONTEXT_VERIFIED.load_verify_locations(cafile=_CA_BUNDLE)
logger.debug("Patched aiohttp _SSL_CONTEXT_VERIFIED with %s", _CA_BUNDLE)
except Exception:
logger.warning("Could not patch aiohttp SSL context", exc_info=True)
_SENTIMENT_PROMPT = """\
Analyze the current market sentiment for the stock ticker {ticker}.
Search the web for recent news articles, social media mentions, and analyst opinions.
@@ -84,7 +100,7 @@ class GeminiSentimentProvider:
raise
except Exception as exc:
msg = str(exc).lower()
if "rate" in msg or "quota" in msg or "429" in msg:
if "429" in msg or "resource exhausted" in msg or "quota" in msg or ("rate" in msg and "limit" in msg):
raise RateLimitError(f"Gemini rate limit hit for {ticker}") from exc
logger.error("Gemini provider error for %s: %s", ticker, exc)
raise ProviderError(f"Gemini provider error for {ticker}: {exc}") from exc

View File

@@ -0,0 +1,136 @@
"""OpenAI sentiment provider using the Responses API with web search."""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
import httpx
from openai import AsyncOpenAI
from app.exceptions import ProviderError, RateLimitError
from app.providers.protocol import SentimentData
logger = logging.getLogger(__name__)
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
_SENTIMENT_PROMPT = """\
Search the web for the LATEST news, analyst opinions, and market developments \
about the stock ticker {ticker} from the past 24-48 hours.
Based on your web search findings, analyze the CURRENT market sentiment.
Respond ONLY with a JSON object in this exact format (no markdown, no extra text):
{{"classification": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation citing recent news>"}}
Rules:
- classification must be exactly one of: bullish, bearish, neutral
- confidence must be an integer from 0 to 100
- reasoning should cite specific recent news or events you found
"""
VALID_CLASSIFICATIONS = {"bullish", "bearish", "neutral"}
class OpenAISentimentProvider:
"""Fetches sentiment analysis from OpenAI Responses API with live web search."""
def __init__(self, api_key: str, model: str = "gpt-4o-mini") -> None:
if not api_key:
raise ProviderError("OpenAI API key is required")
http_kwargs: dict = {}
if _CA_BUNDLE and Path(_CA_BUNDLE).exists():
http_kwargs["verify"] = _CA_BUNDLE
http_client = httpx.AsyncClient(**http_kwargs)
self._client = AsyncOpenAI(api_key=api_key, http_client=http_client)
self._model = model
async def fetch_sentiment(self, ticker: str) -> SentimentData:
"""Use the Responses API with web_search_preview to get live sentiment."""
try:
response = await self._client.responses.create(
model=self._model,
tools=[{"type": "web_search_preview"}],
instructions="You are a financial sentiment analyst. Always respond with valid JSON only, no markdown fences.",
input=_SENTIMENT_PROMPT.format(ticker=ticker),
)
# Extract text from the ResponseOutputMessage in the output
raw_text = ""
for item in response.output:
if item.type == "message" and item.content:
for block in item.content:
if hasattr(block, "text") and block.text:
raw_text = block.text
break
if raw_text:
break
if not raw_text:
raise ProviderError(f"No text output from OpenAI for {ticker}")
raw_text = raw_text.strip()
logger.debug("OpenAI raw response for %s: %s", ticker, raw_text)
# Strip markdown fences if present
clean = raw_text
if clean.startswith("```"):
clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
if clean.endswith("```"):
clean = clean[:-3]
clean = clean.strip()
parsed = json.loads(clean)
classification = parsed.get("classification", "").lower()
if classification not in VALID_CLASSIFICATIONS:
raise ProviderError(
f"Invalid classification '{classification}' from OpenAI for {ticker}"
)
confidence = int(parsed.get("confidence", 50))
confidence = max(0, min(100, confidence))
reasoning = parsed.get("reasoning", "")
if reasoning:
logger.info("OpenAI sentiment for %s: %s (confidence=%d) — %s",
ticker, classification, confidence, reasoning)
# Extract url_citation annotations from response output
citations: list[dict[str, str]] = []
for item in response.output:
if item.type == "message" and item.content:
for block in item.content:
if hasattr(block, "annotations") and block.annotations:
for annotation in block.annotations:
if getattr(annotation, "type", None) == "url_citation":
citations.append({
"url": getattr(annotation, "url", ""),
"title": getattr(annotation, "title", ""),
})
return SentimentData(
ticker=ticker,
classification=classification,
confidence=confidence,
source="openai",
timestamp=datetime.now(timezone.utc),
reasoning=reasoning,
citations=citations,
)
except json.JSONDecodeError as exc:
logger.error("Failed to parse OpenAI JSON for %s: %s — raw: %s", ticker, exc, raw_text)
raise ProviderError(f"Invalid JSON from OpenAI for {ticker}") from exc
except ProviderError:
raise
except Exception as exc:
msg = str(exc).lower()
if "429" in msg or "rate" in msg or "quota" in msg:
raise RateLimitError(f"OpenAI rate limit hit for {ticker}") from exc
logger.error("OpenAI provider error for %s: %s", ticker, exc)
raise ProviderError(f"OpenAI provider error for {ticker}: {exc}") from exc

View File

@@ -7,7 +7,7 @@ transfer data between providers and the service layer.
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Protocol
@@ -39,6 +39,8 @@ class SentimentData:
confidence: int # 0-100
source: str
timestamp: datetime
reasoning: str = ""
citations: list[dict[str, str]] = field(default_factory=list) # [{"url": ..., "title": ...}]
@dataclass(frozen=True, slots=True)
@@ -51,6 +53,7 @@ class FundamentalData:
earnings_surprise: float | None
market_cap: float | None
fetched_at: datetime
unavailable_fields: dict[str, str] = field(default_factory=dict)
# ---------------------------------------------------------------------------

View File

@@ -1,5 +1,7 @@
"""Fundamentals router — fundamental data endpoints."""
import json
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +13,17 @@ from app.services.fundamental_service import get_fundamental
router = APIRouter(tags=["fundamentals"])
def _parse_unavailable_fields(raw_json: str) -> dict[str, str]:
"""Deserialize unavailable_fields_json, defaulting to {} on invalid JSON."""
try:
parsed = json.loads(raw_json)
except (json.JSONDecodeError, TypeError):
return {}
if not isinstance(parsed, dict):
return {}
return {k: v for k, v in parsed.items() if isinstance(k, str) and isinstance(v, str)}
@router.get("/fundamentals/{symbol}", response_model=APIEnvelope)
async def read_fundamentals(
symbol: str,
@@ -30,6 +43,7 @@ async def read_fundamentals(
earnings_surprise=record.earnings_surprise,
market_cap=record.market_cap,
fetched_at=record.fetched_at,
unavailable_fields=_parse_unavailable_fields(record.unavailable_fields_json),
)
return APIEnvelope(status="success", data=data.model_dump())

View File

@@ -19,7 +19,7 @@ from app.exceptions import ProviderError
from app.models.user import User
from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fmp import FMPFundamentalProvider
from app.providers.gemini_sentiment import GeminiSentimentProvider
from app.providers.openai_sentiment import OpenAISentimentProvider
from app.schemas.common import APIEnvelope
from app.services import fundamental_service, ingestion_service, sentiment_service
@@ -67,10 +67,10 @@ async def fetch_symbol(
sources["ohlcv"] = {"status": "error", "records": 0, "message": str(exc)}
# --- Sentiment ---
if settings.gemini_api_key:
if settings.openai_api_key:
try:
sent_provider = GeminiSentimentProvider(
settings.gemini_api_key, settings.gemini_model
sent_provider = OpenAISentimentProvider(
settings.openai_api_key, settings.openai_model
)
data = await sent_provider.fetch_sentiment(symbol_upper)
await sentiment_service.store_sentiment(
@@ -80,6 +80,8 @@ async def fetch_symbol(
confidence=data.confidence,
source=data.source,
timestamp=data.timestamp,
reasoning=data.reasoning,
citations=data.citations,
)
sources["sentiment"] = {
"status": "ok",
@@ -93,7 +95,7 @@ async def fetch_symbol(
else:
sources["sentiment"] = {
"status": "skipped",
"message": "Gemini API key not configured",
"message": "OpenAI API key not configured",
}
# --- Fundamentals ---
@@ -108,6 +110,7 @@ async def fetch_symbol(
revenue_growth=fdata.revenue_growth,
earnings_surprise=fdata.earnings_surprise,
market_cap=fdata.market_cap,
unavailable_fields=fdata.unavailable_fields,
)
sources["fundamentals"] = {"status": "ok", "message": None}
except Exception as exc:

View File

@@ -6,14 +6,41 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access
from app.schemas.common import APIEnvelope
from app.schemas.score import (
CompositeBreakdownResponse,
DimensionScoreResponse,
RankingEntry,
RankingResponse,
ScoreBreakdownResponse,
ScoreResponse,
SubScoreResponse,
WeightUpdateRequest,
)
from app.services.scoring_service import get_rankings, get_score, update_weights
def _map_breakdown(raw: dict | None) -> ScoreBreakdownResponse | None:
"""Convert a raw breakdown dict from the scoring service into a Pydantic model."""
if raw is None:
return None
return ScoreBreakdownResponse(
sub_scores=[SubScoreResponse(**s) for s in raw.get("sub_scores", [])],
formula=raw.get("formula", ""),
unavailable=raw.get("unavailable", []),
)
def _map_composite_breakdown(raw: dict | None) -> CompositeBreakdownResponse | None:
"""Convert a raw composite breakdown dict into a Pydantic model."""
if raw is None:
return None
return CompositeBreakdownResponse(
weights=raw["weights"],
available_dimensions=raw["available_dimensions"],
missing_dimensions=raw["missing_dimensions"],
renormalized_weights=raw["renormalized_weights"],
formula=raw["formula"],
)
router = APIRouter(tags=["scores"])
@@ -32,10 +59,20 @@ async def read_score(
composite_stale=result["composite_stale"],
weights=result["weights"],
dimensions=[
DimensionScoreResponse(**d) for d in result["dimensions"]
DimensionScoreResponse(
dimension=d["dimension"],
score=d["score"],
is_stale=d["is_stale"],
computed_at=d.get("computed_at"),
breakdown=_map_breakdown(d.get("breakdown")),
)
for d in result["dimensions"]
],
missing_dimensions=result["missing_dimensions"],
computed_at=result["computed_at"],
composite_breakdown=_map_composite_breakdown(
result.get("composite_breakdown")
),
)
return APIEnvelope(status="success", data=data.model_dump(mode="json"))

View File

@@ -1,11 +1,13 @@
"""Sentiment router — sentiment data endpoints."""
import json
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access
from app.schemas.common import APIEnvelope
from app.schemas.sentiment import SentimentResponse, SentimentScoreResult
from app.schemas.sentiment import CitationItem, SentimentResponse, SentimentScoreResult
from app.services.sentiment_service import (
compute_sentiment_dimension_score,
get_sentiment_scores,
@@ -14,6 +16,17 @@ from app.services.sentiment_service import (
router = APIRouter(tags=["sentiment"])
def _parse_citations(citations_json: str) -> list[CitationItem]:
"""Deserialize citations_json, defaulting to [] on invalid JSON."""
try:
raw = json.loads(citations_json)
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(raw, list):
return []
return [CitationItem(**item) for item in raw if isinstance(item, dict)]
@router.get("/sentiment/{symbol}", response_model=APIEnvelope)
async def read_sentiment(
symbol: str,
@@ -36,6 +49,8 @@ async def read_sentiment(
confidence=s.confidence,
source=s.source,
timestamp=s.timestamp,
reasoning=s.reasoning,
citations=_parse_citations(s.citations_json),
)
for s in scores
],

View File

@@ -5,8 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access
from app.schemas.common import APIEnvelope
from app.schemas.sr_level import SRLevelResponse, SRLevelResult
from app.services.sr_service import get_sr_levels
from app.schemas.sr_level import SRLevelResponse, SRLevelResult, SRZoneResult
from app.services.price_service import query_ohlcv
from app.services.sr_service import cluster_sr_zones, get_sr_levels
router = APIRouter(tags=["sr-levels"])
@@ -15,24 +16,55 @@ router = APIRouter(tags=["sr-levels"])
async def read_sr_levels(
symbol: str,
tolerance: float = Query(0.005, ge=0, le=0.1, description="Merge tolerance (default 0.5%)"),
max_zones: int = Query(6, ge=0, description="Max S/R zones to return (default 6)"),
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Get support/resistance levels for a symbol, sorted by strength descending."""
levels = await get_sr_levels(db, symbol, tolerance)
level_results = [
SRLevelResult(
id=lvl.id,
price_level=lvl.price_level,
type=lvl.type,
strength=lvl.strength,
detection_method=lvl.detection_method,
created_at=lvl.created_at,
)
for lvl in levels
]
# Compute S/R zones from the fetched levels
zones: list[SRZoneResult] = []
if levels and max_zones > 0:
# Get current price from latest OHLCV close
ohlcv_records = await query_ohlcv(db, symbol)
if ohlcv_records:
current_price = ohlcv_records[-1].close
level_dicts = [
{"price_level": lvl.price_level, "strength": lvl.strength}
for lvl in levels
]
raw_zones = cluster_sr_zones(
level_dicts, current_price, tolerance=0.02, max_zones=max_zones
)
zones = [SRZoneResult(**z) for z in raw_zones]
# Filter levels to only those within at least one zone's [low, high] range
visible_levels: list[SRLevelResult] = []
if zones:
visible_levels = [
lvl
for lvl in level_results
if any(z.low <= lvl.price_level <= z.high for z in zones)
]
data = SRLevelResponse(
symbol=symbol.upper(),
levels=[
SRLevelResult(
id=lvl.id,
price_level=lvl.price_level,
type=lvl.type,
strength=lvl.strength,
detection_method=lvl.detection_method,
created_at=lvl.created_at,
)
for lvl in levels
],
levels=level_results,
zones=zones,
visible_levels=visible_levels,
count=len(levels),
)
return APIEnvelope(status="success", data=data.model_dump())

View File

@@ -27,7 +27,7 @@ from app.models.settings import SystemSetting
from app.models.ticker import Ticker
from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fmp import FMPFundamentalProvider
from app.providers.gemini_sentiment import GeminiSentimentProvider
from app.providers.openai_sentiment import OpenAISentimentProvider
from app.services import fundamental_service, ingestion_service, sentiment_service
from app.services.rr_scanner_service import scan_all_tickers
@@ -174,7 +174,7 @@ async def collect_ohlcv() -> None:
async def collect_sentiment() -> None:
"""Fetch sentiment for all tracked tickers via Gemini.
"""Fetch sentiment for all tracked tickers via OpenAI.
Processes each ticker independently. On rate limit, records last
successful ticker for resume.
@@ -194,12 +194,12 @@ async def collect_sentiment() -> None:
symbols = _resume_tickers(symbols, job_name)
if not settings.gemini_api_key:
logger.warning(json.dumps({"event": "job_skipped", "job": job_name, "reason": "gemini key not configured"}))
if not settings.openai_api_key:
logger.warning(json.dumps({"event": "job_skipped", "job": job_name, "reason": "openai key not configured"}))
return
try:
provider = GeminiSentimentProvider(settings.gemini_api_key, settings.gemini_model)
provider = OpenAISentimentProvider(settings.openai_api_key, settings.openai_model)
except Exception as exc:
logger.error(json.dumps({"event": "job_error", "job": job_name, "error_type": type(exc).__name__, "message": str(exc)}))
return
@@ -217,6 +217,8 @@ async def collect_sentiment() -> None:
confidence=data.confidence,
source=data.source,
timestamp=data.timestamp,
reasoning=data.reasoning,
citations=data.citations,
)
_last_successful[job_name] = symbol
processed += 1
@@ -292,6 +294,7 @@ async def collect_fundamentals() -> None:
revenue_growth=data.revenue_growth,
earnings_surprise=data.earnings_surprise,
market_cap=data.market_cap,
unavailable_fields=data.unavailable_fields,
)
_last_successful[job_name] = symbol
processed += 1

View File

@@ -16,3 +16,4 @@ class FundamentalResponse(BaseModel):
earnings_surprise: float | None = None
market_cap: float | None = None
fetched_at: datetime | None = None
unavailable_fields: dict[str, str] = {}

View File

@@ -7,6 +7,34 @@ from datetime import datetime
from pydantic import BaseModel, Field
class SubScoreResponse(BaseModel):
"""A single sub-score within a dimension breakdown."""
name: str
score: float
weight: float
raw_value: float | str | None = None
description: str = ""
class ScoreBreakdownResponse(BaseModel):
"""Breakdown of a dimension score into sub-scores."""
sub_scores: list[SubScoreResponse]
formula: str
unavailable: list[dict[str, str]] = []
class CompositeBreakdownResponse(BaseModel):
"""Breakdown of the composite score showing dimension weights and re-normalization."""
weights: dict[str, float]
available_dimensions: list[str]
missing_dimensions: list[str]
renormalized_weights: dict[str, float]
formula: str
class DimensionScoreResponse(BaseModel):
"""A single dimension score."""
@@ -14,6 +42,7 @@ class DimensionScoreResponse(BaseModel):
score: float
is_stale: bool
computed_at: datetime | None = None
breakdown: ScoreBreakdownResponse | None = None
class ScoreResponse(BaseModel):
@@ -26,6 +55,7 @@ class ScoreResponse(BaseModel):
dimensions: list[DimensionScoreResponse] = []
missing_dimensions: list[str] = []
computed_at: datetime | None = None
composite_breakdown: CompositeBreakdownResponse | None = None
class WeightUpdateRequest(BaseModel):

View File

@@ -8,6 +8,13 @@ from typing import Literal
from pydantic import BaseModel, Field
class CitationItem(BaseModel):
"""A single citation from the sentiment analysis."""
url: str
title: str
class SentimentScoreResult(BaseModel):
"""A single sentiment score record."""
@@ -16,6 +23,8 @@ class SentimentScoreResult(BaseModel):
confidence: int = Field(ge=0, le=100)
source: str
timestamp: datetime
reasoning: str = ""
citations: list[CitationItem] = []
class SentimentResponse(BaseModel):

View File

@@ -19,9 +19,22 @@ class SRLevelResult(BaseModel):
created_at: datetime
class SRZoneResult(BaseModel):
"""A clustered S/R zone spanning a price range."""
low: float
high: float
midpoint: float
strength: int = Field(ge=0, le=100)
type: Literal["support", "resistance"]
level_count: int
class SRLevelResponse(BaseModel):
"""Envelope-ready S/R levels response."""
symbol: str
levels: list[SRLevelResult]
zones: list[SRZoneResult] = []
visible_levels: list[SRLevelResult] = []
count: int

View File

@@ -6,6 +6,7 @@ and marks the fundamental dimension score as stale on new data.
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
@@ -37,6 +38,7 @@ async def store_fundamental(
revenue_growth: float | None = None,
earnings_surprise: float | None = None,
market_cap: float | None = None,
unavailable_fields: dict[str, str] | None = None,
) -> FundamentalData:
"""Store or update fundamental data for a ticker.
@@ -52,6 +54,7 @@ async def store_fundamental(
existing = result.scalar_one_or_none()
now = datetime.now(timezone.utc)
unavailable_fields_json = json.dumps(unavailable_fields or {})
if existing is not None:
existing.pe_ratio = pe_ratio
@@ -59,6 +62,7 @@ async def store_fundamental(
existing.earnings_surprise = earnings_surprise
existing.market_cap = market_cap
existing.fetched_at = now
existing.unavailable_fields_json = unavailable_fields_json
record = existing
else:
record = FundamentalData(
@@ -68,6 +72,7 @@ async def store_fundamental(
earnings_surprise=earnings_surprise,
market_cap=market_cap,
fetched_at=now,
unavailable_fields_json=unavailable_fields_json,
)
db.add(record)

View File

@@ -34,10 +34,32 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
return ticker
def _compute_quality_score(
rr: float,
strength: int,
distance: float,
entry_price: float,
*,
w_rr: float = 0.35,
w_strength: float = 0.35,
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.
"""
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 scan_ticker(
db: AsyncSession,
symbol: str,
rr_threshold: float = 3.0,
rr_threshold: float = 1.5,
atr_multiplier: float = 1.5,
) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold.
@@ -120,41 +142,65 @@ async def scan_ticker(
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:
target = levels_above[0].price_level
stop = entry_price - (atr_value * atr_multiplier)
reward = target - entry_price
risk = entry_price - stop
if risk > 0 and reward > 0:
rr = reward / risk
if rr >= rr_threshold:
if risk > 0:
best_quality = 0.0
best_candidate_rr = 0.0
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 best_candidate_rr > 0:
setups.append(TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=round(entry_price, 4),
stop_loss=round(stop, 4),
target=round(target, 4),
rr_ratio=round(rr, 4),
target=round(best_candidate_target, 4),
rr_ratio=round(best_candidate_rr, 4),
composite_score=round(composite_score, 4),
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:
target = levels_below[0].price_level
stop = entry_price + (atr_value * atr_multiplier)
reward = entry_price - target
risk = stop - entry_price
if risk > 0 and reward > 0:
rr = reward / risk
if rr >= rr_threshold:
if risk > 0:
best_quality = 0.0
best_candidate_rr = 0.0
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 best_candidate_rr > 0:
setups.append(TradeSetup(
ticker_id=ticker.id,
direction="short",
entry_price=round(entry_price, 4),
stop_loss=round(stop, 4),
target=round(target, 4),
rr_ratio=round(rr, 4),
target=round(best_candidate_target, 4),
rr_ratio=round(best_candidate_rr, 4),
composite_score=round(composite_score, 4),
detected_at=now,
))
@@ -177,7 +223,7 @@ async def scan_ticker(
async def scan_all_tickers(
db: AsyncSession,
rr_threshold: float = 3.0,
rr_threshold: float = 1.5,
atr_multiplier: float = 1.5,
) -> list[TradeSetup]:
"""Scan all tracked tickers for trade setups.

View File

@@ -85,8 +85,14 @@ async def _save_weights(db: AsyncSession, weights: dict[str, float]) -> None:
# Dimension score computation
# ---------------------------------------------------------------------------
async def _compute_technical_score(db: AsyncSession, symbol: str) -> float | None:
"""Compute technical dimension score from ADX, EMA, RSI."""
async def _compute_technical_score(
db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
"""Compute technical dimension score from ADX, EMA, RSI.
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
TypedDict shape: {sub_scores, formula, unavailable}.
"""
from app.services.indicator_service import (
compute_adx,
compute_ema,
@@ -97,147 +103,366 @@ async def _compute_technical_score(db: AsyncSession, symbol: str) -> float | Non
records = await query_ohlcv(db, symbol)
if not records:
return None
return None, None
_, highs, lows, closes, _ = _extract_ohlcv(records)
scores: list[tuple[float, float]] = [] # (weight, score)
sub_scores: list[dict] = []
unavailable: list[dict[str, str]] = []
# ADX (weight 0.4) — needs 28+ bars
try:
adx_result = compute_adx(highs, lows, closes)
scores.append((0.4, adx_result["score"]))
except Exception:
pass
sub_scores.append({
"name": "ADX",
"score": adx_result["score"],
"weight": 0.4,
"raw_value": adx_result["adx"],
"description": "ADX value (0-100). Higher = stronger trend.",
})
except Exception as exc:
unavailable.append({"name": "ADX", "reason": str(exc) or "Insufficient data for ADX"})
# EMA (weight 0.3) — needs period+1 bars
try:
ema_result = compute_ema(closes)
pct_diff = (
round(
(ema_result["latest_close"] - ema_result["ema"])
/ ema_result["ema"]
* 100.0,
4,
)
if ema_result["ema"] != 0
else 0.0
)
scores.append((0.3, ema_result["score"]))
except Exception:
pass
sub_scores.append({
"name": "EMA",
"score": ema_result["score"],
"weight": 0.3,
"raw_value": pct_diff,
"description": f"Price {pct_diff}% {'above' if pct_diff >= 0 else 'below'} EMA(20). Score: 50 + pct_diff * 10.",
})
except Exception as exc:
unavailable.append({"name": "EMA", "reason": str(exc) or "Insufficient data for EMA"})
# RSI (weight 0.3) — needs 15+ bars
try:
rsi_result = compute_rsi(closes)
scores.append((0.3, rsi_result["score"]))
except Exception:
pass
sub_scores.append({
"name": "RSI",
"score": rsi_result["score"],
"weight": 0.3,
"raw_value": rsi_result["rsi"],
"description": "RSI(14) value. Score equals RSI.",
})
except Exception as exc:
unavailable.append({"name": "RSI", "reason": str(exc) or "Insufficient data for RSI"})
if not scores:
return None
breakdown: dict = {
"sub_scores": [],
"formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI, re-normalized if any sub-score unavailable.",
"unavailable": unavailable,
}
return None, breakdown
total_weight = sum(w for w, _ in scores)
if total_weight == 0:
return None
return None, None
weighted = sum(w * s for w, s in scores) / total_weight
return max(0.0, min(100.0, weighted))
final_score = max(0.0, min(100.0, weighted))
breakdown = {
"sub_scores": sub_scores,
"formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI, re-normalized if any sub-score unavailable.",
"unavailable": unavailable,
}
return final_score, breakdown
async def _compute_sr_quality_score(db: AsyncSession, symbol: str) -> float | None:
async def _compute_sr_quality_score(
db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
"""Compute S/R quality dimension score.
Based on number of strong levels, proximity to current price, avg strength.
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
TypedDict shape: {sub_scores, formula, unavailable}.
"""
from app.services.price_service import query_ohlcv
from app.services.sr_service import get_sr_levels
formula = "Sum of sub-scores: Strong Count (max 40) + Proximity (max 30) + Avg Strength (max 30), clamped to [0, 100]."
records = await query_ohlcv(db, symbol)
if not records:
return None
return None, None
current_price = float(records[-1].close)
if current_price <= 0:
return None
return None, None
try:
levels = await get_sr_levels(db, symbol)
except Exception:
return None
return None, None
if not levels:
return None
return None, None
sub_scores: list[dict] = []
# Factor 1: Number of strong levels (strength >= 50) — max 40 pts
strong_count = sum(1 for lv in levels if lv.strength >= 50)
count_score = min(40.0, strong_count * 10.0)
sub_scores.append({
"name": "Strong Count",
"score": count_score,
"weight": 40.0,
"raw_value": strong_count,
"description": f"{strong_count} strong level(s) (strength >= 50). Score: min(40, count * 10).",
})
# Factor 2: Proximity of nearest level to current price — max 30 pts
distances = [
abs(lv.price_level - current_price) / current_price for lv in levels
]
nearest_dist = min(distances) if distances else 1.0
nearest_dist_pct = round(nearest_dist * 100.0, 4)
# Closer = higher score. 0% distance = 30, 5%+ = 0
proximity_score = max(0.0, min(30.0, 30.0 * (1.0 - nearest_dist / 0.05)))
sub_scores.append({
"name": "Proximity",
"score": proximity_score,
"weight": 30.0,
"raw_value": nearest_dist_pct,
"description": f"Nearest S/R level is {nearest_dist_pct}% from price. Score: 30 * (1 - dist/5%), clamped to [0, 30].",
})
# Factor 3: Average strength — max 30 pts
avg_strength = sum(lv.strength for lv in levels) / len(levels)
strength_score = min(30.0, avg_strength * 0.3)
sub_scores.append({
"name": "Avg Strength",
"score": strength_score,
"weight": 30.0,
"raw_value": round(avg_strength, 4),
"description": f"Average level strength: {round(avg_strength, 2)}. Score: min(30, avg * 0.3).",
})
total = count_score + proximity_score + strength_score
return max(0.0, min(100.0, total))
final_score = max(0.0, min(100.0, total))
breakdown: dict = {
"sub_scores": sub_scores,
"formula": formula,
"unavailable": [],
}
return final_score, breakdown
async def _compute_sentiment_score(db: AsyncSession, symbol: str) -> float | None:
"""Compute sentiment dimension score via sentiment service."""
from app.services.sentiment_service import compute_sentiment_dimension_score
async def _compute_sentiment_score(
db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
"""Compute sentiment dimension score via sentiment service.
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
TypedDict shape: {sub_scores, formula, unavailable}.
"""
from app.services.sentiment_service import (
compute_sentiment_dimension_score,
get_sentiment_scores,
)
lookback_hours: float = 24
decay_rate: float = 0.1
try:
return await compute_sentiment_dimension_score(db, symbol)
scores = await get_sentiment_scores(db, symbol, lookback_hours)
except Exception:
return None
return None, None
if not scores:
breakdown: dict = {
"sub_scores": [],
"formula": (
f"Time-decay weighted average over {lookback_hours}h window "
f"with decay_rate={decay_rate}: "
"sum(base_score * exp(-decay_rate * hours_since)) / sum(exp(-decay_rate * hours_since))"
),
"unavailable": [
{"name": "sentiment_records", "reason": "No sentiment records in lookback window"}
],
}
return None, breakdown
try:
score = await compute_sentiment_dimension_score(db, symbol, lookback_hours, decay_rate)
except Exception:
return None, None
sub_scores: list[dict] = [
{
"name": "record_count",
"score": score if score is not None else 0.0,
"weight": 1.0,
"raw_value": len(scores),
"description": f"Number of sentiment records used in the lookback window ({lookback_hours}h).",
},
{
"name": "decay_rate",
"score": score if score is not None else 0.0,
"weight": 1.0,
"raw_value": decay_rate,
"description": "Exponential decay rate applied to older records (higher = faster decay).",
},
{
"name": "lookback_window",
"score": score if score is not None else 0.0,
"weight": 1.0,
"raw_value": lookback_hours,
"description": f"Lookback window in hours for sentiment records ({lookback_hours}h).",
},
]
formula = (
f"Time-decay weighted average over {lookback_hours}h window "
f"with decay_rate={decay_rate}: "
"sum(base_score * exp(-decay_rate * hours_since)) / sum(exp(-decay_rate * hours_since))"
)
breakdown = {
"sub_scores": sub_scores,
"formula": formula,
"unavailable": [],
}
return score, breakdown
async def _compute_fundamental_score(db: AsyncSession, symbol: str) -> float | None:
async def _compute_fundamental_score(
db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
"""Compute fundamental dimension score.
Normalized composite of P/E (lower is better), revenue growth
(higher is better), earnings surprise (higher is better).
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
TypedDict shape: {sub_scores, formula, unavailable}.
"""
from app.services.fundamental_service import get_fundamental
fund = await get_fundamental(db, symbol)
if fund is None:
return None
return None, None
weight = 1.0 / 3.0
scores: list[float] = []
sub_scores: list[dict] = []
unavailable: list[dict[str, str]] = []
formula = (
"Equal-weighted average of available sub-scores: "
"(PE_Ratio + Revenue_Growth + Earnings_Surprise) / count. "
"PE: 100 - (pe - 15) * (100/30), clamped [0,100]. "
"Revenue Growth: 50 + growth% * 2.5, clamped [0,100]. "
"Earnings Surprise: 50 + surprise% * 5.0, clamped [0,100]."
)
# P/E: lower is better. 0-15 = 100, 15-30 = 50-100, 30+ = 0-50
if fund.pe_ratio is not None and fund.pe_ratio > 0:
pe_score = max(0.0, min(100.0, 100.0 - (fund.pe_ratio - 15.0) * (100.0 / 30.0)))
scores.append(pe_score)
sub_scores.append({
"name": "PE Ratio",
"score": pe_score,
"weight": weight,
"raw_value": fund.pe_ratio,
"description": "PE ratio (lower is better). Score: 100 - (pe - 15) * (100/30), clamped [0,100].",
})
else:
unavailable.append({
"name": "PE Ratio",
"reason": "PE ratio not available or not positive",
})
# Revenue growth: higher is better. 0% = 50, 20%+ = 100, -20% = 0
if fund.revenue_growth is not None:
rg_score = max(0.0, min(100.0, 50.0 + fund.revenue_growth * 2.5))
scores.append(rg_score)
sub_scores.append({
"name": "Revenue Growth",
"score": rg_score,
"weight": weight,
"raw_value": fund.revenue_growth,
"description": "Revenue growth %. Score: 50 + growth% * 2.5, clamped [0,100].",
})
else:
unavailable.append({
"name": "Revenue Growth",
"reason": "Revenue growth data not available",
})
# Earnings surprise: higher is better. 0% = 50, 10%+ = 100, -10% = 0
if fund.earnings_surprise is not None:
es_score = max(0.0, min(100.0, 50.0 + fund.earnings_surprise * 5.0))
scores.append(es_score)
sub_scores.append({
"name": "Earnings Surprise",
"score": es_score,
"weight": weight,
"raw_value": fund.earnings_surprise,
"description": "Earnings surprise %. Score: 50 + surprise% * 5.0, clamped [0,100].",
})
else:
unavailable.append({
"name": "Earnings Surprise",
"reason": "Earnings surprise data not available",
})
breakdown: dict = {
"sub_scores": sub_scores,
"formula": formula,
"unavailable": unavailable,
}
if not scores:
return None
return None, breakdown
return sum(scores) / len(scores)
return sum(scores) / len(scores), breakdown
async def _compute_momentum_score(db: AsyncSession, symbol: str) -> float | None:
async def _compute_momentum_score(
db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
"""Compute momentum dimension score.
Rate of change of price over 5-day and 20-day lookback periods.
Returns (score, breakdown) where breakdown follows the ScoreBreakdown
TypedDict shape: {sub_scores, formula, unavailable}.
"""
from app.services.price_service import query_ohlcv
formula = "Weighted average: 0.5 * ROC_5 + 0.5 * ROC_20, re-normalized if any sub-score unavailable."
records = await query_ohlcv(db, symbol)
if not records or len(records) < 6:
return None
return None, None
closes = [float(r.close) for r in records]
latest = closes[-1]
scores: list[tuple[float, float]] = [] # (weight, score)
sub_scores: list[dict] = []
unavailable: list[dict[str, str]] = []
# 5-day ROC (weight 0.5)
if len(closes) >= 6 and closes[-6] > 0:
@@ -245,21 +470,52 @@ async def _compute_momentum_score(db: AsyncSession, symbol: str) -> float | None
# Map: -10% → 0, 0% → 50, +10% → 100
score_5 = max(0.0, min(100.0, 50.0 + roc_5 * 5.0))
scores.append((0.5, score_5))
sub_scores.append({
"name": "5-day ROC",
"score": score_5,
"weight": 0.5,
"raw_value": round(roc_5, 4),
"description": f"5-day rate of change: {round(roc_5, 2)}%. Score: 50 + ROC * 5, clamped to [0, 100].",
})
else:
unavailable.append({"name": "5-day ROC", "reason": "Need at least 6 closing prices"})
# 20-day ROC (weight 0.5)
if len(closes) >= 21 and closes[-21] > 0:
roc_20 = (latest - closes[-21]) / closes[-21] * 100.0
score_20 = max(0.0, min(100.0, 50.0 + roc_20 * 5.0))
scores.append((0.5, score_20))
sub_scores.append({
"name": "20-day ROC",
"score": score_20,
"weight": 0.5,
"raw_value": round(roc_20, 4),
"description": f"20-day rate of change: {round(roc_20, 2)}%. Score: 50 + ROC * 5, clamped to [0, 100].",
})
else:
unavailable.append({"name": "20-day ROC", "reason": "Need at least 21 closing prices"})
if not scores:
return None
breakdown: dict = {
"sub_scores": [],
"formula": formula,
"unavailable": unavailable,
}
return None, breakdown
total_weight = sum(w for w, _ in scores)
if total_weight == 0:
return None
return None, None
weighted = sum(w * s for w, s in scores) / total_weight
return max(0.0, min(100.0, weighted))
final_score = max(0.0, min(100.0, weighted))
breakdown = {
"sub_scores": sub_scores,
"formula": formula,
"unavailable": unavailable,
}
return final_score, breakdown
_DIMENSION_COMPUTERS = {
@@ -289,7 +545,13 @@ async def compute_dimension_score(
)
ticker = await _get_ticker(db, symbol)
score_val = await _DIMENSION_COMPUTERS[dimension](db, symbol)
raw_result = await _DIMENSION_COMPUTERS[dimension](db, symbol)
# Handle both tuple (score, breakdown) and plain float | None returns
if isinstance(raw_result, tuple):
score_val = raw_result[0]
else:
score_val = raw_result
now = datetime.now(timezone.utc)
@@ -406,13 +668,15 @@ async def compute_composite_score(
return composite, missing
async def get_score(
db: AsyncSession, symbol: str
) -> dict:
"""Get composite + all dimension scores for a ticker.
Recomputes stale dimensions on demand, then recomputes composite.
Returns a dict suitable for ScoreResponse.
Returns a dict suitable for ScoreResponse, including dimension breakdowns
and composite breakdown with re-normalization info.
"""
ticker = await _get_ticker(db, symbol)
weights = await _get_weights(db)
@@ -450,19 +714,64 @@ async def get_score(
)
comp = comp_result.scalar_one_or_none()
# Compute breakdowns for each dimension by calling the dimension computers
breakdowns: dict[str, dict | None] = {}
for dim in DIMENSIONS:
try:
raw_result = await _DIMENSION_COMPUTERS[dim](db, symbol)
if isinstance(raw_result, tuple) and len(raw_result) == 2:
breakdowns[dim] = raw_result[1]
else:
breakdowns[dim] = None
except Exception:
breakdowns[dim] = None
# Build dimension entries with breakdowns
dimensions = []
missing = []
available_dims: list[str] = []
for dim in DIMENSIONS:
found = next((ds for ds in dim_scores_list if ds.dimension == dim), None)
if found is not None:
if found is not None and not found.is_stale and found.score is not None:
dimensions.append({
"dimension": found.dimension,
"score": found.score,
"is_stale": found.is_stale,
"computed_at": found.computed_at,
"breakdown": breakdowns.get(dim),
})
w = weights.get(dim, 0.0)
if w > 0:
available_dims.append(dim)
else:
missing.append(dim)
# Still include stale dimensions in the list if they exist in DB
if found is not None:
dimensions.append({
"dimension": found.dimension,
"score": found.score,
"is_stale": found.is_stale,
"computed_at": found.computed_at,
"breakdown": breakdowns.get(dim),
})
# Build composite breakdown with re-normalization info
composite_breakdown = None
available_weight_sum = sum(weights.get(d, 0.0) for d in available_dims)
if available_weight_sum > 0:
renormalized_weights = {
d: weights.get(d, 0.0) / available_weight_sum for d in available_dims
}
else:
renormalized_weights = {}
composite_breakdown = {
"weights": weights,
"available_dimensions": available_dims,
"missing_dimensions": missing,
"renormalized_weights": renormalized_weights,
"formula": "Weighted average of available dimensions with re-normalized weights: sum(weight_i * score_i) / sum(weight_i)",
}
return {
"symbol": ticker.symbol,
@@ -472,9 +781,11 @@ async def get_score(
"dimensions": dimensions,
"missing_dimensions": missing,
"computed_at": comp.computed_at if comp else None,
"composite_breakdown": composite_breakdown,
}
async def get_rankings(db: AsyncSession) -> dict:
"""Get all tickers ranked by composite score descending.

View File

@@ -6,6 +6,7 @@ using a time-decay weighted average over a configurable lookback window.
from __future__ import annotations
import json
import math
from datetime import datetime, timedelta, timezone
@@ -34,6 +35,8 @@ async def store_sentiment(
confidence: int,
source: str,
timestamp: datetime | None = None,
reasoning: str = "",
citations: list[dict] | None = None,
) -> SentimentScore:
"""Store a new sentiment record for a ticker."""
ticker = await _get_ticker(db, symbol)
@@ -41,12 +44,17 @@ async def store_sentiment(
if timestamp is None:
timestamp = datetime.now(timezone.utc)
if citations is None:
citations = []
record = SentimentScore(
ticker_id=ticker.id,
classification=classification,
confidence=confidence,
source=source,
timestamp=timestamp,
reasoning=reasoning,
citations_json=json.dumps(citations),
)
db.add(record)
await db.commit()

View File

@@ -204,6 +204,121 @@ def detect_sr_levels(
return tagged
def cluster_sr_zones(
levels: list[dict],
current_price: float,
tolerance: float = 0.02,
max_zones: int | None = None,
) -> list[dict]:
"""Cluster nearby S/R levels into zones.
Returns list of zone dicts:
{
"low": float,
"high": float,
"midpoint": float,
"strength": int, # sum of constituent strengths, capped at 100
"type": "support" | "resistance",
"level_count": int,
}
"""
if not levels:
return []
if max_zones is not None and max_zones <= 0:
return []
# 1. Sort levels by price_level ascending
sorted_levels = sorted(levels, key=lambda x: x["price_level"])
# 2. Greedy merge into clusters
clusters: list[list[dict]] = []
current_cluster: list[dict] = [sorted_levels[0]]
for level in sorted_levels[1:]:
# Compute current cluster midpoint
prices = [l["price_level"] for l in current_cluster]
cluster_low = min(prices)
cluster_high = max(prices)
cluster_mid = (cluster_low + cluster_high) / 2.0
# Check if within tolerance of cluster midpoint
if cluster_mid != 0:
distance_pct = abs(level["price_level"] - cluster_mid) / cluster_mid
else:
distance_pct = abs(level["price_level"])
if distance_pct <= tolerance:
current_cluster.append(level)
else:
clusters.append(current_cluster)
current_cluster = [level]
clusters.append(current_cluster)
# 3. Compute zone for each cluster
zones: list[dict] = []
for cluster in clusters:
prices = [l["price_level"] for l in cluster]
low = min(prices)
high = max(prices)
midpoint = (low + high) / 2.0
strength = min(100, sum(l["strength"] for l in cluster))
level_count = len(cluster)
# 4. Tag zone type
zone_type = "support" if midpoint < current_price else "resistance"
zones.append({
"low": low,
"high": high,
"midpoint": midpoint,
"strength": strength,
"type": zone_type,
"level_count": level_count,
})
# 5. Split into support and resistance pools, each sorted by strength desc
support_zones = sorted(
[z for z in zones if z["type"] == "support"],
key=lambda z: z["strength"],
reverse=True,
)
resistance_zones = sorted(
[z for z in zones if z["type"] == "resistance"],
key=lambda z: z["strength"],
reverse=True,
)
# 6. Interleave pick: alternate strongest from each pool
selected: list[dict] = []
limit = max_zones if max_zones is not None else len(zones)
si, ri = 0, 0
pick_support = True # start with support pool
while len(selected) < limit and (si < len(support_zones) or ri < len(resistance_zones)):
if pick_support:
if si < len(support_zones):
selected.append(support_zones[si])
si += 1
elif ri < len(resistance_zones):
selected.append(resistance_zones[ri])
ri += 1
else:
if ri < len(resistance_zones):
selected.append(resistance_zones[ri])
ri += 1
elif si < len(support_zones):
selected.append(support_zones[si])
si += 1
pick_support = not pick_support
# 7. Sort final selection by strength descending
selected.sort(key=lambda z: z["strength"], reverse=True)
return selected
async def recalculate_sr_levels(
db: AsyncSession,