major update
This commit is contained in:
@@ -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
|
||||
|
||||
52
app/main.py
52
app/main.py
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
136
app/providers/openai_sentiment.py
Normal file
136
app/providers/openai_sentiment.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 0–1 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user