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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user