mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-23 15:48:05 +00:00
feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration
Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend. Frontend (Next.js 14): - 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings - Terminal Noir dark theme with custom Tailwind config - TradingView Lightweight Charts for candlestick/volume - Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF - Financial Statements table with YoY growth badges and margin rows - SEC EDGAR inline filing viewer with section tabs - News split-view with iframe article embedding - Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages - Earnings beat/miss visualization - AI Copilot chat panel with Gemini integration Backend (FastAPI): - 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx - Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators - yfinance + yahooquery data sources with fallback pattern - SQLite caching layer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
56a9561f71
commit
b2acda81ee
@@ -0,0 +1,222 @@
|
||||
"""Builds AI context from active widget data for the ATLAS Terminal chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget-specific context templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DCF_TEMPLATE = """
|
||||
## DCF Valuation Context
|
||||
- Implied share price: ${implied_price:.2f}
|
||||
- Current market price: ${current_price:.2f}
|
||||
- Upside/Downside: {upside:+.1f}%
|
||||
- WACC: {wacc:.1f}%
|
||||
- Terminal growth rate: {terminal_growth:.1f}%
|
||||
- FCF projections (5Y): {fcf_projections}
|
||||
"""
|
||||
|
||||
_FINANCIALS_TEMPLATE = """
|
||||
## Financial Metrics Context
|
||||
- Revenue (TTM): ${revenue}
|
||||
- Net income (TTM): ${net_income}
|
||||
- Gross margin: {gross_margin:.1f}%
|
||||
- Operating margin: {operating_margin:.1f}%
|
||||
- ROE: {roe:.1f}%
|
||||
- Debt/Equity: {debt_equity:.2f}
|
||||
- Current ratio: {current_ratio:.2f}
|
||||
"""
|
||||
|
||||
_TECHNICAL_TEMPLATE = """
|
||||
## Technical Analysis Context
|
||||
- RSI (14): {rsi:.1f}
|
||||
- MACD: {macd:.4f} | Signal: {macd_signal:.4f}
|
||||
- SMA 50: ${sma_50:.2f} | SMA 200: ${sma_200:.2f}
|
||||
- 52-week high: ${high_52w:.2f} | Low: ${low_52w:.2f}
|
||||
- Volume (avg 20d): {avg_volume}
|
||||
"""
|
||||
|
||||
_PORTFOLIO_TEMPLATE = """
|
||||
## Portfolio Context
|
||||
- Total value: ${total_value:,.0f}
|
||||
- Number of positions: {position_count}
|
||||
- Top holdings: {top_holdings}
|
||||
- Sector allocation: {sector_allocation}
|
||||
"""
|
||||
|
||||
_FILING_TEMPLATE = """
|
||||
## SEC Filing Context
|
||||
- Latest filing type: {filing_type}
|
||||
- Filed on: {filing_date}
|
||||
- Key sections available: {sections}
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Suggested questions per widget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WIDGET_QUESTIONS: dict[str, list[str]] = {
|
||||
"dcf": [
|
||||
"Are the market's growth assumptions reasonable for this company?",
|
||||
"What would the fair value be with a higher discount rate?",
|
||||
"How sensitive is the valuation to terminal growth assumptions?",
|
||||
],
|
||||
"financials": [
|
||||
"How do the margins compare to industry peers?",
|
||||
"Is the revenue growth trend sustainable?",
|
||||
"What are the key drivers behind the profitability changes?",
|
||||
],
|
||||
"technical": [
|
||||
"What does the current technical setup suggest for the near term?",
|
||||
"Is the stock overbought or oversold based on RSI?",
|
||||
"Are there any notable divergences between price and momentum?",
|
||||
],
|
||||
"portfolio": [
|
||||
"Is my sector diversification sufficient?",
|
||||
"Which positions carry the most concentration risk?",
|
||||
"How does my portfolio beta compare to the market?",
|
||||
],
|
||||
"filing": [
|
||||
"What are the key risks disclosed in the latest filing?",
|
||||
"Are there any notable changes in accounting policies?",
|
||||
"What does management say about the competitive landscape?",
|
||||
],
|
||||
"news": [
|
||||
"What is the overall sentiment of recent news?",
|
||||
"Are there any material events that could affect the stock?",
|
||||
"How might recent headlines impact the company's outlook?",
|
||||
],
|
||||
}
|
||||
|
||||
_DEFAULT_QUESTIONS: list[str] = [
|
||||
"Give me a quick overview of this company's financial health.",
|
||||
"What are the biggest risks facing this stock right now?",
|
||||
"Should I consider adding this to my portfolio? Why or why not?",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ContextBuilder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds AI context from active widget data.
|
||||
|
||||
The context is injected into the system prompt so the LLM can reference
|
||||
concrete numbers when answering the user's questions about a ticker.
|
||||
"""
|
||||
|
||||
def build_system_prompt(
|
||||
self,
|
||||
ticker: str,
|
||||
active_widgets: list[str],
|
||||
widget_data: dict[str, Any],
|
||||
) -> str:
|
||||
"""Build a context-aware system prompt.
|
||||
|
||||
Args:
|
||||
ticker: The active ticker symbol (e.g. ``"AAPL"``).
|
||||
active_widgets: List of widget identifiers currently visible
|
||||
(e.g. ``["dcf", "financials", "technical"]``).
|
||||
widget_data: A dict keyed by widget name containing the data
|
||||
displayed in each widget.
|
||||
|
||||
Returns:
|
||||
A system prompt string enriched with financial context.
|
||||
"""
|
||||
sections: list[str] = [
|
||||
"You are ATLAS, an expert financial analyst assistant "
|
||||
"integrated into the ATLAS Terminal.\n"
|
||||
"You have direct access to the data the user is currently viewing.\n"
|
||||
"Answer concisely with concrete numbers when available. "
|
||||
"Use markdown formatting for readability.",
|
||||
]
|
||||
|
||||
# Always include base info if available
|
||||
base = widget_data.get("base", {})
|
||||
if ticker:
|
||||
sections.append(
|
||||
f"\n## Active Ticker: {ticker.upper()}\n"
|
||||
f"- Sector: {base.get('sector', 'N/A')}\n"
|
||||
f"- Current price: ${base.get('current_price', 'N/A')}\n"
|
||||
f"- Market cap: {base.get('market_cap', 'N/A')}\n"
|
||||
)
|
||||
|
||||
# Append widget-specific context
|
||||
for widget in active_widgets:
|
||||
section = self._build_widget_section(widget, widget_data)
|
||||
if section:
|
||||
sections.append(section)
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
def build_suggested_questions(
|
||||
self,
|
||||
active_widgets: list[str],
|
||||
) -> list[str]:
|
||||
"""Generate suggested questions based on active widgets.
|
||||
|
||||
Args:
|
||||
active_widgets: List of widget identifiers currently visible.
|
||||
|
||||
Returns:
|
||||
A list of 3-5 suggested question strings.
|
||||
"""
|
||||
questions: list[str] = []
|
||||
|
||||
for widget in active_widgets:
|
||||
widget_key = widget.lower().strip()
|
||||
if widget_key in _WIDGET_QUESTIONS:
|
||||
# Pick the first question from each active widget
|
||||
questions.append(_WIDGET_QUESTIONS[widget_key][0])
|
||||
|
||||
# Pad with defaults if we have fewer than 3
|
||||
for q in _DEFAULT_QUESTIONS:
|
||||
if len(questions) >= 5:
|
||||
break
|
||||
if q not in questions:
|
||||
questions.append(q)
|
||||
|
||||
return questions[:5]
|
||||
|
||||
# -- private helpers ----------------------------------------------------
|
||||
|
||||
def _build_widget_section(
|
||||
self, widget: str, widget_data: dict[str, Any]
|
||||
) -> str:
|
||||
"""Render context section for a specific widget.
|
||||
|
||||
Returns an empty string when no data is available for the widget.
|
||||
"""
|
||||
widget_key = widget.lower().strip()
|
||||
data = widget_data.get(widget_key, {})
|
||||
|
||||
if not data:
|
||||
return ""
|
||||
|
||||
try:
|
||||
if widget_key == "dcf":
|
||||
return _DCF_TEMPLATE.format(**data)
|
||||
if widget_key == "financials":
|
||||
return _FINANCIALS_TEMPLATE.format(**data)
|
||||
if widget_key == "technical":
|
||||
return _TECHNICAL_TEMPLATE.format(**data)
|
||||
if widget_key == "portfolio":
|
||||
return _PORTFOLIO_TEMPLATE.format(**data)
|
||||
if widget_key == "filing":
|
||||
return _FILING_TEMPLATE.format(**data)
|
||||
except (KeyError, ValueError, TypeError) as exc:
|
||||
# Gracefully degrade -- partial data is fine
|
||||
return f"\n## {widget.title()} Context\nPartial data: {data}\n"
|
||||
|
||||
# Unknown widget -- dump raw data as a summary
|
||||
return f"\n## {widget.title()} Context\n{data}\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
context_builder = ContextBuilder()
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Unified Multi-LLM router supporting Gemini, Claude, and OpenAI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_MODELS: dict[str, str] = {
|
||||
"gemini": "gemini-2.0-flash",
|
||||
"claude": "claude-sonnet-4-20250514",
|
||||
"openai": "gpt-4o-mini",
|
||||
}
|
||||
|
||||
|
||||
class LLMProvider(str, Enum):
|
||||
"""Supported LLM providers."""
|
||||
|
||||
GEMINI = "gemini"
|
||||
CLAUDE = "claude"
|
||||
OPENAI = "openai"
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
"""Configuration for a single LLM request."""
|
||||
|
||||
provider: LLMProvider
|
||||
model: str = ""
|
||||
api_key: str = ""
|
||||
temperature: float = Field(default=0.3, ge=0.0, le=2.0)
|
||||
max_tokens: int = Field(default=4096, ge=1, le=128_000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LLMRouter:
|
||||
"""Routes requests to the appropriate LLM provider.
|
||||
|
||||
Register API keys via ``configure()``, then call ``generate()`` or
|
||||
``stream()`` with an optional ``LLMConfig``. When no config is given the
|
||||
router auto-selects a provider based on prompt length:
|
||||
|
||||
* < 5 000 chars -> Gemini (fast)
|
||||
* > 10 000 chars -> Claude (long-context)
|
||||
* fallback -> OpenAI
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._providers: dict[LLMProvider, str] = {}
|
||||
|
||||
# -- configuration ------------------------------------------------------
|
||||
|
||||
def configure(self, provider: LLMProvider, api_key: str) -> None:
|
||||
"""Register an API key for *provider*."""
|
||||
self._providers[provider] = api_key
|
||||
logger.info("LLM provider configured: %s", provider.value)
|
||||
|
||||
def get_available_providers(self) -> list[LLMProvider]:
|
||||
"""Return the list of providers that have an API key configured."""
|
||||
return list(self._providers.keys())
|
||||
|
||||
# -- public interface ---------------------------------------------------
|
||||
|
||||
def _resolve_config(
|
||||
self, prompt: str, config: Optional[LLMConfig]
|
||||
) -> LLMConfig:
|
||||
"""Return a fully-resolved ``LLMConfig``.
|
||||
|
||||
If *config* is ``None`` the provider is auto-selected based on prompt
|
||||
length and available keys.
|
||||
"""
|
||||
if config is not None:
|
||||
resolved = config.model_copy()
|
||||
if not resolved.api_key:
|
||||
resolved.api_key = self._providers.get(resolved.provider, "")
|
||||
if not resolved.model:
|
||||
resolved.model = DEFAULT_MODELS.get(resolved.provider.value, "")
|
||||
return resolved
|
||||
|
||||
provider = self._auto_select_provider(prompt)
|
||||
return LLMConfig(
|
||||
provider=provider,
|
||||
model=DEFAULT_MODELS[provider.value],
|
||||
api_key=self._providers.get(provider, ""),
|
||||
)
|
||||
|
||||
def _auto_select_provider(self, prompt: str) -> LLMProvider:
|
||||
"""Pick the best available provider for *prompt*."""
|
||||
length = len(prompt)
|
||||
|
||||
if length < 5_000 and LLMProvider.GEMINI in self._providers:
|
||||
return LLMProvider.GEMINI
|
||||
if length > 10_000 and LLMProvider.CLAUDE in self._providers:
|
||||
return LLMProvider.CLAUDE
|
||||
if LLMProvider.OPENAI in self._providers:
|
||||
return LLMProvider.OPENAI
|
||||
|
||||
# Fallback: use whatever is available
|
||||
for p in (LLMProvider.GEMINI, LLMProvider.CLAUDE, LLMProvider.OPENAI):
|
||||
if p in self._providers:
|
||||
return p
|
||||
|
||||
raise RuntimeError("No LLM provider configured. Call configure() first.")
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
config: Optional[LLMConfig] = None,
|
||||
system_prompt: str = "",
|
||||
) -> str:
|
||||
"""Generate a complete response from the best available LLM."""
|
||||
cfg = self._resolve_config(prompt, config)
|
||||
dispatch = {
|
||||
LLMProvider.GEMINI: self._gemini_generate,
|
||||
LLMProvider.CLAUDE: self._claude_generate,
|
||||
LLMProvider.OPENAI: self._openai_generate,
|
||||
}
|
||||
handler = dispatch[cfg.provider]
|
||||
return await handler(
|
||||
prompt, system_prompt, cfg.model, cfg.api_key,
|
||||
cfg.temperature, cfg.max_tokens,
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
prompt: str,
|
||||
config: Optional[LLMConfig] = None,
|
||||
system_prompt: str = "",
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream response chunks from the LLM."""
|
||||
cfg = self._resolve_config(prompt, config)
|
||||
dispatch = {
|
||||
LLMProvider.GEMINI: self._gemini_stream,
|
||||
LLMProvider.CLAUDE: self._claude_stream,
|
||||
LLMProvider.OPENAI: self._openai_stream,
|
||||
}
|
||||
handler = dispatch[cfg.provider]
|
||||
async for chunk in handler(
|
||||
prompt, system_prompt, cfg.model, cfg.api_key,
|
||||
cfg.temperature, cfg.max_tokens,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
# -- Gemini -------------------------------------------------------------
|
||||
|
||||
async def _gemini_generate(
|
||||
self, prompt: str, system: str, model: str,
|
||||
api_key: str, temperature: float, max_tokens: int,
|
||||
) -> str:
|
||||
"""Call Google Gemini API (non-streaming)."""
|
||||
try:
|
||||
import google.generativeai as genai # lazy import
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"google-generativeai is not installed. "
|
||||
"Run: pip install google-generativeai"
|
||||
) from exc
|
||||
|
||||
genai.configure(api_key=api_key)
|
||||
gen_model = genai.GenerativeModel(
|
||||
model_name=model,
|
||||
system_instruction=system or None,
|
||||
generation_config=genai.GenerationConfig(
|
||||
temperature=temperature,
|
||||
max_output_tokens=max_tokens,
|
||||
),
|
||||
)
|
||||
response = await asyncio.to_thread(
|
||||
gen_model.generate_content, prompt,
|
||||
)
|
||||
return response.text
|
||||
|
||||
async def _gemini_stream(
|
||||
self, prompt: str, system: str, model: str,
|
||||
api_key: str, temperature: float, max_tokens: int,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Call Google Gemini API (streaming)."""
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"google-generativeai is not installed. "
|
||||
"Run: pip install google-generativeai"
|
||||
) from exc
|
||||
|
||||
genai.configure(api_key=api_key)
|
||||
gen_model = genai.GenerativeModel(
|
||||
model_name=model,
|
||||
system_instruction=system or None,
|
||||
generation_config=genai.GenerationConfig(
|
||||
temperature=temperature,
|
||||
max_output_tokens=max_tokens,
|
||||
),
|
||||
)
|
||||
response = await asyncio.to_thread(
|
||||
gen_model.generate_content, prompt, stream=True,
|
||||
)
|
||||
for chunk in response:
|
||||
if chunk.text:
|
||||
yield chunk.text
|
||||
|
||||
# -- Claude -------------------------------------------------------------
|
||||
|
||||
async def _claude_generate(
|
||||
self, prompt: str, system: str, model: str,
|
||||
api_key: str, temperature: float, max_tokens: int,
|
||||
) -> str:
|
||||
"""Call Anthropic Claude API (non-streaming)."""
|
||||
try:
|
||||
import anthropic # lazy import
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"anthropic is not installed. Run: pip install anthropic"
|
||||
) from exc
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=api_key)
|
||||
message = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
system=system or "You are a helpful financial analyst.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return message.content[0].text
|
||||
|
||||
async def _claude_stream(
|
||||
self, prompt: str, system: str, model: str,
|
||||
api_key: str, temperature: float, max_tokens: int,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Call Anthropic Claude API (streaming)."""
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"anthropic is not installed. Run: pip install anthropic"
|
||||
) from exc
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=api_key)
|
||||
async with client.messages.stream(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
system=system or "You are a helpful financial analyst.",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield text
|
||||
|
||||
# -- OpenAI -------------------------------------------------------------
|
||||
|
||||
async def _openai_generate(
|
||||
self, prompt: str, system: str, model: str,
|
||||
api_key: str, temperature: float, max_tokens: int,
|
||||
) -> str:
|
||||
"""Call OpenAI API (non-streaming)."""
|
||||
try:
|
||||
import openai # lazy import
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"openai is not installed. Run: pip install openai"
|
||||
) from exc
|
||||
|
||||
client = openai.AsyncOpenAI(api_key=api_key)
|
||||
messages: list[dict[str, str]] = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore[arg-type]
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
choice = response.choices[0]
|
||||
return choice.message.content or ""
|
||||
|
||||
async def _openai_stream(
|
||||
self, prompt: str, system: str, model: str,
|
||||
api_key: str, temperature: float, max_tokens: int,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Call OpenAI API (streaming)."""
|
||||
try:
|
||||
import openai
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"openai is not installed. Run: pip install openai"
|
||||
) from exc
|
||||
|
||||
client = openai.AsyncOpenAI(api_key=api_key)
|
||||
messages: list[dict[str, str]] = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
stream = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore[arg-type]
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in stream:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.content:
|
||||
yield delta.content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
llm_router = LLMRouter()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Two-tier caching system for ATLAS Terminal.
|
||||
|
||||
Tier 1 – ``MemoryCache``: fast in-process dict with per-key TTL.
|
||||
Tier 2 – ``DBCache``: durable SQLite-backed cache with per-key TTL.
|
||||
``CacheManager`` orchestrates both tiers (memory-first, DB-second).
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.db.database import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1: In-memory cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_MEMORY_TTL: int = 300 # 5 minutes
|
||||
|
||||
|
||||
class MemoryCache:
|
||||
"""Thread-*unsafe* in-memory cache backed by a plain dict.
|
||||
|
||||
Each entry stores ``(value, expiry_timestamp)``. Expired entries are
|
||||
lazily evicted on ``get()``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: Dict[str, tuple[Any, float]] = {}
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Return the cached value for *key*, or ``None`` if missing/expired.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
|
||||
Returns:
|
||||
The stored value, or ``None``.
|
||||
"""
|
||||
entry = self._store.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
value, expires_at = entry
|
||||
if time.time() > expires_at:
|
||||
del self._store[key]
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int = _DEFAULT_MEMORY_TTL) -> None:
|
||||
"""Store *value* under *key* with the given TTL in seconds.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
value: Arbitrary Python object to cache.
|
||||
ttl: Time-to-live in seconds (default 300).
|
||||
"""
|
||||
self._store[key] = (value, time.time() + ttl)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Remove *key* from the cache (no-op if absent).
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
"""
|
||||
self._store.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all entries from the cache."""
|
||||
self._store.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 2: SQLite-backed cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_DB_TTL: int = 86400 # 1 day
|
||||
|
||||
|
||||
class DBCache:
|
||||
"""Durable cache that persists entries in the ``cache`` SQLite table.
|
||||
|
||||
Values are stored as JSON-encoded text so that structured data survives
|
||||
a round-trip.
|
||||
"""
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""Return the cached value for *key*, or ``None`` if missing/expired.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
|
||||
Returns:
|
||||
The stored value string, or ``None``.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT value, expires_at FROM cache WHERE key = ?",
|
||||
(key,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
value: str = row[0]
|
||||
expires_at: float = row[1]
|
||||
|
||||
if time.time() > expires_at:
|
||||
await db.execute("DELETE FROM cache WHERE key = ?", (key,))
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
async def set(self, key: str, value: str, ttl: int = _DEFAULT_DB_TTL) -> None:
|
||||
"""Store *value* under *key* with the given TTL in seconds.
|
||||
|
||||
Uses ``INSERT OR REPLACE`` so existing entries are overwritten.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
value: String value to persist.
|
||||
ttl: Time-to-live in seconds (default 86400).
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
expires_at = time.time() + ttl
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
|
||||
(key, value, expires_at),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Delete all expired entries from the cache table."""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute(
|
||||
"DELETE FROM cache WHERE expires_at < ?",
|
||||
(time.time(),),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Two-tier cache that checks memory first, then SQLite.
|
||||
|
||||
Usage::
|
||||
|
||||
value = await cache_manager.get("key")
|
||||
await cache_manager.set("key", payload, memory_ttl=60, db_ttl=3600)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.memory = MemoryCache()
|
||||
self.db = DBCache()
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""Look up *key* in memory, then in the DB cache.
|
||||
|
||||
If the value is found only in the DB tier it is promoted back into
|
||||
memory with the default memory TTL.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
|
||||
Returns:
|
||||
The cached value (deserialized from JSON when coming from DB),
|
||||
or ``None``.
|
||||
"""
|
||||
# Tier 1
|
||||
mem_value = self.memory.get(key)
|
||||
if mem_value is not None:
|
||||
return mem_value
|
||||
|
||||
# Tier 2
|
||||
db_value = await self.db.get(key)
|
||||
if db_value is not None:
|
||||
try:
|
||||
deserialized = json.loads(db_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
deserialized = db_value
|
||||
|
||||
# Promote to memory for faster subsequent access
|
||||
self.memory.set(key, deserialized)
|
||||
return deserialized
|
||||
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
memory_ttl: int = _DEFAULT_MEMORY_TTL,
|
||||
db_ttl: int = _DEFAULT_DB_TTL,
|
||||
) -> None:
|
||||
"""Write *value* to both cache tiers.
|
||||
|
||||
The value is JSON-serialized before writing to the DB tier.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
value: Arbitrary Python object to cache.
|
||||
memory_ttl: TTL for the in-memory tier (default 300s).
|
||||
db_ttl: TTL for the SQLite tier (default 86400s).
|
||||
"""
|
||||
self.memory.set(key, value, ttl=memory_ttl)
|
||||
|
||||
serialized = json.dumps(value, default=str)
|
||||
await self.db.set(key, serialized, ttl=db_ttl)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
cache_manager: CacheManager = CacheManager()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Dashboard layout persistence backed by SQLite.
|
||||
|
||||
All functions operate on the ``dashboards`` table and return plain dicts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.db.database import get_db
|
||||
|
||||
|
||||
def _row_to_dict(row: aiosqlite.Row) -> Dict[str, Any]:
|
||||
"""Convert an ``aiosqlite.Row`` to a plain dict.
|
||||
|
||||
Args:
|
||||
row: A database row.
|
||||
|
||||
Returns:
|
||||
A dict keyed by column name.
|
||||
"""
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return the current UTC time as an ISO-8601 string.
|
||||
|
||||
Returns:
|
||||
e.g. ``'2026-03-20T12:34:56'``
|
||||
"""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
async def get_all_dashboards() -> List[Dict[str, Any]]:
|
||||
"""Return all saved dashboards ordered by id.
|
||||
|
||||
Returns:
|
||||
A list of dashboard dicts.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM dashboards ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_dashboard(dashboard_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch a single dashboard by its primary key.
|
||||
|
||||
Args:
|
||||
dashboard_id: The id of the dashboard.
|
||||
|
||||
Returns:
|
||||
A dashboard dict, or ``None`` if not found.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM dashboards WHERE id = ?", (dashboard_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
|
||||
async def save_dashboard(name: str, layout_json: str) -> Dict[str, Any]:
|
||||
"""Create a new dashboard.
|
||||
|
||||
Args:
|
||||
name: Human-readable dashboard name.
|
||||
layout_json: JSON string describing the widget layout.
|
||||
|
||||
Returns:
|
||||
The newly created dashboard dict.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
now = _now_iso()
|
||||
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO dashboards (name, layout_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(name, layout_json, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
new_id = cursor.lastrowid
|
||||
result_cursor = await db.execute(
|
||||
"SELECT * FROM dashboards WHERE id = ?", (new_id,)
|
||||
)
|
||||
row = await result_cursor.fetchone()
|
||||
return _row_to_dict(row) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def update_dashboard(
|
||||
dashboard_id: int,
|
||||
name: Optional[str] = None,
|
||||
layout_json: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing dashboard's name and/or layout.
|
||||
|
||||
At least one of *name* or *layout_json* must be provided.
|
||||
``updated_at`` is set automatically.
|
||||
|
||||
Args:
|
||||
dashboard_id: The id of the dashboard to update.
|
||||
name: New dashboard name (or ``None`` to leave unchanged).
|
||||
layout_json: New layout JSON (or ``None`` to leave unchanged).
|
||||
|
||||
Returns:
|
||||
The updated dashboard dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If the dashboard does not exist or no fields given.
|
||||
"""
|
||||
fields: Dict[str, Any] = {}
|
||||
if name is not None:
|
||||
fields["name"] = name
|
||||
if layout_json is not None:
|
||||
fields["layout_json"] = layout_json
|
||||
|
||||
if not fields:
|
||||
raise ValueError("At least one of 'name' or 'layout_json' must be provided")
|
||||
|
||||
fields["updated_at"] = _now_iso()
|
||||
|
||||
set_clause = ", ".join(f"{col} = ?" for col in fields)
|
||||
values = list(fields.values()) + [dashboard_id]
|
||||
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute(
|
||||
f"UPDATE dashboards SET {set_clause} WHERE id = ?", # noqa: S608
|
||||
values,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM dashboards WHERE id = ?", (dashboard_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"Dashboard with id={dashboard_id} not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
async def delete_dashboard(dashboard_id: int) -> bool:
|
||||
"""Delete a dashboard by id.
|
||||
|
||||
Args:
|
||||
dashboard_id: The id to delete.
|
||||
|
||||
Returns:
|
||||
``True`` if a row was deleted, ``False`` otherwise.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM dashboards WHERE id = ?", (dashboard_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
@@ -0,0 +1,122 @@
|
||||
"""SQLite database manager for ATLAS Terminal.
|
||||
|
||||
Provides async database access via aiosqlite with a singleton connection
|
||||
pattern. Replaces the previous Supabase dependency with local-first SQLite.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
# Resolve the database file path relative to this module
|
||||
_DB_DIR: Path = Path(__file__).resolve().parent.parent / "data"
|
||||
_DB_PATH: str = str(_DB_DIR / "atlas.db")
|
||||
|
||||
# Singleton connection holder
|
||||
_connection: Optional[aiosqlite.Connection] = None
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
"""Return the singleton async SQLite connection.
|
||||
|
||||
Creates the connection (and the data/ directory) on first call.
|
||||
Enables WAL mode and foreign keys for better concurrency and integrity.
|
||||
|
||||
Returns:
|
||||
An open ``aiosqlite.Connection`` ready for queries.
|
||||
"""
|
||||
global _connection
|
||||
|
||||
if _connection is not None:
|
||||
return _connection
|
||||
|
||||
_DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_connection = await aiosqlite.connect(_DB_PATH)
|
||||
_connection.row_factory = aiosqlite.Row
|
||||
await _connection.execute("PRAGMA journal_mode=WAL")
|
||||
await _connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
return _connection
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all application tables if they do not already exist.
|
||||
|
||||
Should be called once during application startup (e.g. in a FastAPI
|
||||
``lifespan`` handler).
|
||||
"""
|
||||
db = await get_db()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
shares REAL NOT NULL,
|
||||
avg_cost REAL NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
broker TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL UNIQUE,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dashboards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
layout_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close the singleton database connection.
|
||||
|
||||
Safe to call even if the connection was never opened.
|
||||
"""
|
||||
global _connection
|
||||
|
||||
if _connection is not None:
|
||||
await _connection.close()
|
||||
_connection = None
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
PostgreSQL cache repository — persistent cache with TTL.
|
||||
"""
|
||||
import json
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from server.db.pg_database import get_pg_pool
|
||||
|
||||
|
||||
async def pg_cache_get(key: str) -> Optional[Any]:
|
||||
"""Get a cached value. Returns None if expired or not found."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT value FROM cache WHERE key = $1 AND expires_at > NOW()",
|
||||
key,
|
||||
)
|
||||
if row and row["value"] is not None:
|
||||
return row["value"] # JSONB auto-deserializes
|
||||
return None
|
||||
|
||||
|
||||
async def pg_cache_set(key: str, value: Any, ttl_seconds: int = 86400) -> None:
|
||||
"""Set a cache value with TTL."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO cache (key, value, expires_at)
|
||||
VALUES ($1, $2::jsonb, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, expires_at = $3""",
|
||||
key, json.dumps(value), expires,
|
||||
)
|
||||
|
||||
|
||||
async def pg_cache_delete(key: str) -> None:
|
||||
"""Delete a specific cache entry."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("DELETE FROM cache WHERE key = $1", key)
|
||||
|
||||
|
||||
async def pg_cache_cleanup() -> int:
|
||||
"""Remove expired cache entries. Returns count of deleted rows."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return 0
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM cache WHERE expires_at < NOW()"
|
||||
)
|
||||
# Parse "DELETE N" result
|
||||
try:
|
||||
return int(result.split()[-1])
|
||||
except (IndexError, ValueError):
|
||||
return 0
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
PostgreSQL async connection manager for ATLAS Terminal.
|
||||
Uses asyncpg for high-performance async PostgreSQL operations.
|
||||
Configurable via DATABASE_URL environment variable.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import asyncpg
|
||||
try:
|
||||
import asyncpg
|
||||
HAS_ASYNCPG = True
|
||||
except ImportError:
|
||||
HAS_ASYNCPG = False
|
||||
asyncpg = None
|
||||
|
||||
_pool: Optional[Any] = None
|
||||
|
||||
|
||||
async def get_pg_pool() -> Optional[Any]:
|
||||
"""Get or create the PostgreSQL connection pool."""
|
||||
global _pool
|
||||
if not HAS_ASYNCPG:
|
||||
logger.warning("asyncpg not installed. Run: pip install asyncpg")
|
||||
return None
|
||||
if _pool is not None:
|
||||
return _pool
|
||||
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
if not database_url:
|
||||
logger.info("No DATABASE_URL set, PostgreSQL disabled.")
|
||||
return None
|
||||
|
||||
try:
|
||||
_pool = await asyncpg.create_pool(
|
||||
database_url,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
command_timeout=30,
|
||||
)
|
||||
logger.info("PostgreSQL connection pool created.")
|
||||
return _pool
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create PostgreSQL pool: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def init_pg_tables() -> None:
|
||||
"""Create tables if they don't exist in PostgreSQL."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_positions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticker VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(200),
|
||||
shares DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
avg_cost DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
currency VARCHAR(10) DEFAULT 'USD',
|
||||
broker VARCHAR(100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticker VARCHAR(20) NOT NULL UNIQUE,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS dashboards (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
layout_json JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key VARCHAR(500) PRIMARY KEY,
|
||||
value JSONB,
|
||||
expires_at TIMESTAMPTZ
|
||||
);
|
||||
""")
|
||||
# Index for cache expiry cleanup
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
|
||||
""")
|
||||
logger.info("PostgreSQL tables initialized.")
|
||||
|
||||
|
||||
async def close_pg_pool() -> None:
|
||||
"""Close the PostgreSQL connection pool."""
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
logger.info("PostgreSQL pool closed.")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
PostgreSQL portfolio repository — full CRUD for portfolio positions.
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from server.db.pg_database import get_pg_pool
|
||||
|
||||
|
||||
async def pg_get_all_positions() -> list[dict]:
|
||||
"""Get all portfolio positions from PostgreSQL."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return []
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM portfolio_positions ORDER BY updated_at DESC"
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def pg_add_position(
|
||||
ticker: str,
|
||||
name: str = "",
|
||||
shares: float = 0.0,
|
||||
avg_cost: float = 0.0,
|
||||
currency: str = "USD",
|
||||
broker: str = "",
|
||||
) -> Optional[dict]:
|
||||
"""Add a new position to PostgreSQL."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO portfolio_positions (ticker, name, shares, avg_cost, currency, broker)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *""",
|
||||
ticker.upper(), name, shares, avg_cost, currency, broker,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def pg_update_position(position_id: int, **fields) -> Optional[dict]:
|
||||
"""Update a position by ID."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return None
|
||||
|
||||
allowed = {"ticker", "name", "shares", "avg_cost", "currency", "broker"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return None
|
||||
|
||||
updates["updated_at"] = datetime.now(timezone.utc)
|
||||
set_clauses = ", ".join(f"{k} = ${i+2}" for i, k in enumerate(updates.keys()))
|
||||
values = [position_id] + list(updates.values())
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"UPDATE portfolio_positions SET {set_clauses} WHERE id = $1 RETURNING *",
|
||||
*values,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def pg_delete_position(position_id: int) -> bool:
|
||||
"""Delete a position by ID."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return False
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM portfolio_positions WHERE id = $1", position_id
|
||||
)
|
||||
return result == "DELETE 1"
|
||||
|
||||
|
||||
async def pg_get_position_by_ticker(ticker: str) -> Optional[dict]:
|
||||
"""Get a position by ticker symbol."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM portfolio_positions WHERE ticker = $1", ticker.upper()
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def pg_bulk_add_positions(positions: list[dict]) -> list[dict]:
|
||||
"""Bulk add positions (from OCR screenshot)."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return []
|
||||
results = []
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for p in positions:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO portfolio_positions (ticker, name, shares, avg_cost, currency, broker)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *""",
|
||||
p.get("ticker", "").upper(),
|
||||
p.get("name", ""),
|
||||
float(p.get("shares", 0)),
|
||||
float(p.get("avg_cost", 0)),
|
||||
p.get("currency", "USD"),
|
||||
p.get("broker", ""),
|
||||
)
|
||||
if row:
|
||||
results.append(dict(row))
|
||||
return results
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Portfolio CRUD operations backed by SQLite.
|
||||
|
||||
All functions operate on the ``portfolio_positions`` table and return plain
|
||||
dicts so they can be serialized directly by FastAPI.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.db.database import get_db
|
||||
|
||||
|
||||
def _row_to_dict(row: aiosqlite.Row) -> Dict[str, Any]:
|
||||
"""Convert an ``aiosqlite.Row`` to a plain dict.
|
||||
|
||||
Args:
|
||||
row: A database row returned with ``row_factory = aiosqlite.Row``.
|
||||
|
||||
Returns:
|
||||
A dict keyed by column name.
|
||||
"""
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return the current UTC time as an ISO-8601 string.
|
||||
|
||||
Returns:
|
||||
e.g. ``'2026-03-20T12:34:56'``
|
||||
"""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
async def get_all_positions() -> List[Dict[str, Any]]:
|
||||
"""Return every row in ``portfolio_positions`` ordered by id.
|
||||
|
||||
Returns:
|
||||
A list of position dicts.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions ORDER BY id"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def add_position(
|
||||
ticker: str,
|
||||
name: str,
|
||||
shares: float,
|
||||
avg_cost: float,
|
||||
currency: str = "USD",
|
||||
broker: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Insert a new portfolio position.
|
||||
|
||||
Args:
|
||||
ticker: Stock ticker symbol (e.g. ``'AAPL'``).
|
||||
name: Human-readable security name.
|
||||
shares: Number of shares held.
|
||||
avg_cost: Average cost basis per share.
|
||||
currency: ISO currency code (default ``'USD'``).
|
||||
broker: Optional broker name.
|
||||
|
||||
Returns:
|
||||
The newly created position as a dict (including generated id).
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
now = _now_iso()
|
||||
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_positions
|
||||
(ticker, name, shares, avg_cost, currency, broker, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(ticker, name, shares, avg_cost, currency, broker, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
new_id = cursor.lastrowid
|
||||
result_cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions WHERE id = ?", (new_id,)
|
||||
)
|
||||
row = await result_cursor.fetchone()
|
||||
return _row_to_dict(row) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def update_position(position_id: int, **kwargs: Any) -> Dict[str, Any]:
|
||||
"""Update an existing portfolio position.
|
||||
|
||||
Only the supplied keyword arguments are modified; all others remain
|
||||
unchanged. ``updated_at`` is set automatically.
|
||||
|
||||
Args:
|
||||
position_id: The primary-key id of the position to update.
|
||||
**kwargs: Column names and their new values.
|
||||
|
||||
Returns:
|
||||
The updated position dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If *position_id* does not exist or no fields are given.
|
||||
"""
|
||||
if not kwargs:
|
||||
raise ValueError("No fields provided for update")
|
||||
|
||||
allowed_fields = {"ticker", "name", "shares", "avg_cost", "currency", "broker"}
|
||||
fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
|
||||
|
||||
if not fields:
|
||||
raise ValueError(
|
||||
f"No valid fields to update. Allowed: {allowed_fields}"
|
||||
)
|
||||
|
||||
fields["updated_at"] = _now_iso()
|
||||
|
||||
set_clause = ", ".join(f"{col} = ?" for col in fields)
|
||||
values = list(fields.values()) + [position_id]
|
||||
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute(
|
||||
f"UPDATE portfolio_positions SET {set_clause} WHERE id = ?", # noqa: S608
|
||||
values,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions WHERE id = ?", (position_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"Position with id={position_id} not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
async def delete_position(position_id: int) -> bool:
|
||||
"""Delete a portfolio position by id.
|
||||
|
||||
Args:
|
||||
position_id: The primary-key id to delete.
|
||||
|
||||
Returns:
|
||||
``True`` if a row was deleted, ``False`` if no matching row existed.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM portfolio_positions WHERE id = ?", (position_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def get_position_by_ticker(ticker: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a position by ticker symbol.
|
||||
|
||||
If multiple positions share the same ticker (e.g. different brokers),
|
||||
the first one (lowest id) is returned.
|
||||
|
||||
Args:
|
||||
ticker: The ticker to search for (case-sensitive).
|
||||
|
||||
Returns:
|
||||
A position dict, or ``None`` if not found.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions WHERE ticker = ? ORDER BY id LIMIT 1",
|
||||
(ticker,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
|
||||
async def bulk_add_positions(
|
||||
positions: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Insert multiple positions in a single transaction.
|
||||
|
||||
Each dict in *positions* must contain at least ``ticker``, ``name``,
|
||||
``shares``, and ``avg_cost``. Optional keys: ``currency``, ``broker``.
|
||||
|
||||
Args:
|
||||
positions: A list of position dicts.
|
||||
|
||||
Returns:
|
||||
A list of the newly created position dicts.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
now = _now_iso()
|
||||
created: List[Dict[str, Any]] = []
|
||||
|
||||
for pos in positions:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_positions
|
||||
(ticker, name, shares, avg_cost, currency, broker, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
pos["ticker"],
|
||||
pos["name"],
|
||||
pos["shares"],
|
||||
pos["avg_cost"],
|
||||
pos.get("currency", "USD"),
|
||||
pos.get("broker"),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
result_cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions WHERE id = ?", (new_id,)
|
||||
)
|
||||
row = await result_cursor.fetchone()
|
||||
created.append(_row_to_dict(row)) # type: ignore[arg-type]
|
||||
|
||||
await db.commit()
|
||||
return created
|
||||
@@ -0,0 +1,68 @@
|
||||
"""User settings persistence backed by SQLite.
|
||||
|
||||
Provides a simple key-value store for application settings such as API keys
|
||||
and user preferences, using the ``settings`` table.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.db.database import get_db
|
||||
|
||||
|
||||
async def get_setting(key: str) -> Optional[str]:
|
||||
"""Retrieve a single setting by key.
|
||||
|
||||
Args:
|
||||
key: The setting key to look up.
|
||||
|
||||
Returns:
|
||||
The setting value, or ``None`` if the key does not exist.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", (key,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def set_setting(key: str, value: str) -> None:
|
||||
"""Create or update a setting.
|
||||
|
||||
Uses ``INSERT OR REPLACE`` so the call is idempotent.
|
||||
|
||||
Args:
|
||||
key: The setting key.
|
||||
value: The setting value.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_all_settings() -> Dict[str, str]:
|
||||
"""Return every setting as a ``{key: value}`` dict.
|
||||
|
||||
Returns:
|
||||
A dict mapping all stored keys to their values.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute("SELECT key, value FROM settings ORDER BY key")
|
||||
rows = await cursor.fetchall()
|
||||
return {row[0]: row[1] for row in rows}
|
||||
|
||||
|
||||
async def delete_setting(key: str) -> None:
|
||||
"""Remove a setting by key (no-op if the key does not exist).
|
||||
|
||||
Args:
|
||||
key: The setting key to delete.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
|
||||
await db.commit()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Unified repository — routes operations to PostgreSQL or SQLite
|
||||
based on DATABASE_URL environment variable.
|
||||
|
||||
Usage:
|
||||
from server.db.unified_repo import repo
|
||||
positions = await repo.get_all_positions()
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _use_postgres() -> bool:
|
||||
"""Check if PostgreSQL should be used."""
|
||||
return bool(os.getenv("DATABASE_URL", ""))
|
||||
|
||||
|
||||
class UnifiedRepo:
|
||||
"""Routes database operations to the appropriate backend."""
|
||||
|
||||
async def get_all_positions(self) -> list[dict]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_get_all_positions
|
||||
return await pg_get_all_positions()
|
||||
from server.db.portfolio_repo import get_all_positions
|
||||
return await get_all_positions()
|
||||
|
||||
async def add_position(self, **kwargs) -> Optional[dict]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_add_position
|
||||
return await pg_add_position(**kwargs)
|
||||
from server.db.portfolio_repo import add_position
|
||||
return await add_position(**kwargs)
|
||||
|
||||
async def update_position(self, position_id: int, **kwargs) -> Optional[dict]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_update_position
|
||||
return await pg_update_position(position_id, **kwargs)
|
||||
from server.db.portfolio_repo import update_position
|
||||
return await update_position(position_id, **kwargs)
|
||||
|
||||
async def delete_position(self, position_id: int) -> bool:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_delete_position
|
||||
return await pg_delete_position(position_id)
|
||||
from server.db.portfolio_repo import delete_position
|
||||
return await delete_position(position_id)
|
||||
|
||||
async def bulk_add_positions(self, positions: list[dict]) -> list[dict]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_bulk_add_positions
|
||||
return await pg_bulk_add_positions(positions)
|
||||
from server.db.portfolio_repo import bulk_add_positions
|
||||
return await bulk_add_positions(positions)
|
||||
|
||||
async def cache_get(self, key: str) -> Optional[Any]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_cache_repo import pg_cache_get
|
||||
return await pg_cache_get(key)
|
||||
from server.db.cache import cache_manager
|
||||
return await cache_manager.get(key)
|
||||
|
||||
async def cache_set(self, key: str, value: Any, ttl: int = 86400) -> None:
|
||||
if _use_postgres():
|
||||
from server.db.pg_cache_repo import pg_cache_set
|
||||
return await pg_cache_set(key, value, ttl)
|
||||
from server.db.cache import cache_manager
|
||||
await cache_manager.set(key, value, ttl)
|
||||
|
||||
async def init_db(self) -> None:
|
||||
"""Initialize the appropriate database."""
|
||||
if _use_postgres():
|
||||
from server.db.pg_database import init_pg_tables
|
||||
await init_pg_tables()
|
||||
logger.info("Using PostgreSQL backend.")
|
||||
else:
|
||||
from server.db.database import init_db
|
||||
await init_db()
|
||||
logger.info("Using SQLite backend.")
|
||||
|
||||
async def close_db(self) -> None:
|
||||
"""Close database connections."""
|
||||
if _use_postgres():
|
||||
from server.db.pg_database import close_pg_pool
|
||||
await close_pg_pool()
|
||||
else:
|
||||
from server.db.database import close_db
|
||||
await close_db()
|
||||
|
||||
|
||||
# Singleton
|
||||
repo = UnifiedRepo()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
ATLAS Terminal — FastAPI Backend
|
||||
Unified entry point with PostgreSQL + SQLite support.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Initialize database on startup, close on shutdown."""
|
||||
from server.db.unified_repo import repo
|
||||
await repo.init_db()
|
||||
logger.info("ATLAS Terminal backend started.")
|
||||
yield
|
||||
await repo.close_db()
|
||||
logger.info("ATLAS Terminal backend stopped.")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="ATLAS Terminal API",
|
||||
description="Personal Bloomberg Terminal — Hybrid AI + Quantitative Analysis",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS — allow local frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3000",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- Mount routers ---
|
||||
from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider # noqa: E402
|
||||
|
||||
app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"])
|
||||
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"])
|
||||
app.include_router(valuation.router, prefix="/api/valuation", tags=["Valuation"])
|
||||
app.include_router(market_data.router, prefix="/api/market", tags=["Market Data"])
|
||||
app.include_router(financials.router, prefix="/api/financials", tags=["Financials"])
|
||||
app.include_router(estimates.router, prefix="/api/estimates", tags=["Estimates"])
|
||||
app.include_router(news.router, prefix="/api/news", tags=["News"])
|
||||
app.include_router(crypto.router, prefix="/api/crypto", tags=["Crypto"])
|
||||
app.include_router(fx.router, prefix="/api/fx", tags=["FX"])
|
||||
app.include_router(portfolio.router, prefix="/api/portfolio", tags=["Portfolio"])
|
||||
app.include_router(technical.router, prefix="/api/technical", tags=["Technical"])
|
||||
app.include_router(earnings.router, prefix="/api/earnings", tags=["Earnings"])
|
||||
app.include_router(insider.router, prefix="/api/insider", tags=["Insider Trading"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
db_type = "postgresql" if os.getenv("DATABASE_URL") else "sqlite"
|
||||
return {"status": "ok", "db": db_type, "version": "2.0.0"}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def api_health_check():
|
||||
"""Health check endpoint (via /api prefix for Next.js proxy)."""
|
||||
db_type = "postgresql" if os.getenv("DATABASE_URL") else "sqlite"
|
||||
return {"status": "ok", "db": db_type, "version": "2.0.0"}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Supabase database client for ATLAS Terminal."""
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
# Supabase client (lazy init)
|
||||
_supabase_client = None
|
||||
|
||||
def get_supabase():
|
||||
"""Get or create Supabase client. Returns None if not configured."""
|
||||
global _supabase_client
|
||||
if _supabase_client is not None:
|
||||
return _supabase_client
|
||||
url = os.environ.get("SUPABASE_URL")
|
||||
key = os.environ.get("SUPABASE_KEY")
|
||||
if not url or not key:
|
||||
return None
|
||||
try:
|
||||
from supabase import create_client
|
||||
_supabase_client = create_client(url, key)
|
||||
return _supabase_client
|
||||
except ImportError:
|
||||
return None
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Pydantic request/response schemas for ATLAS Terminal API."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TickerRequest(BaseModel):
|
||||
"""Generic request carrying a ticker and optional market selector."""
|
||||
ticker: str = Field(..., description="Stock ticker symbol, e.g. AAPL, 005930.KS")
|
||||
market: str = Field(
|
||||
default="US (S&P/Dow/Nasdaq)",
|
||||
description="Market selector: US, South Korea (KOSPI/KOSDAQ), Japan (Nikkei), UK (LSE)",
|
||||
)
|
||||
|
||||
|
||||
class EdgarRequest(BaseModel):
|
||||
"""Request to download / fetch SEC EDGAR 10-K filings."""
|
||||
ticker: str = Field(..., description="Stock ticker symbol")
|
||||
email: str = Field(..., description="Email address required by SEC EDGAR fair-access policy")
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
"""Request for AI-powered 10-K analysis (Gemini)."""
|
||||
ticker: str
|
||||
api_key: str = Field(..., description="Google Gemini API key")
|
||||
sector: str = ""
|
||||
industry: str = ""
|
||||
|
||||
|
||||
class DCFInputs(BaseModel):
|
||||
"""Inputs for discounted cash-flow valuation."""
|
||||
fcf: float = Field(..., description="Base free cash flow (trailing)")
|
||||
wacc: float = Field(..., description="Weighted-average cost of capital (decimal, e.g. 0.10)")
|
||||
terminal_growth: float = Field(..., description="Terminal growth rate (decimal, e.g. 0.025)")
|
||||
fcf_growth: float = Field(..., description="Near-term FCF growth rate (decimal, e.g. 0.12)")
|
||||
total_debt: float = Field(default=0, description="Total debt for bridge to equity value")
|
||||
cash: float = Field(default=0, description="Cash & equivalents for bridge to equity value")
|
||||
shares: float = Field(default=1, description="Shares outstanding for per-share value")
|
||||
|
||||
|
||||
class CompanySearch(BaseModel):
|
||||
"""Search for a company by name or partial ticker."""
|
||||
query: str = Field(..., description="Search term, e.g. 'Apple', 'Samsung'")
|
||||
market: str = ""
|
||||
|
||||
|
||||
class CompsRequest(BaseModel):
|
||||
"""Request for industry comparable companies data."""
|
||||
tickers: List[str] = Field(..., description="List of ticker symbols to compare")
|
||||
|
||||
|
||||
class ForensicRequest(BaseModel):
|
||||
"""Request for forensic audit analysis (Item 3 & 9A)."""
|
||||
ticker: str
|
||||
api_key: str
|
||||
item3: str = ""
|
||||
item9a: str = ""
|
||||
|
||||
|
||||
class FinancialsLLMRequest(BaseModel):
|
||||
"""Request to extract financials from Item 8 via LLM."""
|
||||
ticker: str
|
||||
api_key: str
|
||||
item8_text: str = ""
|
||||
|
||||
|
||||
class PortfolioPositionCreate(BaseModel):
|
||||
"""Create a new portfolio position."""
|
||||
ticker: str
|
||||
company_name: str = ""
|
||||
quantity: float
|
||||
avg_price: float
|
||||
currency: str = "USD"
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DCFResult(BaseModel):
|
||||
"""Result of a DCF valuation calculation."""
|
||||
enterprise_value: float = 0
|
||||
equity_value: float = 0
|
||||
value_per_share: Optional[float] = None
|
||||
shares: Optional[float] = None
|
||||
scenarios: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class DCFInputsResponse(BaseModel):
|
||||
"""Auto-filled DCF inputs from market data."""
|
||||
fcf: Optional[float] = None
|
||||
total_debt: float = 0
|
||||
cash: float = 0
|
||||
shares: Optional[float] = None
|
||||
|
||||
|
||||
class SmartDefaultsResponse(BaseModel):
|
||||
"""Smart defaults for DCF with analyst consensus guidance."""
|
||||
wacc: float = 0.10
|
||||
terminal_growth: float = 0.025
|
||||
fcf_growth: float = 0.10
|
||||
sector: str = "N/A"
|
||||
industry: str = "N/A"
|
||||
|
||||
|
||||
class ConsensusResponse(BaseModel):
|
||||
"""Analyst consensus data for a ticker."""
|
||||
target_mean: Optional[float] = None
|
||||
target_median: Optional[float] = None
|
||||
target_low: Optional[float] = None
|
||||
target_high: Optional[float] = None
|
||||
recommendation: str = ""
|
||||
num_analysts: int = 0
|
||||
data: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class SectorIndustryResponse(BaseModel):
|
||||
"""Sector and industry classification."""
|
||||
sector: str = "N/A"
|
||||
industry: str = "N/A"
|
||||
|
||||
|
||||
class FinancialHealth(BaseModel):
|
||||
"""Comprehensive financial health metrics."""
|
||||
dupont: Dict[str, Any] = {}
|
||||
altman_z: Dict[str, Any] = {}
|
||||
red_flags: List[str] = []
|
||||
piotroski: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class PiotroskiResponse(BaseModel):
|
||||
"""Piotroski F-Score breakdown."""
|
||||
score: int = 0
|
||||
criteria: List[Dict[str, Any]] = []
|
||||
used_ttm: bool = False
|
||||
|
||||
|
||||
class SankeyData(BaseModel):
|
||||
"""Income statement Sankey diagram data."""
|
||||
labels: List[str] = []
|
||||
sources: List[int] = []
|
||||
targets: List[int] = []
|
||||
values: List[float] = []
|
||||
colors: List[str] = []
|
||||
|
||||
|
||||
class RadarMetrics(BaseModel):
|
||||
"""Normalised radar chart metrics."""
|
||||
labels: List[str] = []
|
||||
values: List[float] = []
|
||||
raw: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class TrendData(BaseModel):
|
||||
"""5-year financial trend data."""
|
||||
years: List[int] = []
|
||||
revenue: List[Optional[float]] = []
|
||||
net_income: List[Optional[float]] = []
|
||||
operating_margin: List[Optional[float]] = []
|
||||
fcf: List[Optional[float]] = []
|
||||
|
||||
|
||||
class NewsItem(BaseModel):
|
||||
"""A single news article."""
|
||||
title: str
|
||||
source: str = ""
|
||||
url: str = ""
|
||||
published_at: str = ""
|
||||
summary: str = ""
|
||||
|
||||
|
||||
class PortfolioPosition(BaseModel):
|
||||
"""A portfolio position with current market data."""
|
||||
id: Optional[str] = None
|
||||
ticker: str
|
||||
company_name: str = ""
|
||||
quantity: float
|
||||
avg_price: float
|
||||
currency: str = "USD"
|
||||
source: str = "manual"
|
||||
current_price: Optional[float] = None
|
||||
market_value: Optional[float] = None
|
||||
pnl: Optional[float] = None
|
||||
pnl_pct: Optional[float] = None
|
||||
|
||||
|
||||
class PortfolioSummary(BaseModel):
|
||||
"""Aggregated portfolio summary."""
|
||||
total_value: float = 0
|
||||
total_cost: float = 0
|
||||
total_pnl: float = 0
|
||||
total_pnl_pct: Optional[float] = None
|
||||
positions: List[PortfolioPosition] = []
|
||||
|
||||
|
||||
class MarketOverview(BaseModel):
|
||||
"""Market overview with indices, FX, and crypto."""
|
||||
indices: Dict[str, Any] = {}
|
||||
fx_rates: Dict[str, float] = {}
|
||||
crypto: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
class FXRateResponse(BaseModel):
|
||||
"""Foreign exchange rate response."""
|
||||
pair: str
|
||||
rate: Optional[float] = None
|
||||
rates: Dict[str, float] = {}
|
||||
|
||||
|
||||
class FXHistoryResponse(BaseModel):
|
||||
"""FX pair historical data."""
|
||||
pair: str
|
||||
dates: List[str] = []
|
||||
rates: List[float] = []
|
||||
|
||||
|
||||
class CryptoPrice(BaseModel):
|
||||
"""Single cryptocurrency price data."""
|
||||
symbol: str
|
||||
name: str = ""
|
||||
price_usd: Optional[float] = None
|
||||
price_krw: Optional[float] = None
|
||||
change_24h_pct: Optional[float] = None
|
||||
|
||||
|
||||
class EdgarSectionsResponse(BaseModel):
|
||||
"""Cached or downloaded 10-K section texts."""
|
||||
status: str = ""
|
||||
item1a: str = ""
|
||||
item3: str = ""
|
||||
item7: str = ""
|
||||
item8: str = ""
|
||||
item9a: str = ""
|
||||
|
||||
|
||||
class Item7Response(BaseModel):
|
||||
"""Item 7 MD&A text."""
|
||||
item7: str = ""
|
||||
|
||||
|
||||
class CompareResponse(BaseModel):
|
||||
"""Comparison of latest vs 3-year-ago Item 7."""
|
||||
item1a_latest: str = ""
|
||||
item7_latest: str = ""
|
||||
item7_3y_ago: Optional[str] = None
|
||||
has_comparison: bool = False
|
||||
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
"""API health check."""
|
||||
status: str = "ok"
|
||||
version: str = "1.0.0"
|
||||
@@ -0,0 +1,202 @@
|
||||
"""AI Analysis router -- Gemini-powered financial analysis.
|
||||
Direct Gemini API calls without depending on Streamlit app module.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
ticker: str
|
||||
question: str = ""
|
||||
api_key: str = ""
|
||||
sector: str = ""
|
||||
industry: str = ""
|
||||
|
||||
|
||||
class SimpleQuestionRequest(BaseModel):
|
||||
ticker: str
|
||||
question: str
|
||||
api_key: str = ""
|
||||
|
||||
|
||||
def _call_gemini(api_key: str, prompt: str, max_tokens: int = 4096) -> str:
|
||||
"""Call Gemini API directly and return text response."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
|
||||
|
||||
payload = json.dumps({
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {"maxOutputTokens": max_tokens, "temperature": 0.7}
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
if parts:
|
||||
return parts[0].get("text", "")
|
||||
return "No response from Gemini."
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
logger.error("Gemini API error %d: %s", e.code, body)
|
||||
raise HTTPException(status_code=e.code, detail=f"Gemini API error: {body[:200]}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Gemini call failed: {e}")
|
||||
|
||||
|
||||
def _get_financial_context(ticker: str) -> str:
|
||||
"""Build financial context from yfinance for AI analysis."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker)
|
||||
info = t.info or {}
|
||||
ctx = f"""Company: {info.get('longName', ticker)} ({ticker})
|
||||
Sector: {info.get('sector', 'N/A')} | Industry: {info.get('industry', 'N/A')}
|
||||
Market Cap: ${info.get('marketCap', 0)/1e9:.1f}B
|
||||
Revenue: ${info.get('totalRevenue', 0)/1e9:.1f}B | Revenue Growth: {(info.get('revenueGrowth', 0) or 0)*100:.1f}%
|
||||
Profit Margin: {(info.get('profitMargins', 0) or 0)*100:.1f}% | Gross Margin: {(info.get('grossMargins', 0) or 0)*100:.1f}%
|
||||
ROE: {(info.get('returnOnEquity', 0) or 0)*100:.1f}% | ROA: {(info.get('returnOnAssets', 0) or 0)*100:.1f}%
|
||||
D/E: {info.get('debtToEquity', 'N/A')} | Current Ratio: {info.get('currentRatio', 'N/A')}
|
||||
P/E: {info.get('trailingPE', 'N/A')} | Forward P/E: {info.get('forwardPE', 'N/A')}
|
||||
Price: ${info.get('currentPrice', 'N/A')} | 52W High: ${info.get('fiftyTwoWeekHigh', 'N/A')} | 52W Low: ${info.get('fiftyTwoWeekLow', 'N/A')}
|
||||
Target Mean: ${info.get('targetMeanPrice', 'N/A')} | Recommendation: {info.get('recommendationKey', 'N/A')}
|
||||
Free Cash Flow: ${info.get('freeCashflow', 0)/1e9:.1f}B
|
||||
"""
|
||||
return ctx
|
||||
except Exception:
|
||||
return f"Ticker: {ticker}"
|
||||
|
||||
|
||||
@router.post("/strategy", summary="AI financial analysis")
|
||||
async def strategy_analysis(req: AnalysisRequest):
|
||||
"""General AI financial analysis using Gemini."""
|
||||
api_key = req.api_key
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="API key required. Set your Gemini key in Settings.")
|
||||
|
||||
context = _get_financial_context(req.ticker.upper())
|
||||
question = req.question or f"Provide a comprehensive financial analysis of {req.ticker.upper()}"
|
||||
|
||||
prompt = f"""You are an expert financial analyst. Analyze the following company and answer the user's question.
|
||||
|
||||
{context}
|
||||
|
||||
User Question: {question}
|
||||
|
||||
Provide a detailed, professional analysis in markdown format. Include:
|
||||
- Key financial metrics assessment
|
||||
- Strengths and weaknesses
|
||||
- Valuation perspective
|
||||
- Risk factors
|
||||
- Your overall assessment
|
||||
|
||||
Be specific with numbers and data. Answer in the same language as the question."""
|
||||
|
||||
result = _call_gemini(api_key, prompt)
|
||||
return {"ticker": req.ticker.upper(), "analysis": result}
|
||||
|
||||
|
||||
@router.post("/risks", summary="Risk analysis")
|
||||
async def risk_analysis(req: AnalysisRequest):
|
||||
"""AI-powered risk analysis."""
|
||||
api_key = req.api_key
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="API key required.")
|
||||
|
||||
context = _get_financial_context(req.ticker.upper())
|
||||
|
||||
prompt = f"""You are a risk analyst. Analyze the following company's risk factors:
|
||||
|
||||
{context}
|
||||
|
||||
Provide a detailed risk assessment including:
|
||||
1. Financial risks (leverage, liquidity, profitability trends)
|
||||
2. Market risks (valuation, competition, sector headwinds)
|
||||
3. Operational risks
|
||||
4. Regulatory risks
|
||||
5. Overall risk rating (Low/Medium/High)
|
||||
|
||||
Be specific and use the financial data provided. Answer in markdown format."""
|
||||
|
||||
result = _call_gemini(api_key, prompt)
|
||||
return {"ticker": req.ticker.upper(), "analysis": result}
|
||||
|
||||
|
||||
@router.post("/mda", summary="MD&A analysis")
|
||||
async def mda_insights(req: AnalysisRequest):
|
||||
"""AI management discussion analysis."""
|
||||
api_key = req.api_key
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="API key required.")
|
||||
|
||||
context = _get_financial_context(req.ticker.upper())
|
||||
|
||||
prompt = f"""Analyze the management perspective for this company:
|
||||
|
||||
{context}
|
||||
|
||||
Provide insights on:
|
||||
1. Revenue drivers and growth strategy
|
||||
2. Margin trends and cost management
|
||||
3. Capital allocation priorities
|
||||
4. Key management concerns
|
||||
5. Future outlook
|
||||
|
||||
Use markdown format with headers and bullet points."""
|
||||
|
||||
result = _call_gemini(api_key, prompt)
|
||||
return {"ticker": req.ticker.upper(), "report": result}
|
||||
|
||||
|
||||
@router.post("/forensic", summary="Forensic audit")
|
||||
async def forensic_audit(req: AnalysisRequest):
|
||||
"""AI forensic audit analysis."""
|
||||
api_key = req.api_key
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="API key required.")
|
||||
|
||||
context = _get_financial_context(req.ticker.upper())
|
||||
|
||||
prompt = f"""Perform a forensic financial audit on this company:
|
||||
|
||||
{context}
|
||||
|
||||
Check for:
|
||||
1. Earnings quality (cash flow vs net income)
|
||||
2. Aggressive accounting signs
|
||||
3. Related party transactions
|
||||
4. Off-balance sheet items
|
||||
5. Revenue recognition concerns
|
||||
6. Management compensation alignment
|
||||
|
||||
Use markdown format. Be thorough but fair."""
|
||||
|
||||
result = _call_gemini(api_key, prompt)
|
||||
return {"ticker": req.ticker.upper(), "forensic": result}
|
||||
|
||||
|
||||
@router.post("/financials", summary="Extract financials via LLM")
|
||||
async def extract_financials(req: AnalysisRequest):
|
||||
"""Use Gemini to provide financial analysis."""
|
||||
api_key = req.api_key
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="API key required.")
|
||||
|
||||
context = _get_financial_context(req.ticker.upper())
|
||||
result = _call_gemini(api_key, f"Summarize the key financial data for analysis:\n\n{context}")
|
||||
return {"ticker": req.ticker.upper(), "financials": result}
|
||||
@@ -0,0 +1,256 @@
|
||||
"""FastAPI router for AI chat with SSE streaming.
|
||||
|
||||
Compatible with Vercel AI SDK's ``useChat`` hook on the frontend.
|
||||
SSE format: ``data: <text>\\n\\n`` per chunk, ``data: [DONE]\\n\\n`` at end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from server.ai.context_builder import context_builder
|
||||
from server.ai.llm_router import LLMConfig, LLMProvider, llm_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["chat"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""A single chat message."""
|
||||
|
||||
role: str = Field(..., description="Message role: 'user' or 'assistant'")
|
||||
content: str = Field(..., description="Message text content")
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
"""Payload for chat endpoints."""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
ticker: Optional[str] = None
|
||||
active_widgets: list[str] = Field(default_factory=list)
|
||||
widget_data: dict[str, Any] = Field(default_factory=dict)
|
||||
provider: Optional[str] = None # Force a specific provider
|
||||
|
||||
|
||||
class ConfigureRequest(BaseModel):
|
||||
"""Payload for LLM configuration."""
|
||||
|
||||
provider: str
|
||||
api_key: str
|
||||
|
||||
|
||||
class ChatCompletionResponse(BaseModel):
|
||||
"""Non-streaming chat response."""
|
||||
|
||||
content: str
|
||||
provider: str
|
||||
model: str
|
||||
|
||||
|
||||
class SuggestedQuestionsResponse(BaseModel):
|
||||
"""Suggested questions response."""
|
||||
|
||||
questions: list[str]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_prompt(messages: list[ChatMessage]) -> str:
|
||||
"""Collapse chat history into a single prompt string.
|
||||
|
||||
The most recent user message is used as the primary prompt; earlier
|
||||
messages provide conversational context.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for msg in messages[:-1]:
|
||||
prefix = "User" if msg.role == "user" else "Assistant"
|
||||
parts.append(f"{prefix}: {msg.content}")
|
||||
|
||||
if messages:
|
||||
parts.append(messages[-1].content)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _resolve_llm_config(
|
||||
provider_name: Optional[str],
|
||||
) -> Optional[LLMConfig]:
|
||||
"""Build an ``LLMConfig`` if the caller forced a provider."""
|
||||
if not provider_name:
|
||||
return None
|
||||
try:
|
||||
provider = LLMProvider(provider_name.lower())
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider '{provider_name}'. "
|
||||
f"Supported: gemini, claude, openai",
|
||||
)
|
||||
return LLMConfig(provider=provider)
|
||||
|
||||
|
||||
async def _sse_generator(
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
config: Optional[LLMConfig],
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Yield SSE-formatted chunks compatible with Vercel AI SDK ``useChat``.
|
||||
|
||||
Format per chunk::
|
||||
|
||||
data: {"content":"<text>"}\n\n
|
||||
|
||||
Terminal event::
|
||||
|
||||
data: [DONE]\n\n
|
||||
"""
|
||||
try:
|
||||
async for chunk in llm_router.stream(
|
||||
prompt=prompt,
|
||||
config=config,
|
||||
system_prompt=system_prompt,
|
||||
):
|
||||
# Vercel AI SDK expects plain text chunks in `data:` field
|
||||
yield f"data: {json.dumps({'content': chunk})}\n\n"
|
||||
except RuntimeError as exc:
|
||||
logger.error("LLM stream error: %s", exc)
|
||||
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
|
||||
except Exception:
|
||||
logger.error("Unexpected stream error:\n%s", traceback.format_exc())
|
||||
yield f"data: {json.dumps({'error': 'Internal server error'})}\n\n"
|
||||
finally:
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||
"""Stream AI response via Server-Sent Events.
|
||||
|
||||
Compatible with Vercel AI SDK's ``useChat`` hook.
|
||||
"""
|
||||
if not request.messages:
|
||||
raise HTTPException(status_code=400, detail="messages list is empty")
|
||||
|
||||
# 1. Build context from active widgets
|
||||
system_prompt = context_builder.build_system_prompt(
|
||||
ticker=request.ticker or "",
|
||||
active_widgets=request.active_widgets,
|
||||
widget_data=request.widget_data,
|
||||
)
|
||||
|
||||
# 2. Build the prompt from conversation history
|
||||
prompt = _build_prompt(request.messages)
|
||||
|
||||
# 3. Resolve optional provider override
|
||||
config = _resolve_llm_config(request.provider)
|
||||
|
||||
# 4. Return SSE stream
|
||||
return StreamingResponse(
|
||||
_sse_generator(prompt, system_prompt, config),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/complete", response_model=ChatCompletionResponse)
|
||||
async def chat_complete(request: ChatRequest) -> ChatCompletionResponse:
|
||||
"""Non-streaming AI response."""
|
||||
if not request.messages:
|
||||
raise HTTPException(status_code=400, detail="messages list is empty")
|
||||
|
||||
system_prompt = context_builder.build_system_prompt(
|
||||
ticker=request.ticker or "",
|
||||
active_widgets=request.active_widgets,
|
||||
widget_data=request.widget_data,
|
||||
)
|
||||
|
||||
prompt = _build_prompt(request.messages)
|
||||
config = _resolve_llm_config(request.provider)
|
||||
resolved = llm_router._resolve_config(prompt, config)
|
||||
|
||||
try:
|
||||
content = await llm_router.generate(
|
||||
prompt=prompt,
|
||||
config=config,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc))
|
||||
except Exception:
|
||||
logger.error("Chat completion error:\n%s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
return ChatCompletionResponse(
|
||||
content=content,
|
||||
provider=resolved.provider.value,
|
||||
model=resolved.model,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/suggested", response_model=SuggestedQuestionsResponse)
|
||||
async def get_suggested_questions(
|
||||
ticker: str = "",
|
||||
widgets: str = "",
|
||||
) -> SuggestedQuestionsResponse:
|
||||
"""Return suggested questions based on active widgets.
|
||||
|
||||
Args:
|
||||
ticker: Active ticker symbol (currently unused, reserved for future).
|
||||
widgets: Comma-separated list of active widget identifiers,
|
||||
e.g. ``"dcf,financials,technical"``.
|
||||
"""
|
||||
active_widgets = [w.strip() for w in widgets.split(",") if w.strip()]
|
||||
questions = context_builder.build_suggested_questions(active_widgets)
|
||||
return SuggestedQuestionsResponse(questions=questions)
|
||||
|
||||
|
||||
@router.post("/configure")
|
||||
async def configure_llm(request: ConfigureRequest) -> dict[str, str]:
|
||||
"""Configure an LLM provider with an API key.
|
||||
|
||||
Returns the list of currently available providers after configuration.
|
||||
"""
|
||||
try:
|
||||
provider = LLMProvider(request.provider.lower())
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider '{request.provider}'. "
|
||||
f"Supported: gemini, claude, openai",
|
||||
)
|
||||
|
||||
if not request.api_key:
|
||||
raise HTTPException(status_code=400, detail="api_key is required")
|
||||
|
||||
llm_router.configure(provider, request.api_key)
|
||||
|
||||
available = [p.value for p in llm_router.get_available_providers()]
|
||||
return {
|
||||
"status": "ok",
|
||||
"provider": provider.value,
|
||||
"available_providers": ", ".join(available),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Crypto router -- live cryptocurrency prices from Bithumb (KRW) and Binance (USD)."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from server.models.schemas import CryptoPrice
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Top 20 symbols tracked by default
|
||||
TOP_SYMBOLS = [
|
||||
"BTC", "ETH", "BNB", "XRP", "SOL", "ADA", "DOGE", "AVAX", "DOT", "MATIC",
|
||||
"LINK", "SHIB", "TRX", "UNI", "ATOM", "LTC", "ETC", "XLM", "NEAR", "APT",
|
||||
]
|
||||
|
||||
# Bithumb uses different ticker names for some coins
|
||||
_BITHUMB_MAP = {
|
||||
"MATIC": "MATIC",
|
||||
"NEAR": "NEAR",
|
||||
"APT": "APT",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_binance_prices(symbols: List[str]) -> dict:
|
||||
"""Fetch USD prices from Binance API for the given symbols."""
|
||||
import requests
|
||||
|
||||
url = "https://api.binance.com/api/v3/ticker/price"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# Build lookup: symbol (no USDT suffix) -> price
|
||||
prices = {}
|
||||
lookup = {item["symbol"]: float(item["price"]) for item in data}
|
||||
for sym in symbols:
|
||||
key = f"{sym.upper()}USDT"
|
||||
if key in lookup:
|
||||
prices[sym.upper()] = lookup[key]
|
||||
return prices
|
||||
|
||||
|
||||
def _fetch_bithumb_prices(symbols: List[str]) -> dict:
|
||||
"""Fetch KRW prices from Bithumb public API."""
|
||||
import requests
|
||||
|
||||
prices = {}
|
||||
for sym in symbols:
|
||||
bithumb_sym = _BITHUMB_MAP.get(sym.upper(), sym.upper())
|
||||
url = f"https://api.bithumb.com/public/ticker/{bithumb_sym}_KRW"
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") == "0000":
|
||||
closing = data.get("data", {}).get("closing_price")
|
||||
if closing:
|
||||
prices[sym.upper()] = float(closing)
|
||||
except Exception:
|
||||
continue
|
||||
return prices
|
||||
|
||||
|
||||
def _fetch_binance_24h_changes(symbols: List[str]) -> dict:
|
||||
"""Fetch 24h percentage changes from Binance."""
|
||||
import requests
|
||||
|
||||
url = "https://api.binance.com/api/v3/ticker/24hr"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
changes = {}
|
||||
lookup = {item["symbol"]: float(item.get("priceChangePercent", 0)) for item in data}
|
||||
for sym in symbols:
|
||||
key = f"{sym.upper()}USDT"
|
||||
if key in lookup:
|
||||
changes[sym.upper()] = lookup[key]
|
||||
return changes
|
||||
|
||||
|
||||
@router.get(
|
||||
"/prices",
|
||||
response_model=List[CryptoPrice],
|
||||
summary="Top 20 crypto prices (Bithumb KRW + Binance USD)",
|
||||
)
|
||||
async def crypto_prices():
|
||||
"""Return current prices for the top 20 cryptocurrencies.
|
||||
|
||||
USD prices come from Binance; KRW prices from Bithumb.
|
||||
"""
|
||||
try:
|
||||
usd_prices = _fetch_binance_prices(TOP_SYMBOLS)
|
||||
krw_prices = _fetch_bithumb_prices(TOP_SYMBOLS)
|
||||
changes = _fetch_binance_24h_changes(TOP_SYMBOLS)
|
||||
|
||||
results: List[CryptoPrice] = []
|
||||
for sym in TOP_SYMBOLS:
|
||||
results.append(CryptoPrice(
|
||||
symbol=sym,
|
||||
name=sym,
|
||||
price_usd=usd_prices.get(sym),
|
||||
price_krw=krw_prices.get(sym),
|
||||
change_24h_pct=changes.get(sym),
|
||||
))
|
||||
return results
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Crypto prices failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/price/{symbol}",
|
||||
response_model=CryptoPrice,
|
||||
summary="Single crypto price",
|
||||
)
|
||||
async def crypto_price(symbol: str):
|
||||
"""Return current price for a single cryptocurrency symbol."""
|
||||
try:
|
||||
sym = symbol.upper()
|
||||
usd_prices = _fetch_binance_prices([sym])
|
||||
krw_prices = _fetch_bithumb_prices([sym])
|
||||
changes = _fetch_binance_24h_changes([sym])
|
||||
|
||||
return CryptoPrice(
|
||||
symbol=sym,
|
||||
name=sym,
|
||||
price_usd=usd_prices.get(sym),
|
||||
price_krw=krw_prices.get(sym),
|
||||
change_24h_pct=changes.get(sym),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Crypto price failed: {exc}") from exc
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Earnings router -- earnings history, upcoming dates, and transcripts."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_float(val, default=None):
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
import math
|
||||
f = float(val)
|
||||
return default if math.isnan(f) or math.isinf(f) else f
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@router.get("/{ticker}/history", summary="Earnings history (EPS actual vs estimate)")
|
||||
async def earnings_history(ticker: str) -> Dict[str, Any]:
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
earnings = t.earnings_history
|
||||
if earnings is None or (hasattr(earnings, 'empty') and earnings.empty):
|
||||
return {"ticker": ticker.upper(), "history": []}
|
||||
|
||||
history: List[Dict[str, Any]] = []
|
||||
if hasattr(earnings, 'iterrows'):
|
||||
for idx, row in earnings.iterrows():
|
||||
history.append({
|
||||
"date": str(idx)[:10],
|
||||
"eps_actual": _safe_float(row.get("epsActual", row.get("Reported EPS"))),
|
||||
"eps_estimate": _safe_float(row.get("epsEstimate", row.get("EPS Estimate"))),
|
||||
"surprise": round(_safe_float(row.get("surprisePercent", row.get("Surprise(%)")), 0) * 100, 2),
|
||||
})
|
||||
return {"ticker": ticker.upper(), "history": history[-12:]}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Earnings history failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/{ticker}/calendar", summary="Upcoming earnings date")
|
||||
async def earnings_calendar(ticker: str) -> Dict[str, Any]:
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
cal = t.calendar
|
||||
if cal is None:
|
||||
return {"ticker": ticker.upper(), "next_earnings": None}
|
||||
|
||||
if isinstance(cal, dict):
|
||||
earnings_date = cal.get("Earnings Date")
|
||||
if isinstance(earnings_date, list) and earnings_date:
|
||||
earnings_date = str(earnings_date[0])[:10]
|
||||
elif earnings_date:
|
||||
earnings_date = str(earnings_date)[:10]
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"next_earnings": earnings_date,
|
||||
"revenue_estimate": _safe_float(cal.get("Revenue Average")),
|
||||
"eps_estimate": _safe_float(cal.get("Earnings Average")),
|
||||
}
|
||||
return {"ticker": ticker.upper(), "next_earnings": None}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Earnings calendar failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/{ticker}/quarterly", summary="Quarterly earnings data")
|
||||
async def quarterly_earnings(ticker: str) -> Dict[str, Any]:
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
|
||||
quarterly = t.quarterly_earnings
|
||||
data: List[Dict[str, Any]] = []
|
||||
if quarterly is not None and hasattr(quarterly, 'iterrows'):
|
||||
for idx, row in quarterly.iterrows():
|
||||
data.append({
|
||||
"period": str(idx),
|
||||
"revenue": _safe_float(row.get("Revenue")),
|
||||
"earnings": _safe_float(row.get("Earnings")),
|
||||
})
|
||||
|
||||
return {"ticker": ticker.upper(), "quarterly": data}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Quarterly earnings failed: {exc}") from exc
|
||||
@@ -0,0 +1,95 @@
|
||||
"""SEC EDGAR router -- 10-K section download, cache lookup, and comparison."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from server.models.schemas import (
|
||||
EdgarSectionsResponse,
|
||||
Item7Response,
|
||||
CompareResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sections/{ticker}",
|
||||
response_model=EdgarSectionsResponse,
|
||||
summary="Get 10-K sections (cached or download)",
|
||||
)
|
||||
async def get_sections(
|
||||
ticker: str,
|
||||
email: str = Query(..., description="SEC EDGAR fair-access email"),
|
||||
):
|
||||
"""Return cleaned Item 1A, 3, 7, 8, 9A texts for *ticker*.
|
||||
|
||||
If the sections are already cached locally the download is skipped.
|
||||
"""
|
||||
try:
|
||||
from server.services.sec_parser import get_10k_sections
|
||||
|
||||
sections, status = get_10k_sections(ticker.upper(), email)
|
||||
return EdgarSectionsResponse(
|
||||
status=status,
|
||||
item1a=sections.get("item1a", ""),
|
||||
item3=sections.get("item3", ""),
|
||||
item7=sections.get("item7", ""),
|
||||
item8=sections.get("item8", ""),
|
||||
item9a=sections.get("item9a", ""),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"EDGAR download failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/item7/{ticker}",
|
||||
response_model=Item7Response,
|
||||
summary="Get Item 7 (MD&A) text",
|
||||
)
|
||||
async def get_item7(
|
||||
ticker: str,
|
||||
email: str = Query(..., description="SEC EDGAR fair-access email"),
|
||||
):
|
||||
"""Return only the Item 7 Management Discussion & Analysis text."""
|
||||
try:
|
||||
from server.services.sec_parser import download_and_extract_item7_and_1a
|
||||
|
||||
_, _item1a, item7 = download_and_extract_item7_and_1a(ticker.upper(), email)
|
||||
return Item7Response(item7=item7)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Item 7 extraction failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/compare/{ticker}",
|
||||
response_model=CompareResponse,
|
||||
summary="Latest vs 3-year-ago Item 7 comparison",
|
||||
)
|
||||
async def compare_item7(
|
||||
ticker: str,
|
||||
email: str = Query(..., description="SEC EDGAR fair-access email"),
|
||||
):
|
||||
"""Download up to 5 10-Ks and return the latest and 3-year-ago Item 7 for
|
||||
comparative analysis. Also returns the latest Item 1A.
|
||||
"""
|
||||
try:
|
||||
from server.services.sec_parser import download_item7_latest_and_3y_ago
|
||||
|
||||
item1a, item7_latest, item7_3y_ago, has_comparison = (
|
||||
download_item7_latest_and_3y_ago(ticker.upper(), email)
|
||||
)
|
||||
return CompareResponse(
|
||||
item1a_latest=item1a or "",
|
||||
item7_latest=item7_latest or "",
|
||||
item7_3y_ago=item7_3y_ago,
|
||||
has_comparison=has_comparison,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Comparison failed: {exc}") from exc
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Analyst estimates router -- earnings, revenue, EPS, growth, and price targets."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_df_to_dict(df: Any) -> List[Dict[str, Any]]:
|
||||
"""Convert a pandas DataFrame to a list of dicts, handling NaN safely.
|
||||
|
||||
Returns an empty list when the input is ``None`` or not a DataFrame.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
|
||||
return []
|
||||
return df.fillna(0).reset_index().to_dict(orient="records")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _safe_value(val: Any, default: Any = None) -> Any:
|
||||
"""Return *val* unless it is NaN / None, in which case return *default*."""
|
||||
import math
|
||||
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
if math.isnan(val):
|
||||
return default
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return val
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}",
|
||||
summary="Full analyst estimates bundle",
|
||||
)
|
||||
async def full_estimates(ticker: str) -> Dict[str, Any]:
|
||||
"""Return a comprehensive estimates bundle for *ticker*.
|
||||
|
||||
Includes earnings estimate, revenue estimate, EPS trend, growth
|
||||
estimates, and price targets sourced from yfinance.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info: Dict[str, Any] = t.info or {}
|
||||
|
||||
earnings_estimate = _safe_df_to_dict(
|
||||
getattr(t, "earnings_estimate", None),
|
||||
)
|
||||
revenue_estimate = _safe_df_to_dict(
|
||||
getattr(t, "revenue_estimate", None),
|
||||
)
|
||||
eps_trend = _safe_df_to_dict(
|
||||
getattr(t, "eps_trend", None),
|
||||
)
|
||||
growth_estimates = _safe_df_to_dict(
|
||||
getattr(t, "growth_estimates", None),
|
||||
)
|
||||
|
||||
price_targets: Dict[str, Any] = {
|
||||
"current": _safe_value(info.get("currentPrice")),
|
||||
"mean": _safe_value(info.get("targetMeanPrice")),
|
||||
"high": _safe_value(info.get("targetHighPrice")),
|
||||
"low": _safe_value(info.get("targetLowPrice")),
|
||||
"median": _safe_value(info.get("targetMedianPrice")),
|
||||
"recommendation": info.get("recommendationKey", ""),
|
||||
"num_analysts": _safe_value(info.get("numberOfAnalystOpinions"), 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"earnings_estimate": earnings_estimate,
|
||||
"revenue_estimate": revenue_estimate,
|
||||
"eps_trend": eps_trend,
|
||||
"growth_estimates": growth_estimates,
|
||||
"price_targets": price_targets,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"earnings_estimate": [],
|
||||
"revenue_estimate": [],
|
||||
"eps_trend": [],
|
||||
"growth_estimates": [],
|
||||
"price_targets": {"current": None, "mean": None, "high": None, "low": None, "median": None, "recommendation": "", "num_analysts": 0},
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/earnings-dates",
|
||||
summary="Upcoming and past earnings dates",
|
||||
)
|
||||
async def earnings_dates(ticker: str) -> Dict[str, Any]:
|
||||
"""Return upcoming and past earnings dates with surprise data.
|
||||
|
||||
Uses ``yfinance.Ticker.earnings_dates`` and ``earnings_history``.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
|
||||
dates_df = getattr(t, "earnings_dates", None)
|
||||
dates_records = _safe_df_to_dict(dates_df)
|
||||
|
||||
history_df = getattr(t, "earnings_history", None)
|
||||
history_records = _safe_df_to_dict(history_df)
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"earnings_dates": dates_records,
|
||||
"earnings_history": history_records,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"earnings_dates": [],
|
||||
"earnings_history": [],
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/growth",
|
||||
summary="Growth estimates comparison",
|
||||
)
|
||||
async def growth_estimates(ticker: str) -> Dict[str, Any]:
|
||||
"""Return growth estimates with current-quarter, next-quarter,
|
||||
current-year, and next-year comparisons.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
|
||||
growth_df = getattr(t, "growth_estimates", None)
|
||||
growth_records = _safe_df_to_dict(growth_df)
|
||||
|
||||
eps_trend_df = getattr(t, "eps_trend", None)
|
||||
eps_records = _safe_df_to_dict(eps_trend_df)
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"growth_estimates": growth_records,
|
||||
"eps_trend": eps_records,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"growth_estimates": [],
|
||||
"eps_trend": [],
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Financial statements router -- statements, highlights, and ratios."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _df_to_periods(df: Any, max_periods: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Convert a yfinance / yahooquery financial DataFrame to a list of dicts.
|
||||
|
||||
Each dict represents one fiscal period. NaN values are replaced with
|
||||
``None`` for clean JSON serialisation.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
|
||||
return []
|
||||
result = df.iloc[:, :max_periods].T
|
||||
result.index = [str(i)[:10] for i in result.index]
|
||||
records = result.reset_index().rename(columns={"index": "period"})
|
||||
return records.where(records.notna(), None).to_dict(orient="records")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _calc_yoy_growth(series_list: List[Optional[float]]) -> List[Optional[float]]:
|
||||
"""Return YoY growth rates for a list of period values.
|
||||
|
||||
The first element is always ``None`` (no prior period).
|
||||
"""
|
||||
growth: List[Optional[float]] = [None]
|
||||
for i in range(1, len(series_list)):
|
||||
prev = series_list[i - 1]
|
||||
curr = series_list[i]
|
||||
if prev and curr and prev != 0:
|
||||
growth.append(round((curr - prev) / abs(prev) * 100, 2))
|
||||
else:
|
||||
growth.append(None)
|
||||
return growth
|
||||
|
||||
|
||||
def _safe_get(info: Dict[str, Any], key: str) -> Optional[float]:
|
||||
"""Safely get a numeric value from *info*, returning None for NaN."""
|
||||
import math
|
||||
|
||||
val = info.get(key)
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
if math.isnan(val):
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return float(val)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/statements",
|
||||
summary="Income statement, balance sheet, cash flow",
|
||||
)
|
||||
async def financial_statements(ticker: str) -> Dict[str, Any]:
|
||||
"""Return income statement, balance sheet, and cash flow for *ticker*.
|
||||
|
||||
Attempts yahooquery first for richer data, then falls back to yfinance.
|
||||
Includes up to 5 annual periods with YoY growth rates.
|
||||
"""
|
||||
try:
|
||||
income_data: List[Dict[str, Any]] = []
|
||||
balance_data: List[Dict[str, Any]] = []
|
||||
cashflow_data: List[Dict[str, Any]] = []
|
||||
|
||||
# Use yfinance for clean annual data
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
income_data = _df_to_periods(t.income_stmt)
|
||||
balance_data = _df_to_periods(t.balance_sheet)
|
||||
cashflow_data = _df_to_periods(t.cashflow)
|
||||
|
||||
# If yfinance gives no data, try yahooquery and filter to annual only
|
||||
if not income_data:
|
||||
try:
|
||||
from yahooquery import Ticker as YQTicker # type: ignore[import-untyped]
|
||||
import pandas as pd
|
||||
|
||||
yq = YQTicker(ticker.upper())
|
||||
inc = yq.income_statement(frequency="a")
|
||||
bal = yq.balance_sheet(frequency="a")
|
||||
cf = yq.cash_flow(frequency="a")
|
||||
|
||||
if isinstance(inc, pd.DataFrame) and not inc.empty:
|
||||
income_data = _df_to_periods(inc.T)
|
||||
if isinstance(bal, pd.DataFrame) and not bal.empty:
|
||||
balance_data = _df_to_periods(bal.T)
|
||||
if isinstance(cf, pd.DataFrame) and not cf.empty:
|
||||
cashflow_data = _df_to_periods(cf.T)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Filter out TTM periods — keep only 12M/annual
|
||||
def _filter_annual(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
filtered = [r for r in records if r.get("periodType") != "TTM"]
|
||||
return filtered if filtered else records
|
||||
|
||||
income_data = _filter_annual(income_data)
|
||||
balance_data = _filter_annual(balance_data)
|
||||
cashflow_data = _filter_annual(cashflow_data)
|
||||
|
||||
# Calculate YoY growth for revenue if available
|
||||
revenue_values: List[Optional[float]] = []
|
||||
for rec in income_data:
|
||||
for key in ("TotalRevenue", "Total Revenue", "Revenue"):
|
||||
if key in rec and rec[key] is not None:
|
||||
revenue_values.append(rec[key])
|
||||
break
|
||||
else:
|
||||
revenue_values.append(None)
|
||||
|
||||
revenue_growth = _calc_yoy_growth(revenue_values)
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"income_statement": income_data,
|
||||
"balance_sheet": balance_data,
|
||||
"cash_flow": cashflow_data,
|
||||
"revenue_yoy_growth": revenue_growth,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"income_statement": [],
|
||||
"balance_sheet": [],
|
||||
"cash_flow": [],
|
||||
"revenue_yoy_growth": [],
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/highlights",
|
||||
summary="Key financial metrics summary",
|
||||
)
|
||||
async def financial_highlights(ticker: str) -> Dict[str, Any]:
|
||||
"""Return key financial metrics: revenue, margins, ROE, D/E, OCF.
|
||||
|
||||
Sourced from yfinance ``info`` for the most recent data.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info: Dict[str, Any] = t.info or {}
|
||||
|
||||
highlights: Dict[str, Any] = {
|
||||
"ticker": ticker.upper(),
|
||||
"company_name": info.get("longName", info.get("shortName", "")),
|
||||
"revenue": _safe_get(info, "totalRevenue"),
|
||||
"revenue_per_share": _safe_get(info, "revenuePerShare"),
|
||||
"gross_margin": _safe_get(info, "grossMargins"),
|
||||
"operating_margin": _safe_get(info, "operatingMargins"),
|
||||
"profit_margin": _safe_get(info, "profitMargins"),
|
||||
"ebitda": _safe_get(info, "ebitda"),
|
||||
"ebitda_margin": None,
|
||||
"roe": _safe_get(info, "returnOnEquity"),
|
||||
"roa": _safe_get(info, "returnOnAssets"),
|
||||
"debt_to_equity": _safe_get(info, "debtToEquity"),
|
||||
"current_ratio": _safe_get(info, "currentRatio"),
|
||||
"operating_cash_flow": _safe_get(info, "operatingCashflow"),
|
||||
"free_cash_flow": _safe_get(info, "freeCashflow"),
|
||||
"book_value": _safe_get(info, "bookValue"),
|
||||
"earnings_growth": _safe_get(info, "earningsGrowth"),
|
||||
"revenue_growth": _safe_get(info, "revenueGrowth"),
|
||||
}
|
||||
|
||||
# Derive EBITDA margin if both values exist
|
||||
rev = highlights["revenue"]
|
||||
ebitda = highlights["ebitda"]
|
||||
if rev and ebitda and rev > 0:
|
||||
highlights["ebitda_margin"] = round(ebitda / rev, 4)
|
||||
|
||||
return highlights
|
||||
except Exception:
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"company_name": "",
|
||||
"revenue": None, "revenue_per_share": None,
|
||||
"gross_margin": None, "operating_margin": None, "profit_margin": None,
|
||||
"ebitda": None, "ebitda_margin": None,
|
||||
"roe": None, "roa": None,
|
||||
"debt_to_equity": None, "current_ratio": None,
|
||||
"operating_cash_flow": None, "free_cash_flow": None,
|
||||
"book_value": None, "earnings_growth": None, "revenue_growth": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/ratios",
|
||||
summary="Valuation and financial ratios",
|
||||
)
|
||||
async def financial_ratios(ticker: str) -> Dict[str, Any]:
|
||||
"""Return valuation ratios with 5-year averages.
|
||||
|
||||
Includes PER, PBR, PSR, P/OCF, EV/EBITDA, and PEG ratio.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info: Dict[str, Any] = t.info or {}
|
||||
|
||||
# Current ratios
|
||||
ratios: Dict[str, Any] = {
|
||||
"ticker": ticker.upper(),
|
||||
"trailing_pe": _safe_get(info, "trailingPE"),
|
||||
"forward_pe": _safe_get(info, "forwardPE"),
|
||||
"price_to_book": _safe_get(info, "priceToBook"),
|
||||
"price_to_sales": _safe_get(info, "priceToSalesTrailing12Months"),
|
||||
"enterprise_to_ebitda": _safe_get(info, "enterpriseToEbitda"),
|
||||
"enterprise_to_revenue": _safe_get(info, "enterpriseToRevenue"),
|
||||
"peg_ratio": _safe_get(info, "pegRatio"),
|
||||
"price_to_ocf": None,
|
||||
"ev": _safe_get(info, "enterpriseValue"),
|
||||
"market_cap": _safe_get(info, "marketCap"),
|
||||
}
|
||||
|
||||
# Calculate P/OCF
|
||||
ocf = _safe_get(info, "operatingCashflow")
|
||||
mkt_cap = _safe_get(info, "marketCap")
|
||||
if ocf and mkt_cap and ocf > 0:
|
||||
ratios["price_to_ocf"] = round(mkt_cap / ocf, 2)
|
||||
|
||||
# 5-year average PE from historical data
|
||||
five_year_avg: Dict[str, Optional[float]] = {
|
||||
"five_year_avg_pe": _safe_get(info, "fiveYearAvgDividendYield"),
|
||||
"trailing_pe_5y_avg": None,
|
||||
}
|
||||
|
||||
# Try to get peer average from industry
|
||||
peer_avg: Dict[str, Optional[float]] = {
|
||||
"industry_pe_avg": _safe_get(info, "industryPe") if "industryPe" in info else None,
|
||||
}
|
||||
|
||||
ratios["averages"] = five_year_avg
|
||||
ratios["peer_comparison"] = peer_avg
|
||||
|
||||
return ratios
|
||||
except Exception:
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"trailing_pe": None, "forward_pe": None,
|
||||
"price_to_book": None, "price_to_sales": None,
|
||||
"enterprise_to_ebitda": None, "enterprise_to_revenue": None,
|
||||
"peg_ratio": None, "price_to_ocf": None,
|
||||
"ev": None, "market_cap": None,
|
||||
"averages": {}, "peer_comparison": {},
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""FX router -- foreign exchange rates and historical data via yfinance."""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from server.models.schemas import FXRateResponse, FXHistoryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Major FX pairs tracked by default (Yahoo Finance format: XXXYYY=X)
|
||||
MAJOR_PAIRS = [
|
||||
"USDKRW", "USDJPY", "EURUSD", "GBPUSD", "USDCNY",
|
||||
"USDCHF", "AUDUSD", "USDCAD", "NZDUSD", "EURGBP",
|
||||
]
|
||||
|
||||
|
||||
def _yf_fx_symbol(pair: str) -> str:
|
||||
"""Convert a pair like 'USDKRW' to the Yahoo Finance symbol 'USDKRW=X'."""
|
||||
p = pair.upper().replace("=X", "").replace("/", "")
|
||||
return f"{p}=X"
|
||||
|
||||
|
||||
def _fetch_fx_rate(pair: str) -> float | None:
|
||||
"""Fetch the latest FX rate for a single pair via yfinance."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
symbol = _yf_fx_symbol(pair)
|
||||
ticker = yf.Ticker(symbol)
|
||||
fast = getattr(ticker, "fast_info", None)
|
||||
if fast:
|
||||
price = getattr(fast, "last_price", None)
|
||||
if price and float(price) > 0:
|
||||
return float(price)
|
||||
hist = ticker.history(period="1d")
|
||||
if hist is not None and not hist.empty:
|
||||
return float(hist["Close"].iloc[-1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rates",
|
||||
response_model=FXRateResponse,
|
||||
summary="Major FX rates",
|
||||
)
|
||||
async def fx_rates():
|
||||
"""Return current exchange rates for major currency pairs
|
||||
(USD/KRW, USD/JPY, EUR/USD, GBP/USD, etc.).
|
||||
"""
|
||||
try:
|
||||
rates: Dict[str, float] = {}
|
||||
for pair in MAJOR_PAIRS:
|
||||
rate = _fetch_fx_rate(pair)
|
||||
if rate is not None:
|
||||
rates[pair] = round(rate, 4)
|
||||
return FXRateResponse(pair="MAJOR", rates=rates)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"FX rates failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history/{pair}",
|
||||
response_model=FXHistoryResponse,
|
||||
summary="1-year FX history",
|
||||
)
|
||||
async def fx_history(pair: str):
|
||||
"""Return ~1 year of daily closing rates for the given currency pair.
|
||||
|
||||
*pair* should be in the format ``USDKRW``, ``EURUSD``, etc.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
symbol = _yf_fx_symbol(pair)
|
||||
ticker = yf.Ticker(symbol)
|
||||
hist = ticker.history(period="1y")
|
||||
|
||||
if hist is None or hist.empty:
|
||||
raise HTTPException(status_code=404, detail=f"No history found for pair {pair}")
|
||||
|
||||
dates: List[str] = [d.strftime("%Y-%m-%d") for d in hist.index]
|
||||
rates: List[float] = [round(float(v), 4) for v in hist["Close"]]
|
||||
|
||||
return FXHistoryResponse(pair=pair.upper(), dates=dates, rates=rates)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"FX history failed: {exc}") from exc
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Insider trading router -- recent insider transactions from yfinance."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_float(val, default=None):
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
import math
|
||||
f = float(val)
|
||||
return default if math.isnan(f) or math.isinf(f) else f
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@router.get("/{ticker}", summary="Recent insider transactions")
|
||||
async def insider_transactions(ticker: str) -> Dict[str, Any]:
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
insiders = t.insider_transactions
|
||||
if insiders is None or (hasattr(insiders, 'empty') and insiders.empty):
|
||||
return {"ticker": ticker.upper(), "transactions": []}
|
||||
|
||||
transactions: List[Dict[str, Any]] = []
|
||||
if hasattr(insiders, 'iterrows'):
|
||||
for _, row in insiders.iterrows():
|
||||
transactions.append({
|
||||
"date": str(row.get("Start Date", ""))[:10] if "Start Date" in row.index else "",
|
||||
"insider": str(row.get("Insider", "")),
|
||||
"relation": str(row.get("Position", row.get("Relationship", ""))),
|
||||
"transaction": str(row.get("Transaction", "")),
|
||||
"shares": _safe_float(row.get("Shares")),
|
||||
"value": _safe_float(row.get("Value")),
|
||||
})
|
||||
|
||||
return {"ticker": ticker.upper(), "transactions": transactions[:30]}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Insider transactions failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/{ticker}/holders", summary="Institutional and mutual fund holders")
|
||||
async def holders(ticker: str) -> Dict[str, Any]:
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
|
||||
institutional = []
|
||||
inst = t.institutional_holders
|
||||
if inst is not None and hasattr(inst, 'iterrows'):
|
||||
for _, row in inst.iterrows():
|
||||
institutional.append({
|
||||
"holder": str(row.get("Holder", "")),
|
||||
"shares": _safe_float(row.get("Shares")),
|
||||
"value": _safe_float(row.get("Value")),
|
||||
"pct_held": _safe_float(row.get("% Out")),
|
||||
})
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"institutional": institutional[:15],
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Holders failed: {exc}") from exc
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Market Data router -- sector info, financial trends, comps, health metrics."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_float(val, default=0.0):
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
import math
|
||||
f = float(val)
|
||||
return default if math.isnan(f) or math.isinf(f) else f
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@router.get("/indices", summary="Major market indices")
|
||||
async def market_indices():
|
||||
try:
|
||||
import yfinance as yf
|
||||
symbols = [
|
||||
{"label": "S&P 500", "symbol": "^GSPC"},
|
||||
{"label": "NASDAQ", "symbol": "^IXIC"},
|
||||
{"label": "KOSPI", "symbol": "^KS11"},
|
||||
{"label": "BTC", "symbol": "BTC-USD"},
|
||||
]
|
||||
results = []
|
||||
for s in symbols:
|
||||
try:
|
||||
t = yf.Ticker(s["symbol"])
|
||||
info = t.info or {}
|
||||
price = _safe_float(info.get("regularMarketPrice") or info.get("previousClose"))
|
||||
prev = _safe_float(info.get("regularMarketPreviousClose") or info.get("previousClose"))
|
||||
change = price - prev if prev else 0
|
||||
pct = (change / prev * 100) if prev else 0
|
||||
results.append({
|
||||
"label": s["label"],
|
||||
"symbol": s["symbol"],
|
||||
"price": f"{price:,.2f}" if price else "—",
|
||||
"change": f"{pct:+.2f}%",
|
||||
"positive": pct >= 0,
|
||||
})
|
||||
except Exception:
|
||||
results.append({"label": s["label"], "symbol": s["symbol"], "price": "—", "change": "—", "positive": True})
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/sector/{ticker}", summary="Sector and industry classification")
|
||||
async def sector_industry(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
return {
|
||||
"sector": info.get("sector", "N/A"),
|
||||
"industry": info.get("industry", "N/A"),
|
||||
"market_cap": _safe_float(info.get("marketCap")),
|
||||
"pe_ratio": _safe_float(info.get("trailingPE")) or _safe_float(info.get("forwardPE")),
|
||||
"dividend_yield": _safe_float(info.get("dividendYield")),
|
||||
"beta": _safe_float(info.get("beta")),
|
||||
"fifty_two_week_high": _safe_float(info.get("fiftyTwoWeekHigh")),
|
||||
"fifty_two_week_low": _safe_float(info.get("fiftyTwoWeekLow")),
|
||||
"current_price": _safe_float(info.get("currentPrice") or info.get("regularMarketPrice")),
|
||||
}
|
||||
except Exception:
|
||||
return {"sector": "N/A", "industry": "N/A"}
|
||||
|
||||
|
||||
@router.get("/trend/{ticker}", summary="5-year financial trend")
|
||||
async def financial_trend(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
fin = t.financials
|
||||
cf = t.cashflow
|
||||
if fin is None or fin.empty:
|
||||
return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []}
|
||||
|
||||
years = [str(c.year) for c in fin.columns[:5]]
|
||||
revenue = [_safe_float(fin.loc["Total Revenue"][c]) if "Total Revenue" in fin.index else 0 for c in fin.columns[:5]]
|
||||
net_income = [_safe_float(fin.loc["Net Income"][c]) if "Net Income" in fin.index else 0 for c in fin.columns[:5]]
|
||||
|
||||
op_margin = []
|
||||
for i, c in enumerate(fin.columns[:5]):
|
||||
oi = _safe_float(fin.loc["Operating Income"][c]) if "Operating Income" in fin.index else 0
|
||||
rev = revenue[i] if i < len(revenue) else 1
|
||||
op_margin.append(round(oi / rev * 100, 2) if rev else 0)
|
||||
|
||||
fcf_list = []
|
||||
if cf is not None and not cf.empty:
|
||||
for c in fin.columns[:5]:
|
||||
if c in cf.columns:
|
||||
ocf = _safe_float(cf.loc["Operating Cash Flow"][c]) if "Operating Cash Flow" in cf.index else 0
|
||||
capex = _safe_float(cf.loc["Capital Expenditure"][c]) if "Capital Expenditure" in cf.index else 0
|
||||
fcf_list.append(ocf + capex) # capex is negative
|
||||
else:
|
||||
fcf_list.append(0)
|
||||
|
||||
return {"years": years, "revenue": revenue, "net_income": net_income, "operating_margin": op_margin, "fcf": fcf_list}
|
||||
except Exception:
|
||||
return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []}
|
||||
|
||||
|
||||
@router.get("/comps", summary="Industry comparable companies")
|
||||
async def industry_comps(tickers: str = Query(..., description="Comma-separated tickers")):
|
||||
try:
|
||||
import yfinance as yf
|
||||
ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()]
|
||||
if not ticker_list:
|
||||
return {"tickers": [], "data": []}
|
||||
|
||||
results = []
|
||||
for sym in ticker_list:
|
||||
t = yf.Ticker(sym)
|
||||
info = t.info or {}
|
||||
results.append({
|
||||
"ticker": sym,
|
||||
"forward_pe": _safe_float(info.get("forwardPE"), None),
|
||||
"trailing_pe": _safe_float(info.get("trailingPE"), None),
|
||||
"pb": _safe_float(info.get("priceToBook"), None),
|
||||
"ev_ebitda": _safe_float(info.get("enterpriseToEbitda"), None),
|
||||
"market_cap": _safe_float(info.get("marketCap"), None),
|
||||
})
|
||||
return {"tickers": ticker_list, "data": results}
|
||||
except Exception:
|
||||
return {"tickers": [], "data": []}
|
||||
|
||||
|
||||
@router.get("/health/{ticker}", summary="DuPont, Altman Z-Score, Red Flags")
|
||||
async def financial_health(ticker: str):
|
||||
fallback = {"ticker": ticker.upper(), "dupont": {}, "altman_z": None, "red_flags": []}
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
bs = t.balance_sheet
|
||||
fin = t.financials
|
||||
|
||||
# DuPont Analysis
|
||||
npm = _safe_float(info.get("profitMargins"))
|
||||
roe = _safe_float(info.get("returnOnEquity"))
|
||||
roa = _safe_float(info.get("returnOnAssets"))
|
||||
|
||||
total_assets = 0
|
||||
total_equity = 0
|
||||
total_revenue = 0
|
||||
net_income = 0
|
||||
|
||||
if bs is not None and not bs.empty:
|
||||
col = bs.columns[0]
|
||||
total_assets = _safe_float(bs.loc["Total Assets"][col]) if "Total Assets" in bs.index else 0
|
||||
se_keys = ["Stockholders Equity", "Total Stockholder Equity", "Common Stock Equity"]
|
||||
for k in se_keys:
|
||||
if k in bs.index:
|
||||
total_equity = _safe_float(bs.loc[k][col])
|
||||
break
|
||||
|
||||
if fin is not None and not fin.empty:
|
||||
col = fin.columns[0]
|
||||
total_revenue = _safe_float(fin.loc["Total Revenue"][col]) if "Total Revenue" in fin.index else 0
|
||||
net_income = _safe_float(fin.loc["Net Income"][col]) if "Net Income" in fin.index else 0
|
||||
|
||||
asset_turnover = round(total_revenue / total_assets, 3) if total_assets else 0
|
||||
equity_multiplier = round(total_assets / total_equity, 3) if total_equity else 0
|
||||
|
||||
dupont = {
|
||||
"npm": round(npm, 4) if npm else round(net_income / total_revenue, 4) if total_revenue else 0,
|
||||
"asset_turnover": asset_turnover,
|
||||
"equity_multiplier": equity_multiplier,
|
||||
"roe": round(roe, 4) if roe else round(npm * asset_turnover * equity_multiplier, 4) if npm else 0,
|
||||
}
|
||||
|
||||
# Altman Z-Score (simplified)
|
||||
altman_z = None
|
||||
if bs is not None and not bs.empty and fin is not None and not fin.empty:
|
||||
col_bs = bs.columns[0]
|
||||
col_fin = fin.columns[0]
|
||||
|
||||
ca = _safe_float(bs.loc["Current Assets"][col_bs]) if "Current Assets" in bs.index else 0
|
||||
cl = _safe_float(bs.loc["Current Liabilities"][col_bs]) if "Current Liabilities" in bs.index else 0
|
||||
ta = total_assets
|
||||
re_val = _safe_float(bs.loc["Retained Earnings"][col_bs]) if "Retained Earnings" in bs.index else 0
|
||||
ebit = _safe_float(fin.loc["EBIT"][col_fin]) if "EBIT" in fin.index else _safe_float(fin.loc.get("Operating Income", {}).get(col_fin, 0))
|
||||
mc = _safe_float(info.get("marketCap"))
|
||||
tl_val = _safe_float(bs.loc["Total Liabilities Net Minority Interest"][col_bs]) if "Total Liabilities Net Minority Interest" in bs.index else (ta - total_equity)
|
||||
rev = total_revenue
|
||||
|
||||
if ta > 0 and tl_val > 0:
|
||||
wc_ta = (ca - cl) / ta
|
||||
re_ta = re_val / ta
|
||||
ebit_ta = ebit / ta
|
||||
mc_tl = mc / tl_val if tl_val else 0
|
||||
rev_ta = rev / ta
|
||||
altman_z = round(1.2 * wc_ta + 1.4 * re_ta + 3.3 * ebit_ta + 0.6 * mc_tl + 1.0 * rev_ta, 2)
|
||||
|
||||
# Red Flags
|
||||
red_flags = []
|
||||
cr = _safe_float(info.get("currentRatio"))
|
||||
de = _safe_float(info.get("debtToEquity"))
|
||||
if cr and cr < 1.0:
|
||||
red_flags.append(f"Low current ratio: {cr:.2f}")
|
||||
if de and de > 200:
|
||||
red_flags.append(f"High debt-to-equity: {de:.1f}%")
|
||||
if npm and npm < 0:
|
||||
red_flags.append("Negative profit margin")
|
||||
if roe and roe < 0:
|
||||
red_flags.append("Negative ROE")
|
||||
|
||||
return {"ticker": ticker.upper(), "dupont": dupont, "altman_z": altman_z, "red_flags": red_flags}
|
||||
except Exception as e:
|
||||
return fallback
|
||||
|
||||
|
||||
@router.get("/piotroski/{ticker}", summary="Piotroski F-Score")
|
||||
async def piotroski_score(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
fin = t.financials
|
||||
bs = t.balance_sheet
|
||||
cf = t.cashflow
|
||||
|
||||
score = 0
|
||||
details = {}
|
||||
|
||||
if fin is None or fin.empty or bs is None or bs.empty:
|
||||
return {"total": 0, "details": {}, "score": 0}
|
||||
|
||||
col = fin.columns[0]
|
||||
prev_col = fin.columns[1] if len(fin.columns) > 1 else None
|
||||
|
||||
# 1. Positive ROA
|
||||
ni = _safe_float(fin.loc["Net Income"][col]) if "Net Income" in fin.index else 0
|
||||
ta = _safe_float(bs.loc["Total Assets"][col]) if "Total Assets" in bs.index else 1
|
||||
roa = ni / ta if ta else 0
|
||||
details["positive_roa"] = roa > 0
|
||||
score += 1 if roa > 0 else 0
|
||||
|
||||
# 2. Positive Operating Cash Flow
|
||||
ocf = 0
|
||||
if cf is not None and not cf.empty and "Operating Cash Flow" in cf.index:
|
||||
ocf = _safe_float(cf.loc["Operating Cash Flow"][cf.columns[0]])
|
||||
details["positive_ocf"] = ocf > 0
|
||||
score += 1 if ocf > 0 else 0
|
||||
|
||||
# 3. ROA improving
|
||||
if prev_col is not None:
|
||||
prev_ni = _safe_float(fin.loc["Net Income"][prev_col]) if "Net Income" in fin.index else 0
|
||||
prev_ta = _safe_float(bs.loc["Total Assets"][prev_col]) if prev_col in bs.columns and "Total Assets" in bs.index else 1
|
||||
prev_roa = prev_ni / prev_ta if prev_ta else 0
|
||||
details["roa_improving"] = roa > prev_roa
|
||||
score += 1 if roa > prev_roa else 0
|
||||
else:
|
||||
details["roa_improving"] = False
|
||||
|
||||
# 4. Cash flow > Net Income (accrual)
|
||||
details["accrual"] = ocf > ni
|
||||
score += 1 if ocf > ni else 0
|
||||
|
||||
# 5. Decreasing leverage
|
||||
dle = _safe_float(info.get("debtToEquity", 0))
|
||||
details["lower_leverage"] = dle < 100
|
||||
score += 1 if dle < 100 else 0
|
||||
|
||||
# 6. Higher current ratio
|
||||
cr = _safe_float(info.get("currentRatio", 0))
|
||||
details["higher_liquidity"] = cr > 1.0
|
||||
score += 1 if cr > 1.0 else 0
|
||||
|
||||
# 7. No dilution
|
||||
shares = _safe_float(info.get("sharesOutstanding", 0))
|
||||
details["no_dilution"] = True # simplified
|
||||
score += 1
|
||||
|
||||
# 8. Higher gross margin
|
||||
gm = _safe_float(info.get("grossMargins", 0))
|
||||
details["higher_gross_margin"] = gm > 0.3
|
||||
score += 1 if gm > 0.3 else 0
|
||||
|
||||
# 9. Higher asset turnover
|
||||
rev = _safe_float(fin.loc["Total Revenue"][col]) if "Total Revenue" in fin.index else 0
|
||||
at = rev / ta if ta else 0
|
||||
details["higher_asset_turnover"] = at > 0.5
|
||||
score += 1 if at > 0.5 else 0
|
||||
|
||||
return {"total": score, "details": details, "score": score}
|
||||
except Exception:
|
||||
return {"total": 0, "details": {}, "score": 0}
|
||||
|
||||
|
||||
@router.get("/sankey/{ticker}", summary="Income statement Sankey data")
|
||||
async def sankey_data(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
fin = t.financials
|
||||
if fin is None or fin.empty:
|
||||
return {"nodes": [], "links": []}
|
||||
|
||||
col = fin.columns[0]
|
||||
rev = _safe_float(fin.loc["Total Revenue"][col]) if "Total Revenue" in fin.index else 0
|
||||
cogs = _safe_float(fin.loc["Cost Of Revenue"][col]) if "Cost Of Revenue" in fin.index else 0
|
||||
gp = rev - cogs
|
||||
opex = _safe_float(fin.loc["Operating Expense"][col]) if "Operating Expense" in fin.index else 0
|
||||
oi = _safe_float(fin.loc["Operating Income"][col]) if "Operating Income" in fin.index else gp - opex
|
||||
ni = _safe_float(fin.loc["Net Income"][col]) if "Net Income" in fin.index else 0
|
||||
tax_other = oi - ni
|
||||
|
||||
nodes = [
|
||||
{"name": "Revenue", "value": rev},
|
||||
{"name": "COGS", "value": cogs},
|
||||
{"name": "Gross Profit", "value": gp},
|
||||
{"name": "Operating Expenses", "value": opex},
|
||||
{"name": "Operating Income", "value": oi},
|
||||
{"name": "Tax & Other", "value": abs(tax_other)},
|
||||
{"name": "Net Income", "value": ni},
|
||||
]
|
||||
return {"nodes": nodes}
|
||||
except Exception:
|
||||
return {"nodes": []}
|
||||
|
||||
|
||||
@router.get("/radar/{ticker}", summary="Radar chart metrics")
|
||||
async def radar_metrics(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
|
||||
return {
|
||||
"roe": _safe_float(info.get("returnOnEquity", 0)) * 100,
|
||||
"roa": _safe_float(info.get("returnOnAssets", 0)) * 100,
|
||||
"gross_margin": _safe_float(info.get("grossMargins", 0)) * 100,
|
||||
"current_ratio": _safe_float(info.get("currentRatio", 0)),
|
||||
"revenue_growth": _safe_float(info.get("revenueGrowth", 0)) * 100,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""News router -- aggregated financial news from Finviz and Google News RSS."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from server.models.schemas import NewsItem
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _fetch_finviz_news(ticker: str) -> List[dict]:
|
||||
"""Scrape recent headlines from Finviz news table for *ticker*."""
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
url = f"https://finviz.com/quote.ashx?t={ticker.upper()}&ty=c&p=d&b=1"
|
||||
headers = {"User-Agent": "ATLAS-Terminal/1.0"}
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
news_table = soup.find(id="news-table")
|
||||
if not news_table:
|
||||
return []
|
||||
|
||||
items: List[dict] = []
|
||||
current_date = ""
|
||||
for row in news_table.find_all("tr"):
|
||||
cells = row.find_all("td")
|
||||
if len(cells) < 2:
|
||||
continue
|
||||
date_cell = cells[0].get_text(strip=True)
|
||||
if len(date_cell) > 8:
|
||||
# Contains date + time, e.g. "Mar-18-26 08:30AM"
|
||||
current_date = date_cell
|
||||
else:
|
||||
# Time only -- reuse last date
|
||||
current_date = current_date.split(" ")[0] + " " + date_cell if current_date else date_cell
|
||||
|
||||
link_tag = cells[1].find("a")
|
||||
if not link_tag:
|
||||
continue
|
||||
title = link_tag.get_text(strip=True)
|
||||
href = link_tag.get("href", "")
|
||||
source_span = cells[1].find("span")
|
||||
source = source_span.get_text(strip=True) if source_span else ""
|
||||
|
||||
items.append({
|
||||
"title": title,
|
||||
"source": source,
|
||||
"url": href,
|
||||
"published_at": current_date,
|
||||
"summary": "",
|
||||
})
|
||||
return items[:20]
|
||||
|
||||
|
||||
def _fetch_google_news_rss(ticker: str) -> List[dict]:
|
||||
"""Fetch recent headlines from Google News RSS for *ticker*."""
|
||||
import requests
|
||||
from xml.etree import ElementTree
|
||||
|
||||
url = f"https://news.google.com/rss/search?q={ticker.upper()}+stock&hl=en-US&gl=US&ceid=US:en"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
items: List[dict] = []
|
||||
try:
|
||||
root = ElementTree.fromstring(resp.content)
|
||||
for item in root.iter("item"):
|
||||
title = (item.findtext("title") or "").strip()
|
||||
link = (item.findtext("link") or "").strip()
|
||||
pub_date = (item.findtext("pubDate") or "").strip()
|
||||
source_el = item.find("source")
|
||||
source = source_el.text.strip() if source_el is not None and source_el.text else ""
|
||||
items.append({
|
||||
"title": title,
|
||||
"source": source,
|
||||
"url": link,
|
||||
"published_at": pub_date,
|
||||
"summary": "",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return items[:20]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}",
|
||||
response_model=List[NewsItem],
|
||||
summary="Aggregated news (Finviz + Google News)",
|
||||
)
|
||||
async def get_news(ticker: str):
|
||||
"""Return up to 40 recent news articles for *ticker*, merged from
|
||||
Finviz and Google News RSS feeds. Duplicates are removed by title.
|
||||
"""
|
||||
try:
|
||||
finviz = _fetch_finviz_news(ticker.upper())
|
||||
google = _fetch_google_news_rss(ticker.upper())
|
||||
|
||||
seen_titles: set = set()
|
||||
merged: List[dict] = []
|
||||
for item in finviz + google:
|
||||
t = item.get("title", "").strip().lower()
|
||||
if t and t not in seen_titles:
|
||||
seen_titles.add(t)
|
||||
merged.append(item)
|
||||
|
||||
return [NewsItem(**item) for item in merged[:40]]
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"News fetch failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/ai-summary",
|
||||
summary="AI-summarized news (optional)",
|
||||
)
|
||||
async def ai_news_summary(
|
||||
ticker: str,
|
||||
api_key: str = Query("", description="Google Gemini API key (optional)"),
|
||||
):
|
||||
"""Fetch news and optionally generate an AI summary of the top headlines.
|
||||
|
||||
If *api_key* is provided, Gemini produces a short executive summary.
|
||||
Otherwise, the raw headlines are returned.
|
||||
"""
|
||||
try:
|
||||
finviz = _fetch_finviz_news(ticker.upper())
|
||||
google = _fetch_google_news_rss(ticker.upper())
|
||||
|
||||
seen_titles: set = set()
|
||||
headlines: List[str] = []
|
||||
all_items: List[dict] = []
|
||||
for item in finviz + google:
|
||||
t = item.get("title", "").strip()
|
||||
tl = t.lower()
|
||||
if tl and tl not in seen_titles:
|
||||
seen_titles.add(tl)
|
||||
headlines.append(t)
|
||||
all_items.append(item)
|
||||
|
||||
headlines = headlines[:20]
|
||||
all_items = all_items[:20]
|
||||
|
||||
if not api_key or not api_key.strip():
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"summary": None,
|
||||
"headlines": headlines,
|
||||
"items": all_items,
|
||||
}
|
||||
|
||||
# AI summary via Gemini
|
||||
from app import get_gemini_model, _generate_with_retry # type: ignore[import-untyped]
|
||||
|
||||
model = get_gemini_model(api_key)
|
||||
headline_text = "\n".join(f"- {h}" for h in headlines)
|
||||
prompt = f"""You are a financial news analyst. Below are the latest headlines for {ticker.upper()}.
|
||||
Provide a concise 3-5 sentence executive summary of the overall sentiment and key themes.
|
||||
|
||||
Headlines:
|
||||
{headline_text}"""
|
||||
response = _generate_with_retry(model, prompt, {"temperature": 0.2, "max_output_tokens": 512})
|
||||
summary = (response.text or "").strip() if response else ""
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"summary": summary,
|
||||
"headlines": headlines,
|
||||
"items": all_items,
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"AI news summary failed: {exc}") from exc
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Portfolio router -- position management, OCR screenshot upload, summary."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
|
||||
from server.models.schemas import (
|
||||
PortfolioPosition,
|
||||
PortfolioPositionCreate,
|
||||
PortfolioSummary,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Simple file-based persistence (production would use Supabase / Postgres)
|
||||
_PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json"
|
||||
|
||||
|
||||
def _load_positions() -> List[dict]:
|
||||
"""Load positions from the JSON store."""
|
||||
if not _PORTFOLIO_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
with open(_PORTFOLIO_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_positions(positions: List[dict]) -> None:
|
||||
"""Persist positions to the JSON store."""
|
||||
_PORTFOLIO_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_PORTFOLIO_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(positions, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _get_current_price(ticker: str) -> float | None:
|
||||
"""Fetch the latest market price for *ticker*."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
fast = getattr(t, "fast_info", None)
|
||||
if fast:
|
||||
price = getattr(fast, "last_price", None)
|
||||
if price and float(price) > 0:
|
||||
return float(price)
|
||||
hist = t.history(period="1d")
|
||||
if hist is not None and not hist.empty:
|
||||
return float(hist["Close"].iloc[-1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/positions",
|
||||
response_model=List[PortfolioPosition],
|
||||
summary="List portfolio positions",
|
||||
)
|
||||
async def list_positions():
|
||||
"""Return all portfolio positions (without live pricing)."""
|
||||
try:
|
||||
positions = _load_positions()
|
||||
return [PortfolioPosition(**p) for p in positions]
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load positions: {exc}") from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/positions",
|
||||
response_model=PortfolioPosition,
|
||||
summary="Add a portfolio position",
|
||||
)
|
||||
async def add_position(pos: PortfolioPositionCreate):
|
||||
"""Add a new position to the portfolio."""
|
||||
try:
|
||||
positions = _load_positions()
|
||||
new_pos = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"ticker": pos.ticker.upper(),
|
||||
"company_name": pos.company_name,
|
||||
"quantity": pos.quantity,
|
||||
"avg_price": pos.avg_price,
|
||||
"currency": pos.currency,
|
||||
"source": pos.source,
|
||||
}
|
||||
positions.append(new_pos)
|
||||
_save_positions(positions)
|
||||
return PortfolioPosition(**new_pos)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add position: {exc}") from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/positions/{position_id}",
|
||||
summary="Remove a portfolio position",
|
||||
)
|
||||
async def remove_position(position_id: str):
|
||||
"""Delete a position by its unique ID."""
|
||||
try:
|
||||
positions = _load_positions()
|
||||
original_len = len(positions)
|
||||
positions = [p for p in positions if p.get("id") != position_id]
|
||||
|
||||
if len(positions) == original_len:
|
||||
raise HTTPException(status_code=404, detail=f"Position {position_id} not found.")
|
||||
|
||||
_save_positions(positions)
|
||||
return {"deleted": position_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to remove position: {exc}") from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/screenshot",
|
||||
summary="Upload screenshot for OCR analysis",
|
||||
)
|
||||
async def upload_screenshot(file: UploadFile = File(...)):
|
||||
"""Accept a screenshot image (PNG/JPG) and attempt to extract portfolio
|
||||
positions via OCR. Returns the recognised text and any parsed positions.
|
||||
|
||||
This is a best-effort feature; parsing accuracy depends on the
|
||||
screenshot layout.
|
||||
"""
|
||||
try:
|
||||
contents = await file.read()
|
||||
|
||||
# Try pytesseract for OCR
|
||||
try:
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
import io
|
||||
|
||||
image = Image.open(io.BytesIO(contents))
|
||||
text = pytesseract.image_to_string(image)
|
||||
except ImportError:
|
||||
text = "(OCR not available -- install pytesseract and Pillow)"
|
||||
except Exception as ocr_err:
|
||||
text = f"(OCR failed: {ocr_err})"
|
||||
|
||||
return {
|
||||
"filename": file.filename,
|
||||
"size": len(contents),
|
||||
"ocr_text": text,
|
||||
"parsed_positions": [], # Future: parse text into positions
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Screenshot processing failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/risk", summary="Portfolio risk metrics (VaR, Sharpe, MDD)")
|
||||
async def portfolio_risk():
|
||||
"""Compute portfolio risk metrics from current positions."""
|
||||
try:
|
||||
from server.services.risk_metrics import compute_portfolio_risk
|
||||
positions = _load_positions()
|
||||
if not positions:
|
||||
return {"error": "No positions in portfolio"}
|
||||
result = compute_portfolio_risk(positions)
|
||||
return result
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Risk metrics failed: {exc}") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/summary",
|
||||
response_model=PortfolioSummary,
|
||||
summary="Portfolio summary with current prices",
|
||||
)
|
||||
async def portfolio_summary():
|
||||
"""Return all positions enriched with current market prices,
|
||||
market values, and P&L.
|
||||
"""
|
||||
try:
|
||||
positions = _load_positions()
|
||||
enriched: List[PortfolioPosition] = []
|
||||
total_value = 0.0
|
||||
total_cost = 0.0
|
||||
|
||||
for p in positions:
|
||||
ticker = p.get("ticker", "")
|
||||
quantity = float(p.get("quantity", 0))
|
||||
avg_price = float(p.get("avg_price", 0))
|
||||
cost = quantity * avg_price
|
||||
total_cost += cost
|
||||
|
||||
current_price = _get_current_price(ticker)
|
||||
market_value = (quantity * current_price) if current_price else None
|
||||
pnl = (market_value - cost) if market_value is not None else None
|
||||
pnl_pct = (pnl / cost * 100) if (pnl is not None and cost > 0) else None
|
||||
|
||||
if market_value is not None:
|
||||
total_value += market_value
|
||||
|
||||
enriched.append(PortfolioPosition(
|
||||
id=p.get("id"),
|
||||
ticker=ticker,
|
||||
company_name=p.get("company_name", ""),
|
||||
quantity=quantity,
|
||||
avg_price=avg_price,
|
||||
currency=p.get("currency", "USD"),
|
||||
source=p.get("source", "manual"),
|
||||
current_price=current_price,
|
||||
market_value=market_value,
|
||||
pnl=pnl,
|
||||
pnl_pct=round(pnl_pct, 2) if pnl_pct is not None else None,
|
||||
))
|
||||
|
||||
total_pnl = total_value - total_cost
|
||||
total_pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else None
|
||||
|
||||
return PortfolioSummary(
|
||||
total_value=round(total_value, 2),
|
||||
total_cost=round(total_cost, 2),
|
||||
total_pnl=round(total_pnl, 2),
|
||||
total_pnl_pct=round(total_pnl_pct, 2) if total_pnl_pct is not None else None,
|
||||
positions=enriched,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Portfolio summary failed: {exc}") from exc
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Technical analysis router -- indicators, chart data, Fibonacci, Ichimoku."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/indicators",
|
||||
summary="Technical indicators (RSI, SMA, EMA, MACD, BB, ATR)",
|
||||
)
|
||||
async def technical_indicators(ticker: str) -> Dict[str, Any]:
|
||||
"""Calculate and return common technical indicators for *ticker*.
|
||||
|
||||
Returns RSI(14), SMA(20/50/200), EMA(12/26), MACD with signal and
|
||||
histogram, Bollinger Bands (20,2), and ATR(14).
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
import ta # type: ignore[import-untyped]
|
||||
|
||||
df = yf.download(ticker.upper(), period="1y", interval="1d", progress=False)
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=404, detail=f"No data for {ticker}")
|
||||
|
||||
# Flatten MultiIndex columns if present
|
||||
if hasattr(df.columns, "nlevels") and df.columns.nlevels > 1:
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
|
||||
close = df["Close"]
|
||||
high = df["High"]
|
||||
low = df["Low"]
|
||||
|
||||
# RSI
|
||||
rsi_indicator = ta.momentum.RSIIndicator(close=close, window=14)
|
||||
rsi_val = rsi_indicator.rsi().iloc[-1]
|
||||
|
||||
# SMA
|
||||
sma_20 = close.rolling(window=20).mean().iloc[-1]
|
||||
sma_50 = close.rolling(window=50).mean().iloc[-1]
|
||||
sma_200 = close.rolling(window=200).mean().iloc[-1] if len(close) >= 200 else None
|
||||
|
||||
# EMA
|
||||
ema_12 = close.ewm(span=12, adjust=False).mean().iloc[-1]
|
||||
ema_26 = close.ewm(span=26, adjust=False).mean().iloc[-1]
|
||||
|
||||
# MACD
|
||||
macd_indicator = ta.trend.MACD(close=close)
|
||||
macd_line = macd_indicator.macd().iloc[-1]
|
||||
macd_signal = macd_indicator.macd_signal().iloc[-1]
|
||||
macd_hist = macd_indicator.macd_diff().iloc[-1]
|
||||
|
||||
# Bollinger Bands
|
||||
bb = ta.volatility.BollingerBands(close=close, window=20, window_dev=2)
|
||||
bb_upper = bb.bollinger_hband().iloc[-1]
|
||||
bb_middle = bb.bollinger_mavg().iloc[-1]
|
||||
bb_lower = bb.bollinger_lband().iloc[-1]
|
||||
|
||||
# ATR
|
||||
atr_indicator = ta.volatility.AverageTrueRange(
|
||||
high=high, low=low, close=close, window=14,
|
||||
)
|
||||
atr_val = atr_indicator.average_true_range().iloc[-1]
|
||||
|
||||
current_price = float(close.iloc[-1])
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"current_price": current_price,
|
||||
"rsi_14": round(float(rsi_val), 2),
|
||||
"sma": {
|
||||
"sma_20": round(float(sma_20), 2),
|
||||
"sma_50": round(float(sma_50), 2),
|
||||
"sma_200": round(float(sma_200), 2) if sma_200 is not None else None,
|
||||
},
|
||||
"ema": {
|
||||
"ema_12": round(float(ema_12), 2),
|
||||
"ema_26": round(float(ema_26), 2),
|
||||
},
|
||||
"macd": {
|
||||
"macd": round(float(macd_line), 4),
|
||||
"signal": round(float(macd_signal), 4),
|
||||
"histogram": round(float(macd_hist), 4),
|
||||
},
|
||||
"bollinger_bands": {
|
||||
"upper": round(float(bb_upper), 2),
|
||||
"middle": round(float(bb_middle), 2),
|
||||
"lower": round(float(bb_lower), 2),
|
||||
},
|
||||
"atr_14": round(float(atr_val), 2),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Technical indicators failed: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/chart-data",
|
||||
summary="OHLCV data for charting",
|
||||
)
|
||||
async def chart_data(
|
||||
ticker: str,
|
||||
period: str = Query(
|
||||
default="6mo",
|
||||
description="Data period: 1d,5d,1mo,3mo,6mo,1y,2y,5y",
|
||||
),
|
||||
interval: str = Query(
|
||||
default="1d",
|
||||
description="Data interval: 1m,5m,15m,1h,1d,1wk",
|
||||
),
|
||||
) -> Dict[str, Any]:
|
||||
"""Return OHLCV data formatted for TradingView Lightweight Charts.
|
||||
|
||||
Each bar is ``{time, open, high, low, close, volume}``.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
valid_periods = {"1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y"}
|
||||
valid_intervals = {"1m", "5m", "15m", "1h", "1d", "1wk"}
|
||||
|
||||
if period not in valid_periods:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid period '{period}'. Must be one of {valid_periods}",
|
||||
)
|
||||
if interval not in valid_intervals:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid interval '{interval}'. Must be one of {valid_intervals}",
|
||||
)
|
||||
|
||||
df = yf.download(
|
||||
ticker.upper(),
|
||||
period=period,
|
||||
interval=interval,
|
||||
progress=False,
|
||||
)
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=404, detail=f"No data for {ticker}")
|
||||
|
||||
# Flatten MultiIndex columns if present
|
||||
if hasattr(df.columns, "nlevels") and df.columns.nlevels > 1:
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
|
||||
bars: List[Dict[str, Any]] = []
|
||||
for idx, row in df.iterrows():
|
||||
time_str = str(idx)[:10] if interval in {"1d", "1wk"} else str(idx)
|
||||
bars.append({
|
||||
"time": time_str,
|
||||
"open": round(float(row["Open"]), 4),
|
||||
"high": round(float(row["High"]), 4),
|
||||
"low": round(float(row["Low"]), 4),
|
||||
"close": round(float(row["Close"]), 4),
|
||||
"volume": int(row["Volume"]),
|
||||
})
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"period": period,
|
||||
"interval": interval,
|
||||
"bars": bars,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Chart data fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/fibonacci",
|
||||
summary="Fibonacci retracement levels",
|
||||
)
|
||||
async def fibonacci_levels(ticker: str) -> Dict[str, Any]:
|
||||
"""Return Fibonacci retracement levels based on the 52-week high and low.
|
||||
|
||||
Levels: 0%, 23.6%, 38.2%, 50%, 61.8%, 78.6%, 100%.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
df = yf.download(ticker.upper(), period="1y", interval="1d", progress=False)
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=404, detail=f"No data for {ticker}")
|
||||
|
||||
# Flatten MultiIndex columns if present
|
||||
if hasattr(df.columns, "nlevels") and df.columns.nlevels > 1:
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
|
||||
high_52w: float = float(df["High"].max())
|
||||
low_52w: float = float(df["Low"].min())
|
||||
diff: float = high_52w - low_52w
|
||||
|
||||
ratios = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0]
|
||||
levels: Dict[str, float] = {}
|
||||
for r in ratios:
|
||||
label = f"{r * 100:.1f}%"
|
||||
levels[label] = round(high_52w - diff * r, 2)
|
||||
|
||||
current_price = float(df["Close"].iloc[-1])
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"high_52w": round(high_52w, 2),
|
||||
"low_52w": round(low_52w, 2),
|
||||
"current_price": round(current_price, 2),
|
||||
"levels": levels,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Fibonacci levels failed: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticker}/ichimoku",
|
||||
summary="Ichimoku cloud data",
|
||||
)
|
||||
async def ichimoku_cloud(ticker: str) -> Dict[str, Any]:
|
||||
"""Return Ichimoku cloud components for *ticker*.
|
||||
|
||||
Components: Tenkan-sen (9), Kijun-sen (26), Senkou Span A,
|
||||
Senkou Span B (52), and Chikou Span.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
import pandas as pd
|
||||
|
||||
df = yf.download(ticker.upper(), period="1y", interval="1d", progress=False)
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=404, detail=f"No data for {ticker}")
|
||||
|
||||
# Flatten MultiIndex columns if present
|
||||
if hasattr(df.columns, "nlevels") and df.columns.nlevels > 1:
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
|
||||
high = df["High"]
|
||||
low = df["Low"]
|
||||
close = df["Close"]
|
||||
|
||||
# Tenkan-sen (Conversion Line): (9-period high + 9-period low) / 2
|
||||
nine_high = high.rolling(window=9).max()
|
||||
nine_low = low.rolling(window=9).min()
|
||||
tenkan = (nine_high + nine_low) / 2
|
||||
|
||||
# Kijun-sen (Base Line): (26-period high + 26-period low) / 2
|
||||
k_high = high.rolling(window=26).max()
|
||||
k_low = low.rolling(window=26).min()
|
||||
kijun = (k_high + k_low) / 2
|
||||
|
||||
# Senkou Span A (Leading Span A): (Tenkan + Kijun) / 2, shifted 26
|
||||
senkou_a = ((tenkan + kijun) / 2).shift(26)
|
||||
|
||||
# Senkou Span B (Leading Span B): (52-period high + low) / 2, shifted 26
|
||||
b_high = high.rolling(window=52).max()
|
||||
b_low = low.rolling(window=52).min()
|
||||
senkou_b = ((b_high + b_low) / 2).shift(26)
|
||||
|
||||
# Chikou Span (Lagging Span): Close shifted back 26 periods
|
||||
chikou = close.shift(-26)
|
||||
|
||||
# Take last 100 data points for response
|
||||
n = min(100, len(df))
|
||||
dates = [str(d)[:10] for d in df.index[-n:]]
|
||||
|
||||
def _to_list(series: pd.Series) -> List[Optional[float]]:
|
||||
"""Convert the last *n* values of a series to a list of floats."""
|
||||
vals = series.iloc[-n:]
|
||||
result: List[Optional[float]] = []
|
||||
for v in vals:
|
||||
try:
|
||||
result.append(round(float(v), 2))
|
||||
except (ValueError, TypeError):
|
||||
result.append(None)
|
||||
return result
|
||||
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"dates": dates,
|
||||
"tenkan_sen": _to_list(tenkan),
|
||||
"kijun_sen": _to_list(kijun),
|
||||
"senkou_span_a": _to_list(senkou_a),
|
||||
"senkou_span_b": _to_list(senkou_b),
|
||||
"chikou_span": _to_list(chikou),
|
||||
"close": _to_list(close),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ichimoku cloud failed: {exc}",
|
||||
) from exc
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Valuation router -- DCF calculation, smart defaults, analyst consensus,
|
||||
sensitivity analysis, Monte Carlo simulation, reverse DCF, and tornado charts."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_float(val, default=0.0):
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
import math
|
||||
f = float(val)
|
||||
return default if math.isnan(f) or math.isinf(f) else f
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
class DCFInputsBody(BaseModel):
|
||||
ticker: str = ""
|
||||
base_fcf: float = 0
|
||||
fcf: float = 0 # alias
|
||||
shares: float = 0
|
||||
shares_outstanding: float = 0 # alias
|
||||
total_debt: float = 0
|
||||
cash: float = 0
|
||||
wacc: float = 0.09
|
||||
terminal_growth: float = 0.025
|
||||
fcf_growth: float = 0.10
|
||||
fcf_growth_rate: float = 0 # alias
|
||||
|
||||
|
||||
@router.get("/dcf-inputs/{ticker}", summary="Auto-fill DCF inputs from market data")
|
||||
async def dcf_inputs(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
cf = t.cashflow
|
||||
bs = t.balance_sheet
|
||||
|
||||
fcf = _safe_float(info.get("freeCashflow"))
|
||||
if not fcf and cf is not None and not cf.empty:
|
||||
col = cf.columns[0]
|
||||
ocf = _safe_float(cf.loc["Operating Cash Flow"][col]) if "Operating Cash Flow" in cf.index else 0
|
||||
capex = _safe_float(cf.loc["Capital Expenditure"][col]) if "Capital Expenditure" in cf.index else 0
|
||||
fcf = ocf + capex
|
||||
|
||||
total_debt = _safe_float(info.get("totalDebt"))
|
||||
cash = _safe_float(info.get("totalCash"))
|
||||
shares = _safe_float(info.get("sharesOutstanding"))
|
||||
|
||||
return {"fcf": fcf, "total_debt": total_debt, "cash": cash, "shares": shares}
|
||||
except Exception:
|
||||
return {"fcf": None, "total_debt": 0, "cash": 0, "shares": None}
|
||||
|
||||
|
||||
@router.post("/dcf", summary="Calculate 3-scenario DCF valuation")
|
||||
async def calculate_dcf(inputs: DCFInputsBody):
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
base_fcf = inputs.base_fcf or inputs.fcf
|
||||
_shares = inputs.shares or inputs.shares_outstanding
|
||||
wacc = inputs.wacc
|
||||
tg = inputs.terminal_growth
|
||||
fcf_g = inputs.fcf_growth or inputs.fcf_growth_rate or 0.10
|
||||
projection_years = 10
|
||||
|
||||
def _dcf(fcf, w, g, tgr):
|
||||
if w <= tgr:
|
||||
return None
|
||||
projected = []
|
||||
current = fcf
|
||||
for _ in range(projection_years):
|
||||
current *= (1 + g)
|
||||
projected.append(current)
|
||||
|
||||
terminal = projected[-1] * (1 + tgr) / (w - tgr)
|
||||
pv_fcfs = sum(f / (1 + w) ** (i + 1) for i, f in enumerate(projected))
|
||||
pv_terminal = terminal / (1 + w) ** projection_years
|
||||
ev = pv_fcfs + pv_terminal
|
||||
eq = ev - inputs.total_debt + inputs.cash
|
||||
per_share = eq / _shares if _shares else None
|
||||
return per_share
|
||||
|
||||
base_val = _dcf(base_fcf, wacc, fcf_g, tg)
|
||||
bull_val = _dcf(base_fcf, max(wacc - 0.005, tg + 0.005), fcf_g + 0.02, tg)
|
||||
bear_val = _dcf(base_fcf, wacc + 0.01, max(fcf_g - 0.03, tg + 0.005), tg)
|
||||
|
||||
# Get current price
|
||||
current_price = None
|
||||
ticker_sym = inputs.ticker or ""
|
||||
if ticker_sym:
|
||||
try:
|
||||
t = yf.Ticker(ticker_sym.upper())
|
||||
current_price = _safe_float(t.info.get("currentPrice") or t.info.get("regularMarketPrice"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _upside(val):
|
||||
if val is None or current_price is None or current_price == 0:
|
||||
return 0
|
||||
return round((val / current_price - 1) * 100, 1)
|
||||
|
||||
return {
|
||||
"base": round(base_val, 2) if base_val else None,
|
||||
"bull": round(bull_val, 2) if bull_val else None,
|
||||
"bear": round(bear_val, 2) if bear_val else None,
|
||||
"current_price": current_price,
|
||||
"scenarios": {
|
||||
"bull": {"intrinsic_value": round(bull_val, 2) if bull_val else None, "upside": _upside(bull_val)},
|
||||
"base": {"intrinsic_value": round(base_val, 2) if base_val else None, "upside": _upside(base_val)},
|
||||
"bear": {"intrinsic_value": round(bear_val, 2) if bear_val else None, "upside": _upside(bear_val)},
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
return {"base": None, "bull": None, "bear": None, "current_price": None}
|
||||
|
||||
|
||||
@router.get("/smart-defaults/{ticker}", summary="Smart DCF defaults")
|
||||
async def smart_defaults(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
|
||||
sector = info.get("sector", "N/A")
|
||||
industry = info.get("industry", "N/A")
|
||||
|
||||
# Sector-based WACC heuristics
|
||||
wacc_map = {
|
||||
"Technology": 10, "Healthcare": 9, "Financial Services": 8,
|
||||
"Consumer Cyclical": 9, "Consumer Defensive": 7.5,
|
||||
"Industrials": 8.5, "Energy": 10.5, "Utilities": 6.5,
|
||||
"Real Estate": 7, "Communication Services": 9, "Basic Materials": 9,
|
||||
}
|
||||
wacc = wacc_map.get(sector, 9.0)
|
||||
rev_growth = _safe_float(info.get("revenueGrowth", 0.1)) * 100
|
||||
fcf_growth = min(max(rev_growth, 3), 35)
|
||||
|
||||
return {
|
||||
"wacc": wacc, "terminal_growth": 2.5, "fcf_growth": round(fcf_growth, 1),
|
||||
"sector": sector, "industry": industry,
|
||||
}
|
||||
except Exception:
|
||||
return {"wacc": 9, "terminal_growth": 2.5, "fcf_growth": 10, "sector": "N/A", "industry": "N/A"}
|
||||
|
||||
|
||||
class SensitivityBody(BaseModel):
|
||||
fcf: float = 0
|
||||
total_debt: float = 0
|
||||
cash: float = 0
|
||||
shares: float = 0
|
||||
wacc: float = 0.09
|
||||
terminal_growth: float = 0.025
|
||||
fcf_growth: float = 0.10
|
||||
|
||||
|
||||
class MonteCarloBody(BaseModel):
|
||||
ticker: str = ""
|
||||
fcf: float = 0
|
||||
wacc_mean: float = 0.09
|
||||
wacc_std: float = 0.015
|
||||
growth_mean: float = 0.10
|
||||
growth_std: float = 0.03
|
||||
term_growth: float = 0.025
|
||||
total_debt: float = 0
|
||||
cash: float = 0
|
||||
shares: float = 0
|
||||
n_simulations: int = 5000
|
||||
|
||||
|
||||
class ReverseDCFBody(BaseModel):
|
||||
ticker: str = ""
|
||||
fcf: float = 0
|
||||
shares: float = 0
|
||||
total_debt: float = 0
|
||||
cash: float = 0
|
||||
wacc: float = 0.09
|
||||
terminal_growth: float = 0.025
|
||||
|
||||
|
||||
@router.post("/sensitivity", summary="Sensitivity matrix (WACC vs Terminal Growth)")
|
||||
async def sensitivity_analysis(body: SensitivityBody):
|
||||
try:
|
||||
from server.services.sensitivity import build_sensitivity_matrix
|
||||
result = build_sensitivity_matrix(
|
||||
fcf=body.fcf, total_debt=body.total_debt, cash=body.cash,
|
||||
shares=body.shares, base_wacc=body.wacc, base_tg=body.terminal_growth,
|
||||
fcf_growth=body.fcf_growth,
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@router.post("/tornado", summary="Tornado chart data")
|
||||
async def tornado_chart(body: SensitivityBody):
|
||||
try:
|
||||
from server.services.sensitivity import build_tornado_data
|
||||
result = build_tornado_data(
|
||||
fcf=body.fcf, wacc=body.wacc, tg=body.terminal_growth,
|
||||
growth=body.fcf_growth, debt=body.total_debt,
|
||||
cash=body.cash, shares=body.shares,
|
||||
)
|
||||
return {"data": result}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@router.post("/monte-carlo", summary="Monte Carlo DCF simulation")
|
||||
async def monte_carlo_dcf(body: MonteCarloBody):
|
||||
try:
|
||||
import yfinance as yf
|
||||
from server.services.monte_carlo import run_monte_carlo_dcf
|
||||
|
||||
current_price = None
|
||||
if body.ticker:
|
||||
try:
|
||||
t = yf.Ticker(body.ticker.upper())
|
||||
current_price = _safe_float(t.info.get("currentPrice") or t.info.get("regularMarketPrice"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = run_monte_carlo_dcf(
|
||||
fcf=body.fcf, wacc_mean=body.wacc_mean, wacc_std=body.wacc_std,
|
||||
growth_mean=body.growth_mean, growth_std=body.growth_std,
|
||||
term_growth=body.term_growth, total_debt=body.total_debt,
|
||||
cash=body.cash, shares=body.shares, n_simulations=body.n_simulations,
|
||||
current_price=current_price,
|
||||
)
|
||||
values = result.get("values", [])
|
||||
if values:
|
||||
import numpy as np
|
||||
arr = np.array(values)
|
||||
counts, bin_edges = np.histogram(arr, bins=50)
|
||||
result["histogram"] = {
|
||||
"counts": counts.tolist(),
|
||||
"bin_edges": [round(b, 2) for b in bin_edges.tolist()],
|
||||
}
|
||||
result["values"] = []
|
||||
return result
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@router.post("/reverse-dcf", summary="Reverse DCF — implied growth rate")
|
||||
async def reverse_dcf_endpoint(body: ReverseDCFBody):
|
||||
try:
|
||||
import yfinance as yf
|
||||
from server.services.dcf_engine import reverse_dcf
|
||||
|
||||
current_price = None
|
||||
if body.ticker:
|
||||
try:
|
||||
t = yf.Ticker(body.ticker.upper())
|
||||
current_price = _safe_float(t.info.get("currentPrice") or t.info.get("regularMarketPrice"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not current_price:
|
||||
return {"implied_growth": None, "current_price": None, "error": "No current price"}
|
||||
|
||||
implied = reverse_dcf(
|
||||
current_price=current_price, shares=body.shares,
|
||||
total_debt=body.total_debt, cash=body.cash,
|
||||
wacc=body.wacc, term_growth=body.terminal_growth,
|
||||
fcf_base=body.fcf,
|
||||
)
|
||||
return {
|
||||
"implied_growth": round(implied * 100, 2) if implied is not None else None,
|
||||
"current_price": current_price,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@router.get("/consensus/{ticker}", summary="Analyst consensus data")
|
||||
async def analyst_consensus(ticker: str):
|
||||
try:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
|
||||
return {
|
||||
"target_mean": _safe_float(info.get("targetMeanPrice"), None),
|
||||
"target_high": _safe_float(info.get("targetHighPrice"), None),
|
||||
"target_low": _safe_float(info.get("targetLowPrice"), None),
|
||||
"target_median": _safe_float(info.get("targetMedianPrice"), None),
|
||||
"recommendation": info.get("recommendationKey", "N/A"),
|
||||
"num_analysts": info.get("numberOfAnalystOpinions", 0),
|
||||
}
|
||||
except Exception:
|
||||
return {"target_mean": None, "target_high": None, "target_low": None, "target_median": None, "recommendation": "N/A", "num_analysts": 0}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Crypto price fetcher -- Bithumb (KRW) and Binance (USD) public APIs.
|
||||
|
||||
Provides standalone fetcher functions that can be used by the crypto router
|
||||
or any other service that needs cryptocurrency price data.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top 20 coins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOP_20_COINS: List[str] = [
|
||||
"BTC", "ETH", "BNB", "XRP", "SOL", "ADA", "DOGE", "AVAX", "DOT", "MATIC",
|
||||
"LINK", "SHIB", "TRX", "UNI", "ATOM", "LTC", "ETC", "XLM", "NEAR", "APT",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_cache: Dict[str, Any] = {}
|
||||
_cache_ts: Dict[str, float] = {}
|
||||
_CACHE_TTL = 30 # seconds
|
||||
|
||||
|
||||
def _get_cached(key: str) -> Optional[Any]:
|
||||
if key in _cache and (time.time() - _cache_ts.get(key, 0)) < _CACHE_TTL:
|
||||
return _cache[key]
|
||||
return None
|
||||
|
||||
|
||||
def _set_cached(key: str, value: Any) -> None:
|
||||
_cache[key] = value
|
||||
_cache_ts[key] = time.time()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bithumb (KRW)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_bithumb_all_krw(symbols: Optional[List[str]] = None) -> Dict[str, float]:
|
||||
"""Fetch KRW prices from Bithumb ALL_KRW endpoint.
|
||||
|
||||
Uses the bulk endpoint (https://api.bithumb.com/public/ticker/ALL_KRW)
|
||||
to avoid per-symbol rate limits.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
symbols:
|
||||
Coin symbols to include. Defaults to TOP_20_COINS.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Mapping of symbol -> KRW price (float).
|
||||
"""
|
||||
cached = _get_cached("bithumb_all_krw")
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
symbols = symbols or TOP_20_COINS
|
||||
url = "https://api.bithumb.com/public/ticker/ALL_KRW"
|
||||
prices: Dict[str, float] = {}
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
if body.get("status") != "0000":
|
||||
return prices
|
||||
data = body.get("data", {})
|
||||
for sym in symbols:
|
||||
coin_data = data.get(sym.upper())
|
||||
if coin_data and isinstance(coin_data, dict):
|
||||
closing = coin_data.get("closing_price")
|
||||
if closing:
|
||||
prices[sym.upper()] = float(closing)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_set_cached("bithumb_all_krw", prices)
|
||||
return prices
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binance (USD)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_binance_prices(symbols: Optional[List[str]] = None) -> Dict[str, float]:
|
||||
"""Fetch USD prices from Binance ticker/price endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
symbols:
|
||||
Coin symbols to include. Defaults to TOP_20_COINS.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Mapping of symbol -> USD price (float).
|
||||
"""
|
||||
cached = _get_cached("binance_prices")
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
symbols = symbols or TOP_20_COINS
|
||||
url = "https://api.binance.com/api/v3/ticker/price"
|
||||
prices: Dict[str, float] = {}
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
lookup = {item["symbol"]: float(item["price"]) for item in data}
|
||||
for sym in symbols:
|
||||
key = f"{sym.upper()}USDT"
|
||||
if key in lookup:
|
||||
prices[sym.upper()] = lookup[key]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_set_cached("binance_prices", prices)
|
||||
return prices
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Combined
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_top20_prices(
|
||||
symbols: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return top-20 crypto prices with both KRW and USD.
|
||||
|
||||
Each entry contains:
|
||||
- symbol: str
|
||||
- price_usd: float | None
|
||||
- price_krw: float | None
|
||||
|
||||
Parameters
|
||||
----------
|
||||
symbols:
|
||||
Override default TOP_20_COINS list.
|
||||
"""
|
||||
symbols = symbols or TOP_20_COINS
|
||||
usd = fetch_binance_prices(symbols)
|
||||
krw = fetch_bithumb_all_krw(symbols)
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for sym in symbols:
|
||||
results.append({
|
||||
"symbol": sym.upper(),
|
||||
"price_usd": usd.get(sym.upper()),
|
||||
"price_krw": krw.get(sym.upper()),
|
||||
})
|
||||
return results
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Discounted Cash Flow (DCF) valuation engine.
|
||||
|
||||
Implements multiple DCF model variants:
|
||||
- Simple 5-year single-stage DCF
|
||||
- 10-year two-stage DCF (growth fades from Stage 1 to terminal)
|
||||
- Excel-style full DCF (EV -> Equity -> per-share value)
|
||||
|
||||
Also includes Damodaran sector WACC reference data and smart-default
|
||||
assumption generation from CAPM beta and analyst growth estimates.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from server.utils.safe_float import _safe_float
|
||||
|
||||
try:
|
||||
from scipy.optimize import brentq
|
||||
except ImportError:
|
||||
brentq = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None # type: ignore[assignment]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Damodaran sector WACC reference (approx. 2024/2025 baseline)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DAMODARAN_WACC: Dict[str, float] = {
|
||||
"Software": 8.5,
|
||||
"Retail": 7.5,
|
||||
"Hardware": 9.0,
|
||||
"Financials": 8.0,
|
||||
"Healthcare": 7.2,
|
||||
"Consumer": 7.5,
|
||||
"Technology": 8.5,
|
||||
"Industrial": 7.8,
|
||||
"Energy": 8.2,
|
||||
"Utilities": 6.5,
|
||||
}
|
||||
|
||||
DAMODARAN_ERP_PCT: float = 4.6
|
||||
"""US Equity Risk Premium (Damodaran estimate)."""
|
||||
|
||||
DAMODARAN_RF_PCT: float = 4.2
|
||||
"""10-year risk-free rate (Damodaran estimate)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DCF models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def dcf_intrinsic_value(
|
||||
fcf: float,
|
||||
wacc: float,
|
||||
terminal_growth: float,
|
||||
fcf_growth: float,
|
||||
years: int = 5,
|
||||
) -> float:
|
||||
"""5-year single-stage DCF returning enterprise value.
|
||||
|
||||
Projects FCF at *fcf_growth* for *years* periods, then computes a
|
||||
Gordon Growth terminal value discounted at *wacc*.
|
||||
"""
|
||||
if fcf is None or fcf <= 0:
|
||||
return 0.0
|
||||
if wacc <= terminal_growth or wacc <= 0:
|
||||
return 0.0
|
||||
pv = 0.0
|
||||
fcft = float(fcf)
|
||||
for t in range(1, years + 1):
|
||||
pv += fcft / ((1 + wacc) ** t)
|
||||
fcft *= (1 + fcf_growth)
|
||||
terminal_fcf = fcft
|
||||
tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth)
|
||||
pv += tv / ((1 + wacc) ** years)
|
||||
return pv
|
||||
|
||||
|
||||
def dcf_10y_2stage(
|
||||
fcf: float,
|
||||
wacc: float,
|
||||
term_growth: float,
|
||||
fcf_growth: float,
|
||||
) -> float:
|
||||
"""10-year two-stage DCF.
|
||||
|
||||
Stage 1 (Y1-5): FCF grows at *fcf_growth*.
|
||||
Stage 2 (Y6-10): growth linearly fades to *term_growth*.
|
||||
Terminal value at Y10 using Gordon Growth.
|
||||
"""
|
||||
if fcf is None or fcf <= 0:
|
||||
return 0.0
|
||||
if wacc <= term_growth or wacc <= 0:
|
||||
return 0.0
|
||||
pv = 0.0
|
||||
fcft = float(fcf)
|
||||
for t in range(1, 6):
|
||||
pv += fcft / ((1 + wacc) ** t)
|
||||
fcft *= (1 + fcf_growth)
|
||||
for t in range(6, 11):
|
||||
fade = (t - 6) / 4.0
|
||||
g_t = fcf_growth + fade * (term_growth - fcf_growth)
|
||||
fcft *= (1 + g_t)
|
||||
pv += fcft / ((1 + wacc) ** t)
|
||||
tv = fcft * (1 + term_growth) / (wacc - term_growth)
|
||||
pv += tv / ((1 + wacc) ** 10)
|
||||
return pv
|
||||
|
||||
|
||||
def excel_style_dcf(
|
||||
fcf_base: float,
|
||||
wacc: float,
|
||||
term_growth: float,
|
||||
fcf_growth: float,
|
||||
total_debt: float,
|
||||
cash: float,
|
||||
shares: float,
|
||||
) -> Dict[str, Optional[float]]:
|
||||
"""Full DCF: EV -> Equity Value -> Value per Share.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Keys: ``ev``, ``equity_value``, ``value_per_share``, ``shares``.
|
||||
"""
|
||||
ev = dcf_10y_2stage(fcf_base, wacc, term_growth, fcf_growth)
|
||||
equity = ev - total_debt + cash
|
||||
shares_safe = float(shares) if (shares is not None and float(shares) > 0) else None
|
||||
value_per_share = (equity / shares_safe) if shares_safe else None
|
||||
return {
|
||||
"ev": ev,
|
||||
"equity_value": equity,
|
||||
"value_per_share": value_per_share,
|
||||
"shares": shares_safe,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WACC helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def reverse_dcf(
|
||||
current_price: float,
|
||||
shares: float,
|
||||
total_debt: float,
|
||||
cash: float,
|
||||
wacc: float,
|
||||
term_growth: float,
|
||||
fcf_base: float,
|
||||
projection_years: int = 10,
|
||||
) -> Optional[float]:
|
||||
"""Solve for the implied FCF growth rate that produces the current market price.
|
||||
|
||||
Uses Brent's root-finding method (scipy.optimize.brentq) to find the
|
||||
growth rate *g* such that ``excel_style_dcf(..., g)["value_per_share"] == current_price``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float | None
|
||||
Implied annual FCF growth rate (decimal), or None if no solution is found.
|
||||
"""
|
||||
if brentq is None:
|
||||
return None
|
||||
if shares <= 0 or current_price <= 0 or wacc <= term_growth:
|
||||
return None
|
||||
|
||||
def _objective(g: float) -> float:
|
||||
result = excel_style_dcf(fcf_base, wacc, term_growth, g, total_debt, cash, shares)
|
||||
vps = result.get("value_per_share")
|
||||
if vps is None:
|
||||
return -current_price
|
||||
return vps - current_price
|
||||
|
||||
try:
|
||||
implied_growth = brentq(_objective, -0.50, 1.00, xtol=1e-6, maxiter=200)
|
||||
return round(implied_growth, 6)
|
||||
except (ValueError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _damodaran_wacc_for_sector(sector: str) -> float:
|
||||
"""Map a yfinance sector string to closest Damodaran WACC (default 8.0%)."""
|
||||
if not sector:
|
||||
return 8.0
|
||||
s = (sector or "").lower()
|
||||
if "software" in s or "technology" in s or "internet" in s:
|
||||
return DAMODARAN_WACC.get("Software", 8.5)
|
||||
if "hardware" in s or "semiconductor" in s:
|
||||
return DAMODARAN_WACC.get("Hardware", 9.0)
|
||||
if "retail" in s or "consumer" in s or "cyclical" in s:
|
||||
return DAMODARAN_WACC.get("Retail", 7.5)
|
||||
if "financial" in s or "bank" in s or "insurance" in s:
|
||||
return DAMODARAN_WACC.get("Financials", 8.0)
|
||||
if "health" in s or "pharma" in s:
|
||||
return DAMODARAN_WACC.get("Healthcare", 7.2)
|
||||
if "industrial" in s:
|
||||
return DAMODARAN_WACC.get("Industrial", 7.8)
|
||||
if "energy" in s or "oil" in s:
|
||||
return DAMODARAN_WACC.get("Energy", 8.2)
|
||||
if "utilities" in s:
|
||||
return DAMODARAN_WACC.get("Utilities", 6.5)
|
||||
return 8.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Smart defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_dcf_smart_defaults(ticker: str) -> Dict[str, float]:
|
||||
"""Auto-generate WACC, Terminal Growth, and FCF Growth from CAPM beta and analyst estimates.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Keys: ``wacc_pct``, ``term_growth_pct``, ``fcf_growth_pct``.
|
||||
"""
|
||||
out: Dict[str, float] = {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0}
|
||||
if not yf or not ticker:
|
||||
return out
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
beta = info.get("beta")
|
||||
if beta is None:
|
||||
beta = 1.0
|
||||
else:
|
||||
try:
|
||||
beta = float(beta)
|
||||
except (TypeError, ValueError):
|
||||
beta = 1.0
|
||||
risk_free = 4.0
|
||||
market_risk_premium = 5.0
|
||||
calculated_wacc = risk_free + (beta * market_risk_premium)
|
||||
out["wacc_pct"] = round(min(20.0, max(4.0, calculated_wacc)), 1)
|
||||
out["term_growth_pct"] = 2.5
|
||||
rev_growth = info.get("revenueGrowth") or info.get("earningsGrowth")
|
||||
if rev_growth is not None:
|
||||
try:
|
||||
g = float(rev_growth)
|
||||
out["fcf_growth_pct"] = round(min(30.0, max(-10.0, g * 100)), 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Financial health metrics: DuPont, Altman Z, Piotroski F-Score, radar, and sector-specific.
|
||||
|
||||
All functions return pure data (dicts, DataFrames) with no presentation logic.
|
||||
Consumers (API routers, Streamlit UI) handle display and charting.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from server.utils.safe_float import _safe_float
|
||||
from server.services.market_fetcher import (
|
||||
_get_annual_financials_balance_cashflow,
|
||||
_get_row_series,
|
||||
)
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Radar normalisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _radar_norm(
|
||||
roe_pct: Optional[float],
|
||||
current_ratio: Optional[float],
|
||||
asset_turnover: Optional[float],
|
||||
equity_mult: Optional[float],
|
||||
rev_yoy_pct: Optional[float],
|
||||
) -> List[float]:
|
||||
"""Normalise five raw metrics to 0-100 for radar chart display."""
|
||||
def n_roe(x: Optional[float]) -> float:
|
||||
return min(100, max(0, (x + 10) / 40 * 100)) if x is not None else 50
|
||||
def n_cr(x: Optional[float]) -> float:
|
||||
return min(100, max(0, x / 3 * 100)) if x is not None else 50
|
||||
def n_at(x: Optional[float]) -> float:
|
||||
return min(100, max(0, x * 50)) if x is not None else 50
|
||||
def n_em(x: Optional[float]) -> float:
|
||||
return min(100, max(0, (x - 0.5) / 2.5 * 100)) if x is not None else 50
|
||||
def n_yoy(x: Optional[float]) -> float:
|
||||
return min(100, max(0, (x + 20) / 50 * 100)) if x is not None else 50
|
||||
return [n_roe(roe_pct), n_cr(current_ratio), n_at(asset_turnover), n_em(equity_mult), n_yoy(rev_yoy_pct)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DuPont / Altman Z / Red Flags / YoY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_dupont_altman_redflags_yoy(ticker: str) -> Dict[str, Any]:
|
||||
"""DuPont 3-step ROE, Altman Z-Score, red flags, and YoY ratio changes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Keys: ``dupont`` (DataFrame), ``yoy`` (list), ``altman_z`` (float|None),
|
||||
``red_flags`` (list of dicts).
|
||||
"""
|
||||
try:
|
||||
fin, bal, _ = _get_annual_financials_balance_cashflow(ticker)
|
||||
if fin is None or fin.empty or bal is None or bal.empty:
|
||||
return {}
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
col_list = fin.columns.tolist()
|
||||
if col_list and str(col_list[0]).startswith("TTM"):
|
||||
dates = col_list[:3]
|
||||
else:
|
||||
dates = sorted(col_list, reverse=True)[:3]
|
||||
if not dates:
|
||||
return {}
|
||||
|
||||
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
|
||||
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
|
||||
ebit = _get_row_series(fin, "Operating Income", "EBIT")
|
||||
gross = _get_row_series(fin, "Gross Profit")
|
||||
interest = _get_row_series(fin, "Interest Expense", "Interest Expense Net")
|
||||
total_assets = _get_row_series(bal, "Total Assets")
|
||||
total_equity = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
|
||||
current_assets = _get_row_series(bal, "Current Assets")
|
||||
current_liab = _get_row_series(bal, "Current Liabilities")
|
||||
retained = _get_row_series(bal, "Retained Earnings")
|
||||
total_liab = _get_row_series(bal, "Total Liabilities")
|
||||
market_cap = info.get("marketCap") or info.get("Market Cap")
|
||||
|
||||
def _v(s: Optional[pd.Series], d: Any) -> Optional[float]:
|
||||
if s is None or d not in s.index:
|
||||
return None
|
||||
return _safe_float(s.get(d))
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for i, d in enumerate(dates):
|
||||
yr = int(str(d)[:4]) if (isinstance(d, str) and str(d)[:4].isdigit()) else (d.year if hasattr(d, "year") else (2024 - i))
|
||||
r = _v(rev, d)
|
||||
net_i = _v(ni, d)
|
||||
ta = _v(total_assets, d)
|
||||
te = _v(total_equity, d)
|
||||
if ta and ta > 0 and te and te > 0 and r and r != 0:
|
||||
npm = (net_i / r * 100) if net_i is not None else None
|
||||
at = r / ta
|
||||
em = ta / te
|
||||
roe = (net_i / te * 100) if net_i else None
|
||||
else:
|
||||
npm = at = em = roe = None
|
||||
gross_p = _v(gross, d)
|
||||
gross_margin = (gross_p / r * 100) if (gross_p and r and r != 0) else None
|
||||
op_inc = _v(ebit, d)
|
||||
op_margin = (op_inc / r * 100) if (op_inc and r and r != 0) else None
|
||||
ca = _v(current_assets, d)
|
||||
cl = _v(current_liab, d)
|
||||
current_ratio = (ca / cl) if (ca and cl and cl != 0) else None
|
||||
int_exp = _v(interest, d)
|
||||
interest_cov: Optional[float] = None
|
||||
if op_inc is not None and int_exp is not None and int_exp != 0:
|
||||
_ic = op_inc / int_exp
|
||||
interest_cov = round(_ic, 2) if (_ic == _ic and not pd.isna(_ic)) else None
|
||||
rows.append({
|
||||
"Year": yr, "Revenue": r, "Net Income": net_i,
|
||||
"NPM %": round(npm, 2) if npm is not None else None,
|
||||
"Asset Turnover": round(at, 4) if at is not None else None,
|
||||
"Equity Mult.": round(em, 2) if em is not None else None,
|
||||
"ROE %": round(roe, 2) if roe is not None else None,
|
||||
"Gross Margin %": round(gross_margin, 2) if gross_margin is not None else None,
|
||||
"Operating Margin %": round(op_margin, 2) if op_margin is not None else None,
|
||||
"Current Ratio": round(current_ratio, 2) if current_ratio is not None else None,
|
||||
"Interest Coverage": interest_cov,
|
||||
})
|
||||
|
||||
dupont_df = pd.DataFrame(rows)
|
||||
|
||||
# YoY
|
||||
yoy: List[Dict[str, Any]] = []
|
||||
if len(dupont_df) >= 2:
|
||||
for col in ["NPM %", "ROE %", "Gross Margin %", "Operating Margin %", "Current Ratio", "Interest Coverage"]:
|
||||
if col not in dupont_df.columns:
|
||||
continue
|
||||
cur = dupont_df[col].iloc[0]
|
||||
prev = dupont_df[col].iloc[1]
|
||||
if cur is None or prev is None or prev == 0 or pd.isna(cur) or pd.isna(prev):
|
||||
continue
|
||||
if "Margin" in col or "NPM" in col or "ROE" in col:
|
||||
chg_pp = cur - prev
|
||||
if pd.isna(chg_pp):
|
||||
continue
|
||||
yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY (pp)": round(chg_pp, 2),
|
||||
"Comment": f"{'Improved' if chg_pp > 0 else 'Declined'} by {abs(chg_pp):.1f}% YoY"})
|
||||
else:
|
||||
pct = (cur - prev) / abs(prev) * 100
|
||||
if pd.isna(pct):
|
||||
continue
|
||||
yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY %": round(pct, 1),
|
||||
"Comment": f"{'Up' if pct > 0 else 'Down'} {abs(round(pct, 1))}% YoY"})
|
||||
|
||||
# Altman Z
|
||||
latest_bal_d = bal.columns[0]
|
||||
wc = (_v(current_assets, latest_bal_d) or 0) - (_v(current_liab, latest_bal_d) or 0)
|
||||
ta_l = _v(total_assets, latest_bal_d)
|
||||
re_l = _v(retained, latest_bal_d)
|
||||
tl_l = _v(total_liab, latest_bal_d)
|
||||
ebit_l = _v(ebit, fin.columns[0])
|
||||
sales_l = _v(rev, fin.columns[0])
|
||||
altman_z: Optional[float] = None
|
||||
if ta_l and ta_l > 0 and market_cap is not None and tl_l and tl_l != 0 and sales_l:
|
||||
a = wc / ta_l
|
||||
b = (re_l or 0) / ta_l
|
||||
c = (ebit_l or 0) / ta_l
|
||||
dd = market_cap / tl_l
|
||||
e = sales_l / ta_l
|
||||
altman_z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * dd + 1.0 * e
|
||||
|
||||
# Red flags
|
||||
red_flags: List[Dict[str, Any]] = []
|
||||
if len(dupont_df) > 0:
|
||||
row0 = dupont_df.iloc[0]
|
||||
cr = row0.get("Current Ratio")
|
||||
if cr is not None and cr < 1.0:
|
||||
red_flags.append({"metric": "Current Ratio", "value": cr, "threshold": 1.0, "flag": "WARNING",
|
||||
"comment": "Current assets do not cover current liabilities; liquidity risk."})
|
||||
ic = row0.get("Interest Coverage")
|
||||
if ic is not None and ic < 1.5:
|
||||
red_flags.append({"metric": "Interest Coverage", "value": ic, "threshold": 1.5, "flag": "WARNING",
|
||||
"comment": "EBIT barely covers interest; default risk."})
|
||||
|
||||
return {"dupont": dupont_df, "yoy": yoy, "altman_z": round(altman_z, 2) if altman_z is not None else None, "red_flags": red_flags}
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Extended financial metrics: Piotroski F-Score, Sankey, radar, sector-specific, quarterly.
|
||||
|
||||
Complements :mod:`server.services.financial_metrics` with scoring models,
|
||||
income-statement flow data, and quarterly momentum indicators.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from server.utils.safe_float import _safe_float
|
||||
from server.services.market_fetcher import (
|
||||
_get_annual_financials_balance_cashflow,
|
||||
_get_row_series,
|
||||
)
|
||||
from server.services.financial_metrics import (
|
||||
_radar_norm,
|
||||
get_dupont_altman_redflags_yoy,
|
||||
)
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Income Statement Sankey
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_income_statement_sankey_data(ticker: str) -> Dict[str, float]:
|
||||
"""Revenue -> COGS -> Gross Profit -> OpEx -> OpIncome -> Net Income."""
|
||||
out: Dict[str, float] = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0}
|
||||
fin, _, _ = _get_annual_financials_balance_cashflow(ticker)
|
||||
if fin is None or fin.empty:
|
||||
return out
|
||||
try:
|
||||
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
|
||||
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
|
||||
gross = _get_row_series(fin, "Gross Profit")
|
||||
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
|
||||
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
|
||||
if rev is None or len(rev) == 0:
|
||||
return out
|
||||
d = rev.index[0]
|
||||
revenue = abs(_safe_float(rev.get(d)) or 0)
|
||||
cogs_val = abs(_safe_float(cogs.get(d)) if cogs is not None and d in cogs.index else 0) or 0
|
||||
gross_val = _safe_float(gross.get(d)) if gross is not None and d in gross.index else None
|
||||
if gross_val is None:
|
||||
gross_val = (revenue - cogs_val) if revenue and cogs_val is not None else revenue
|
||||
gross_val = abs(gross_val) if gross_val is not None else 0
|
||||
op_inc_val = _safe_float(op_inc.get(d)) if op_inc is not None and d in op_inc.index else 0
|
||||
ni_val = _safe_float(ni.get(d)) if ni is not None and d in ni.index else 0
|
||||
opex_val = max(0, gross_val - op_inc_val) if gross_val >= op_inc_val else 0
|
||||
tax_interest_other = max(0, op_inc_val - ni_val) if (op_inc_val - ni_val) > 0 else abs(min(0, op_inc_val - ni_val))
|
||||
return {"revenue": max(revenue, 1), "cogs": min(cogs_val, revenue - 1e-6), "gross_profit": gross_val,
|
||||
"opex": opex_val, "operating_income": op_inc_val, "tax_interest_other": tax_interest_other, "net_income": ni_val}
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
|
||||
def sankey_data_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""Build Sankey input from ``get_sec_financials_llm`` result."""
|
||||
cur = (ai_dict or {}).get("current_yr") or {}
|
||||
revenue = max(0, (cur.get("Revenue") or 0))
|
||||
cogs = max(0, min(cur.get("CostOfRevenue") or 0, revenue - 1e-6))
|
||||
gross_profit = revenue - cogs
|
||||
opex = max(0, cur.get("OperatingExpenses") or 0)
|
||||
operating_income = gross_profit - opex
|
||||
net_income = cur.get("NetIncome") or 0
|
||||
tax_interest_other = max(0, operating_income - net_income) if operating_income > net_income else abs(min(0, operating_income - net_income))
|
||||
return {"revenue": max(revenue, 1), "cogs": cogs, "gross_profit": gross_profit, "opex": opex,
|
||||
"operating_income": operating_income, "tax_interest_other": tax_interest_other, "net_income": net_income}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Piotroski F-Score
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def piotroski_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Piotroski F-Score (0-9) from AI-extracted current/previous year."""
|
||||
out: Dict[str, Any] = {"score": 0, "criteria": [], "used_ttm": True}
|
||||
cur = (ai_dict or {}).get("current_yr") or {}
|
||||
prev = (ai_dict or {}).get("previous_yr") or {}
|
||||
if not cur:
|
||||
return out
|
||||
def v(d: dict, k: str) -> float:
|
||||
return d.get(k) or 0
|
||||
ni0, ni1 = v(cur, "NetIncome"), v(prev, "NetIncome")
|
||||
ocf0 = v(cur, "OperatingCashFlow")
|
||||
ta0, ta1 = v(cur, "TotalAssets"), v(prev, "TotalAssets")
|
||||
roa0 = (ni0 / ta0 * 100) if ta0 and ta0 != 0 else None
|
||||
roa1 = (ni1 / ta1 * 100) if ta1 and ta1 != 0 else None
|
||||
lt0, lt1 = v(cur, "LongTermDebt"), v(prev, "LongTermDebt")
|
||||
ca0, cl0 = v(cur, "CurrentAssets"), v(cur, "CurrentLiabilities")
|
||||
ca1, cl1 = v(prev, "CurrentAssets"), v(prev, "CurrentLiabilities")
|
||||
cr0 = (ca0 / cl0) if cl0 and cl0 != 0 else None
|
||||
cr1 = (ca1 / cl1) if cl1 and cl1 != 0 else None
|
||||
sh0, sh1 = v(cur, "SharesOutstanding"), v(prev, "SharesOutstanding")
|
||||
rev0, rev1 = v(cur, "Revenue"), v(prev, "Revenue")
|
||||
gm0 = ((rev0 - v(cur, "CostOfRevenue")) / rev0 * 100) if rev0 and rev0 != 0 else None
|
||||
gm1 = ((rev1 - v(prev, "CostOfRevenue")) / rev1 * 100) if rev1 and rev1 != 0 else None
|
||||
at0 = (rev0 / ta0) if rev0 and ta0 and ta0 != 0 else None
|
||||
at1 = (rev1 / ta1) if rev1 and ta1 and ta1 != 0 else None
|
||||
criteria: List[tuple] = [
|
||||
("Net Income > 0 (profitability)", ni0 > 0),
|
||||
("Operating Cash Flow > 0 (cash generative)", ocf0 > 0),
|
||||
("ROA increased vs prior period (improving returns)", roa0 is not None and roa1 is not None and roa0 > roa1),
|
||||
("OCF > Net Income (earnings quality, less accruals)", ocf0 > ni0),
|
||||
("Leverage decreased: LT Debt/Assets lower (less debt)", ta0 and ta1 and (lt0 / ta0) < (lt1 / ta1) if ta0 and ta1 else False),
|
||||
("Current Ratio improved (better liquidity)", cr0 is not None and cr1 is not None and cr0 > cr1),
|
||||
("No dilution: shares unchanged or lower (no equity raise)", (sh0 <= sh1) if (sh0 and sh1) else True),
|
||||
("Gross Margin improved (pricing power)", gm0 is not None and gm1 is not None and gm0 > gm1),
|
||||
("Asset Turnover improved (efficiency)", at0 is not None and at1 is not None and at0 > at1),
|
||||
]
|
||||
out["score"] = sum(1 for _, p in criteria if p)
|
||||
out["criteria"] = criteria
|
||||
return out
|
||||
|
||||
|
||||
def get_piotroski_fscore(ticker: str) -> Dict[str, Any]:
|
||||
"""Piotroski F-Score from yahooquery/yfinance data."""
|
||||
out: Dict[str, Any] = {"score": 0, "criteria": [], "used_ttm": False}
|
||||
fin, bal, cf = _get_annual_financials_balance_cashflow(ticker)
|
||||
if fin is None or fin.empty or bal is None or bal.empty:
|
||||
return out
|
||||
if cf is None or cf.empty:
|
||||
cf = pd.DataFrame()
|
||||
try:
|
||||
ncol = min(2, len(fin.columns))
|
||||
rev = _get_row_series(fin, "Total Revenue", "Revenue")
|
||||
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
|
||||
gross = _get_row_series(fin, "Gross Profit")
|
||||
ta = _get_row_series(bal, "Total Assets")
|
||||
lt_debt = _get_row_series(bal, "Long Term Debt")
|
||||
ca = _get_row_series(bal, "Current Assets")
|
||||
cl = _get_row_series(bal, "Current Liabilities")
|
||||
ocf = _get_row_series(cf, "Operating Cash Flow", "Cash From Operating Activities") if not cf.empty else None
|
||||
shares = _get_row_series(bal, "Share Issued") or _get_row_series(bal, "Ordinary Shares Number")
|
||||
if shares is None and yf:
|
||||
ti = yf.Ticker(ticker.upper())
|
||||
info = getattr(ti, "info", None) or {}
|
||||
sh_info = info.get("sharesOutstanding") or info.get("Shares Outstanding")
|
||||
if sh_info is not None:
|
||||
try:
|
||||
shares = pd.Series([float(sh_info)] * ncol, index=fin.columns[:ncol])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
def v0(s: Optional[pd.Series]) -> Optional[float]:
|
||||
if s is None or len(s) == 0:
|
||||
return None
|
||||
x = _safe_float(s.iloc[0])
|
||||
return x if (x is not None and x == x and not pd.isna(x)) else None
|
||||
def v1(s: Optional[pd.Series]) -> Optional[float]:
|
||||
if s is None or len(s) < 2:
|
||||
return None
|
||||
x = _safe_float(s.iloc[1])
|
||||
return x if (x is not None and x == x and not pd.isna(x)) else None
|
||||
ni0, ni1 = v0(ni), v1(ni)
|
||||
ocf0 = v0(ocf) if ocf is not None else None
|
||||
ta0, ta1 = v0(ta), v1(ta)
|
||||
roa0 = (ni0 / ta0 * 100) if (ni0 is not None and ta0 and ta0 != 0) else None
|
||||
roa1 = (ni1 / ta1 * 100) if (ni1 is not None and ta1 and ta1 != 0) else None
|
||||
lt0 = v0(lt_debt) or 0
|
||||
lt1 = v1(lt_debt) or 0
|
||||
cl0, cl1 = v0(cl), v1(cl)
|
||||
ca0, ca1 = v0(ca), v1(ca)
|
||||
cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None
|
||||
cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None
|
||||
sh0, sh1 = v0(shares), v1(shares)
|
||||
rev0, rev1 = v0(rev), v1(rev)
|
||||
gm0 = (v0(gross) / rev0 * 100) if (gross is not None and rev0 and rev0 != 0) else None
|
||||
gm1 = (v1(gross) / rev1 * 100) if (gross is not None and rev1 and rev1 != 0) else None
|
||||
at0 = (rev0 / ta0) if (rev0 and ta0 and ta0 != 0) else None
|
||||
at1 = (rev1 / ta1) if (rev1 and ta1 and ta1 != 0) else None
|
||||
criteria = [
|
||||
("Net Income > 0 (profitability)", ni0 is not None and ni0 > 0),
|
||||
("Operating Cash Flow > 0 (cash generative)", ocf0 is not None and ocf0 > 0),
|
||||
("ROA increased vs prior period (improving returns)", roa0 is not None and roa1 is not None and roa0 > roa1),
|
||||
("OCF > Net Income (earnings quality, less accruals)", ocf0 is not None and ni0 is not None and ocf0 > ni0),
|
||||
("Leverage decreased: LT Debt/Assets lower (less debt)", ta0 and ta0 != 0 and ta1 and ta1 != 0 and (lt0 / ta0) < (lt1 / ta1)),
|
||||
("Current Ratio improved (better liquidity)", cr0 is not None and cr1 is not None and cr0 > cr1),
|
||||
("No dilution: shares unchanged or lower (no equity raise)", (sh0 is not None and sh1 is not None and sh0 <= sh1) if (sh0 is not None and sh1 is not None) else True),
|
||||
("Gross Margin improved (pricing power)", gm0 is not None and gm1 is not None and gm0 > gm1),
|
||||
("Asset Turnover improved (efficiency)", at0 is not None and at1 is not None and at0 > at1),
|
||||
]
|
||||
out["score"] = sum(1 for _, p in criteria if p)
|
||||
out["criteria"] = criteria
|
||||
out["used_ttm"] = bool(any(str(c).startswith("TTM") for c in fin.columns))
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Radar metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def radar_metrics_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build radar chart data from AI-extracted financials."""
|
||||
cur = (ai_dict or {}).get("current_yr") or {}
|
||||
prev = (ai_dict or {}).get("previous_yr") or {}
|
||||
if not cur:
|
||||
return {}
|
||||
eq0 = (cur.get("TotalAssets") or 0) - (cur.get("CurrentLiabilities") or 0) - (cur.get("LongTermDebt") or 0)
|
||||
if eq0 <= 0:
|
||||
eq0 = (cur.get("TotalAssets") or 0) * 0.5
|
||||
roe = (cur.get("NetIncome") or 0) / eq0 * 100 if eq0 else 0
|
||||
ca, cl = cur.get("CurrentAssets") or 0, cur.get("CurrentLiabilities") or 0
|
||||
current_ratio = (ca / cl) if cl and cl != 0 else 0
|
||||
ta = cur.get("TotalAssets") or 1
|
||||
asset_turnover = (cur.get("Revenue") or 0) / ta
|
||||
equity_mult = (cur.get("TotalAssets") or 0) / eq0 if eq0 else 0
|
||||
rev0, rev1 = cur.get("Revenue") or 0, prev.get("Revenue") or 0
|
||||
rev_yoy = ((rev0 - rev1) / rev1 * 100) if rev1 and rev1 != 0 else 0
|
||||
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
|
||||
return {"theta": theta, "r": _radar_norm(roe, current_ratio, asset_turnover, equity_mult, rev_yoy), "labels": theta}
|
||||
|
||||
|
||||
def get_radar_metrics_normalized(ticker: str) -> Dict[str, Any]:
|
||||
"""ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY normalised 0-100."""
|
||||
if not ticker:
|
||||
return {}
|
||||
q = get_dupont_altman_redflags_yoy(ticker)
|
||||
if not q:
|
||||
return {}
|
||||
dupont_df = q.get("dupont")
|
||||
if dupont_df is None or dupont_df.empty or len(dupont_df) < 2:
|
||||
return {}
|
||||
row0 = dupont_df.iloc[0]
|
||||
roe = row0.get("ROE %") or 0
|
||||
cr = row0.get("Current Ratio") or 0
|
||||
at = row0.get("Asset Turnover") or 0
|
||||
em = row0.get("Equity Mult.") or 0
|
||||
rev0 = dupont_df["Revenue"].iloc[0] if "Revenue" in dupont_df.columns else None
|
||||
rev1 = dupont_df["Revenue"].iloc[1] if "Revenue" in dupont_df.columns else None
|
||||
rev_yoy = ((rev0 - rev1) / rev1 * 100) if (rev0 and rev1 and rev1 != 0) else 0
|
||||
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
|
||||
return {"theta": theta, "r": _radar_norm(roe, cr, at, em, rev_yoy), "labels": theta}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sector-specific metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_sector_specific_metrics(ticker: str, sector: str) -> Dict[str, Any]:
|
||||
"""Technology: Rule of 40, R&D %. Retail: Inventory Turnover. Financials: ROE/ROA."""
|
||||
if not yf:
|
||||
return {}
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
fin = t.financials
|
||||
bal = t.balance_sheet
|
||||
if fin is None or fin.empty:
|
||||
fin = getattr(t, "quarterly_financials", None)
|
||||
if fin is not None and not fin.empty:
|
||||
fin = fin.iloc[:, :4].sum(axis=1).to_frame()
|
||||
if bal is None or bal.empty:
|
||||
bal = getattr(t, "quarterly_balance_sheet", None)
|
||||
out: Dict[str, Any] = {}
|
||||
sector_lower = (sector or "").lower()
|
||||
if "technology" in sector_lower or "software" in sector_lower or "tech" in sector_lower:
|
||||
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
|
||||
cf_source = t.cashflow or getattr(t, "quarterly_cashflow", None)
|
||||
ocf = _get_row_series(cf_source, "Operating Cash Flow", "Cash From Operating Activities")
|
||||
capx = _get_row_series(cf_source, "Capital Expenditure", "Capital Expenditures")
|
||||
rd = _get_row_series(fin, "Research And Development", "Research And Development Expense")
|
||||
if rev is not None and len(rev) > 0:
|
||||
r0 = _safe_float(rev.iloc[0])
|
||||
if ocf is not None and len(ocf) > 0 and capx is not None and len(capx) > 0:
|
||||
fcf = _safe_float(ocf.iloc[0]) - _safe_float(capx.iloc[0])
|
||||
out["FCF Margin %"] = round(fcf / r0 * 100, 2) if r0 and fcf is not None else None
|
||||
if rd is not None and len(rd) > 0:
|
||||
out["R&D % of Revenue"] = round(_safe_float(rd.iloc[0]) / r0 * 100, 2) if r0 else None
|
||||
if len(rev) >= 2:
|
||||
cur_r, prev_r = _safe_float(rev.iloc[0]), _safe_float(rev.iloc[1])
|
||||
rev_growth = ((cur_r - prev_r) / prev_r * 100) if prev_r and prev_r != 0 else None
|
||||
if rev_growth is not None and "FCF Margin %" in out and out["FCF Margin %"] is not None:
|
||||
out["Rule of 40 (Rev Growth + FCF Margin)"] = round(rev_growth + out["FCF Margin %"], 1)
|
||||
if "consumer" in sector_lower or "retail" in sector_lower or "cyclical" in sector_lower:
|
||||
inv = _get_row_series(bal, "Inventory", "Total Inventory")
|
||||
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
|
||||
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
|
||||
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
|
||||
if inv is not None and len(inv) > 0 and cogs is not None and len(cogs) > 0:
|
||||
out["Inventory Turnover"] = round(_safe_float(cogs.iloc[0]) / _safe_float(inv.iloc[0]), 2) if _safe_float(inv.iloc[0]) else None
|
||||
if rev is not None and len(rev) > 0 and op_inc is not None and len(op_inc) > 0:
|
||||
out["Operating Margin %"] = round(_safe_float(op_inc.iloc[0]) / _safe_float(rev.iloc[0]) * 100, 2) if _safe_float(rev.iloc[0]) else None
|
||||
if "financial" in sector_lower or "bank" in sector_lower or "insurance" in sector_lower:
|
||||
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
|
||||
te = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
|
||||
ta_s = _get_row_series(bal, "Total Assets")
|
||||
if ni is not None and te is not None and len(ni) > 0 and len(te) > 0:
|
||||
out["ROE %"] = round(_safe_float(ni.iloc[0]) / _safe_float(te.iloc[0]) * 100, 2) if _safe_float(te.iloc[0]) else None
|
||||
if ni is not None and ta_s is not None and len(ni) > 0 and len(ta_s) > 0:
|
||||
out["ROA %"] = round(_safe_float(ni.iloc[0]) / _safe_float(ta_s.iloc[0]) * 100, 2) if _safe_float(ta_s.iloc[0]) else None
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quarterly momentum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_quarterly_momentum(ticker: str) -> Dict[str, Any]:
|
||||
"""Last 4 quarters Revenue/NI with QoQ growth for the most recent."""
|
||||
out: Dict[str, Any] = {"df": None, "qoq_revenue_pct": None, "qoq_ni_pct": None}
|
||||
if not yf or not ticker:
|
||||
return out
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
qfin = getattr(t, "quarterly_financials", None)
|
||||
if qfin is None or qfin.empty or len(qfin.columns) < 2:
|
||||
return out
|
||||
rev = _get_row_series(qfin, "Total Revenue", "Revenue", "Net Revenue")
|
||||
ni = _get_row_series(qfin, "Net Income", "Net Income Common Stockholders")
|
||||
if rev is None and ni is None:
|
||||
return out
|
||||
cols = list(qfin.columns)[:4]
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for c in cols:
|
||||
try:
|
||||
if hasattr(c, "strftime"):
|
||||
q = (c.month - 1) // 3 + 1
|
||||
label = c.strftime("%Y") + f"-Q{q}"
|
||||
else:
|
||||
label = str(c)[:12]
|
||||
except Exception:
|
||||
label = str(c)[:12]
|
||||
r_val = _safe_float(rev.loc[c]) if rev is not None and c in rev.index else None
|
||||
n_val = _safe_float(ni.loc[c]) if ni is not None and c in ni.index else None
|
||||
rows.append({"Quarter": label, "Revenue": r_val, "Net Income": n_val})
|
||||
out["df"] = pd.DataFrame(rows)
|
||||
if len(rows) >= 2:
|
||||
r0, r1 = rows[0].get("Revenue"), rows[1].get("Revenue")
|
||||
n0, n1 = rows[0].get("Net Income"), rows[1].get("Net Income")
|
||||
if r0 is not None and r1 is not None and r1 != 0:
|
||||
out["qoq_revenue_pct"] = round((r0 - r1) / abs(r1) * 100, 1)
|
||||
if n0 is not None and n1 is not None and n1 != 0:
|
||||
out["qoq_ni_pct"] = round((n0 - n1) / abs(n1) * 100, 1)
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
|
||||
def get_quarterly_ratio_changes(ticker: str) -> List[Dict[str, Any]]:
|
||||
"""QoQ ratio changes for NPM, ROE, Gross/Operating Margin, Current Ratio, Interest Coverage."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
if not yf or not ticker:
|
||||
return out
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
qf = getattr(t, "quarterly_financials", None)
|
||||
qb = getattr(t, "quarterly_balance_sheet", None)
|
||||
if qf is None or qf.empty or qb is None or qb.empty or len(qf.columns) < 2 or len(qb.columns) < 2:
|
||||
return out
|
||||
rev = _get_row_series(qf, "Total Revenue", "Revenue", "Net Revenue")
|
||||
ni = _get_row_series(qf, "Net Income", "Net Income Common Stockholders")
|
||||
gross = _get_row_series(qf, "Gross Profit")
|
||||
ebit = _get_row_series(qf, "Operating Income", "EBIT")
|
||||
interest = _get_row_series(qf, "Interest Expense", "Interest Expense Net")
|
||||
ta = _get_row_series(qb, "Total Assets")
|
||||
te = _get_row_series(qb, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
|
||||
ca = _get_row_series(qb, "Current Assets")
|
||||
cl = _get_row_series(qb, "Current Liabilities")
|
||||
def v(s: Optional[pd.Series], col: Any) -> Optional[float]:
|
||||
if s is None or col not in s.index:
|
||||
return None
|
||||
return _safe_float(s.get(col))
|
||||
c0, c1 = qf.columns[0], qf.columns[1]
|
||||
b0, b1 = qb.columns[0], qb.columns[1]
|
||||
r0, r1 = v(rev, c0), v(rev, c1)
|
||||
n0, n1 = v(ni, c0), v(ni, c1)
|
||||
g0, g1 = v(gross, c0), v(gross, c1)
|
||||
e0, e1 = v(ebit, c0), v(ebit, c1)
|
||||
i0, i1 = v(interest, c0), v(interest, c1)
|
||||
te0, te1 = v(te, b0), v(te, b1)
|
||||
ca0, ca1 = v(ca, b0), v(ca, b1)
|
||||
cl0, cl1 = v(cl, b0), v(cl, b1)
|
||||
npm0 = (n0 / r0 * 100) if (n0 is not None and r0 and r0 != 0) else None
|
||||
npm1 = (n1 / r1 * 100) if (n1 is not None and r1 and r1 != 0) else None
|
||||
roe0 = (n0 / te0 * 100) if (n0 is not None and te0 and te0 != 0) else None
|
||||
roe1 = (n1 / te1 * 100) if (n1 is not None and te1 and te1 != 0) else None
|
||||
gm0 = (g0 / r0 * 100) if (g0 is not None and r0 and r0 != 0) else None
|
||||
gm1 = (g1 / r1 * 100) if (g1 is not None and r1 and r1 != 0) else None
|
||||
om0 = (e0 / r0 * 100) if (e0 is not None and r0 and r0 != 0) else None
|
||||
om1 = (e1 / r1 * 100) if (e1 is not None and r1 and r1 != 0) else None
|
||||
cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None
|
||||
cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None
|
||||
ic0 = (e0 / i0) if (e0 is not None and i0 and i0 != 0) else None
|
||||
ic1 = (e1 / i1) if (e1 is not None and i1 and i1 != 0) else None
|
||||
def make_row(metric: str, cur: Optional[float], prev: Optional[float], is_pct_point: bool = False) -> Optional[Dict[str, Any]]:
|
||||
if cur is None:
|
||||
return None
|
||||
if prev is None:
|
||||
return {"Metric": metric, "Current Value": round(cur, 2), "Change": "-", "Trend": "-"}
|
||||
chg = (cur - prev) if is_pct_point else (((cur - prev) / abs(prev) * 100) if prev != 0 else 0)
|
||||
trend = "up" if chg > 0 else ("down" if chg < 0 else "flat")
|
||||
chg_str = f"{chg:+.1f}%" if not is_pct_point else f"{chg:+.1f} pp"
|
||||
return {"Metric": metric, "Current Value": round(cur, 2), "Change": chg_str, "Trend": trend}
|
||||
for name, cur_v, prev_v, is_pp in [
|
||||
("NPM %", npm0, npm1, True), ("ROE %", roe0, roe1, True), ("Gross Margin %", gm0, gm1, True),
|
||||
("Operating Margin %", om0, om1, True), ("Current Ratio", cr0, cr1, False), ("Interest Coverage", ic0, ic1, False),
|
||||
]:
|
||||
r = make_row(name, cur_v, prev_v, is_pp)
|
||||
if r:
|
||||
out.append(r)
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
@@ -0,0 +1,151 @@
|
||||
"""FX rate fetcher -- yfinance-based with in-memory TTL cache.
|
||||
|
||||
Provides current rates and 1-year history for major currency pairs.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory cache with configurable TTL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_cache: Dict[str, Any] = {}
|
||||
_cache_ts: Dict[str, float] = {}
|
||||
_CACHE_TTL = 60 # seconds
|
||||
|
||||
# Default pairs
|
||||
DEFAULT_PAIRS: List[str] = ["USDKRW=X", "GBPUSD=X", "EURUSD=X", "USDJPY=X"]
|
||||
|
||||
|
||||
def _get_cached(key: str) -> Optional[Any]:
|
||||
if key in _cache and (time.time() - _cache_ts.get(key, 0)) < _CACHE_TTL:
|
||||
return _cache[key]
|
||||
return None
|
||||
|
||||
|
||||
def _set_cached(key: str, value: Any) -> None:
|
||||
_cache[key] = value
|
||||
_cache_ts[key] = time.time()
|
||||
|
||||
|
||||
def _normalise_pair(pair: str) -> str:
|
||||
"""Ensure pair is in Yahoo Finance format (e.g. 'USDKRW=X')."""
|
||||
p = pair.upper().replace("/", "").strip()
|
||||
if not p.endswith("=X"):
|
||||
p = f"{p}=X"
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Current rates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_fx_rate(pair: str) -> Optional[float]:
|
||||
"""Fetch the latest exchange rate for a single currency pair.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pair:
|
||||
Currency pair string, e.g. ``"USDKRW"``, ``"USDKRW=X"``, ``"EUR/USD"``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float or None
|
||||
The latest rate, or None if unavailable.
|
||||
"""
|
||||
symbol = _normalise_pair(pair)
|
||||
cache_key = f"fx_rate:{symbol}"
|
||||
cached = _get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
ticker = yf.Ticker(symbol)
|
||||
fast = getattr(ticker, "fast_info", None)
|
||||
if fast:
|
||||
price = getattr(fast, "last_price", None)
|
||||
if price and float(price) > 0:
|
||||
rate = float(price)
|
||||
_set_cached(cache_key, rate)
|
||||
return rate
|
||||
hist = ticker.history(period="1d")
|
||||
if hist is not None and not hist.empty:
|
||||
rate = float(hist["Close"].iloc[-1])
|
||||
_set_cached(cache_key, rate)
|
||||
return rate
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def fetch_multiple_rates(
|
||||
pairs: Optional[List[str]] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""Fetch current rates for multiple pairs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pairs:
|
||||
List of pair strings. Defaults to DEFAULT_PAIRS.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Mapping of normalised pair symbol -> rate.
|
||||
"""
|
||||
pairs = pairs or DEFAULT_PAIRS
|
||||
rates: Dict[str, float] = {}
|
||||
for pair in pairs:
|
||||
rate = fetch_fx_rate(pair)
|
||||
if rate is not None:
|
||||
key = _normalise_pair(pair).replace("=X", "")
|
||||
rates[key] = round(rate, 4)
|
||||
return rates
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1-year history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_fx_history(
|
||||
pair: str,
|
||||
period: str = "1y",
|
||||
) -> Tuple[List[str], List[float]]:
|
||||
"""Fetch historical daily closing rates for a currency pair.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pair:
|
||||
Currency pair string.
|
||||
period:
|
||||
yfinance period string (default ``"1y"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple of (dates, rates)
|
||||
dates: list of ISO date strings
|
||||
rates: list of float closing prices
|
||||
"""
|
||||
symbol = _normalise_pair(pair)
|
||||
cache_key = f"fx_hist:{symbol}:{period}"
|
||||
cached = _get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
ticker = yf.Ticker(symbol)
|
||||
hist = ticker.history(period=period)
|
||||
if hist is None or hist.empty:
|
||||
return ([], [])
|
||||
dates = [d.strftime("%Y-%m-%d") for d in hist.index]
|
||||
rates = [round(float(v), 4) for v in hist["Close"]]
|
||||
result = (dates, rates)
|
||||
_set_cached(cache_key, result)
|
||||
return result
|
||||
except Exception:
|
||||
return ([], [])
|
||||
@@ -0,0 +1,204 @@
|
||||
"""High-level Gemini analysis orchestrators.
|
||||
|
||||
Contains the composite analysis functions that combine multiple Gemini
|
||||
calls (chunked insights, comparative MD&A, industry outlook). These build
|
||||
on the primitives in :mod:`server.services.gemini_service`.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from server.services.gemini_service import (
|
||||
_gemini_forensic_audit,
|
||||
_gemini_summarize_segment,
|
||||
_gemini_synthesize_report,
|
||||
_generate_with_retry,
|
||||
_is_rate_limit_error,
|
||||
get_gemini_model,
|
||||
)
|
||||
from server.services.text_chunker import clean_text_for_llm, smart_chunk, _split_into_chunks
|
||||
|
||||
|
||||
def get_mda_chunked_insights(
|
||||
api_key: str,
|
||||
sections: Dict[str, str],
|
||||
ticker: str,
|
||||
sector: str,
|
||||
industry: str,
|
||||
progress_callback: Optional[Callable[[str], None]] = None,
|
||||
) -> str:
|
||||
"""Full-text analysis: chunk 1A+7, summarise each, synthesise, then append forensic.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_key:
|
||||
Google Gemini API key.
|
||||
sections:
|
||||
Dict with keys ``item1a``, ``item7``, ``item3``, ``item9a``.
|
||||
ticker:
|
||||
Stock ticker symbol.
|
||||
sector / industry:
|
||||
Used for sector-aware KPI extraction.
|
||||
progress_callback:
|
||||
Optional ``fn(msg: str)`` called with status updates.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Markdown-formatted Executive Insight Report.
|
||||
"""
|
||||
def _progress(msg: str) -> None:
|
||||
if progress_callback:
|
||||
progress_callback(msg)
|
||||
|
||||
combined = (sections.get("item1a") or "") + "\n\n---\n\n" + (sections.get("item7") or "")
|
||||
combined = combined.strip()
|
||||
if not combined:
|
||||
return "No 10-K text available to analyse."
|
||||
|
||||
chunks = _split_into_chunks(combined, max_chars=22_000)
|
||||
if not chunks:
|
||||
return "No content extracted."
|
||||
|
||||
summaries = []
|
||||
n = len(chunks)
|
||||
for i, ch in enumerate(chunks):
|
||||
_progress(f"Analyzing Segment {i + 1}/{n}...")
|
||||
summary = _gemini_summarize_segment(api_key, ch, ticker, f"Segment {i + 1}/{n}")
|
||||
if summary:
|
||||
summaries.append(summary)
|
||||
|
||||
if not summaries:
|
||||
return "Segment analysis produced no summaries."
|
||||
|
||||
_progress("Synthesizing final report...")
|
||||
report = _gemini_synthesize_report(api_key, summaries, ticker, sector or "N/A", industry or "N/A")
|
||||
|
||||
_progress("Running forensic audit (Item 3 & 9A)...")
|
||||
forensic = _gemini_forensic_audit(api_key, sections.get("item3") or "", sections.get("item9a") or "", ticker)
|
||||
|
||||
return (report or "") + "\n\n---\n\n**Forensic (Item 3 & 9A)**\n\n" + (forensic or "")
|
||||
|
||||
|
||||
def get_mda_insights(
|
||||
api_key: str,
|
||||
item1a_text: str,
|
||||
item7_text: str,
|
||||
ticker: str,
|
||||
) -> str:
|
||||
"""Single-shot analysis of Item 1A + Item 7 (tone, strategy, risks)."""
|
||||
model = get_gemini_model(api_key)
|
||||
combined = []
|
||||
if item1a_text:
|
||||
combined.append(clean_text_for_llm(item1a_text))
|
||||
if item7_text:
|
||||
combined.append(clean_text_for_llm(item7_text))
|
||||
combined_text = smart_chunk("\n\n---\n\n".join(combined), max_chars=22_000)
|
||||
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. Use British English.\n\n"
|
||||
f"The text below is from the 10-K for {ticker}: **Item 1A** and **Item 7**.\n\n"
|
||||
"Provide a concise report:\n"
|
||||
"1. **Management's Tone (Sentiment)**\n"
|
||||
"2. **Key Strategic Shifts**\n"
|
||||
"3. **Major Hidden Risks**\n\n"
|
||||
"Use clear headings. Under 800 words."
|
||||
)
|
||||
full = f"--- 10-K Excerpt ---\n\n{combined_text}\n\n---\n\n{prompt}"
|
||||
try:
|
||||
response = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
|
||||
except Exception as api_err:
|
||||
if _is_rate_limit_error(api_err):
|
||||
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
|
||||
raise
|
||||
if not response or not response.text:
|
||||
return "No analysis generated."
|
||||
return response.text.strip()
|
||||
|
||||
|
||||
def get_mda_comparative_insights(
|
||||
api_key: str,
|
||||
item1a_text: str,
|
||||
item7_latest: str,
|
||||
item7_3y_ago: Optional[str],
|
||||
ticker: str,
|
||||
sector: Optional[str] = None,
|
||||
industry: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Comparative or single-year MD&A deep-dive with sector-aware KPIs."""
|
||||
model = get_gemini_model(api_key)
|
||||
sector_label = (sector or "N/A").strip()
|
||||
industry_label = (industry or "N/A").strip()
|
||||
kpi_instruction = (
|
||||
f" Given that this company is in the **{sector_label}** sector"
|
||||
+ (f" (industry: {industry_label})" if industry_label != "N/A" else "")
|
||||
+ ", extract **industry-specific Non-GAAP KPIs** in a markdown table."
|
||||
)
|
||||
|
||||
if not item7_3y_ago or not item7_3y_ago.strip():
|
||||
combined = []
|
||||
if item1a_text:
|
||||
combined.append(clean_text_for_llm(item1a_text))
|
||||
if item7_latest:
|
||||
combined.append(clean_text_for_llm(item7_latest))
|
||||
combined_text = smart_chunk("\n\n---\n\n".join(combined), max_chars=22_000)
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. Use British English.\n"
|
||||
f"Latest 10-K only for {ticker} (Item 1A + Item 7). Provide:\n"
|
||||
"1. **Management's Tone**\n2. **Current Strategy & Priorities**\n"
|
||||
"3. **Major Hidden Risks**\n4. **Forensic / Quality of Earnings**\n"
|
||||
f"{kpi_instruction}\nUnder 800 words."
|
||||
)
|
||||
full = f"--- 10-K Excerpt (Latest Year) ---\n\n{combined_text}\n\n---\n\n{prompt}"
|
||||
else:
|
||||
latest_clean = smart_chunk(clean_text_for_llm(item7_latest), max_chars=12_000)
|
||||
past_clean = smart_chunk(clean_text_for_llm(item7_3y_ago), max_chars=12_000)
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. Use British English.\n"
|
||||
f"Below are Item 7 from the 10-K for {ticker}: LATEST and THREE YEARS AGO.\n"
|
||||
"1. **Core strategy** changes\n2. **Emerging risks**\n"
|
||||
"3. **Management's tone** shift\n4. **Industry-specific KPIs**\n"
|
||||
f"{kpi_instruction}\nUnder 900 words."
|
||||
)
|
||||
full = (
|
||||
f"--- MD&A LATEST YEAR ---\n\n{latest_clean}\n\n"
|
||||
f"--- MD&A THREE YEARS AGO ---\n\n{past_clean}\n\n---\n\n{prompt}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
|
||||
except Exception as api_err:
|
||||
if _is_rate_limit_error(api_err):
|
||||
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
|
||||
raise
|
||||
if not response or not response.text:
|
||||
return "No analysis generated."
|
||||
return response.text.strip()
|
||||
|
||||
|
||||
def get_industry_outlook(
|
||||
api_key: str,
|
||||
industry_name: str,
|
||||
tickers: list,
|
||||
) -> str:
|
||||
"""Generate a Wall Street macro-analyst-style Industry Outlook (12-18 months)."""
|
||||
model = get_gemini_model(api_key)
|
||||
ticker_list_str = ", ".join(str(t).upper() for t in tickers if t)
|
||||
prompt = (
|
||||
f"Act as an elite Wall Street macro analyst. Provide a concise "
|
||||
f"**Industry Outlook** for the **{industry_name}** sector, "
|
||||
f"which includes companies like {ticker_list_str}.\n\n"
|
||||
"Focus on:\n"
|
||||
"1. **Macro trends** (next 12-18 months)\n"
|
||||
"2. **Major growth drivers**\n"
|
||||
"3. **Key headwinds or regulatory risks**\n\n"
|
||||
"Use clear headings. Under 600 words."
|
||||
)
|
||||
try:
|
||||
response = _generate_with_retry(model, prompt, {"temperature": 0.4, "max_output_tokens": 2048})
|
||||
except Exception as api_err:
|
||||
if _is_rate_limit_error(api_err):
|
||||
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
|
||||
raise
|
||||
if not response or not response.text:
|
||||
return "No industry outlook generated."
|
||||
return response.text.strip()
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Gemini LLM integration for qualitative financial analysis.
|
||||
|
||||
All functions in this module talk to Google Gemini (via the
|
||||
``google.generativeai`` SDK) and return plain strings or dicts.
|
||||
No Streamlit dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, Generator, List, Optional
|
||||
|
||||
from server.utils.safe_float import _safe_float
|
||||
from server.services.text_chunker import clean_text_for_llm, smart_chunk, _split_into_chunks
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GEMINI_MODEL: str = "gemini-2.0-flash"
|
||||
RATE_LIMIT_WAIT_SEC: int = 60
|
||||
|
||||
_REQUIRED_FINANCIAL_KEYS: List[str] = [
|
||||
"Revenue", "CostOfRevenue", "OperatingExpenses", "NetIncome",
|
||||
"TotalAssets", "CurrentAssets", "CurrentLiabilities", "LongTermDebt",
|
||||
"OperatingCashFlow", "SharesOutstanding",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model initialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_gemini_model(api_key: str) -> Any:
|
||||
"""Configure and return a ``GenerativeModel`` for :data:`GEMINI_MODEL`."""
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=api_key)
|
||||
return genai.GenerativeModel(GEMINI_MODEL)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retry / streaming helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_rate_limit_error(e: Exception) -> bool:
|
||||
"""Return ``True`` if *e* looks like a 429 / resource-exhausted error."""
|
||||
err_msg = str(e).lower()
|
||||
return (
|
||||
"429" in err_msg
|
||||
or "resourcelimited" in err_msg
|
||||
or "resource exhausted" in err_msg
|
||||
or getattr(e, "code", None) == 429
|
||||
)
|
||||
|
||||
|
||||
def _generate_with_retry(
|
||||
model: Any,
|
||||
content: str,
|
||||
config: Dict[str, Any],
|
||||
max_retries: int = 3,
|
||||
) -> Any:
|
||||
"""Call ``model.generate_content`` with automatic rate-limit back-off."""
|
||||
last_err: Optional[Exception] = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return model.generate_content(content, generation_config=config)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < max_retries and _is_rate_limit_error(e):
|
||||
time.sleep(RATE_LIMIT_WAIT_SEC)
|
||||
continue
|
||||
raise
|
||||
raise last_err # type: ignore[misc]
|
||||
|
||||
|
||||
def _generate_stream(
|
||||
model: Any,
|
||||
content: str,
|
||||
config: Dict[str, Any],
|
||||
) -> Generator[str, None, None]:
|
||||
"""Yield text chunks from Gemini with ``stream=True``."""
|
||||
response = model.generate_content(content, generation_config=config, stream=True)
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "text") and chunk.text:
|
||||
yield chunk.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Segment-level helpers (chunked analysis)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _gemini_summarize_segment(
|
||||
api_key: str,
|
||||
segment_text: str,
|
||||
ticker: str,
|
||||
segment_label: str,
|
||||
) -> str:
|
||||
"""Summarise one segment of Item 1A / Item 7 text."""
|
||||
model = get_gemini_model(api_key)
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. The following is one segment of "
|
||||
f"the 10-K for {ticker} (Item 1A Risk Factors and/or Item 7 MD&A).\n"
|
||||
"Extract and list all significant: (1) strategic shifts or priorities, "
|
||||
"(2) hidden or material risks, (3) management tone cues. Use concise "
|
||||
f"bullet points. Do not omit important details. Segment: {segment_label}."
|
||||
)
|
||||
full = f"--- 10-K Segment ---\n\n{segment_text[:50000]}\n\n---\n\n{prompt}"
|
||||
try:
|
||||
r = _generate_with_retry(model, full, {"temperature": 0.2, "max_output_tokens": 2048})
|
||||
return (r.text or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _gemini_synthesize_report(
|
||||
api_key: str,
|
||||
segment_summaries: List[str],
|
||||
ticker: str,
|
||||
sector: str,
|
||||
industry: str,
|
||||
) -> str:
|
||||
"""Synthesise segment summaries into an Executive Insight Report."""
|
||||
model = get_gemini_model(api_key)
|
||||
combined = "\n\n---\n\n".join(segment_summaries)
|
||||
kpi_note = (
|
||||
f" Sector: {sector}; Industry: {industry}. Include industry-specific KPIs if mentioned."
|
||||
if sector and sector != "N/A"
|
||||
else ""
|
||||
)
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. Use British English. Below are "
|
||||
f"summarized insights from the full 10-K for {ticker} (Item 1A and "
|
||||
"Item 7). Create the final **Executive Insight Report** with these sections:\n\n"
|
||||
"1. **Management's Tone (Sentiment)**: Overall tone and supporting evidence.\n"
|
||||
"2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers.\n"
|
||||
"3. **Major Hidden Risks**: The 3-4 most material risks investors might overlook.\n"
|
||||
"4. **Forensic / Quality of Earnings**: Accounting caveats, one-offs, cash flow vs earnings."
|
||||
f"{kpi_note}\n\n"
|
||||
"Use clear headings. Do not invent figures. Keep under 900 words."
|
||||
)
|
||||
full = f"--- Segment Summaries ---\n\n{combined}\n\n---\n\n{prompt}"
|
||||
try:
|
||||
r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
|
||||
return (r.text or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _gemini_forensic_audit(
|
||||
api_key: str,
|
||||
item3: str,
|
||||
item9a: str,
|
||||
ticker: str,
|
||||
) -> str:
|
||||
"""Check Item 3 & 9A for material weaknesses, lawsuits, red flags."""
|
||||
model = get_gemini_model(api_key)
|
||||
combined = (item3 or "") + "\n\n---\n\n" + (item9a or "")
|
||||
if not combined.strip():
|
||||
return "No Item 3 / 9A text provided; skip forensic."
|
||||
prompt = (
|
||||
f"From the following 10-K excerpts for {ticker} (Item 3 Legal Proceedings "
|
||||
"and Item 9A Controls/Internal Control), list any:\n"
|
||||
"- Material weaknesses in internal control\n"
|
||||
"- Significant legal proceedings or litigation\n"
|
||||
"- Off-balance-sheet or governance red flags\n"
|
||||
'If none, output: "No material red flags or special issues detected '
|
||||
'in Item 3 and 9A."\nBe concise (under 150 words).'
|
||||
)
|
||||
full = f"--- Item 3 & 9A ---\n\n{combined[:30000]}\n\n---\n\n{prompt}"
|
||||
try:
|
||||
r = _generate_with_retry(model, full, {"temperature": 0.1, "max_output_tokens": 512})
|
||||
return (r.text or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public analysis functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_sec_financials_llm(api_key: str, item8_text: str, ticker: str) -> Dict[str, Any]:
|
||||
"""Extract current/previous year financials from Item 8 via Gemini."""
|
||||
if not (api_key or "").strip() or not (item8_text or "").strip():
|
||||
return {}
|
||||
payload = smart_chunk((item8_text or "").strip(), max_chars=35_000)
|
||||
model = get_gemini_model(api_key)
|
||||
prompt = (
|
||||
f"You are a financial analyst. Below is Item 8 (Financial Statements "
|
||||
f"and Supplementary Data) from the latest 10-K for {ticker}.\n\n"
|
||||
"Extract figures for **Current Year** and **Previous Year**. "
|
||||
"Monetary values in millions. Shares in millions.\n\n"
|
||||
"Return ONLY valid JSON:\n"
|
||||
'{"current_yr": {...}, "previous_yr": {...}}\n'
|
||||
"Keys: Revenue, CostOfRevenue, OperatingExpenses, NetIncome, "
|
||||
"TotalAssets, CurrentAssets, CurrentLiabilities, LongTermDebt, "
|
||||
"OperatingCashFlow, SharesOutstanding.\n"
|
||||
"If not found use 0. Output nothing except JSON."
|
||||
)
|
||||
full = f"--- Item 8 ---\n\n{payload}\n\n---\n\n{prompt}"
|
||||
try:
|
||||
r = _generate_with_retry(model, full, {"temperature": 0.0, "max_output_tokens": 2048})
|
||||
raw = (r.text or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
raw = re.sub(r"^```\s*json\s*", "", raw)
|
||||
raw = re.sub(r"^```\s*", "", raw)
|
||||
raw = re.sub(r"\s*```\s*$", "", raw)
|
||||
raw = raw.strip()
|
||||
out = json.loads(raw)
|
||||
cur = out.get("current_yr") or {}
|
||||
prev = out.get("previous_yr") or {}
|
||||
for key in _REQUIRED_FINANCIAL_KEYS:
|
||||
cur[key] = _safe_float(cur.get(key)) or 0
|
||||
prev[key] = _safe_float(prev.get(key)) or 0
|
||||
return {"current_yr": cur, "previous_yr": prev}
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return {}
|
||||
|
||||
|
||||
def get_gemini_item7_strategy(
|
||||
api_key: str,
|
||||
item7_text: str,
|
||||
ticker: str,
|
||||
sector: str,
|
||||
industry: str,
|
||||
) -> str:
|
||||
"""Analyse Item 7 for business performance and strategic shifts."""
|
||||
if not (item7_text or "").strip():
|
||||
return "No Item 7 (MD&A) text available."
|
||||
model = get_gemini_model(api_key)
|
||||
text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10_000)
|
||||
sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else ""
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. Use British English. The text below is "
|
||||
f"**Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note}\n\n"
|
||||
"Provide a concise **Management Strategy** report:\n"
|
||||
"1. **Business performance**\n2. **Strategic shifts**\n3. **Capital allocation**\n"
|
||||
"Use clear headings. Under 600 words. Output in British English even if source is another language."
|
||||
)
|
||||
full = f"--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}"
|
||||
try:
|
||||
r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
|
||||
return (r.text or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_gemini_item7_strategy_stream(
|
||||
api_key: str,
|
||||
item7_text: str,
|
||||
ticker: str,
|
||||
sector: str,
|
||||
industry: str,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Yield MD&A strategy report chunks for real-time streaming."""
|
||||
if not (item7_text or "").strip():
|
||||
yield "No Item 7 (MD&A) text available."
|
||||
return
|
||||
model = get_gemini_model(api_key)
|
||||
text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10_000)
|
||||
sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else ""
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. Use British English. The text below is "
|
||||
f"**Item 7 (MD&A)** from the latest 10-K for {ticker}.{sector_note}\n\n"
|
||||
"Provide a concise **Management Strategy** report:\n"
|
||||
"1. **Business performance**\n2. **Strategic shifts**\n3. **Capital allocation**\n"
|
||||
"Under 600 words. British English."
|
||||
)
|
||||
full = f"--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}"
|
||||
yield from _generate_stream(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
|
||||
|
||||
|
||||
def get_gemini_item1a_risks(
|
||||
api_key: str,
|
||||
item1a_text: str,
|
||||
item3: str,
|
||||
item9a: str,
|
||||
ticker: str,
|
||||
) -> str:
|
||||
"""Analyse Item 1A risks and append forensic audit of Items 3 & 9A."""
|
||||
if not (item1a_text or "").strip():
|
||||
return "No Item 1A (Risk Factors) text available."
|
||||
model = get_gemini_model(api_key)
|
||||
text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10_000)
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. Use British English. The text below is "
|
||||
f"**Item 1A (Risk Factors)** from the latest 10-K for {ticker}.\n\n"
|
||||
"Provide a concise **Risk Factors** report:\n"
|
||||
"1. **Legal & regulatory risks**\n2. **Operational risks**\n3. **Market & competitive risks**\n"
|
||||
"Under 500 words. British English."
|
||||
)
|
||||
full = f"--- Item 1A ---\n\n{text}\n\n---\n\n{prompt}"
|
||||
try:
|
||||
report = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
|
||||
risks = (report.text or "").strip()
|
||||
except Exception:
|
||||
risks = ""
|
||||
forensic = _gemini_forensic_audit(api_key, item3 or "", item9a or "", ticker)
|
||||
return (risks or "") + "\n\n---\n\n**Forensic Audit (Item 3 & 9A)**\n\n" + (forensic or "")
|
||||
|
||||
|
||||
def get_gemini_item1a_risks_stream(
|
||||
api_key: str,
|
||||
item1a_text: str,
|
||||
ticker: str,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Yield Risk Factors report chunks; caller appends forensic separately."""
|
||||
if not (item1a_text or "").strip():
|
||||
yield "No Item 1A (Risk Factors) text available."
|
||||
return
|
||||
model = get_gemini_model(api_key)
|
||||
text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10_000)
|
||||
prompt = (
|
||||
f"You are a senior equity analyst. Use British English. The text below is "
|
||||
f"**Item 1A (Risk Factors)** from the latest 10-K for {ticker}.\n\n"
|
||||
"Provide a concise **Risk Factors** report:\n"
|
||||
"1. **Legal & regulatory risks**\n2. **Operational risks**\n3. **Market & competitive risks**\n"
|
||||
"Under 500 words. British English."
|
||||
)
|
||||
full = f"--- Item 1A ---\n\n{text}\n\n---\n\n{prompt}"
|
||||
yield from _generate_stream(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Market data endpoints: DCF inputs, analyst consensus, and comps.
|
||||
|
||||
Complements :mod:`server.services.market_fetcher` with higher-level data
|
||||
retrieval functions that consume the raw financial statements and produce
|
||||
ready-to-use outputs for the DCF engine and industry comparison panels.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from server.utils.safe_float import _safe_float
|
||||
from server.services.market_fetcher import (
|
||||
_get_annual_financials_balance_cashflow,
|
||||
_get_row_series,
|
||||
)
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def get_dcf_inputs(ticker: str) -> Dict[str, Optional[float]]:
|
||||
"""Return FCF, Total Debt, Cash, and Shares Outstanding for DCF.
|
||||
|
||||
Tries yahooquery (via ``_get_annual_financials_balance_cashflow``)
|
||||
first, then falls back to direct yfinance lookups.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Keys: ``fcf``, ``total_debt``, ``cash``, ``shares`` (any may be ``None``).
|
||||
"""
|
||||
out: Dict[str, Optional[float]] = {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None}
|
||||
if not ticker:
|
||||
return out
|
||||
try:
|
||||
fin, bal, cf = _get_annual_financials_balance_cashflow(ticker)
|
||||
if bal is not None and not bal.empty and cf is not None and not cf.empty:
|
||||
sh = _get_row_series(bal, "Share Issued")
|
||||
out["shares"] = _safe_float(sh.iloc[0]) if sh is not None and len(sh) > 0 else None
|
||||
td = _get_row_series(bal, "Total Debt")
|
||||
out["total_debt"] = float(td.iloc[0] or 0) if td is not None and len(td) > 0 else 0.0
|
||||
cash_s = _get_row_series(bal, "Cash And Cash Equivalents")
|
||||
out["cash"] = float(cash_s.iloc[0] or 0) if cash_s is not None and len(cash_s) > 0 else 0.0
|
||||
ocf = _get_row_series(cf, "Operating Cash Flow")
|
||||
capx = _get_row_series(cf, "Capital Expenditure")
|
||||
if ocf is not None and len(ocf) > 0:
|
||||
ocf_val = _safe_float(ocf.iloc[0])
|
||||
capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0
|
||||
if ocf_val is not None:
|
||||
out["fcf"] = ocf_val - (capx_val or 0)
|
||||
if out.get("fcf") is not None or out.get("shares") is not None:
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not yf:
|
||||
return out
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
fast_info = getattr(t, "fast_info", None)
|
||||
cashflow = getattr(t, "cashflow", None)
|
||||
if cashflow is None or cashflow.empty:
|
||||
cashflow = getattr(t, "quarterly_cashflow", None)
|
||||
balance = getattr(t, "balance_sheet", None)
|
||||
if balance is None or balance.empty:
|
||||
balance = getattr(t, "quarterly_balance_sheet", None)
|
||||
|
||||
# Shares
|
||||
shares: Optional[float] = None
|
||||
if fast_info is not None:
|
||||
try:
|
||||
s = getattr(fast_info, "shares", None)
|
||||
if s is None and hasattr(fast_info, "get"):
|
||||
s = fast_info.get("shares")
|
||||
if s is not None and float(s) > 0:
|
||||
shares = float(s)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
pass
|
||||
if shares is None:
|
||||
for key in ("sharesOutstanding", "Shares Outstanding", "impliedSharesOutstanding", "Float Shares"):
|
||||
s = info.get(key)
|
||||
if s is not None and float(s) > 0:
|
||||
shares = float(s)
|
||||
break
|
||||
if shares is None and balance is not None and not balance.empty:
|
||||
try:
|
||||
if "Share Issued" in balance.index:
|
||||
shares = _safe_float(balance.loc["Share Issued"].iloc[0])
|
||||
if (shares is None or shares <= 0) and "Ordinary Shares Number" in balance.index:
|
||||
shares = _safe_float(balance.loc["Ordinary Shares Number"].iloc[0])
|
||||
except (KeyError, TypeError, IndexError):
|
||||
pass
|
||||
out["shares"] = shares if (shares is not None and shares > 0) else None
|
||||
|
||||
# Total Debt
|
||||
total_debt: Optional[float] = None
|
||||
if fast_info is not None:
|
||||
try:
|
||||
d = getattr(fast_info, "total_debt", None) or (fast_info.get("total_debt") if hasattr(fast_info, "get") else None)
|
||||
if d is not None and float(d) >= 0:
|
||||
total_debt = float(d)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
pass
|
||||
if total_debt is None:
|
||||
total_debt = info.get("Total Debt")
|
||||
if total_debt is None and balance is not None and not balance.empty:
|
||||
try:
|
||||
if "Total Debt" in balance.index:
|
||||
total_debt = _safe_float(balance.loc["Total Debt"].iloc[0])
|
||||
except (KeyError, TypeError, IndexError):
|
||||
pass
|
||||
out["total_debt"] = float(total_debt) if total_debt is not None else 0.0
|
||||
|
||||
# Cash
|
||||
cash: Optional[float] = None
|
||||
if fast_info is not None:
|
||||
try:
|
||||
c = getattr(fast_info, "cash", None) or (fast_info.get("cash") if hasattr(fast_info, "get") else None)
|
||||
if c is not None and float(c) >= 0:
|
||||
cash = float(c)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
pass
|
||||
if cash is None:
|
||||
cash = info.get("Cash And Cash Equivalents") or info.get("Cash")
|
||||
if cash is None and balance is not None and not balance.empty:
|
||||
try:
|
||||
for row_name in ("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments", "Cash"):
|
||||
if row_name in balance.index:
|
||||
cash = _safe_float(balance.loc[row_name].iloc[0])
|
||||
if cash is not None:
|
||||
break
|
||||
except (KeyError, TypeError, IndexError):
|
||||
pass
|
||||
out["cash"] = float(cash) if cash is not None else 0.0
|
||||
|
||||
# FCF
|
||||
ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations") if cashflow is not None else None
|
||||
capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment") if cashflow is not None else None
|
||||
if ocf is not None and len(ocf) > 0:
|
||||
ocf_val = _safe_float(ocf.iloc[0])
|
||||
capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0
|
||||
if capx_val is None:
|
||||
capx_val = 0.0
|
||||
if ocf_val is not None:
|
||||
latest_fcf = ocf_val - capx_val
|
||||
if latest_fcf == latest_fcf and not (isinstance(latest_fcf, float) and pd.isna(latest_fcf)):
|
||||
out["fcf"] = latest_fcf
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
|
||||
def get_analyst_consensus(ticker: str) -> Dict[str, str]:
|
||||
"""Fetch analyst consensus from yfinance: target price, recommendation, growth."""
|
||||
out = {"targetMeanPrice": "N/A", "recommendationKey": "N/A", "revenueGrowth": "N/A", "earningsGrowth": "N/A"}
|
||||
if not yf or not ticker:
|
||||
return out
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
tp = info.get("targetMeanPrice")
|
||||
if tp is not None:
|
||||
try:
|
||||
out["targetMeanPrice"] = f"${float(tp):.2f}"
|
||||
except (TypeError, ValueError):
|
||||
out["targetMeanPrice"] = str(tp)
|
||||
rec = info.get("recommendationKey") or info.get("recommendation")
|
||||
if rec is not None:
|
||||
out["recommendationKey"] = str(rec)
|
||||
rg = info.get("revenueGrowth")
|
||||
if rg is not None:
|
||||
try:
|
||||
out["revenueGrowth"] = f"{float(rg) * 100:.1f}%"
|
||||
except (TypeError, ValueError):
|
||||
out["revenueGrowth"] = str(rg)
|
||||
eg = info.get("earningsGrowth")
|
||||
if eg is not None:
|
||||
try:
|
||||
out["earningsGrowth"] = f"{float(eg) * 100:.1f}%"
|
||||
except (TypeError, ValueError):
|
||||
out["earningsGrowth"] = str(eg)
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
|
||||
def get_comps_data(tickers: tuple) -> pd.DataFrame:
|
||||
"""Fetch Forward P/E, EV/EBITDA, P/B for a set of tickers."""
|
||||
if not yf:
|
||||
return pd.DataFrame()
|
||||
rows = []
|
||||
for sym in tickers:
|
||||
sym = str(sym).strip().upper()
|
||||
if not sym:
|
||||
continue
|
||||
try:
|
||||
t = yf.Ticker(sym)
|
||||
info = t.info or {}
|
||||
forward_pe = info.get("forwardPE") or info.get("Forward PE") or info.get("trailingPE") or info.get("Trailing PE")
|
||||
ev_ebitda = info.get("enterpriseToEbitda")
|
||||
if ev_ebitda is None:
|
||||
ev, ebitda = info.get("enterpriseValue"), info.get("ebitda")
|
||||
if ev is not None and ebitda is not None and ebitda != 0:
|
||||
ev_ebitda = ev / ebitda
|
||||
pb = info.get("priceToBook") or info.get("Price To Book")
|
||||
rows.append({
|
||||
"Ticker": sym,
|
||||
"Forward P/E": round(float(forward_pe), 2) if forward_pe is not None and _safe_float(forward_pe) is not None else None,
|
||||
"EV/EBITDA": round(float(ev_ebitda), 2) if ev_ebitda is not None and _safe_float(ev_ebitda) is not None else None,
|
||||
"P/B": round(float(pb), 2) if pb is not None and _safe_float(pb) is not None else None,
|
||||
})
|
||||
except Exception:
|
||||
rows.append({"Ticker": sym, "Forward P/E": None, "EV/EBITDA": None, "P/B": None})
|
||||
return pd.DataFrame(rows) if rows else pd.DataFrame()
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Yahoo Finance / yahooquery data fetching for financial statements.
|
||||
|
||||
Provides functions to retrieve annual income statements, balance sheets,
|
||||
cash-flow statements, sector/industry metadata, DCF inputs, analyst
|
||||
consensus, and peer-comparable multiples. Uses yahooquery as the primary
|
||||
source with yfinance as fallback; builds TTM aggregates from quarterly
|
||||
data when annual data is unavailable.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from server.utils.safe_float import _safe_float
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional imports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from yahooquery import Ticker as YQTicker
|
||||
except ImportError:
|
||||
YQTicker = None # type: ignore[assignment]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row-mapping tables (yahooquery column names -> our canonical names)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INCOME_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
|
||||
("Total Revenue", ("TotalRevenue", "OperatingRevenue", "TotalRevenue")),
|
||||
("Cost Of Revenue", ("CostOfRevenue", "ReconciledCostOfRevenue")),
|
||||
("Gross Profit", ("GrossProfit",)),
|
||||
("Operating Income", ("OperatingIncome", "EBIT", "TotalOperatingIncomeAsReported")),
|
||||
("Net Income", ("NetIncome", "NetIncomeCommonStockholders", "NetIncomeContinuousOperations", "DilutedNIAvailtoComStockholders")),
|
||||
("Operating Expense", ("OperatingExpense", "OperatingExpenses", "TotalExpenses")),
|
||||
("Interest Expense", ("InterestExpense", "InterestExpenseNonOperating")),
|
||||
("Research And Development Expenses", ("ResearchAndDevelopment", "ResearchAndDevelopmentExpenses")),
|
||||
]
|
||||
|
||||
_BALANCE_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
|
||||
("Total Assets", ("TotalAssets",)),
|
||||
("Total Stockholder Equity", ("StockholdersEquity", "CommonStockEquity", "TotalEquityGrossMinorityInterest")),
|
||||
("Total Liabilities", ("TotalLiabilitiesNetMinorityInterest", "TotalLiabilities")),
|
||||
("Current Assets", ("CurrentAssets",)),
|
||||
("Current Liabilities", ("CurrentLiabilities",)),
|
||||
("Long Term Debt", ("LongTermDebt", "LongTermDebtAndCapitalLeaseObligation")),
|
||||
("Total Debt", ("TotalDebt",)),
|
||||
("Share Issued", ("OrdinarySharesNumber", "ShareIssued", "BasicAverageShares", "DilutedAverageShares")),
|
||||
("Cash And Cash Equivalents", ("CashAndCashEquivalents", "CashCashEquivalentsAndShortTermInvestments", "EndCashPosition")),
|
||||
("Retained Earnings", ("RetainedEarnings",)),
|
||||
]
|
||||
|
||||
_CASHFLOW_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
|
||||
("Operating Cash Flow", ("OperatingCashFlow", "CashFromOperatingActivities")),
|
||||
("Capital Expenditure", ("CapitalExpenditure", "CapitalExpenditures")),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _yq_df_to_our_shape(
|
||||
df: pd.DataFrame,
|
||||
row_map: List[Tuple[str, Tuple[str, ...]]],
|
||||
date_col: str = "asOfDate",
|
||||
) -> Optional[pd.DataFrame]:
|
||||
"""Pivot a yahooquery DataFrame to index=line-items, columns=dates."""
|
||||
if df is None or df.empty or date_col not in df.columns:
|
||||
return None
|
||||
df = df.dropna(subset=[date_col]).sort_values(date_col, ascending=False).head(5)
|
||||
if df.empty:
|
||||
return None
|
||||
dates = df[date_col].astype(str).str[:10].tolist()
|
||||
data: Dict[str, list] = {}
|
||||
for our_name, yq_cols in row_map:
|
||||
cols = yq_cols if isinstance(yq_cols, tuple) else (yq_cols,)
|
||||
val_col = next((c for c in cols if c in df.columns), None)
|
||||
if val_col is None:
|
||||
data[our_name] = [None] * len(dates)
|
||||
else:
|
||||
data[our_name] = [_safe_float(v) for v in df[val_col].tolist()]
|
||||
out = pd.DataFrame(data, index=dates).T
|
||||
out.columns = dates
|
||||
return out
|
||||
|
||||
|
||||
def _share_issued_from_yq_balance(df_bal: pd.DataFrame) -> Optional[pd.Series]:
|
||||
"""Extract shares outstanding series from yahooquery balance sheet."""
|
||||
if df_bal is None or df_bal.empty:
|
||||
return None
|
||||
for col in ("OrdinarySharesNumber", "ShareIssued"):
|
||||
if col in df_bal.columns and "asOfDate" in df_bal.columns:
|
||||
s = df_bal.set_index("asOfDate")[col].sort_index(ascending=False)
|
||||
s.index = s.index.astype(str).str[:10]
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _get_row_series(df: Optional[pd.DataFrame], *names: str) -> Optional[pd.Series]:
|
||||
"""Return the first matching row from *df* as a Series, or ``None``."""
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
for name in names:
|
||||
try:
|
||||
if name in df.index:
|
||||
return df.loc[name].copy()
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _fin_or_bal_empty(df: object) -> bool:
|
||||
"""True if *df* is missing, empty, or has no columns."""
|
||||
return df is None or (hasattr(df, "empty") and df.empty) or (hasattr(df, "columns") and len(df.columns) == 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core fetchers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_annual_financials_balance_cashflow_yahooquery(
|
||||
ticker: str,
|
||||
) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[pd.DataFrame]]:
|
||||
"""Fetch annual financials from yahooquery with TTM fallback."""
|
||||
if not YQTicker or not ticker:
|
||||
return (None, None, None)
|
||||
try:
|
||||
yq = YQTicker(ticker.upper())
|
||||
inc_a = yq.income_statement(frequency="a", trailing=False)
|
||||
bal_a = yq.balance_sheet(frequency="a", trailing=False)
|
||||
cf_a = yq.cash_flow(frequency="a", trailing=False)
|
||||
if inc_a is None or inc_a.empty or bal_a is None or bal_a.empty:
|
||||
inc_q = yq.income_statement(frequency="q", trailing=False)
|
||||
bal_q = yq.balance_sheet(frequency="q", trailing=False)
|
||||
cf_q = yq.cash_flow(frequency="q", trailing=False)
|
||||
if inc_q is not None and not inc_q.empty and len(inc_q) >= 4:
|
||||
ttm0 = inc_q.head(4).sum(numeric_only=True)
|
||||
row0 = ttm0.to_dict()
|
||||
row0["asOfDate"] = inc_q["asOfDate"].iloc[0] if "asOfDate" in inc_q.columns else "TTM0"
|
||||
rows_inc = [row0]
|
||||
if len(inc_q) >= 8:
|
||||
ttm1 = inc_q.iloc[4:8].sum(numeric_only=True)
|
||||
row1 = ttm1.to_dict()
|
||||
row1["asOfDate"] = inc_q["asOfDate"].iloc[4] if "asOfDate" in inc_q.columns else "TTM1"
|
||||
rows_inc.append(row1)
|
||||
inc_a = pd.DataFrame(rows_inc)
|
||||
if bal_q is not None and not bal_q.empty:
|
||||
bal_a = bal_q.head(2) if (bal_a is None or bal_a.empty) else bal_a
|
||||
if cf_q is not None and not cf_q.empty and len(cf_q) >= 4 and (cf_a is None or cf_a.empty):
|
||||
ttm0_cf = cf_q.head(4).sum(numeric_only=True)
|
||||
row0_cf = ttm0_cf.to_dict()
|
||||
row0_cf["asOfDate"] = cf_q["asOfDate"].iloc[0] if "asOfDate" in cf_q.columns else "TTM0"
|
||||
rows_cf = [row0_cf]
|
||||
if len(cf_q) >= 8:
|
||||
ttm1_cf = cf_q.iloc[4:8].sum(numeric_only=True)
|
||||
row1_cf = ttm1_cf.to_dict()
|
||||
row1_cf["asOfDate"] = cf_q["asOfDate"].iloc[4] if "asOfDate" in cf_q.columns else "TTM1"
|
||||
rows_cf.append(row1_cf)
|
||||
cf_a = pd.DataFrame(rows_cf)
|
||||
|
||||
fin_df = _yq_df_to_our_shape(inc_a, _INCOME_ROW_MAP)
|
||||
bal_df = _yq_df_to_our_shape(bal_a, _BALANCE_ROW_MAP)
|
||||
if bal_df is not None and "Share Issued" not in bal_df.index and bal_a is not None and not bal_a.empty:
|
||||
for sh_col in ("OrdinarySharesNumber", "ShareIssued"):
|
||||
if sh_col in bal_a.columns:
|
||||
row = {"Share Issued": [_safe_float(bal_a[sh_col].iloc[0])]}
|
||||
if bal_df is not None and not bal_df.empty:
|
||||
d = str(bal_a["asOfDate"].iloc[0])[:10] if "asOfDate" in bal_a.columns else bal_df.columns[0]
|
||||
extra = pd.DataFrame(row, index=[d]).T
|
||||
extra.columns = [d]
|
||||
bal_df = pd.concat([bal_df, extra], axis=0)
|
||||
break
|
||||
cf_df = _yq_df_to_our_shape(cf_a, _CASHFLOW_ROW_MAP)
|
||||
return (fin_df, bal_df, cf_df)
|
||||
except Exception:
|
||||
return (None, None, None)
|
||||
|
||||
|
||||
def _get_annual_financials_balance_cashflow(
|
||||
ticker: str,
|
||||
) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[pd.DataFrame]]:
|
||||
"""Return ``(fin_df, bal_df, cf_df)`` using yahooquery then yfinance fallback."""
|
||||
if not ticker:
|
||||
return (None, None, None)
|
||||
fin_df, bal_df, cf_df = _get_annual_financials_balance_cashflow_yahooquery(ticker)
|
||||
if fin_df is not None and not fin_df.empty and bal_df is not None and not bal_df.empty:
|
||||
return (fin_df, bal_df, cf_df)
|
||||
if not yf:
|
||||
return (None, None, None)
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
fin = getattr(t, "financials", None)
|
||||
bal = getattr(t, "balance_sheet", None)
|
||||
cf = getattr(t, "cashflow", None)
|
||||
if _fin_or_bal_empty(fin):
|
||||
qf = getattr(t, "quarterly_financials", None)
|
||||
if qf is not None and not qf.empty:
|
||||
n = len(qf.columns)
|
||||
if n >= 8:
|
||||
fin = pd.concat([qf.iloc[:, :4].sum(axis=1), qf.iloc[:, 4:8].sum(axis=1)], axis=1)
|
||||
fin.columns = ["TTM0", "TTM1"]
|
||||
elif n >= 5:
|
||||
fin = pd.concat([qf.iloc[:, :4].sum(axis=1), qf.iloc[:, 4:n].sum(axis=1)], axis=1)
|
||||
fin.columns = ["TTM0", "TTM1"]
|
||||
else:
|
||||
fin = qf.iloc[:, :min(4, n)].sum(axis=1).to_frame("TTM0")
|
||||
if _fin_or_bal_empty(bal):
|
||||
qb = getattr(t, "quarterly_balance_sheet", None)
|
||||
if qb is not None and not qb.empty:
|
||||
n = len(qb.columns)
|
||||
bal = qb.iloc[:, :min(2, n)].copy()
|
||||
bal.columns = ["B0", "B1"] if bal.shape[1] >= 2 else ["B0"]
|
||||
if _fin_or_bal_empty(cf):
|
||||
qc = getattr(t, "quarterly_cashflow", None)
|
||||
if qc is not None and not qc.empty:
|
||||
cf = qc.iloc[:, :min(4, len(qc.columns))].sum(axis=1).to_frame("TTM0")
|
||||
return (fin, bal, cf)
|
||||
except Exception:
|
||||
return (None, None, None)
|
||||
|
||||
|
||||
def get_sector_industry(ticker: str) -> Dict[str, str]:
|
||||
"""Return ``{'sector': ..., 'industry': ...}`` from yfinance."""
|
||||
if not yf:
|
||||
return {"sector": "N/A", "industry": "N/A"}
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
sector = (info.get("sector") or info.get("sectorDisp") or "N/A").strip() or "N/A"
|
||||
industry = (info.get("industry") or info.get("industryDisp") or "N/A").strip() or "N/A"
|
||||
return {"sector": sector, "industry": industry}
|
||||
except Exception:
|
||||
return {"sector": "N/A", "industry": "N/A"}
|
||||
|
||||
|
||||
def get_5yr_financial_trend(ticker: str) -> pd.DataFrame:
|
||||
"""Up to 5 years of Revenue, Net Income, Operating Margin, FCF."""
|
||||
if not yf:
|
||||
return pd.DataFrame()
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
financials = t.financials
|
||||
cashflow = t.cashflow
|
||||
if financials is None or financials.empty or cashflow is None or cashflow.empty:
|
||||
return pd.DataFrame()
|
||||
dates = sorted(financials.columns.tolist(), reverse=True)[:5]
|
||||
ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations")
|
||||
capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment")
|
||||
revenue = _get_row_series(financials, "Total Revenue", "Revenue", "Net Revenue")
|
||||
ni = _get_row_series(financials, "Net Income", "Net Income Common Stockholders")
|
||||
op_income = _get_row_series(financials, "Operating Income", "EBIT")
|
||||
rows = []
|
||||
for d in dates:
|
||||
yr = d.year if hasattr(d, "year") else int(str(d)[:4])
|
||||
rev = _safe_float(revenue.get(d)) if revenue is not None and d in revenue.index else None
|
||||
net_i = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None
|
||||
op_i = _safe_float(op_income.get(d)) if op_income is not None and d in op_income.index else None
|
||||
oper_margin = (op_i / rev * 100) if (op_i is not None and rev and rev != 0) else ((net_i / rev * 100) if (net_i is not None and rev and rev != 0) else None)
|
||||
ocf_val = _safe_float(ocf.get(d)) if ocf is not None and d in ocf.index else None
|
||||
capx_val = _safe_float(capx.get(d)) if capx is not None and d in capx.index else None
|
||||
fcf = (ocf_val - capx_val) if (ocf_val is not None and capx_val is not None) else (ocf_val if ocf_val is not None else None)
|
||||
rows.append({
|
||||
"Year": yr,
|
||||
"Revenue": rev,
|
||||
"Net Income": net_i,
|
||||
"Operating Margin %": round(oper_margin, 2) if oper_margin is not None else None,
|
||||
"FCF": fcf,
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
except Exception:
|
||||
return pd.DataFrame()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Monte Carlo simulation for DCF valuation.
|
||||
|
||||
Runs N random DCF scenarios by sampling WACC and FCF growth from
|
||||
normal distributions, then reports distributional statistics.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def run_monte_carlo_dcf(
|
||||
fcf: float,
|
||||
wacc_mean: float,
|
||||
wacc_std: float,
|
||||
growth_mean: float,
|
||||
growth_std: float,
|
||||
term_growth: float,
|
||||
total_debt: float,
|
||||
cash: float,
|
||||
shares: float,
|
||||
n_simulations: int = 5000,
|
||||
current_price: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run a Monte Carlo DCF simulation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fcf : float
|
||||
Base free cash flow.
|
||||
wacc_mean / wacc_std : float
|
||||
Mean and standard deviation for WACC sampling (decimal, e.g. 0.09).
|
||||
growth_mean / growth_std : float
|
||||
Mean and standard deviation for FCF growth sampling (decimal).
|
||||
term_growth : float
|
||||
Terminal growth rate (constant across simulations).
|
||||
total_debt, cash, shares : float
|
||||
Balance-sheet items for equity bridge.
|
||||
n_simulations : int
|
||||
Number of Monte Carlo iterations (default 5 000).
|
||||
current_price : float | None
|
||||
Current market price; used to compute prob_above_current.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
values – list of per-share intrinsic values (sorted)
|
||||
percentile_10 – 10th percentile
|
||||
median – 50th percentile
|
||||
percentile_90 – 90th percentile
|
||||
mean – arithmetic mean
|
||||
prob_above_current – probability the simulated value exceeds current_price
|
||||
current_price – echo back
|
||||
n_simulations – echo back
|
||||
"""
|
||||
if shares <= 0 or fcf <= 0:
|
||||
return {
|
||||
"values": [],
|
||||
"percentile_10": None,
|
||||
"median": None,
|
||||
"percentile_90": None,
|
||||
"mean": None,
|
||||
"prob_above_current": None,
|
||||
"current_price": current_price,
|
||||
"n_simulations": n_simulations,
|
||||
}
|
||||
|
||||
rng = np.random.default_rng()
|
||||
|
||||
# Sample WACC and growth; clip to sensible bounds
|
||||
waccs = rng.normal(wacc_mean, max(wacc_std, 1e-6), n_simulations)
|
||||
waccs = np.clip(waccs, 0.01, 0.40)
|
||||
|
||||
growths = rng.normal(growth_mean, max(growth_std, 1e-6), n_simulations)
|
||||
growths = np.clip(growths, -0.30, 0.60)
|
||||
|
||||
projection_years = 10
|
||||
values: List[float] = []
|
||||
|
||||
for w, g in zip(waccs, growths):
|
||||
if w <= term_growth:
|
||||
continue
|
||||
# 10-year two-stage DCF (simplified: constant growth then terminal)
|
||||
pv = 0.0
|
||||
fcft = float(fcf)
|
||||
for t in range(1, projection_years + 1):
|
||||
fcft *= (1 + g)
|
||||
pv += fcft / ((1 + w) ** t)
|
||||
tv = fcft * (1 + term_growth) / (w - term_growth)
|
||||
pv += tv / ((1 + w) ** projection_years)
|
||||
equity = pv - total_debt + cash
|
||||
per_share = equity / shares
|
||||
if per_share > 0:
|
||||
values.append(round(per_share, 2))
|
||||
|
||||
if not values:
|
||||
return {
|
||||
"values": [],
|
||||
"percentile_10": None,
|
||||
"median": None,
|
||||
"percentile_90": None,
|
||||
"mean": None,
|
||||
"prob_above_current": None,
|
||||
"current_price": current_price,
|
||||
"n_simulations": n_simulations,
|
||||
}
|
||||
|
||||
arr = np.array(values)
|
||||
arr.sort()
|
||||
|
||||
prob_above = None
|
||||
if current_price is not None and current_price > 0:
|
||||
prob_above = round(float(np.mean(arr > current_price) * 100), 1)
|
||||
|
||||
return {
|
||||
"values": arr.tolist(),
|
||||
"percentile_10": round(float(np.percentile(arr, 10)), 2),
|
||||
"median": round(float(np.median(arr)), 2),
|
||||
"percentile_90": round(float(np.percentile(arr, 90)), 2),
|
||||
"mean": round(float(np.mean(arr)), 2),
|
||||
"prob_above_current": prob_above,
|
||||
"current_price": current_price,
|
||||
"n_simulations": n_simulations,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""News aggregation from Finviz RSS and Google News RSS.
|
||||
|
||||
Fetches, deduplicates, and sorts financial news articles for a given
|
||||
ticker/company combination. No API keys required -- uses public RSS feeds.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
|
||||
_USER_AGENT = "ATLAS-Terminal/1.0 (news aggregator)"
|
||||
_TIMEOUT_SEC = 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_xml(url: str) -> Optional[str]:
|
||||
"""Fetch a URL and return its body as a string, or ``None`` on error."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": _USER_AGENT})
|
||||
with urlopen(req, timeout=_TIMEOUT_SEC) as resp:
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
except (URLError, OSError, Exception):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_rss_items(xml_text: str) -> List[Dict[str, Any]]:
|
||||
"""Parse standard RSS 2.0 ``<item>`` elements into dicts."""
|
||||
items: List[Dict[str, Any]] = []
|
||||
if not xml_text:
|
||||
return items
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
return items
|
||||
|
||||
for item in root.iter("item"):
|
||||
title = (item.findtext("title") or "").strip()
|
||||
link = (item.findtext("link") or "").strip()
|
||||
pub_date_str = (item.findtext("pubDate") or "").strip()
|
||||
description = (item.findtext("description") or "").strip()
|
||||
source = (item.findtext("source") or "").strip()
|
||||
|
||||
pub_dt: Optional[datetime] = None
|
||||
if pub_date_str:
|
||||
try:
|
||||
pub_dt = parsedate_to_datetime(pub_date_str)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if title and link:
|
||||
items.append({
|
||||
"title": title,
|
||||
"link": link,
|
||||
"published": pub_dt.isoformat() if pub_dt else pub_date_str,
|
||||
"published_dt": pub_dt,
|
||||
"description": description[:500] if description else "",
|
||||
"source": source,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _dedup_by_title(articles: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Remove duplicate articles based on normalised title."""
|
||||
seen: set = set()
|
||||
unique: List[Dict[str, Any]] = []
|
||||
for art in articles:
|
||||
key = re.sub(r"\s+", " ", art["title"].lower().strip())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(art)
|
||||
return unique
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_finviz_news(ticker: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch recent news for *ticker* from the Finviz RSS feed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ticker:
|
||||
Stock ticker symbol (e.g. ``'AAPL'``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict]
|
||||
Each dict has keys: ``title``, ``link``, ``published``,
|
||||
``description``, ``source``.
|
||||
"""
|
||||
if not ticker or not ticker.strip():
|
||||
return []
|
||||
url = f"https://finviz.com/quote.ashx?t={ticker.strip().upper()}&ty=c&p=d&b=1"
|
||||
# Finviz RSS endpoint
|
||||
rss_url = f"https://finviz.com/news_export.ashx?t={ticker.strip().upper()}"
|
||||
xml = _fetch_xml(rss_url)
|
||||
if not xml:
|
||||
return []
|
||||
items = _parse_rss_items(xml)
|
||||
for item in items:
|
||||
if not item.get("source"):
|
||||
item["source"] = "Finviz"
|
||||
return items
|
||||
|
||||
|
||||
def fetch_google_news(company_name: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch recent news for *company_name* from Google News RSS.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
company_name:
|
||||
Full company name (e.g. ``'Apple Inc.'``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict]
|
||||
Same structure as :func:`fetch_finviz_news`.
|
||||
"""
|
||||
if not company_name or not company_name.strip():
|
||||
return []
|
||||
# URL-encode the query
|
||||
query = company_name.strip().replace(" ", "+")
|
||||
rss_url = f"https://news.google.com/rss/search?q={query}+stock&hl=en-US&gl=US&ceid=US:en"
|
||||
xml = _fetch_xml(rss_url)
|
||||
if not xml:
|
||||
return []
|
||||
items = _parse_rss_items(xml)
|
||||
for item in items:
|
||||
if not item.get("source"):
|
||||
item["source"] = "Google News"
|
||||
return items
|
||||
|
||||
|
||||
def aggregate_news(
|
||||
ticker: str,
|
||||
company_name: str,
|
||||
max_articles: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Aggregate news from Finviz and Google News, deduplicated and sorted.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ticker:
|
||||
Stock ticker symbol.
|
||||
company_name:
|
||||
Full company name for broader search coverage.
|
||||
max_articles:
|
||||
Maximum number of articles to return (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict]
|
||||
Deduplicated articles sorted by publication time (newest first).
|
||||
Each dict has: ``title``, ``link``, ``published``, ``description``,
|
||||
``source``.
|
||||
"""
|
||||
finviz_articles = fetch_finviz_news(ticker)
|
||||
google_articles = fetch_google_news(company_name)
|
||||
|
||||
all_articles = finviz_articles + google_articles
|
||||
unique = _dedup_by_title(all_articles)
|
||||
|
||||
# Sort by datetime (newest first); articles without a parseable date go last
|
||||
def sort_key(art: Dict[str, Any]) -> float:
|
||||
dt = art.get("published_dt")
|
||||
if dt is not None:
|
||||
return -dt.timestamp()
|
||||
return float("inf")
|
||||
|
||||
unique.sort(key=sort_key)
|
||||
|
||||
# Strip internal datetime field before returning
|
||||
for art in unique:
|
||||
art.pop("published_dt", None)
|
||||
|
||||
return unique[:max_articles]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Portfolio risk metrics -- VaR, Sharpe, Sortino, MDD, Beta, Correlation."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def compute_portfolio_risk(positions: list, benchmark: str = "SPY") -> dict:
|
||||
"""Compute VaR, Sharpe, Sortino, MDD, Beta, Correlation for portfolio."""
|
||||
import yfinance as yf
|
||||
|
||||
tickers = [p["ticker"] for p in positions]
|
||||
if not tickers:
|
||||
return {}
|
||||
|
||||
values = [
|
||||
p.get("value", p.get("quantity", 0) * p.get("avg_price", 0))
|
||||
for p in positions
|
||||
]
|
||||
total = sum(values) or 1
|
||||
weights = np.array([v / total for v in values])
|
||||
|
||||
data = yf.download(tickers + [benchmark], period="1y", progress=False)["Close"]
|
||||
if data.empty:
|
||||
return {}
|
||||
|
||||
returns = data.pct_change().dropna()
|
||||
|
||||
if len(tickers) == 1:
|
||||
port_returns = (
|
||||
returns[tickers[0]]
|
||||
if tickers[0] in returns.columns
|
||||
else returns.iloc[:, 0]
|
||||
)
|
||||
else:
|
||||
ticker_returns = (
|
||||
returns[tickers]
|
||||
if all(t in returns.columns for t in tickers)
|
||||
else returns.iloc[:, : len(tickers)]
|
||||
)
|
||||
port_returns = (ticker_returns * weights).sum(axis=1)
|
||||
|
||||
bench_returns = (
|
||||
returns[benchmark] if benchmark in returns.columns else returns.iloc[:, -1]
|
||||
)
|
||||
|
||||
# VaR
|
||||
var_95 = float(np.percentile(port_returns, 5))
|
||||
var_99 = float(np.percentile(port_returns, 1))
|
||||
|
||||
# Sharpe (annualized, rf=0.04)
|
||||
rf_daily = 0.04 / 252
|
||||
excess = port_returns - rf_daily
|
||||
sharpe = (
|
||||
float(np.sqrt(252) * excess.mean() / excess.std())
|
||||
if excess.std() > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
# Sortino
|
||||
downside = excess[excess < 0]
|
||||
sortino = (
|
||||
float(np.sqrt(252) * excess.mean() / downside.std())
|
||||
if len(downside) > 0 and downside.std() > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
# Max Drawdown
|
||||
cumulative = (1 + port_returns).cumprod()
|
||||
peak = cumulative.expanding().max()
|
||||
drawdown = (cumulative - peak) / peak
|
||||
max_dd = float(drawdown.min())
|
||||
|
||||
# Beta
|
||||
cov = np.cov(port_returns, bench_returns)
|
||||
beta = float(cov[0, 1] / cov[1, 1]) if cov[1, 1] > 0 else 1.0
|
||||
|
||||
# Correlation matrix
|
||||
corr = {}
|
||||
if len(tickers) > 1:
|
||||
corr_df = (
|
||||
returns[tickers].corr()
|
||||
if all(t in returns.columns for t in tickers)
|
||||
else {}
|
||||
)
|
||||
if hasattr(corr_df, "to_dict"):
|
||||
corr = {
|
||||
str(k): {str(k2): round(v2, 3) for k2, v2 in v.items()}
|
||||
for k, v in corr_df.to_dict().items()
|
||||
}
|
||||
|
||||
return {
|
||||
"var_95": round(var_95 * 100, 2),
|
||||
"var_99": round(var_99 * 100, 2),
|
||||
"sharpe": round(sharpe, 2),
|
||||
"sortino": round(sortino, 2),
|
||||
"max_drawdown": round(max_dd * 100, 2),
|
||||
"beta": round(beta, 2),
|
||||
"correlation_matrix": corr,
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Portfolio screenshot OCR using Gemini Vision.
|
||||
|
||||
Analyses screenshots from Trading 212 or Interactive Brokers (IBKR) portfolio
|
||||
views and extracts structured position data (ticker, quantity, market value,
|
||||
gain/loss) via the Gemini multimodal API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def _get_vision_model(api_key: str) -> Any:
|
||||
"""Configure Gemini and return a multimodal model."""
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=api_key)
|
||||
return genai.GenerativeModel("gemini-2.0-flash")
|
||||
|
||||
|
||||
def _build_prompt() -> str:
|
||||
"""Return the extraction prompt for portfolio screenshots."""
|
||||
return """You are a financial data extraction assistant.
|
||||
|
||||
Analyse this portfolio screenshot from a brokerage app (Trading 212,
|
||||
Interactive Brokers, or similar).
|
||||
|
||||
Extract every visible position and return ONLY a valid JSON object with
|
||||
this structure:
|
||||
|
||||
{
|
||||
"broker": "Trading 212" | "IBKR" | "Unknown",
|
||||
"currency": "USD" | "GBP" | "EUR" | ...,
|
||||
"positions": [
|
||||
{
|
||||
"ticker": "AAPL",
|
||||
"name": "Apple Inc.",
|
||||
"quantity": 10.5,
|
||||
"avg_price": 150.00,
|
||||
"current_price": 175.00,
|
||||
"market_value": 1837.50,
|
||||
"gain_loss": 262.50,
|
||||
"gain_loss_pct": 16.67
|
||||
}
|
||||
],
|
||||
"total_value": 50000.00,
|
||||
"total_gain_loss": 5000.00
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use null for any field you cannot read.
|
||||
- quantity may be fractional (e.g. 0.125 shares).
|
||||
- Monetary values should be plain numbers, no currency symbols.
|
||||
- If the screenshot is not a portfolio view, return {"error": "Not a portfolio screenshot"}.
|
||||
- Output ONLY the JSON object, nothing else.
|
||||
"""
|
||||
|
||||
|
||||
def analyze_portfolio_screenshot(
|
||||
api_key: str,
|
||||
image_bytes: bytes,
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract portfolio positions from a brokerage screenshot.
|
||||
|
||||
Uses Gemini Vision (multimodal) to read the image and return
|
||||
structured position data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_key:
|
||||
Google Gemini API key.
|
||||
image_bytes:
|
||||
Raw bytes of the screenshot image (PNG, JPEG, etc.).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Parsed portfolio data with ``broker``, ``currency``,
|
||||
``positions`` (list), ``total_value``, and ``total_gain_loss``.
|
||||
On error, returns ``{"error": "<description>"}``.
|
||||
"""
|
||||
if not api_key or not api_key.strip():
|
||||
return {"error": "API key is required."}
|
||||
if not image_bytes:
|
||||
return {"error": "No image data provided."}
|
||||
|
||||
try:
|
||||
model = _get_vision_model(api_key)
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to initialise Gemini Vision: {e}"}
|
||||
|
||||
prompt = _build_prompt()
|
||||
|
||||
# Build multimodal content: image + text prompt
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
|
||||
# Detect MIME type from magic bytes
|
||||
mime_type = "image/png"
|
||||
if image_bytes[:3] == b"\xff\xd8\xff":
|
||||
mime_type = "image/jpeg"
|
||||
elif image_bytes[:4] == b"\x89PNG":
|
||||
mime_type = "image/png"
|
||||
elif image_bytes[:4] == b"RIFF":
|
||||
mime_type = "image/webp"
|
||||
|
||||
image_part = {"mime_type": mime_type, "data": image_bytes}
|
||||
response = model.generate_content(
|
||||
[image_part, prompt],
|
||||
generation_config={"temperature": 0.0, "max_output_tokens": 4096},
|
||||
)
|
||||
|
||||
raw = (response.text or "").strip()
|
||||
if not raw:
|
||||
return {"error": "Gemini returned an empty response."}
|
||||
|
||||
# Strip markdown code fences if present
|
||||
raw = re.sub(r"^```\s*json\s*", "", raw)
|
||||
raw = re.sub(r"^```\s*", "", raw)
|
||||
raw = re.sub(r"\s*```\s*$", "", raw)
|
||||
raw = raw.strip()
|
||||
|
||||
result: Dict[str, Any] = json.loads(raw)
|
||||
|
||||
# Validate structure
|
||||
if "error" in result:
|
||||
return result
|
||||
if "positions" not in result:
|
||||
return {"error": "Response missing 'positions' key.", "raw": raw}
|
||||
|
||||
# Coerce numeric fields
|
||||
for pos in result.get("positions", []):
|
||||
for key in ("quantity", "avg_price", "current_price", "market_value", "gain_loss", "gain_loss_pct"):
|
||||
val = pos.get(key)
|
||||
if val is not None:
|
||||
try:
|
||||
pos[key] = float(val)
|
||||
except (TypeError, ValueError):
|
||||
pos[key] = None
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "Failed to parse JSON from Gemini response.", "raw": raw}
|
||||
except Exception as e:
|
||||
return {"error": f"Screenshot analysis failed: {e}"}
|
||||
@@ -0,0 +1,382 @@
|
||||
"""SEC EDGAR 10-K download, parsing, section extraction, and caching.
|
||||
|
||||
Handles the full pipeline from downloading a 10-K filing via
|
||||
``sec_edgar_downloader`` through HTML stripping to isolating individual
|
||||
Item sections (1A, 3, 7, 8, 9A) and persisting the cleaned text to a
|
||||
local JSON cache under ``data/``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from server.services.text_chunker import clean_text_for_llm, smart_chunk
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DATA_DIR: Path = Path(__file__).resolve().parents[3] / "data"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section-header regex patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ITEM1A_PATTERNS: List[str] = [
|
||||
r"Item\s+1A\s*[.:]\s*Risk\s+Factors",
|
||||
r"ITEM\s+1A\s*[.:]\s*Risk\s+Factors",
|
||||
]
|
||||
|
||||
ITEM7_PATTERNS: List[str] = [
|
||||
r"Item\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion\s+and\s+Analysis",
|
||||
r"ITEM\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion",
|
||||
r"Item\s+7\s*[.:]\s*[\w\s]+MD&A",
|
||||
]
|
||||
|
||||
ITEM8_PATTERNS: List[str] = [
|
||||
r"Item\s+8\s*[.:]\s*Financial\s+Statements",
|
||||
r"ITEM\s+8\s*[.:]\s*Financial\s+Statements",
|
||||
]
|
||||
|
||||
ITEM3_PATTERNS: List[str] = [
|
||||
r"Item\s+3\s*[.:]\s*Legal\s+Proceedings",
|
||||
r"ITEM\s+3\s*[.:]\s*Legal\s+Proceedings",
|
||||
]
|
||||
|
||||
ITEM9A_PATTERNS: List[str] = [
|
||||
r"Item\s+9A\s*[.:]\s*Controls\s+and\s+Procedures",
|
||||
r"Item\s+9A\s*[.:]\s*Internal\s+Control",
|
||||
r"ITEM\s+9A\s*[.:]\s*Controls",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _slice_html_items_1a_to_9a(raw_html: str) -> str:
|
||||
"""Fast string-level slice: keep only Item 1A through end of Item 9A."""
|
||||
if not raw_html or len(raw_html) < 5000:
|
||||
return raw_html
|
||||
start = -1
|
||||
for needle in ("Item 1A", "ITEM 1A", "Item 1a"):
|
||||
i = raw_html.find(needle)
|
||||
if i != -1 and (start == -1 or i < start):
|
||||
start = i
|
||||
if start == -1:
|
||||
m = re.search(r"Item\s+1A\s", raw_html, re.IGNORECASE)
|
||||
start = m.start() if m else 0
|
||||
else:
|
||||
start = max(0, start - 200)
|
||||
search_region = raw_html[start:]
|
||||
end_match = re.search(
|
||||
r"Item\s+10\s|Item\s+12\s|Part\s+III\b|PART\s+III\b",
|
||||
search_region,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
end = start + end_match.start() if end_match else len(raw_html)
|
||||
end = min(end, start + 8_000_000)
|
||||
return raw_html[start:end]
|
||||
|
||||
|
||||
def _extract_text_from_html_string(html_str: str) -> str:
|
||||
"""Parse an HTML string and return plain text (tables/scripts removed)."""
|
||||
if not html_str or not html_str.strip():
|
||||
return ""
|
||||
try:
|
||||
soup = BeautifulSoup(html_str, "lxml")
|
||||
except Exception:
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
for tag in soup.find_all(["table", "img", "svg", "style", "script"]):
|
||||
tag.decompose()
|
||||
return soup.get_text(separator="\n", strip=True)
|
||||
|
||||
|
||||
def extract_text_from_html(html_path: Path) -> str:
|
||||
"""Read an HTML file, slice to Items 1A-9A, and return plain text."""
|
||||
try:
|
||||
with open(html_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
raw = f.read()
|
||||
except Exception:
|
||||
with open(html_path, "r", encoding="latin-1", errors="replace") as f:
|
||||
raw = f.read()
|
||||
chunk = _slice_html_items_1a_to_9a(raw)
|
||||
return _extract_text_from_html_string(chunk)
|
||||
|
||||
|
||||
def extract_text_from_file(file_path: Path) -> str:
|
||||
"""Extract plain text from an HTML or TXT file."""
|
||||
suf = file_path.suffix.lower()
|
||||
if suf in (".htm", ".html"):
|
||||
return extract_text_from_html(file_path)
|
||||
if suf == ".txt":
|
||||
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section finders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_section_start(text: str, patterns: List[str], item_num: int) -> int:
|
||||
"""Return character offset where *item_num* section begins, or -1."""
|
||||
for pat in patterns:
|
||||
m = re.search(pat, text, re.IGNORECASE)
|
||||
if m:
|
||||
return m.start()
|
||||
m = re.search(r"\bItem\s+" + str(item_num) + r"\b", text, re.IGNORECASE)
|
||||
return m.start() if m else -1
|
||||
|
||||
|
||||
def find_item_section_generic(
|
||||
text: str,
|
||||
patterns: List[str],
|
||||
item_num: int,
|
||||
title_keywords: List[str],
|
||||
max_chars: int = 120_000,
|
||||
) -> str:
|
||||
"""Extract a single Item section from full 10-K text."""
|
||||
start = _find_section_start(text, patterns, item_num)
|
||||
if start == -1:
|
||||
pattern = re.compile(
|
||||
r"\bItem\s+" + str(item_num)
|
||||
+ r"\b[.\s]*[^\n]*("
|
||||
+ "|".join(re.escape(k) for k in title_keywords)
|
||||
+ r")?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return ""
|
||||
start = match.start()
|
||||
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
|
||||
end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text))
|
||||
return text[start:end].strip()
|
||||
|
||||
|
||||
def _extract_item_from_full(
|
||||
text: str,
|
||||
patterns: List[str],
|
||||
item_num: int,
|
||||
keywords: List[str],
|
||||
max_chars: int = 60_000,
|
||||
) -> str:
|
||||
"""Extract one item section from full 10-K text."""
|
||||
start = _find_section_start(text, patterns, item_num)
|
||||
if start < 0:
|
||||
pat = re.compile(
|
||||
r"\bItem\s+" + str(item_num) + r"[A-Z]?\b[.\s]*[^\n]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
match = pat.search(text)
|
||||
start = match.start() if match else -1
|
||||
if start < 0:
|
||||
return ""
|
||||
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
|
||||
end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text))
|
||||
return text[start:end].strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filing directory helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_edgar_downloader() -> type:
|
||||
"""Lazy import of ``sec_edgar_downloader.Downloader``."""
|
||||
from sec_edgar_downloader import Downloader
|
||||
return Downloader
|
||||
|
||||
|
||||
def find_downloaded_10k_path(download_root: Path, ticker: str) -> Optional[Path]:
|
||||
"""Locate the most recent 10-K filing directory on disk."""
|
||||
ticker_upper = ticker.upper()
|
||||
for base in (download_root / "sec-edgar-filings", download_root):
|
||||
path_10k = base / ticker_upper / "10-K"
|
||||
if path_10k.exists():
|
||||
subdirs = sorted(
|
||||
[d for d in path_10k.iterdir() if d.is_dir()],
|
||||
key=lambda x: x.name,
|
||||
reverse=True,
|
||||
)
|
||||
if subdirs:
|
||||
return subdirs[0]
|
||||
for base in (download_root / "sec-edgar-filings", download_root):
|
||||
if not base.exists():
|
||||
continue
|
||||
for company_dir in base.iterdir():
|
||||
if not company_dir.is_dir():
|
||||
continue
|
||||
path_10k = company_dir / "10-K"
|
||||
if path_10k.exists():
|
||||
subdirs = sorted(
|
||||
[d for d in path_10k.iterdir() if d.is_dir()],
|
||||
key=lambda x: x.name,
|
||||
reverse=True,
|
||||
)
|
||||
if subdirs:
|
||||
return subdirs[0]
|
||||
return None
|
||||
|
||||
|
||||
def find_all_10k_filing_dirs(download_root: Path, ticker: str) -> List[Path]:
|
||||
"""Return all 10-K filing directories sorted newest-first."""
|
||||
ticker_upper = ticker.upper()
|
||||
for base in (download_root / "sec-edgar-filings", download_root):
|
||||
path_10k = base / ticker_upper / "10-K"
|
||||
if path_10k.exists():
|
||||
return sorted(
|
||||
[d for d in path_10k.iterdir() if d.is_dir()],
|
||||
key=lambda x: x.name,
|
||||
reverse=True,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def get_main_10k_text(filing_dir: Path) -> str:
|
||||
"""Return the longest extracted text from all files in *filing_dir*."""
|
||||
all_text: List[tuple] = []
|
||||
for ext in ("*.htm", "*.html", "*.txt"):
|
||||
for path in filing_dir.rglob(ext):
|
||||
try:
|
||||
t = extract_text_from_file(path)
|
||||
if len(t) > 1000:
|
||||
all_text.append((path, t))
|
||||
except Exception:
|
||||
continue
|
||||
if not all_text:
|
||||
return ""
|
||||
_, main_text = max(all_text, key=lambda x: len(x[1]))
|
||||
return main_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache layer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_10k_cache_path(ticker: str) -> Path:
|
||||
"""Path for cached 10-K sections: ``data/TICKER_latest.json``."""
|
||||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _DATA_DIR / f"{ticker.upper()}_latest.json"
|
||||
|
||||
|
||||
def _load_10k_from_cache(ticker: str) -> Optional[Dict[str, str]]:
|
||||
"""Load cached sections or return ``None`` if absent."""
|
||||
path = _get_10k_cache_path(ticker)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_10k_to_cache(ticker: str, data: Dict[str, str]) -> None:
|
||||
"""Persist cleaned 10-K sections to the JSON cache."""
|
||||
path = _get_10k_cache_path(ticker)
|
||||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level download + extract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def download_and_extract_all_items(ticker: str, email: str) -> Dict[str, str]:
|
||||
"""Download latest 10-K, extract Items 1A/3/7/8/9A, clean and cache."""
|
||||
Downloader = _get_edgar_downloader()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
download_root = Path(tmpdir)
|
||||
dl = Downloader("FQDC-10K-Analyzer", email, str(download_root))
|
||||
dl.get("10-K", ticker.upper(), limit=1, download_details=True)
|
||||
filing_dir = find_downloaded_10k_path(download_root, ticker)
|
||||
if not filing_dir:
|
||||
raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.")
|
||||
full_text = get_main_10k_text(filing_dir)
|
||||
if not full_text:
|
||||
raise ValueError("Could not extract text from the 10-K.")
|
||||
|
||||
item1a = find_item_section_generic(full_text, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80_000)
|
||||
item3 = _extract_item_from_full(full_text, ITEM3_PATTERNS, 3, ["Legal", "Proceedings"], max_chars=40_000)
|
||||
item9a = _extract_item_from_full(full_text, ITEM9A_PATTERNS, 9, ["Controls", "Procedures", "Internal"], max_chars=40_000)
|
||||
|
||||
start7 = _find_section_start(full_text, ITEM7_PATTERNS, 7)
|
||||
text_after_7 = full_text[start7:] if start7 >= 0 else full_text
|
||||
item7 = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
|
||||
if not item7 and text_after_7:
|
||||
item7 = text_after_7[:120_000]
|
||||
item8 = _extract_item_from_full(full_text, ITEM8_PATTERNS, 8, ["Financial Statements", "Supplementary Data"], max_chars=200_000)
|
||||
|
||||
data: Dict[str, str] = {
|
||||
"item1a": clean_text_for_llm(item1a or ""),
|
||||
"item3": clean_text_for_llm(item3 or ""),
|
||||
"item9a": clean_text_for_llm(item9a or ""),
|
||||
"item7": clean_text_for_llm(item7 or ""),
|
||||
"item8": clean_text_for_llm(item8 or ""),
|
||||
}
|
||||
_save_10k_to_cache(ticker, data)
|
||||
return data
|
||||
|
||||
|
||||
def get_10k_sections(ticker: str, email: str) -> tuple[Dict[str, str], str]:
|
||||
"""Return ``(sections, status)``; *status* is ``'cache'`` or ``'downloaded'``."""
|
||||
cached = _load_10k_from_cache(ticker)
|
||||
if cached is not None:
|
||||
return cached, "cache"
|
||||
return download_and_extract_all_items(ticker, email), "downloaded"
|
||||
|
||||
|
||||
def download_and_extract_item7_and_1a(ticker: str, email: str) -> tuple[str, str, str]:
|
||||
"""Fetch 10-K and return ``(full_text, item1a, item7)``."""
|
||||
sections, _ = get_10k_sections(ticker, email)
|
||||
return "", sections.get("item1a", "") or "", sections.get("item7", "") or ""
|
||||
|
||||
|
||||
def download_item7_latest_and_3y_ago(
|
||||
ticker: str,
|
||||
email: str,
|
||||
) -> tuple[Optional[str], Optional[str], Optional[str], bool]:
|
||||
"""Download up to 5 10-Ks; return item1a (latest), item7 latest, item7 3y ago, has_comparison."""
|
||||
Downloader = _get_edgar_downloader()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
download_root = Path(tmpdir)
|
||||
dl = Downloader("FQDC-10K-Analyzer", email, str(download_root))
|
||||
dl.get("10-K", ticker.upper(), limit=5, download_details=True)
|
||||
filing_dirs = find_all_10k_filing_dirs(download_root, ticker)
|
||||
if not filing_dirs:
|
||||
raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.")
|
||||
|
||||
full_latest = get_main_10k_text(filing_dirs[0])
|
||||
if not full_latest:
|
||||
raise ValueError("Could not extract text from the latest 10-K.")
|
||||
|
||||
item1a = find_item_section_generic(full_latest, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80_000)
|
||||
|
||||
s7 = _find_section_start(full_latest, ITEM7_PATTERNS, 7)
|
||||
text_after_7 = full_latest[s7:] if s7 >= 0 else full_latest
|
||||
item7_latest = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
|
||||
if not item7_latest and text_after_7:
|
||||
item7_latest = smart_chunk(text_after_7[:120_000], max_chars=20_000)
|
||||
|
||||
item7_3y_ago: Optional[str] = None
|
||||
has_comparison = False
|
||||
if len(filing_dirs) >= 4:
|
||||
full_3y = get_main_10k_text(filing_dirs[3])
|
||||
if full_3y:
|
||||
s7_3y = _find_section_start(full_3y, ITEM7_PATTERNS, 7)
|
||||
text_3y = full_3y[s7_3y:] if s7_3y >= 0 else full_3y
|
||||
item7_3y_ago = find_item_section_generic(text_3y, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
|
||||
if not item7_3y_ago and text_3y:
|
||||
item7_3y_ago = smart_chunk(text_3y[:120_000], max_chars=20_000)
|
||||
has_comparison = bool(item7_3y_ago)
|
||||
|
||||
return item1a or "", item7_latest or "", item7_3y_ago, has_comparison
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Sensitivity analysis for DCF valuation.
|
||||
|
||||
Provides:
|
||||
- WACC vs Terminal Growth sensitivity matrix
|
||||
- Tornado chart data (variable impact ranking)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from server.services.dcf_engine import excel_style_dcf
|
||||
|
||||
|
||||
def build_sensitivity_matrix(
|
||||
fcf: float,
|
||||
total_debt: float,
|
||||
cash: float,
|
||||
shares: float,
|
||||
base_wacc: float,
|
||||
base_tg: float,
|
||||
fcf_growth: float,
|
||||
wacc_steps: int = 6,
|
||||
tg_steps: int = 5,
|
||||
wacc_range: float = 0.02,
|
||||
tg_range: float = 0.01,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a 2-D sensitivity matrix: WACC (rows) x Terminal Growth (cols).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
wacc_values : list[float] – row headers (percentages, e.g. 8.0)
|
||||
tg_values : list[float] – column headers (percentages, e.g. 2.5)
|
||||
matrix : list[list[float | None]] – per-share intrinsic values
|
||||
"""
|
||||
# Generate evenly-spaced WACC and TG values centred on base
|
||||
wacc_values = [
|
||||
round(base_wacc - wacc_range + (2 * wacc_range / max(wacc_steps - 1, 1)) * i, 4)
|
||||
for i in range(wacc_steps)
|
||||
]
|
||||
tg_values = [
|
||||
round(base_tg - tg_range + (2 * tg_range / max(tg_steps - 1, 1)) * i, 4)
|
||||
for i in range(tg_steps)
|
||||
]
|
||||
|
||||
matrix: List[List[Any]] = []
|
||||
for w in wacc_values:
|
||||
row: List[Any] = []
|
||||
for tg in tg_values:
|
||||
if w <= tg or w <= 0 or shares <= 0:
|
||||
row.append(None)
|
||||
else:
|
||||
result = excel_style_dcf(fcf, w, tg, fcf_growth, total_debt, cash, shares)
|
||||
vps = result.get("value_per_share")
|
||||
row.append(round(vps, 2) if vps is not None else None)
|
||||
matrix.append(row)
|
||||
|
||||
return {
|
||||
"wacc_values": [round(w * 100, 2) for w in wacc_values],
|
||||
"tg_values": [round(tg * 100, 2) for tg in tg_values],
|
||||
"matrix": matrix,
|
||||
}
|
||||
|
||||
|
||||
def build_tornado_data(
|
||||
fcf: float,
|
||||
wacc: float,
|
||||
tg: float,
|
||||
growth: float,
|
||||
debt: float,
|
||||
cash: float,
|
||||
shares: float,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Compute tornado-chart data by varying each input ±10 %.
|
||||
|
||||
Returns a list sorted descending by impact range (high − low).
|
||||
Each entry: {"variable", "low", "high", "base"}.
|
||||
"""
|
||||
if shares <= 0:
|
||||
return []
|
||||
|
||||
def _val(f, w, t, g, d, c) -> float | None:
|
||||
if w <= t or w <= 0:
|
||||
return None
|
||||
r = excel_style_dcf(f, w, t, g, d, c, shares)
|
||||
return r.get("value_per_share")
|
||||
|
||||
base_val = _val(fcf, wacc, tg, growth, debt, cash)
|
||||
if base_val is None:
|
||||
return []
|
||||
|
||||
variables = [
|
||||
("WACC", lambda sign: _val(fcf, wacc * (1 + sign * 0.10), tg, growth, debt, cash)),
|
||||
("FCF Growth", lambda sign: _val(fcf, wacc, tg, growth * (1 + sign * 0.10), debt, cash)),
|
||||
("Terminal Growth", lambda sign: _val(fcf, wacc, tg * (1 + sign * 0.10), growth, debt, cash)),
|
||||
("Base FCF", lambda sign: _val(fcf * (1 + sign * 0.10), wacc, tg, growth, debt, cash)),
|
||||
("Total Debt", lambda sign: _val(fcf, wacc, tg, growth, debt * (1 + sign * 0.10), cash)),
|
||||
("Cash", lambda sign: _val(fcf, wacc, tg, growth, debt, cash * (1 + sign * 0.10))),
|
||||
]
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for name, func in variables:
|
||||
val_up = func(0.10)
|
||||
val_dn = func(-0.10)
|
||||
if val_up is None or val_dn is None:
|
||||
continue
|
||||
low = round(min(val_up, val_dn), 2)
|
||||
high = round(max(val_up, val_dn), 2)
|
||||
results.append({
|
||||
"variable": name,
|
||||
"low": low,
|
||||
"high": high,
|
||||
"base": round(base_val, 2),
|
||||
})
|
||||
|
||||
results.sort(key=lambda d: d["high"] - d["low"], reverse=True)
|
||||
return results
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Technical analysis service -- compute indicators and detect signals."""
|
||||
|
||||
import math
|
||||
|
||||
import pandas as pd
|
||||
import ta
|
||||
import yfinance as yf
|
||||
|
||||
|
||||
def _safe(val, default=None):
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
f = float(val)
|
||||
return default if math.isnan(f) or math.isinf(f) else f
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _series_to_list(s):
|
||||
return [_safe(v) for v in s.tolist()]
|
||||
|
||||
|
||||
def compute_all_indicators(ticker: str, period: str = "1y") -> dict:
|
||||
"""Fetch OHLCV from yfinance and compute all TA indicators."""
|
||||
df = yf.Ticker(ticker).history(period=period)
|
||||
if df.empty:
|
||||
return {}
|
||||
|
||||
close = df["Close"]
|
||||
high = df["High"]
|
||||
low = df["Low"]
|
||||
volume = df["Volume"]
|
||||
|
||||
return {
|
||||
"dates": df.index.strftime("%Y-%m-%d").tolist(),
|
||||
"ohlc": {
|
||||
"open": _series_to_list(df["Open"]),
|
||||
"high": _series_to_list(high),
|
||||
"low": _series_to_list(low),
|
||||
"close": _series_to_list(close),
|
||||
},
|
||||
"volume": _series_to_list(volume),
|
||||
"sma_20": _series_to_list(ta.trend.sma_indicator(close, window=20)),
|
||||
"sma_50": _series_to_list(ta.trend.sma_indicator(close, window=50)),
|
||||
"sma_200": _series_to_list(ta.trend.sma_indicator(close, window=200)),
|
||||
"ema_12": _series_to_list(ta.trend.ema_indicator(close, window=12)),
|
||||
"ema_26": _series_to_list(ta.trend.ema_indicator(close, window=26)),
|
||||
"rsi": _series_to_list(ta.momentum.rsi(close, window=14)),
|
||||
"macd": _series_to_list(ta.trend.macd(close)),
|
||||
"macd_signal": _series_to_list(ta.trend.macd_signal(close)),
|
||||
"macd_histogram": _series_to_list(ta.trend.macd_diff(close)),
|
||||
"bb_upper": _series_to_list(ta.volatility.bollinger_hband(close)),
|
||||
"bb_lower": _series_to_list(ta.volatility.bollinger_lband(close)),
|
||||
"bb_middle": _series_to_list(ta.volatility.bollinger_mavg(close)),
|
||||
"ichimoku_a": _series_to_list(ta.trend.ichimoku_a(high, low)),
|
||||
"ichimoku_b": _series_to_list(ta.trend.ichimoku_b(high, low)),
|
||||
"ichimoku_base": _series_to_list(ta.trend.ichimoku_base_line(high, low)),
|
||||
"ichimoku_conversion": _series_to_list(
|
||||
ta.trend.ichimoku_conversion_line(high, low)
|
||||
),
|
||||
"adx": _series_to_list(ta.trend.adx(high, low, close)),
|
||||
"signals": detect_signals(df),
|
||||
}
|
||||
|
||||
|
||||
def detect_signals(df: pd.DataFrame) -> list:
|
||||
"""Detect Golden Cross, Death Cross, RSI signals."""
|
||||
signals = []
|
||||
sma50 = ta.trend.sma_indicator(df["Close"], 50)
|
||||
sma200 = ta.trend.sma_indicator(df["Close"], 200)
|
||||
rsi = ta.momentum.rsi(df["Close"], 14)
|
||||
|
||||
for i in range(1, len(df)):
|
||||
if (
|
||||
pd.notna(sma50.iloc[i])
|
||||
and pd.notna(sma200.iloc[i])
|
||||
and pd.notna(sma50.iloc[i - 1])
|
||||
and pd.notna(sma200.iloc[i - 1])
|
||||
):
|
||||
if (
|
||||
sma50.iloc[i] > sma200.iloc[i]
|
||||
and sma50.iloc[i - 1] <= sma200.iloc[i - 1]
|
||||
):
|
||||
signals.append(
|
||||
{
|
||||
"date": df.index[i].strftime("%Y-%m-%d"),
|
||||
"type": "golden_cross",
|
||||
"label": "Golden Cross",
|
||||
}
|
||||
)
|
||||
if (
|
||||
sma50.iloc[i] < sma200.iloc[i]
|
||||
and sma50.iloc[i - 1] >= sma200.iloc[i - 1]
|
||||
):
|
||||
signals.append(
|
||||
{
|
||||
"date": df.index[i].strftime("%Y-%m-%d"),
|
||||
"type": "death_cross",
|
||||
"label": "Death Cross",
|
||||
}
|
||||
)
|
||||
if pd.notna(rsi.iloc[i]) and pd.notna(rsi.iloc[i - 1]):
|
||||
if rsi.iloc[i] > 30 and rsi.iloc[i - 1] <= 30:
|
||||
signals.append(
|
||||
{
|
||||
"date": df.index[i].strftime("%Y-%m-%d"),
|
||||
"type": "rsi_oversold_bounce",
|
||||
"label": "RSI Oversold Bounce",
|
||||
}
|
||||
)
|
||||
if rsi.iloc[i] > 70 and rsi.iloc[i - 1] <= 70:
|
||||
signals.append(
|
||||
{
|
||||
"date": df.index[i].strftime("%Y-%m-%d"),
|
||||
"type": "rsi_overbought",
|
||||
"label": "RSI Overbought",
|
||||
}
|
||||
)
|
||||
return signals
|
||||
|
||||
|
||||
def compute_fibonacci_levels(high_52w: float, recent_low: float) -> dict:
|
||||
"""Compute Fibonacci retracement levels from 52-week high and recent low."""
|
||||
diff = high_52w - recent_low
|
||||
return {
|
||||
"high": high_52w,
|
||||
"low": recent_low,
|
||||
"level_236": recent_low + diff * 0.236,
|
||||
"level_382": recent_low + diff * 0.382,
|
||||
"level_500": recent_low + diff * 0.500,
|
||||
"level_618": recent_low + diff * 0.618,
|
||||
"level_786": recent_low + diff * 0.786,
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Text cleaning and chunking utilities for LLM payloads.
|
||||
|
||||
Provides aggressive HTML-stripping, whitespace normalisation, and
|
||||
intelligent splitting of long text into sequential chunks that avoid
|
||||
cutting mid-sentence when possible.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def clean_text_for_llm(html_content: str) -> str:
|
||||
"""Strip HTML, collapse whitespace, and remove non-ASCII for LLM input.
|
||||
|
||||
Removes ``<table>``, ``<img>``, ``<style>``, ``<script>``, ``<svg>``,
|
||||
and ``<math>`` elements before extracting text. Drops page-number-only
|
||||
lines and other layout artefacts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
html_content:
|
||||
Raw HTML (or already-plain text with residual tags).
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Clean, single-line-ish text suitable for an LLM prompt.
|
||||
"""
|
||||
if not html_content or not html_content.strip():
|
||||
return ""
|
||||
try:
|
||||
soup = BeautifulSoup(html_content, "lxml")
|
||||
for tag in soup.find_all(["table", "img", "style", "script", "svg", "math"]):
|
||||
tag.decompose()
|
||||
text = soup.get_text(separator=" ")
|
||||
except Exception:
|
||||
text = re.sub(r"<[^>]+>", " ", html_content)
|
||||
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
text = " ".join(text.split())
|
||||
text = re.sub(r"[^\x20-\x7E\n]", " ", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
lines: List[str] = []
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line):
|
||||
continue
|
||||
if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20:
|
||||
continue
|
||||
lines.append(line)
|
||||
|
||||
result = " ".join(lines)
|
||||
result = re.sub(r"\s+", " ", result).strip()
|
||||
return result
|
||||
|
||||
|
||||
def smart_chunk(
|
||||
section: str,
|
||||
max_chars: int = 10_000,
|
||||
head_ratio: float = 0.5,
|
||||
) -> str:
|
||||
"""Truncate *section* to *max_chars* keeping head and tail portions.
|
||||
|
||||
When the text exceeds the limit the middle is replaced with a brief
|
||||
``[ ... middle omitted ... ]`` marker. Approximately
|
||||
``head_ratio * max_chars`` characters come from the start and the
|
||||
remainder from the end.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
section:
|
||||
Full text to be trimmed.
|
||||
max_chars:
|
||||
Hard character budget (default 10 000 ~= 2.5k tokens).
|
||||
head_ratio:
|
||||
Fraction of the budget allocated to the leading portion.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Text guaranteed to be at most *max_chars* characters long.
|
||||
"""
|
||||
if not section or len(section) <= max_chars:
|
||||
return section
|
||||
head_size = int(max_chars * head_ratio)
|
||||
tail_size = max_chars - head_size - 100
|
||||
return section[:head_size] + " [ ... middle omitted ... ] " + section[-tail_size:]
|
||||
|
||||
|
||||
def _split_into_chunks(
|
||||
text: str,
|
||||
max_chars: int = 22_000,
|
||||
min_chunk: int = 5_000,
|
||||
) -> List[str]:
|
||||
"""Split *text* into sequential chunks without cutting mid-sentence.
|
||||
|
||||
Prefers breaking at paragraph boundaries (double newlines). Each chunk
|
||||
is at most *max_chars* characters; the algorithm avoids creating a
|
||||
trailing fragment shorter than *min_chunk* unless it is the only chunk.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
The document to split.
|
||||
max_chars:
|
||||
Maximum characters per chunk.
|
||||
min_chunk:
|
||||
Minimum look-back distance when searching for a break point.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[str]
|
||||
Non-empty stripped chunks in document order.
|
||||
"""
|
||||
if not text or len(text) <= max_chars:
|
||||
return [text] if text and text.strip() else []
|
||||
chunks: List[str] = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(start + max_chars, len(text))
|
||||
if end < len(text):
|
||||
break_at = text.rfind("\n\n", start, end + 1)
|
||||
if break_at > start + min_chunk:
|
||||
end = break_at + 2
|
||||
chunks.append(text[start:end].strip())
|
||||
start = end
|
||||
return [c for c in chunks if c]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Numeric safety utilities for the ATLAS Terminal backend.
|
||||
|
||||
Provides safe type-coercion helpers used across all services to handle
|
||||
None, NaN, and non-numeric values gracefully without raising exceptions.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _safe_float(x: object) -> Optional[float]:
|
||||
"""Convert *x* to ``float``, returning ``None`` for unconvertible values.
|
||||
|
||||
Handles ``None``, ``NaN`` (both Python ``float('nan')`` and pandas
|
||||
``pd.NA``), and arbitrary objects whose ``float()`` conversion fails.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x:
|
||||
Any value that might be numeric.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Optional[float]
|
||||
The float representation, or ``None`` if conversion is impossible.
|
||||
"""
|
||||
if x is None or (isinstance(x, float) and (x != x or pd.isna(x))):
|
||||
return None
|
||||
try:
|
||||
return float(x)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _na(x: object) -> object:
|
||||
"""Return the string ``'N/A'`` for ``None``/``NaN``, otherwise *x* unchanged.
|
||||
|
||||
Useful when building display-ready dictionaries or DataFrames where
|
||||
missing numeric values should appear as a human-readable sentinel.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x:
|
||||
Any value.
|
||||
|
||||
Returns
|
||||
-------
|
||||
object
|
||||
``'N/A'`` when *x* is ``None`` or ``NaN``; *x* otherwise.
|
||||
"""
|
||||
if x is None or (isinstance(x, float) and (pd.isna(x) or x != x)):
|
||||
return "N/A"
|
||||
return x
|
||||
|
||||
|
||||
def _format_shares_display(shares: Optional[float]) -> str:
|
||||
"""Format a share count for human-friendly display.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> _format_shares_display(15_420_000_000)
|
||||
'15.42B Shares'
|
||||
>>> _format_shares_display(1_200_000)
|
||||
'1.20M Shares'
|
||||
>>> _format_shares_display(None)
|
||||
'N/A'
|
||||
|
||||
Parameters
|
||||
----------
|
||||
shares:
|
||||
Raw share count (absolute number, not in millions/billions).
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A concise string such as ``'15.42B Shares'`` or ``'N/A'``.
|
||||
"""
|
||||
if shares is None or shares <= 0:
|
||||
return "N/A"
|
||||
s = float(shares)
|
||||
if s >= 1e9:
|
||||
return f"{s / 1e9:.2f}B Shares"
|
||||
if s >= 1e6:
|
||||
return f"{s / 1e6:.2f}M Shares"
|
||||
if s >= 1e3:
|
||||
return f"{s / 1e3:.2f}K Shares"
|
||||
return f"{s:.0f} Shares"
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Ticker formatting, market inference, and company/sector reference data.
|
||||
|
||||
Centralises the mapping logic that converts bare ticker symbols into
|
||||
Yahoo Finance-compatible identifiers with the correct market suffix,
|
||||
and provides the static lookup tables for companies and sectors.
|
||||
"""
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Company reference data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMPANY_LIST: List[Tuple[str, str]] = [
|
||||
("NVIDIA Corporation", "NVDA"), ("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"),
|
||||
("Amazon.com Inc.", "AMZN"), ("Alphabet Inc.", "GOOGL"), ("Meta Platforms Inc.", "META"),
|
||||
("AMD", "AMD"), ("Intel Corporation", "INTC"), ("Qualcomm Inc.", "QCOM"), ("Tesla Inc.", "TSLA"),
|
||||
("Berkshire Hathaway", "BRK.B"), ("JPMorgan Chase", "JPM"), ("Visa Inc.", "V"),
|
||||
("UnitedHealth", "UNH"), ("Procter & Gamble", "PG"), ("Exxon Mobil", "XOM"),
|
||||
("Johnson & Johnson", "JNJ"), ("Mastercard", "MA"), ("Chevron", "CVX"),
|
||||
("Home Depot", "HD"), ("Merck", "MRK"), ("AbbVie", "ABBV"), ("Costco", "COST"),
|
||||
("PepsiCo", "PEP"), ("Coca-Cola", "KO"), ("Pfizer", "PFE"), ("Walmart", "WMT"),
|
||||
("Netflix", "NFLX"), ("Adobe", "ADBE"), ("Salesforce", "CRM"), ("Comcast", "CMCSA"),
|
||||
("Cisco", "CSCO"), ("Oracle", "ORCL"), ("American Express", "AXP"),
|
||||
("Bank of America", "BAC"), ("Wells Fargo", "WFC"), ("Verizon", "VZ"),
|
||||
("AT&T", "T"), ("Walt Disney", "DIS"), ("Nike", "NKE"), ("McDonald's", "MCD"),
|
||||
("Starbucks", "SBUX"), ("Goldman Sachs", "GS"), ("Morgan Stanley", "MS"),
|
||||
("Target", "TGT"), ("Boeing", "BA"), ("IBM", "IBM"),
|
||||
]
|
||||
|
||||
COMPANY_OPTIONS: List[str] = [f"{t} - {n}" for n, t in COMPANY_LIST]
|
||||
"""Pre-formatted ``'TICKER - Company Name'`` strings for dropdowns."""
|
||||
|
||||
COMPANY_TICKER_MAP: dict[str, str] = {t: n for n, t in COMPANY_LIST}
|
||||
"""Mapping from ticker symbol to full company name."""
|
||||
|
||||
MARKET_OPTIONS: List[str] = [
|
||||
"US (S&P/Dow/Nasdaq)",
|
||||
"South Korea (KOSPI/KOSDAQ)",
|
||||
"Japan (Nikkei)",
|
||||
"UK (LSE)",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sector / industry peer groups (top-down analysis)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SECTORS: dict[str, List[str]] = {
|
||||
"Semiconductors & Hardware": ["NVDA", "AMD", "INTC", "TSM", "AVGO"],
|
||||
"Software & Cloud": ["MSFT", "ADBE", "CRM", "PANW", "CRWD"],
|
||||
"Consumer Retail": ["AMZN", "SBUX", "MCD", "WMT", "HD"],
|
||||
"Financial Services": ["JPM", "BAC", "GS", "MS", "V"],
|
||||
"Healthcare": ["LLY", "UNH", "JNJ", "ABBV", "MRK"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ticker helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_global_ticker(ticker: str, market: str) -> str:
|
||||
"""Append the correct Yahoo Finance suffix based on the selected market.
|
||||
|
||||
US tickers are returned as-is. If the ticker already carries a known
|
||||
suffix (``.KS``, ``.KQ``, ``.T``, ``.L``) it is returned unchanged
|
||||
regardless of the *market* argument.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ticker:
|
||||
Raw ticker string entered by the user.
|
||||
market:
|
||||
One of the values in :data:`MARKET_OPTIONS`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The ticker with an appropriate suffix (or unchanged for US).
|
||||
"""
|
||||
if not (ticker or "").strip():
|
||||
return (ticker or "").strip()
|
||||
t = (ticker or "").strip()
|
||||
if t.upper().endswith((".KS", ".KQ", ".T", ".L")):
|
||||
return t
|
||||
m = (market or "").strip()
|
||||
if "US" in m or not m:
|
||||
return t
|
||||
if "Korea" in m or "KOSPI" in m or "KOSDAQ" in m:
|
||||
return t + ".KS"
|
||||
if "Japan" in m or "Nikkei" in m:
|
||||
return t + ".T"
|
||||
if "UK" in m or "LSE" in m:
|
||||
return t + ".L"
|
||||
return t
|
||||
|
||||
|
||||
def infer_market_from_ticker(ticker: str) -> str:
|
||||
"""Guess the market label from a ticker's suffix.
|
||||
|
||||
Useful when the caller has a fully-qualified ticker (e.g. ``005930.KS``)
|
||||
but no explicit market selection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ticker:
|
||||
A ticker string that may include a market suffix.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The best-matching entry from :data:`MARKET_OPTIONS`.
|
||||
"""
|
||||
if not (ticker or "").strip():
|
||||
return MARKET_OPTIONS[0]
|
||||
t = (ticker or "").strip().upper()
|
||||
if t.endswith(".KS") or t.endswith(".KQ"):
|
||||
return "South Korea (KOSPI/KOSDAQ)"
|
||||
if t.endswith(".T"):
|
||||
return "Japan (Nikkei)"
|
||||
if t.endswith(".L"):
|
||||
return "UK (LSE)"
|
||||
return "US (S&P/Dow/Nasdaq)"
|
||||
Reference in New Issue
Block a user