diff --git a/routes_technical.py b/routes_technical.py index 0e0120c..39970dd 100644 --- a/routes_technical.py +++ b/routes_technical.py @@ -164,14 +164,23 @@ async def stock_vwap(symbol: str = Path(..., min_length=1, max_length=20)): return ApiResponse(data=data) -@router.get("/stock/{symbol}/technical/relative-rotation", response_model=ApiResponse) +@router.get("/technical/relative-rotation", response_model=ApiResponse) @safe -async def stock_relative_rotation( - symbol: str = Path(..., min_length=1, max_length=20), +async def relative_rotation( + symbols: str = Query(..., min_length=1, max_length=200, description="Comma-separated symbols, e.g. AAPL,MSFT,GOOGL"), benchmark: str = Query(default="SPY", min_length=1, max_length=20), + study: str = Query(default="price", pattern="^(price|volume|volatility)$"), ): - """Relative Rotation -- strength vs benchmark (sector rotation analysis).""" - symbol = validate_symbol(symbol) + """Relative Rotation Graph -- compare multiple symbols vs benchmark. + + Returns RS-Ratio and RS-Momentum for each symbol, indicating + RRG quadrant: Leading, Weakening, Lagging, or Improving. + """ + symbol_list = [validate_symbol(s.strip()) for s in symbols.split(",") if s.strip()] + if not symbol_list: + return ApiResponse(data=[], error="No valid symbols provided") benchmark = validate_symbol(benchmark) - data = await technical_service.get_relative_rotation(symbol, benchmark=benchmark) + data = await technical_service.get_relative_rotation( + symbol_list, benchmark=benchmark, study=study, + ) return ApiResponse(data=data) diff --git a/technical_service.py b/technical_service.py index ee15246..0e6d3b3 100644 --- a/technical_service.py +++ b/technical_service.py @@ -400,23 +400,66 @@ async def get_vwap(symbol: str, days: int = 5) -> dict[str, Any]: async def get_relative_rotation( - symbols: str, benchmark: str = "SPY", days: int = 365, + symbols: list[str], + benchmark: str = "SPY", + days: int = 365, + study: str = "price", ) -> dict[str, Any]: - """Relative Rotation -- strength ratio and momentum vs benchmark.""" - hist = await fetch_historical(symbols, days) - bench_hist = await fetch_historical(benchmark, days) - if hist is None or bench_hist is None: - return {"symbols": symbols, "benchmark": benchmark, "error": "No historical data"} + """Relative Rotation -- strength ratio and momentum vs benchmark. + + Requires multiple symbols compared against a single benchmark. + Returns RS-Ratio and RS-Momentum for each symbol, indicating + which RRG quadrant they occupy (Leading/Weakening/Lagging/Improving). + """ + from datetime import datetime, timedelta, timezone + + start = (datetime.now(tz=timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") + all_symbols = ",".join(symbols + [benchmark]) + try: + # Fetch multi-symbol historical data in one call + hist = await asyncio.to_thread( + obb.equity.price.historical, + all_symbols, + start_date=start, + provider="yfinance", + ) + if hist is None or hist.results is None: + return {"symbols": symbols, "benchmark": benchmark, "error": "No historical data"} + result = await asyncio.to_thread( obb.technical.relative_rotation, - data=hist.results, benchmark=bench_hist.results, + data=hist.results, + benchmark=benchmark, + study=study, ) items = to_list(result) + # Return the latest data point per symbol + latest_by_symbol: dict[str, dict] = {} + for item in items: + sym = item.get("symbol") + if sym and sym != benchmark: + latest_by_symbol[sym] = item + + entries = list(latest_by_symbol.values()) + for entry in entries: + rs_ratio = entry.get("rs_ratio") + rs_momentum = entry.get("rs_momentum") + if rs_ratio is not None and rs_momentum is not None: + if rs_ratio > 100 and rs_momentum > 100: + entry["quadrant"] = "Leading" + elif rs_ratio > 100 and rs_momentum <= 100: + entry["quadrant"] = "Weakening" + elif rs_ratio <= 100 and rs_momentum <= 100: + entry["quadrant"] = "Lagging" + else: + entry["quadrant"] = "Improving" + return { "symbols": symbols, "benchmark": benchmark, - "data": items[-10:] if len(items) > 10 else items, + "study": study, + "data": entries, } except Exception: logger.warning("Relative rotation failed for %s", symbols, exc_info=True)