feat: OpenBB Investment Analysis API

REST API wrapping OpenBB SDK for stock data, sentiment analysis,
technical indicators, macro data, and rule-based portfolio analysis.

- Stock data via yfinance (quote, profile, metrics, financials, historical, news)
- News sentiment via Alpha Vantage (per-article, per-ticker scores)
- Analyst data via Finnhub (recommendations, insider trades, upgrades)
- Macro data via FRED (Fed rate, CPI, GDP, unemployment, treasury yields)
- Technical indicators via openbb-technical (RSI, MACD, SMA, EMA, Bollinger)
- Rule-based portfolio analysis engine (BUY_MORE/HOLD/SELL)
- Stock discovery (gainers, losers, active, undervalued, growth)
- 102 tests, all passing
This commit is contained in:
Yaojia Wang
2026-03-09 00:20:10 +01:00
commit ad45cb429c
30 changed files with 3107 additions and 0 deletions

150
models.py Normal file
View File

@@ -0,0 +1,150 @@
import re
from enum import Enum
from typing import Any
from pydantic import AwareDatetime, BaseModel, Field, field_validator
# --- Constants ---
SYMBOL_PATTERN = re.compile(r"^[A-Za-z0-9.\-]{1,20}$")
# --- Request Models ---
class Holding(BaseModel):
symbol: str = Field(..., description="Stock symbol, e.g. AAPL or VOLV-B.ST")
shares: float = Field(..., gt=0, description="Number of shares held")
buy_in_price: float = Field(..., gt=0, description="Average cost basis per share")
@field_validator("symbol")
@classmethod
def validate_symbol(cls, v: str) -> str:
if not SYMBOL_PATTERN.match(v):
raise ValueError("Invalid symbol format. Use 1-20 alphanumeric chars, dots, or hyphens.")
return v.upper()
class PortfolioRequest(BaseModel):
holdings: list[Holding] = Field(..., min_length=1, max_length=50)
# --- Response Models ---
class ActionEnum(str, Enum):
BUY_MORE = "BUY_MORE"
HOLD = "HOLD"
SELL = "SELL"
class ConfidenceEnum(str, Enum):
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
class QuoteResponse(BaseModel):
symbol: str
name: str | None = None
price: float | None = None
change: float | None = None
change_percent: float | None = None
volume: int | None = None
market_cap: float | None = None
currency: str | None = None
class ProfileResponse(BaseModel):
symbol: str
name: str | None = None
sector: str | None = None
industry: str | None = None
country: str | None = None
description: str | None = None
website: str | None = None
employees: int | None = None
class MetricsResponse(BaseModel):
symbol: str
pe_ratio: float | None = None
pb_ratio: float | None = None
ps_ratio: float | None = None
peg_ratio: float | None = None
roe: float | None = None
roa: float | None = None
dividend_yield: float | None = None
beta: float | None = None
eps: float | None = None
revenue_growth: float | None = None
earnings_growth: float | None = None
class HistoricalBar(BaseModel):
date: str
open: float | None = None
high: float | None = None
low: float | None = None
close: float | None = None
volume: int | None = None
class FinancialsResponse(BaseModel):
symbol: str
income: list[dict] = Field(default_factory=list)
balance: list[dict] = Field(default_factory=list)
cash_flow: list[dict] = Field(default_factory=list)
class NewsItem(BaseModel):
title: str | None = None
url: str | None = None
date: str | None = None
source: str | None = None
class SummaryResponse(BaseModel):
quote: QuoteResponse | None = None
profile: ProfileResponse | None = None
metrics: MetricsResponse | None = None
financials: FinancialsResponse | None = None
class AnalysisResult(BaseModel):
action: ActionEnum
confidence: ConfidenceEnum
reasons: list[str]
class HoldingAnalysis(BaseModel):
symbol: str
current_price: float | None = None
buy_in_price: float
shares: float
pnl: float | None = None
pnl_percent: float | None = None
metrics: MetricsResponse | None = None
target_price: float | None = None
analysis: AnalysisResult
class PortfolioResponse(BaseModel):
holdings: list[HoldingAnalysis]
analyzed_at: AwareDatetime
class DiscoverItem(BaseModel):
symbol: str | None = None
name: str | None = None
price: float | None = None
change_percent: float | None = None
volume: int | None = None
market_cap: float | None = None
class ApiResponse(BaseModel):
success: bool = True
data: dict[str, Any] | list[Any] | None = None
error: str | None = None