Files
All-in-one-Financial-Analysis/atlas-terminal/server/routers/technical.py
T
shawnkim1997andClaude Opus 4.6 b2acda81ee 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>
2026-03-21 02:10:10 +00:00

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