431 lines
15 KiB
Python
431 lines
15 KiB
Python
"""OI (Open Interest) 变化 + 资金费率反向信号因子。
|
||
|
||
核心逻辑:
|
||
1. OI Buildup(持仓量蓄力):
|
||
- 价格横盘 + OI 快速增加 = 有人在建仓,即将选方向
|
||
- 价格上涨 + OI 增加 = 新多头进场,趋势健康
|
||
- 价格上涨 + OI 减少 = 空头平仓驱动,动力不持续
|
||
|
||
2. 资金费率反向信号:
|
||
- 底部横盘 + 资金费率极度负值 = 空头拥挤,启动时空头平仓加速
|
||
- 高位 + 资金费率极度正值 = 多头拥挤,回调风险大
|
||
|
||
使用场景(1-3天交易):
|
||
- oi_buildup + 静K蓄力 → 高权重加分(蓄力确认)
|
||
- funding_negative_contrarian + RS强 → 看多信号
|
||
- funding_positive_risk + 高位 → 风险降级
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from typing import Optional
|
||
|
||
import requests
|
||
|
||
from app.config.config_loader import _get_section as _get_cfg_section
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _oi_funding_config() -> dict:
|
||
"""Load config from rules.yaml -> oi_funding section."""
|
||
try:
|
||
cfg = _get_cfg_section("oi_funding") or {}
|
||
except Exception:
|
||
cfg = {}
|
||
return {
|
||
# OI buildup thresholds
|
||
"oi_buildup_min_change_pct": float(cfg.get("oi_buildup_min_change_pct", 8.0)),
|
||
"oi_buildup_price_flat_max_pct": float(cfg.get("oi_buildup_price_flat_max_pct", 3.0)),
|
||
"oi_healthy_trend_min_pct": float(cfg.get("oi_healthy_trend_min_pct", 5.0)),
|
||
"oi_divergence_min_drop_pct": float(cfg.get("oi_divergence_min_drop_pct", -5.0)),
|
||
# Funding thresholds
|
||
"funding_negative_threshold": float(cfg.get("funding_negative_threshold", -0.01)),
|
||
"funding_positive_threshold": float(cfg.get("funding_positive_threshold", 0.05)),
|
||
"funding_extreme_negative": float(cfg.get("funding_extreme_negative", -0.03)),
|
||
"funding_extreme_positive": float(cfg.get("funding_extreme_positive", 0.1)),
|
||
# Weights
|
||
"weight_oi_buildup": float(cfg.get("weight_oi_buildup", 3.0)),
|
||
"weight_oi_healthy": float(cfg.get("weight_oi_healthy", 1.5)),
|
||
"weight_oi_divergence_risk": float(cfg.get("weight_oi_divergence_risk", -2.0)),
|
||
"weight_funding_contrarian": float(cfg.get("weight_funding_contrarian", 3.5)),
|
||
"weight_funding_risk": float(cfg.get("weight_funding_risk", -3.0)),
|
||
# API
|
||
"api_timeout": float(cfg.get("api_timeout", 5.0)),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Data fetching
|
||
# ---------------------------------------------------------------------------
|
||
|
||
BINANCE_FAPI_BASE = "https://fapi.binance.com"
|
||
|
||
# In-memory cache: {symbol: {"data": ..., "ts": ...}}
|
||
_oi_cache: dict = {}
|
||
_OI_CACHE_TTL = 180 # 3 minutes
|
||
|
||
|
||
def fetch_open_interest_history(symbol: str, period: str = "1h", limit: int = 25) -> Optional[list[dict]]:
|
||
"""Fetch OI history from Binance Futures.
|
||
|
||
Args:
|
||
symbol: e.g. "SOL/USDT"
|
||
period: "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1d"
|
||
limit: number of data points
|
||
|
||
Returns:
|
||
List of {"timestamp": int, "sumOpenInterest": float, "sumOpenInterestValue": float}
|
||
"""
|
||
cfg = _oi_funding_config()
|
||
pair = symbol.replace("/", "")
|
||
|
||
# Check cache
|
||
cache_key = f"{pair}_{period}_{limit}"
|
||
now = time.time()
|
||
if cache_key in _oi_cache and now - _oi_cache[cache_key]["ts"] < _OI_CACHE_TTL:
|
||
return _oi_cache[cache_key]["data"]
|
||
|
||
try:
|
||
url = f"{BINANCE_FAPI_BASE}/futures/data/openInterestHist"
|
||
resp = requests.get(url, params={
|
||
"symbol": pair,
|
||
"period": period,
|
||
"limit": limit,
|
||
}, timeout=cfg["api_timeout"])
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if not isinstance(data, list):
|
||
return None
|
||
|
||
result = []
|
||
for item in data:
|
||
result.append({
|
||
"timestamp": int(item.get("timestamp", 0)),
|
||
"sumOpenInterest": float(item.get("sumOpenInterest", 0)),
|
||
"sumOpenInterestValue": float(item.get("sumOpenInterestValue", 0)),
|
||
})
|
||
|
||
_oi_cache[cache_key] = {"data": result, "ts": now}
|
||
return result
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def fetch_current_funding_rate(symbol: str) -> Optional[float]:
|
||
"""Fetch the latest funding rate for a symbol.
|
||
|
||
Args:
|
||
symbol: e.g. "SOL/USDT"
|
||
|
||
Returns:
|
||
Funding rate as float (e.g. 0.0001 = 0.01%)
|
||
"""
|
||
cfg = _oi_funding_config()
|
||
pair = symbol.replace("/", "")
|
||
try:
|
||
url = f"{BINANCE_FAPI_BASE}/fapi/v1/fundingRate"
|
||
resp = requests.get(url, params={
|
||
"symbol": pair,
|
||
"limit": 1,
|
||
}, timeout=cfg["api_timeout"])
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data and isinstance(data, list):
|
||
return float(data[-1].get("fundingRate", 0))
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def fetch_funding_rate_history(symbol: str, limit: int = 8) -> Optional[list[float]]:
|
||
"""Fetch recent funding rate history (last N periods, each 8h).
|
||
|
||
Returns list of rates from oldest to newest.
|
||
"""
|
||
cfg = _oi_funding_config()
|
||
pair = symbol.replace("/", "")
|
||
try:
|
||
url = f"{BINANCE_FAPI_BASE}/fapi/v1/fundingRate"
|
||
resp = requests.get(url, params={
|
||
"symbol": pair,
|
||
"limit": limit,
|
||
}, timeout=cfg["api_timeout"])
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data and isinstance(data, list):
|
||
return [float(item.get("fundingRate", 0)) for item in data]
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# OI Analysis
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def analyze_oi_change(
|
||
symbol: str,
|
||
price_change_pct: float = 0.0,
|
||
oi_history: Optional[list[dict]] = None,
|
||
) -> dict:
|
||
"""Analyze OI change pattern relative to price movement.
|
||
|
||
Args:
|
||
symbol: e.g. "SOL/USDT"
|
||
price_change_pct: price change over the same period as OI lookback
|
||
oi_history: pre-fetched OI history (optional)
|
||
|
||
Returns:
|
||
{
|
||
"oi_change_pct": float,
|
||
"oi_pattern": str, # "buildup" / "healthy_trend" / "divergence" / "neutral"
|
||
"oi_signal": str, # human-readable label
|
||
"available": bool,
|
||
}
|
||
"""
|
||
cfg = _oi_funding_config()
|
||
|
||
if oi_history is None:
|
||
oi_history = fetch_open_interest_history(symbol, period="1h", limit=25)
|
||
|
||
if not oi_history or len(oi_history) < 5:
|
||
return {
|
||
"oi_change_pct": 0.0,
|
||
"oi_change_24h_pct": 0.0,
|
||
"oi_pattern": "unknown",
|
||
"oi_signal": "",
|
||
"available": False,
|
||
}
|
||
|
||
# Calculate OI change over full window
|
||
oi_start = oi_history[0]["sumOpenInterestValue"]
|
||
oi_end = oi_history[-1]["sumOpenInterestValue"]
|
||
if oi_start <= 0:
|
||
return {
|
||
"oi_change_pct": 0.0,
|
||
"oi_change_24h_pct": 0.0,
|
||
"oi_pattern": "unknown",
|
||
"oi_signal": "",
|
||
"available": False,
|
||
}
|
||
|
||
oi_change_pct = round((oi_end - oi_start) / oi_start * 100, 2)
|
||
|
||
# Also compute last 24h (last ~24 data points for 1h period)
|
||
oi_24h_start_idx = max(0, len(oi_history) - 24)
|
||
oi_24h_start = oi_history[oi_24h_start_idx]["sumOpenInterestValue"]
|
||
oi_change_24h_pct = round((oi_end - oi_24h_start) / oi_24h_start * 100, 2) if oi_24h_start > 0 else 0.0
|
||
|
||
# Classify pattern
|
||
price_flat = abs(price_change_pct) < cfg["oi_buildup_price_flat_max_pct"]
|
||
oi_rising = oi_change_pct >= cfg["oi_buildup_min_change_pct"]
|
||
oi_moderate_rise = oi_change_pct >= cfg["oi_healthy_trend_min_pct"]
|
||
oi_dropping = oi_change_pct <= cfg["oi_divergence_min_drop_pct"]
|
||
price_rising = price_change_pct > cfg["oi_buildup_price_flat_max_pct"]
|
||
|
||
if price_flat and oi_rising:
|
||
# Price sideways but OI building up → someone is positioning
|
||
pattern = "buildup"
|
||
signal = f"OI蓄力({oi_change_pct:+.1f}%价格横盘)"
|
||
elif price_rising and oi_moderate_rise:
|
||
# Price up + OI up → healthy trend with new longs
|
||
pattern = "healthy_trend"
|
||
signal = f"OI健康增长({oi_change_pct:+.1f}%)"
|
||
elif price_rising and oi_dropping:
|
||
# Price up but OI dropping → short squeeze driven, not sustainable
|
||
pattern = "divergence"
|
||
signal = f"OI背离风险(价涨{price_change_pct:+.1f}% OI{oi_change_pct:+.1f}%)"
|
||
else:
|
||
pattern = "neutral"
|
||
signal = ""
|
||
|
||
return {
|
||
"oi_change_pct": oi_change_pct,
|
||
"oi_change_24h_pct": oi_change_24h_pct,
|
||
"oi_pattern": pattern,
|
||
"oi_signal": signal,
|
||
"available": True,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Funding Rate Analysis
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def analyze_funding_signal(
|
||
symbol: str,
|
||
current_rate: Optional[float] = None,
|
||
rate_history: Optional[list[float]] = None,
|
||
price_position: str = "neutral",
|
||
) -> dict:
|
||
"""Analyze funding rate for contrarian or risk signals.
|
||
|
||
Args:
|
||
symbol: e.g. "SOL/USDT"
|
||
current_rate: pre-fetched current funding rate (optional)
|
||
rate_history: pre-fetched rate history (optional)
|
||
price_position: "low" / "high" / "neutral" — where price is relative
|
||
to recent range (caller determines this from PA)
|
||
|
||
Returns:
|
||
{
|
||
"funding_rate": float,
|
||
"funding_avg_recent": float,
|
||
"funding_pattern": str, # "contrarian_long" / "crowded_risk" / "neutral"
|
||
"funding_signal": str,
|
||
"consecutive_negative": int,
|
||
"available": bool,
|
||
}
|
||
"""
|
||
cfg = _oi_funding_config()
|
||
|
||
if current_rate is None:
|
||
current_rate = fetch_current_funding_rate(symbol)
|
||
|
||
if current_rate is None:
|
||
return {
|
||
"funding_rate": 0.0,
|
||
"funding_avg_recent": 0.0,
|
||
"funding_pattern": "unknown",
|
||
"funding_signal": "",
|
||
"consecutive_negative": 0,
|
||
"available": False,
|
||
}
|
||
|
||
if rate_history is None:
|
||
rate_history = fetch_funding_rate_history(symbol, limit=8)
|
||
|
||
# Calculate average and consecutive negative count
|
||
avg_rate = 0.0
|
||
consecutive_negative = 0
|
||
if rate_history:
|
||
avg_rate = sum(rate_history) / len(rate_history)
|
||
# Count consecutive negative from the end
|
||
for rate in reversed(rate_history):
|
||
if rate < 0:
|
||
consecutive_negative += 1
|
||
else:
|
||
break
|
||
|
||
# Classify pattern
|
||
rate_pct = current_rate * 100 # Convert to percentage for display
|
||
|
||
if (
|
||
current_rate <= cfg["funding_negative_threshold"]
|
||
and price_position in ("low", "neutral")
|
||
):
|
||
# Negative funding at bottom/neutral = shorts are crowded
|
||
if current_rate <= cfg["funding_extreme_negative"]:
|
||
pattern = "contrarian_long"
|
||
signal = f"资金费率极负({rate_pct:.3f}%)空头拥挤"
|
||
elif consecutive_negative >= 3:
|
||
pattern = "contrarian_long"
|
||
signal = f"资金费率持续为负(连续{consecutive_negative}期)空头积累"
|
||
else:
|
||
pattern = "mild_negative"
|
||
signal = f"资金费率偏负({rate_pct:.3f}%)"
|
||
elif (
|
||
current_rate >= cfg["funding_positive_threshold"]
|
||
and price_position in ("high", "neutral")
|
||
):
|
||
# High positive funding at top = longs are crowded
|
||
if current_rate >= cfg["funding_extreme_positive"]:
|
||
pattern = "crowded_risk"
|
||
signal = f"资金费率极高({rate_pct:.3f}%)多头拥挤风险"
|
||
else:
|
||
pattern = "elevated_risk"
|
||
signal = f"资金费率偏高({rate_pct:.3f}%)注意回调"
|
||
else:
|
||
pattern = "neutral"
|
||
signal = ""
|
||
|
||
return {
|
||
"funding_rate": round(current_rate, 6),
|
||
"funding_rate_pct": round(rate_pct, 4),
|
||
"funding_avg_recent": round(avg_rate, 6),
|
||
"funding_pattern": pattern,
|
||
"funding_signal": signal,
|
||
"consecutive_negative": consecutive_negative,
|
||
"available": True,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Combined factor scoring interface
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def oi_funding_factor_scores(
|
||
symbol: str,
|
||
price_change_pct: float = 0.0,
|
||
price_position: str = "neutral",
|
||
oi_history: Optional[list[dict]] = None,
|
||
current_funding: Optional[float] = None,
|
||
funding_history: Optional[list[float]] = None,
|
||
) -> dict:
|
||
"""Compute OI + funding factor scores for the scoring system.
|
||
|
||
Returns:
|
||
{
|
||
"oi": {analysis dict},
|
||
"funding": {analysis dict},
|
||
"factors": [
|
||
{"code": str, "score": float, "label": str},
|
||
...
|
||
]
|
||
}
|
||
"""
|
||
cfg = _oi_funding_config()
|
||
|
||
oi_result = analyze_oi_change(symbol, price_change_pct, oi_history)
|
||
funding_result = analyze_funding_signal(symbol, current_funding, funding_history, price_position)
|
||
|
||
factors = []
|
||
|
||
# OI factors
|
||
if oi_result.get("available"):
|
||
pattern = oi_result["oi_pattern"]
|
||
if pattern == "buildup":
|
||
factors.append({
|
||
"code": "oi_buildup",
|
||
"score": cfg["weight_oi_buildup"],
|
||
"label": oi_result["oi_signal"],
|
||
})
|
||
elif pattern == "healthy_trend":
|
||
factors.append({
|
||
"code": "oi_healthy_trend",
|
||
"score": cfg["weight_oi_healthy"],
|
||
"label": oi_result["oi_signal"],
|
||
})
|
||
elif pattern == "divergence":
|
||
factors.append({
|
||
"code": "oi_divergence_risk",
|
||
"score": cfg["weight_oi_divergence_risk"],
|
||
"label": oi_result["oi_signal"],
|
||
})
|
||
|
||
# Funding factors
|
||
if funding_result.get("available"):
|
||
pattern = funding_result["funding_pattern"]
|
||
if pattern == "contrarian_long":
|
||
factors.append({
|
||
"code": "funding_negative_contrarian",
|
||
"score": cfg["weight_funding_contrarian"],
|
||
"label": funding_result["funding_signal"],
|
||
})
|
||
elif pattern in ("crowded_risk", "elevated_risk"):
|
||
factors.append({
|
||
"code": "funding_positive_risk",
|
||
"score": cfg["weight_funding_risk"],
|
||
"label": funding_result["funding_signal"],
|
||
})
|
||
|
||
return {
|
||
"oi": oi_result,
|
||
"funding": funding_result,
|
||
"factors": factors,
|
||
}
|