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:
shawnkim1997
2026-03-21 02:10:10 +00:00
co-authored by Claude Opus 4.6
parent 56a9561f71
commit b2acda81ee
111 changed files with 13883 additions and 270 deletions
View File
+222
View File
@@ -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()
+324
View File
@@ -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()
+221
View File
@@ -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()
+159
View File
@@ -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
+122
View File
@@ -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
+62
View File
@@ -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
+115
View File
@@ -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
+222
View File
@@ -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
+68
View File
@@ -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()
+95
View File
@@ -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()
+75
View File
@@ -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"}
+22
View File
@@ -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
+257
View File
@@ -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"
+202
View File
@@ -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}
+256
View File
@@ -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),
}
+139
View File
@@ -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
+88
View File
@@ -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
+95
View File
@@ -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
+159
View File
@@ -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": [],
}
+258
View File
@@ -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": {},
}
+91
View File
@@ -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
+69
View File
@@ -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 {}
+180
View File
@@ -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
+230
View File
@@ -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
+305
View File
@@ -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
+298
View File
@@ -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]
+88
View File
@@ -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"
+122
View File
@@ -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)"