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

149
tests/test_models.py Normal file
View File

@@ -0,0 +1,149 @@
from datetime import datetime, timezone
from pydantic import ValidationError
from models import (
ActionEnum,
AnalysisResult,
ConfidenceEnum,
Holding,
HoldingAnalysis,
MetricsResponse,
PortfolioRequest,
PortfolioResponse,
QuoteResponse,
)
def test_holding_valid():
h = Holding(symbol="AAPL", shares=100, buy_in_price=150.0)
assert h.symbol == "AAPL"
assert h.shares == 100
assert h.buy_in_price == 150.0
def test_holding_swedish_symbol():
h = Holding(symbol="VOLV-B.ST", shares=50, buy_in_price=250.0)
assert h.symbol == "VOLV-B.ST"
def test_holding_symbol_uppercased():
h = Holding(symbol="aapl", shares=10, buy_in_price=150.0)
assert h.symbol == "AAPL"
def test_holding_invalid_symbol_format():
try:
Holding(symbol="AAPL;DROP TABLE", shares=10, buy_in_price=150.0)
assert False, "Should have raised"
except ValidationError:
pass
def test_holding_symbol_too_long():
try:
Holding(symbol="A" * 21, shares=10, buy_in_price=150.0)
assert False, "Should have raised"
except ValidationError:
pass
def test_holding_invalid_shares():
try:
Holding(symbol="AAPL", shares=0, buy_in_price=150.0)
assert False, "Should have raised"
except ValidationError:
pass
def test_holding_invalid_price():
try:
Holding(symbol="AAPL", shares=10, buy_in_price=-5.0)
assert False, "Should have raised"
except ValidationError:
pass
def test_portfolio_request_empty():
try:
PortfolioRequest(holdings=[])
assert False, "Should have raised"
except ValidationError:
pass
def test_portfolio_request_too_many():
holdings = [
Holding(symbol="AAPL", shares=1, buy_in_price=100)
for _ in range(51)
]
try:
PortfolioRequest(holdings=holdings)
assert False, "Should have raised"
except ValidationError:
pass
def test_portfolio_request_valid():
req = PortfolioRequest(
holdings=[Holding(symbol="AAPL", shares=10, buy_in_price=150)]
)
assert len(req.holdings) == 1
def test_quote_response_defaults():
q = QuoteResponse(symbol="AAPL")
assert q.price is None
assert q.change is None
def test_metrics_response():
m = MetricsResponse(symbol="AAPL", pe_ratio=25.0, roe=0.15)
assert m.pe_ratio == 25.0
assert m.pb_ratio is None
def test_analysis_result():
a = AnalysisResult(
action=ActionEnum.BUY_MORE,
confidence=ConfidenceEnum.HIGH,
reasons=["Low PE", "Strong growth"],
)
assert a.action == ActionEnum.BUY_MORE
assert len(a.reasons) == 2
def test_holding_analysis():
ha = HoldingAnalysis(
symbol="AAPL",
current_price=180.0,
buy_in_price=150.0,
shares=100,
pnl=3000.0,
pnl_percent=0.2,
analysis=AnalysisResult(
action=ActionEnum.HOLD,
confidence=ConfidenceEnum.MEDIUM,
reasons=["Within hold range"],
),
)
assert ha.pnl == 3000.0
def test_portfolio_response():
pr = PortfolioResponse(
holdings=[
HoldingAnalysis(
symbol="AAPL",
buy_in_price=150.0,
shares=100,
analysis=AnalysisResult(
action=ActionEnum.HOLD,
confidence=ConfidenceEnum.LOW,
reasons=["No data"],
),
)
],
analyzed_at=datetime.now(timezone.utc),
)
assert len(pr.holdings) == 1