fix: redesign relative rotation for multi-symbol comparison

- Accept comma-separated symbols query param instead of single path param
- Move endpoint from /stock/{symbol}/technical/relative-rotation to
  /technical/relative-rotation?symbols=AAPL,MSFT,GOOGL&benchmark=SPY
- Fetch all symbols + benchmark in single obb.equity.price.historical call
- Add RRG quadrant classification (Leading/Weakening/Lagging/Improving)
- Support study parameter (price/volume/volatility)
This commit is contained in:
Yaojia Wang
2026-03-19 17:34:18 +01:00
parent 615f17a3bb
commit e2cf6e2488
2 changed files with 66 additions and 14 deletions

View File

@@ -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)

View File

@@ -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)