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)
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{symbol}/technical/relative-rotation", response_model=ApiResponse)
|
@router.get("/technical/relative-rotation", response_model=ApiResponse)
|
||||||
@safe
|
@safe
|
||||||
async def stock_relative_rotation(
|
async def relative_rotation(
|
||||||
symbol: str = Path(..., min_length=1, max_length=20),
|
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),
|
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)."""
|
"""Relative Rotation Graph -- compare multiple symbols vs benchmark.
|
||||||
symbol = validate_symbol(symbol)
|
|
||||||
|
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)
|
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)
|
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(
|
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]:
|
) -> dict[str, Any]:
|
||||||
"""Relative Rotation -- strength ratio and momentum vs benchmark."""
|
"""Relative Rotation -- strength ratio and momentum vs benchmark.
|
||||||
hist = await fetch_historical(symbols, days)
|
|
||||||
bench_hist = await fetch_historical(benchmark, days)
|
Requires multiple symbols compared against a single benchmark.
|
||||||
if hist is None or bench_hist is None:
|
Returns RS-Ratio and RS-Momentum for each symbol, indicating
|
||||||
return {"symbols": symbols, "benchmark": benchmark, "error": "No historical data"}
|
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:
|
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(
|
result = await asyncio.to_thread(
|
||||||
obb.technical.relative_rotation,
|
obb.technical.relative_rotation,
|
||||||
data=hist.results, benchmark=bench_hist.results,
|
data=hist.results,
|
||||||
|
benchmark=benchmark,
|
||||||
|
study=study,
|
||||||
)
|
)
|
||||||
items = to_list(result)
|
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 {
|
return {
|
||||||
"symbols": symbols,
|
"symbols": symbols,
|
||||||
"benchmark": benchmark,
|
"benchmark": benchmark,
|
||||||
"data": items[-10:] if len(items) > 10 else items,
|
"study": study,
|
||||||
|
"data": entries,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Relative rotation failed for %s", symbols, exc_info=True)
|
logger.warning("Relative rotation failed for %s", symbols, exc_info=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user