mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-17 12:28:07 +00:00
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>
306 lines
10 KiB
Python
306 lines
10 KiB
Python
"""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
|