alphax/app/core/relative_strength.py
2026-06-01 00:16:01 +08:00

303 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Relative Strength vs BTC — 相对强度因子。
核心逻辑:
- 计算币种过去 N 天涨幅 vs BTC 同期涨幅的差值
- 标准化为 RS 分数(正值=强于BTC负值=弱于BTC
- 提供 RS 排名百分位,用于筛选前置条件
使用场景:
- RS 排名前 30% 的币 → 加分(潜在领涨)
- RS 排名后 50% 的币 → 降级(弱势币即使出信号也成功率低)
- BTC 回调时 RS 仍为正 → 独立买盘,高价值信号
"""
from __future__ import annotations
import time
from typing import Optional
import pandas as pd
import requests
from app.config.config_loader import _get_section as _get_cfg_section
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
def _rs_config() -> dict:
"""Load RS config from rules.yaml -> relative_strength section."""
try:
cfg = _get_cfg_section("relative_strength") or {}
except Exception:
cfg = {}
return {
"lookback_days": int(cfg.get("lookback_days", 5)),
"short_lookback_days": int(cfg.get("short_lookback_days", 3)),
"strong_threshold": float(cfg.get("strong_threshold", 5.0)),
"weak_threshold": float(cfg.get("weak_threshold", -3.0)),
"top_percentile": float(cfg.get("top_percentile", 30)),
"bottom_percentile": float(cfg.get("bottom_percentile", 50)),
"weight_strong": float(cfg.get("weight_strong", 3.0)),
"weight_weak_penalty": float(cfg.get("weight_weak_penalty", -2.0)),
"btc_drawdown_bonus": float(cfg.get("btc_drawdown_bonus", 2.0)),
"btc_drawdown_threshold": float(cfg.get("btc_drawdown_threshold", -2.0)),
}
# ---------------------------------------------------------------------------
# Data fetching
# ---------------------------------------------------------------------------
BINANCE_SPOT_BASE = "https://api.binance.com"
# Simple in-memory cache for BTC klines (refreshed every 5 min)
_btc_cache: dict = {"data": None, "ts": 0}
def _fetch_daily_klines(symbol_raw: str, limit: int = 10) -> Optional[pd.DataFrame]:
"""Fetch daily klines from Binance spot public API.
Args:
symbol_raw: Binance raw symbol like "BTCUSDT"
limit: number of daily candles
"""
try:
url = f"{BINANCE_SPOT_BASE}/api/v3/klines"
resp = requests.get(url, params={
"symbol": symbol_raw,
"interval": "1d",
"limit": limit,
}, timeout=8)
resp.raise_for_status()
data = resp.json()
if not data:
return None
df = pd.DataFrame(data, columns=[
"open_time", "open", "high", "low", "close", "volume",
"close_time", "quote_volume", "trades", "taker_buy_base",
"taker_buy_quote", "ignore",
])
df["open"] = df["open"].astype(float)
df["close"] = df["close"].astype(float)
df["high"] = df["high"].astype(float)
df["low"] = df["low"].astype(float)
return df
except Exception:
return None
def _get_btc_daily(limit: int = 10) -> Optional[pd.DataFrame]:
"""Get BTC daily klines with simple caching (5 min TTL)."""
now = time.time()
if _btc_cache["data"] is not None and now - _btc_cache["ts"] < 300:
return _btc_cache["data"]
df = _fetch_daily_klines("BTCUSDT", limit=limit)
if df is not None:
_btc_cache["data"] = df
_btc_cache["ts"] = now
return df
def _change_over_n_days(df: pd.DataFrame, n: int) -> Optional[float]:
"""Calculate percentage change over last N completed daily candles.
Uses close of (today - N) vs close of yesterday (last completed candle).
The last row might be the current incomplete day, so we use iloc[-2] as
the latest completed close and iloc[-(n+1)] as the start.
"""
if df is None or len(df) < n + 1:
return None
try:
# iloc[-1] = current (possibly incomplete) day
# iloc[-2] = last completed day
# For N-day change: compare close at -(N+1) to close at -1
start_close = float(df["close"].iloc[-(n + 1)])
end_close = float(df["close"].iloc[-1])
if start_close <= 0:
return None
return (end_close - start_close) / start_close * 100
except Exception:
return None
# ---------------------------------------------------------------------------
# Core RS calculation
# ---------------------------------------------------------------------------
def compute_relative_strength(
symbol: str,
coin_df_daily: Optional[pd.DataFrame] = None,
btc_df_daily: Optional[pd.DataFrame] = None,
) -> dict:
"""Compute RS metrics for a single coin vs BTC.
Args:
symbol: e.g. "SOL/USDT"
coin_df_daily: pre-fetched daily klines for the coin (optional)
btc_df_daily: pre-fetched BTC daily klines (optional)
Returns:
{
"rs_5d": float, # 5-day RS (coin_change - btc_change)
"rs_3d": float, # 3-day RS
"rs_score": float, # weighted composite RS
"btc_change_5d": float,
"coin_change_5d": float,
"btc_drawdown": bool, # True if BTC is down significantly
"independent_strength": bool, # coin up while BTC down
}
"""
cfg = _rs_config()
lookback = cfg["lookback_days"]
short_lookback = cfg["short_lookback_days"]
# Fetch BTC data if not provided
if btc_df_daily is None:
btc_df_daily = _get_btc_daily(limit=lookback + 2)
# Fetch coin data if not provided
if coin_df_daily is None:
raw_symbol = symbol.replace("/", "")
coin_df_daily = _fetch_daily_klines(raw_symbol, limit=lookback + 2)
# Calculate changes
btc_change_long = _change_over_n_days(btc_df_daily, lookback)
btc_change_short = _change_over_n_days(btc_df_daily, short_lookback)
coin_change_long = _change_over_n_days(coin_df_daily, lookback)
coin_change_short = _change_over_n_days(coin_df_daily, short_lookback)
if btc_change_long is None or coin_change_long is None:
return {
"rs_5d": 0.0,
"rs_3d": 0.0,
"rs_score": 0.0,
"btc_change_5d": 0.0,
"coin_change_5d": 0.0,
"btc_drawdown": False,
"independent_strength": False,
"available": False,
}
rs_long = round(coin_change_long - btc_change_long, 2)
rs_short = round((coin_change_short or 0) - (btc_change_short or 0), 2)
# Composite: weight short-term RS slightly more for 1-3 day trades
rs_score = round(rs_short * 0.6 + rs_long * 0.4, 2)
# BTC drawdown detection
btc_drawdown = (btc_change_short or 0) < cfg["btc_drawdown_threshold"]
# Independent strength: coin is positive while BTC is negative
independent_strength = (
btc_drawdown
and (coin_change_short or 0) > 0
)
return {
"rs_5d": rs_long,
"rs_3d": rs_short,
"rs_score": rs_score,
"btc_change_5d": round(btc_change_long, 2),
"btc_change_3d": round(btc_change_short or 0, 2),
"coin_change_5d": round(coin_change_long, 2),
"coin_change_3d": round(coin_change_short or 0, 2),
"btc_drawdown": btc_drawdown,
"independent_strength": independent_strength,
"available": True,
}
# ---------------------------------------------------------------------------
# Batch RS ranking
# ---------------------------------------------------------------------------
def rank_universe_rs(
symbols: list[str],
btc_df_daily: Optional[pd.DataFrame] = None,
) -> dict[str, dict]:
"""Compute RS for a list of symbols and attach percentile rank.
Returns: {symbol: {rs_score, rs_percentile, rs_tier, ...}}
where rs_tier is "strong" / "neutral" / "weak".
"""
cfg = _rs_config()
if btc_df_daily is None:
btc_df_daily = _get_btc_daily(limit=cfg["lookback_days"] + 2)
results = {}
for symbol in symbols:
rs = compute_relative_strength(symbol, btc_df_daily=btc_df_daily)
results[symbol] = rs
# Calculate percentile ranks
scores = [r["rs_score"] for r in results.values() if r.get("available")]
if not scores:
return results
sorted_scores = sorted(scores)
n = len(sorted_scores)
for symbol, rs_data in results.items():
if not rs_data.get("available"):
rs_data["rs_percentile"] = 50.0
rs_data["rs_tier"] = "neutral"
continue
score = rs_data["rs_score"]
# Percentile: what % of coins have lower RS
rank = sum(1 for s in sorted_scores if s < score)
percentile = round(rank / n * 100, 1) if n > 0 else 50.0
rs_data["rs_percentile"] = percentile
if percentile >= (100 - cfg["top_percentile"]):
rs_data["rs_tier"] = "strong"
elif percentile <= cfg["bottom_percentile"]:
rs_data["rs_tier"] = "weak"
else:
rs_data["rs_tier"] = "neutral"
return results
# ---------------------------------------------------------------------------
# Factor scoring interface
# ---------------------------------------------------------------------------
def rs_factor_score(rs_data: dict) -> tuple[float, str]:
"""Convert RS data into a factor score for the scoring system.
Returns:
(score_delta, signal_label)
- Positive score for strong RS
- Negative score for weak RS (penalty)
- Extra bonus if independent strength during BTC drawdown
"""
cfg = _rs_config()
if not rs_data.get("available"):
return 0.0, ""
score = 0.0
labels = []
tier = rs_data.get("rs_tier", "neutral")
rs_val = rs_data.get("rs_score", 0)
if tier == "strong":
score += cfg["weight_strong"]
labels.append(f"RS强势({rs_val:+.1f}%)")
elif tier == "weak":
score += cfg["weight_weak_penalty"]
labels.append(f"RS弱势({rs_val:+.1f}%)")
# Bonus: coin holds up while BTC drops
if rs_data.get("independent_strength"):
score += cfg["btc_drawdown_bonus"]
labels.append("BTC回调中独立走强")
label = " | ".join(labels) if labels else ""
return round(score, 2), label