update
This commit is contained in:
parent
ec96f9c012
commit
4459af481b
@ -22,6 +22,41 @@ docker compose build
|
||||
docker compose up -d postgres alphax-web alphax-scheduler
|
||||
```
|
||||
|
||||
## 一键更新已部署服务器
|
||||
|
||||
仓库提供 `scripts/deploy_server.sh`,用于更新一台已经部署过 AlphaX 的服务器。它不会做全新初始化,只会把本地提交推到远端,然后 SSH 到服务器现有项目目录执行 `git pull --ff-only`、`docker compose build` 和 `docker compose up -d`。
|
||||
|
||||
```bash
|
||||
DEPLOY_HOST="user@your-server" \
|
||||
REMOTE_DIR="/srv/alphax-docker" \
|
||||
DEPLOY_MESSAGE="deploy: update alphax" \
|
||||
bash scripts/deploy_server.sh
|
||||
```
|
||||
|
||||
常用参数:
|
||||
|
||||
```bash
|
||||
DEPLOY_BRANCH=main
|
||||
DEPLOY_SERVICES="alphax-web alphax-scheduler alphax-price-streamer"
|
||||
DEPLOY_RUN_MIGRATIONS=1
|
||||
DEPLOY_HEALTHCHECK_URL="http://127.0.0.1:8191/api/stats"
|
||||
DEPLOY_SKIP_COMMIT=1
|
||||
DEPLOY_SKIP_PUSH=1
|
||||
DEPLOY_SKIP_BUILD=1
|
||||
```
|
||||
|
||||
注意:如果本地有未提交改动,脚本会要求提供 `DEPLOY_MESSAGE` 后才自动 `git add -A && git commit`;避免无意识把半成品部署到线上。
|
||||
|
||||
如果代码已经提交并推送过,只想让服务器拉最新代码并重启,可以跳过本地提交和 push:
|
||||
|
||||
```bash
|
||||
DEPLOY_HOST="user@your-server" \
|
||||
REMOTE_DIR="/srv/alphax-docker" \
|
||||
DEPLOY_SKIP_COMMIT=1 \
|
||||
DEPLOY_SKIP_PUSH=1 \
|
||||
bash scripts/deploy_server.sh
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
|
||||
@ -51,6 +51,28 @@ DEFAULT_FACTOR_WEIGHTS = {
|
||||
"false_breakout": 5.0,
|
||||
"high_position_reject": 5.0,
|
||||
"risk_reward_bad": 2.0,
|
||||
# --- 新增因子 v1.8 ---
|
||||
"rs_strong": 3.0,
|
||||
"rs_weak": 2.0,
|
||||
"rs_independent_strength": 2.0,
|
||||
"oi_buildup": 3.0,
|
||||
"oi_healthy_trend": 1.5,
|
||||
"oi_divergence_risk": 2.0,
|
||||
"funding_negative_contrarian": 3.5,
|
||||
"funding_positive_risk": 3.0,
|
||||
"tf_alignment_full": 4.0,
|
||||
"tf_alignment_double": 2.0,
|
||||
"tf_alignment_single_penalty": 1.5,
|
||||
"tf_alignment_conflict_penalty": 3.0,
|
||||
# --- 新增因子 v1.8.1: VCP / Volume Profile / 突破质量 ---
|
||||
"vcp_bull_breakout": 5.0,
|
||||
"vcp_bull_forming": 3.0,
|
||||
"vcp_bear_breakdown": 5.0,
|
||||
"vcp_bear_forming": 3.0,
|
||||
"vp_path_clear": 1.5,
|
||||
"vp_path_blocked": 1.0,
|
||||
"breakout_quality_high": 3.0,
|
||||
"breakout_quality_low": 4.0,
|
||||
}
|
||||
|
||||
FACTOR_GROUPS = {
|
||||
@ -90,17 +112,41 @@ FACTOR_GROUPS = {
|
||||
"false_breakout": "risk",
|
||||
"high_position_reject": "risk",
|
||||
"risk_reward_bad": "risk",
|
||||
# --- 新增因子 v1.8 ---
|
||||
"rs_strong": "relative_strength",
|
||||
"rs_weak": "relative_strength",
|
||||
"rs_independent_strength": "relative_strength",
|
||||
"oi_buildup": "positioning",
|
||||
"oi_healthy_trend": "positioning",
|
||||
"oi_divergence_risk": "risk",
|
||||
"funding_negative_contrarian": "positioning",
|
||||
"funding_positive_risk": "risk",
|
||||
"tf_alignment_full": "alignment",
|
||||
"tf_alignment_double": "alignment",
|
||||
"tf_alignment_single_penalty": "alignment",
|
||||
"tf_alignment_conflict_penalty": "alignment",
|
||||
# --- 新增因子 v1.8.1 ---
|
||||
"vcp_bull_breakout": "structure",
|
||||
"vcp_bull_forming": "structure",
|
||||
"vcp_bear_breakdown": "structure",
|
||||
"vcp_bear_forming": "structure",
|
||||
"vp_path_clear": "entry_quality",
|
||||
"vp_path_blocked": "risk",
|
||||
"breakout_quality_high": "entry_quality",
|
||||
"breakout_quality_low": "risk",
|
||||
}
|
||||
|
||||
GROUP_CAPS = {
|
||||
"momentum": 16.0,
|
||||
"participation": 6.0,
|
||||
"structure": 16.0,
|
||||
"positioning": 4.0,
|
||||
"positioning": 8.0,
|
||||
"narrative": 5.0,
|
||||
"onchain_flow": 6.0,
|
||||
"entry_quality": 7.0,
|
||||
"risk": 12.0,
|
||||
"relative_strength": 6.0,
|
||||
"alignment": 6.0,
|
||||
}
|
||||
|
||||
WEIGHT_ALIASES = {
|
||||
@ -131,6 +177,28 @@ WEIGHT_ALIASES = {
|
||||
"false_breakout": ("假突破",),
|
||||
"high_position_reject": ("高位拒绝",),
|
||||
"risk_reward_bad": ("盈亏比不合格",),
|
||||
# --- 新增因子 v1.8 ---
|
||||
"rs_strong": ("RS强势", "RS相对强势"),
|
||||
"rs_weak": ("RS弱势", "RS相对弱势"),
|
||||
"rs_independent_strength": ("BTC回调中独立走强",),
|
||||
"oi_buildup": ("OI蓄力",),
|
||||
"oi_healthy_trend": ("OI健康增长",),
|
||||
"oi_divergence_risk": ("OI背离风险",),
|
||||
"funding_negative_contrarian": ("资金费率负值反向看多", "空头拥挤"),
|
||||
"funding_positive_risk": ("资金费率过高风险", "多头拥挤"),
|
||||
"tf_alignment_full": ("多周期三重对齐",),
|
||||
"tf_alignment_double": ("多周期双重确认",),
|
||||
"tf_alignment_single_penalty": ("仅单周期支持",),
|
||||
"tf_alignment_conflict_penalty": ("多周期方向矛盾",),
|
||||
# --- 新增因子 v1.8.1 ---
|
||||
"vcp_bull_breakout": ("VCP突破", "VCP多头突破"),
|
||||
"vcp_bull_forming": ("VCP蓄力", "VCP多头蓄力"),
|
||||
"vcp_bear_breakdown": ("顶部分配破位",),
|
||||
"vcp_bear_forming": ("顶部分配蓄力",),
|
||||
"vp_path_clear": ("VP路径清晰",),
|
||||
"vp_path_blocked": ("VP路径受阻",),
|
||||
"breakout_quality_high": ("突破质量高", "破位质量高"),
|
||||
"breakout_quality_low": ("突破质量低", "破位质量低"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
430
app/core/oi_funding.py
Normal file
430
app/core/oi_funding.py
Normal file
@ -0,0 +1,430 @@
|
||||
"""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,
|
||||
}
|
||||
302
app/core/relative_strength.py
Normal file
302
app/core/relative_strength.py
Normal file
@ -0,0 +1,302 @@
|
||||
"""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
|
||||
412
app/core/signal_quality.py
Normal file
412
app/core/signal_quality.py
Normal file
@ -0,0 +1,412 @@
|
||||
"""信号时效指数衰减 + 假突破/假破位概率模型(多空双向)。
|
||||
|
||||
## 信号时效衰减
|
||||
替换硬截断的 stale 判断,改为指数衰减:
|
||||
- 1H 信号:前 2 小时权重 1.0,之后每小时 ×0.6
|
||||
- 4H 信号:前 8 小时权重 1.0,之后每 4 小时 ×0.5
|
||||
- 15m 信号:前 30 分钟权重 1.0,之后每 15 分钟 ×0.5
|
||||
- D1 信号:前 24 小时权重 1.0,之后每天 ×0.7
|
||||
|
||||
## 假突破/假破位概率模型
|
||||
事前估计突破质量(多空通用):
|
||||
- 成交量倍数(突破时量 vs 均量)
|
||||
- 突破幅度 vs ATR
|
||||
- 时段流动性(亚洲时段流动性薄)
|
||||
- 前方供需区距离
|
||||
- 连续突破尝试次数(多次失败后的突破更可靠)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app.config.config_loader import _get_section as _get_cfg_section
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Part 1: 信号时效指数衰减
|
||||
# ===========================================================================
|
||||
|
||||
def _decay_config() -> dict:
|
||||
try:
|
||||
cfg = _get_cfg_section("signal_decay") or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return {
|
||||
# (grace_period_hours, decay_rate_per_period, period_hours)
|
||||
"1h": {
|
||||
"grace_hours": float(cfg.get("1h_grace_hours", 2)),
|
||||
"decay_rate": float(cfg.get("1h_decay_rate", 0.6)),
|
||||
"period_hours": float(cfg.get("1h_period_hours", 1)),
|
||||
},
|
||||
"4h": {
|
||||
"grace_hours": float(cfg.get("4h_grace_hours", 8)),
|
||||
"decay_rate": float(cfg.get("4h_decay_rate", 0.5)),
|
||||
"period_hours": float(cfg.get("4h_period_hours", 4)),
|
||||
},
|
||||
"15m": {
|
||||
"grace_hours": float(cfg.get("15m_grace_hours", 0.5)),
|
||||
"decay_rate": float(cfg.get("15m_decay_rate", 0.5)),
|
||||
"period_hours": float(cfg.get("15m_period_hours", 0.25)),
|
||||
},
|
||||
"1d": {
|
||||
"grace_hours": float(cfg.get("1d_grace_hours", 24)),
|
||||
"decay_rate": float(cfg.get("1d_decay_rate", 0.7)),
|
||||
"period_hours": float(cfg.get("1d_period_hours", 24)),
|
||||
},
|
||||
# Minimum weight below which signal is considered expired
|
||||
"min_weight": float(cfg.get("min_weight", 0.05)),
|
||||
}
|
||||
|
||||
|
||||
def compute_signal_decay(age_hours: float, timeframe: str = "1h") -> float:
|
||||
"""Compute exponential decay weight for a signal based on its age.
|
||||
|
||||
Args:
|
||||
age_hours: how many hours ago the signal fired
|
||||
timeframe: the signal's timeframe ("15m", "1h", "4h", "1d")
|
||||
|
||||
Returns:
|
||||
weight between 0.0 and 1.0
|
||||
"""
|
||||
cfg = _decay_config()
|
||||
tf_cfg = cfg.get(timeframe, cfg["1h"])
|
||||
min_weight = cfg["min_weight"]
|
||||
|
||||
grace = tf_cfg["grace_hours"]
|
||||
decay_rate = tf_cfg["decay_rate"]
|
||||
period = tf_cfg["period_hours"]
|
||||
|
||||
if age_hours <= grace:
|
||||
return 1.0
|
||||
|
||||
# Number of decay periods elapsed after grace
|
||||
elapsed = age_hours - grace
|
||||
periods = elapsed / period if period > 0 else elapsed
|
||||
|
||||
# Exponential decay: weight = decay_rate ^ periods
|
||||
weight = math.pow(decay_rate, periods)
|
||||
|
||||
return max(min_weight, min(1.0, weight))
|
||||
|
||||
|
||||
def apply_decay_to_score(base_score: float, age_hours: float, timeframe: str = "1h") -> float:
|
||||
"""Apply time decay to a factor score.
|
||||
|
||||
Args:
|
||||
base_score: the original score (positive or negative)
|
||||
age_hours: signal age in hours
|
||||
timeframe: signal timeframe
|
||||
|
||||
Returns:
|
||||
decayed score (same sign, reduced magnitude)
|
||||
"""
|
||||
weight = compute_signal_decay(age_hours, timeframe)
|
||||
return round(base_score * weight, 3)
|
||||
|
||||
|
||||
def is_signal_expired(age_hours: float, timeframe: str = "1h") -> bool:
|
||||
"""Check if a signal has decayed below minimum threshold."""
|
||||
cfg = _decay_config()
|
||||
weight = compute_signal_decay(age_hours, timeframe)
|
||||
return weight <= cfg["min_weight"]
|
||||
|
||||
|
||||
def signal_freshness_label(age_hours: float, timeframe: str = "1h") -> str:
|
||||
"""Human-readable freshness label."""
|
||||
weight = compute_signal_decay(age_hours, timeframe)
|
||||
if weight >= 0.9:
|
||||
return "新鲜"
|
||||
elif weight >= 0.5:
|
||||
return "有效"
|
||||
elif weight >= 0.2:
|
||||
return "衰减中"
|
||||
elif weight > 0.05:
|
||||
return "即将过期"
|
||||
else:
|
||||
return "已过期"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Part 2: 假突破/假破位概率模型(多空双向)
|
||||
# ===========================================================================
|
||||
|
||||
def _breakout_quality_config() -> dict:
|
||||
try:
|
||||
cfg = _get_cfg_section("breakout_quality") or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return {
|
||||
# Volume requirements
|
||||
"vol_ratio_strong": float(cfg.get("vol_ratio_strong", 3.0)),
|
||||
"vol_ratio_weak": float(cfg.get("vol_ratio_weak", 1.5)),
|
||||
# ATR requirements
|
||||
"atr_breakout_strong": float(cfg.get("atr_breakout_strong", 1.5)),
|
||||
"atr_breakout_weak": float(cfg.get("atr_breakout_weak", 0.5)),
|
||||
# Time-of-day (UTC hours for Asian session = low liquidity)
|
||||
"low_liquidity_start_utc": int(cfg.get("low_liquidity_start_utc", 0)),
|
||||
"low_liquidity_end_utc": int(cfg.get("low_liquidity_end_utc", 8)),
|
||||
# Prior attempts
|
||||
"prior_fail_lookback": int(cfg.get("prior_fail_lookback", 20)),
|
||||
"prior_fail_bonus_per_attempt": float(cfg.get("prior_fail_bonus_per_attempt", 8)),
|
||||
# Nearby zone penalty
|
||||
"zone_distance_close_pct": float(cfg.get("zone_distance_close_pct", 2.0)),
|
||||
"zone_distance_far_pct": float(cfg.get("zone_distance_far_pct", 5.0)),
|
||||
# Thresholds
|
||||
"high_quality_min": float(cfg.get("high_quality_min", 70)),
|
||||
"low_quality_max": float(cfg.get("low_quality_max", 40)),
|
||||
# Weights
|
||||
"weight_high_quality": float(cfg.get("weight_high_quality", 3.0)),
|
||||
"weight_low_quality_penalty": float(cfg.get("weight_low_quality_penalty", -4.0)),
|
||||
}
|
||||
|
||||
|
||||
def estimate_breakout_quality(
|
||||
df: pd.DataFrame,
|
||||
breakout_bar_index: int = -1,
|
||||
breakout_level: float = 0,
|
||||
direction: str = "long",
|
||||
atr: float = 0,
|
||||
nearby_zones: Optional[list[dict]] = None,
|
||||
) -> dict:
|
||||
"""Estimate the quality/probability of a breakout being genuine vs fake.
|
||||
|
||||
Works for both breakouts (long) and breakdowns (short).
|
||||
|
||||
Args:
|
||||
df: kline DataFrame containing the breakout bar
|
||||
breakout_bar_index: index of the breakout bar (-1 = latest)
|
||||
breakout_level: the price level being broken (resistance for long, support for short)
|
||||
direction: "long" (breakout above) or "short" (breakdown below)
|
||||
atr: pre-computed ATR (if 0, will compute from df)
|
||||
nearby_zones: supply/demand zones near the breakout [{type, top, btm, q_score}]
|
||||
|
||||
Returns:
|
||||
{
|
||||
"quality_score": float (0-100, higher = more likely genuine),
|
||||
"quality_tier": "high" / "medium" / "low",
|
||||
"factors": {
|
||||
"volume_score": float,
|
||||
"magnitude_score": float,
|
||||
"timing_score": float,
|
||||
"prior_attempts_score": float,
|
||||
"zone_clearance_score": float,
|
||||
},
|
||||
"fake_probability": float (0-1),
|
||||
"signal": str,
|
||||
"recommendation": str, # "可即刻买入" / "等确认" / "观察"
|
||||
}
|
||||
"""
|
||||
cfg = _breakout_quality_config()
|
||||
result = {
|
||||
"quality_score": 50.0,
|
||||
"quality_tier": "medium",
|
||||
"factors": {},
|
||||
"fake_probability": 0.5,
|
||||
"signal": "",
|
||||
"recommendation": "等确认",
|
||||
}
|
||||
|
||||
if df is None or len(df) < 20:
|
||||
return result
|
||||
|
||||
# Get breakout bar
|
||||
if breakout_bar_index == -1:
|
||||
breakout_bar_index = len(df) - 1
|
||||
if breakout_bar_index < 0 or breakout_bar_index >= len(df):
|
||||
return result
|
||||
|
||||
bar = df.iloc[breakout_bar_index]
|
||||
bar_close = float(bar["close"])
|
||||
bar_open = float(bar["open"])
|
||||
bar_high = float(bar["high"])
|
||||
bar_low = float(bar["low"])
|
||||
bar_vol = float(bar["volume"])
|
||||
|
||||
# Compute ATR if not provided
|
||||
if atr <= 0:
|
||||
if len(df) >= 15:
|
||||
tr = pd.concat([
|
||||
df["high"] - df["low"],
|
||||
abs(df["high"] - df["close"].shift(1)),
|
||||
abs(df["low"] - df["close"].shift(1)),
|
||||
], axis=1).max(axis=1)
|
||||
atr = float(tr.rolling(14).mean().iloc[-1])
|
||||
if atr <= 0:
|
||||
atr = float(df["high"].iloc[-1] - df["low"].iloc[-1])
|
||||
|
||||
# Average volume (20-bar)
|
||||
vol_window = min(20, len(df) - 1)
|
||||
avg_vol = float(df["volume"].iloc[max(0, breakout_bar_index - vol_window):breakout_bar_index].mean())
|
||||
if avg_vol <= 0:
|
||||
avg_vol = float(df["volume"].mean())
|
||||
|
||||
# --- Factor 1: Volume (0-25 points) ---
|
||||
vol_ratio = bar_vol / avg_vol if avg_vol > 0 else 1
|
||||
if vol_ratio >= cfg["vol_ratio_strong"]:
|
||||
volume_score = 25.0
|
||||
elif vol_ratio >= cfg["vol_ratio_weak"]:
|
||||
volume_score = 10.0 + (vol_ratio - cfg["vol_ratio_weak"]) / (cfg["vol_ratio_strong"] - cfg["vol_ratio_weak"]) * 15
|
||||
else:
|
||||
volume_score = max(0, vol_ratio / cfg["vol_ratio_weak"] * 10)
|
||||
|
||||
# --- Factor 2: Breakout magnitude vs ATR (0-25 points) ---
|
||||
if direction == "long":
|
||||
magnitude = bar_close - breakout_level if breakout_level > 0 else bar_close - bar_open
|
||||
else:
|
||||
magnitude = breakout_level - bar_close if breakout_level > 0 else bar_open - bar_close
|
||||
|
||||
atr_ratio = magnitude / atr if atr > 0 else 0
|
||||
if atr_ratio >= cfg["atr_breakout_strong"]:
|
||||
magnitude_score = 25.0
|
||||
elif atr_ratio >= cfg["atr_breakout_weak"]:
|
||||
magnitude_score = 8.0 + (atr_ratio - cfg["atr_breakout_weak"]) / (cfg["atr_breakout_strong"] - cfg["atr_breakout_weak"]) * 17
|
||||
else:
|
||||
magnitude_score = max(0, atr_ratio / cfg["atr_breakout_weak"] * 8)
|
||||
|
||||
# --- Factor 3: Timing / session (0-15 points) ---
|
||||
# Try to determine time of breakout
|
||||
timing_score = 10.0 # default neutral
|
||||
try:
|
||||
if "timestamp" in df.columns:
|
||||
ts = df["timestamp"].iloc[breakout_bar_index]
|
||||
if hasattr(ts, "hour"):
|
||||
hour_utc = ts.hour
|
||||
else:
|
||||
hour_utc = pd.Timestamp(ts).hour
|
||||
low_liq_start = cfg["low_liquidity_start_utc"]
|
||||
low_liq_end = cfg["low_liquidity_end_utc"]
|
||||
if low_liq_start <= hour_utc < low_liq_end:
|
||||
timing_score = 3.0 # Asian session = higher fake probability
|
||||
elif 13 <= hour_utc <= 20:
|
||||
timing_score = 15.0 # US session = most reliable
|
||||
else:
|
||||
timing_score = 10.0 # European session = decent
|
||||
except Exception:
|
||||
timing_score = 10.0
|
||||
|
||||
# --- Factor 4: Prior failed attempts (0-20 points) ---
|
||||
# More prior failures at this level = more reliable when it finally breaks
|
||||
prior_attempts = 0
|
||||
lookback = min(cfg["prior_fail_lookback"], breakout_bar_index)
|
||||
if breakout_level > 0 and lookback > 0:
|
||||
for i in range(breakout_bar_index - lookback, breakout_bar_index):
|
||||
if i < 0:
|
||||
continue
|
||||
if direction == "long":
|
||||
# Count bars that touched but failed to close above level
|
||||
if float(df["high"].iloc[i]) >= breakout_level and float(df["close"].iloc[i]) < breakout_level:
|
||||
prior_attempts += 1
|
||||
else:
|
||||
# Count bars that touched but failed to close below level
|
||||
if float(df["low"].iloc[i]) <= breakout_level and float(df["close"].iloc[i]) > breakout_level:
|
||||
prior_attempts += 1
|
||||
|
||||
prior_score = min(20.0, prior_attempts * cfg["prior_fail_bonus_per_attempt"])
|
||||
|
||||
# --- Factor 5: Zone clearance (0-15 points) ---
|
||||
# If there's a strong opposing zone very close, breakout is more likely to fail
|
||||
zone_score = 12.0 # default: no zone info = neutral-positive
|
||||
if nearby_zones:
|
||||
closest_opposing_dist = float("inf")
|
||||
for zone in nearby_zones:
|
||||
if direction == "long" and zone.get("type") == "supply":
|
||||
# Supply zone above = resistance
|
||||
zone_dist = (float(zone.get("btm", 0)) - bar_close) / bar_close * 100 if bar_close > 0 else 999
|
||||
if 0 < zone_dist < closest_opposing_dist:
|
||||
closest_opposing_dist = zone_dist
|
||||
elif direction == "short" and zone.get("type") == "demand":
|
||||
# Demand zone below = support
|
||||
zone_dist = (bar_close - float(zone.get("top", 0))) / bar_close * 100 if bar_close > 0 else 999
|
||||
if 0 < zone_dist < closest_opposing_dist:
|
||||
closest_opposing_dist = zone_dist
|
||||
|
||||
if closest_opposing_dist < cfg["zone_distance_close_pct"]:
|
||||
zone_score = 2.0 # Very close opposing zone = high fake risk
|
||||
elif closest_opposing_dist < cfg["zone_distance_far_pct"]:
|
||||
zone_score = 8.0
|
||||
else:
|
||||
zone_score = 15.0 # Clear path
|
||||
|
||||
# --- Combine ---
|
||||
quality_score = volume_score + magnitude_score + timing_score + prior_score + zone_score
|
||||
quality_score = max(0, min(100, quality_score))
|
||||
|
||||
# Determine tier
|
||||
if quality_score >= cfg["high_quality_min"]:
|
||||
quality_tier = "high"
|
||||
recommendation = "可即刻买入" if direction == "long" else "可即刻做空"
|
||||
fake_prob = max(0.05, (100 - quality_score) / 100 * 0.4)
|
||||
elif quality_score <= cfg["low_quality_max"]:
|
||||
quality_tier = "low"
|
||||
recommendation = "观察"
|
||||
fake_prob = min(0.9, (100 - quality_score) / 100)
|
||||
else:
|
||||
quality_tier = "medium"
|
||||
recommendation = "等确认"
|
||||
fake_prob = (100 - quality_score) / 100 * 0.7
|
||||
|
||||
# Build signal text
|
||||
dir_label = "突破" if direction == "long" else "破位"
|
||||
signal_parts = []
|
||||
if volume_score >= 20:
|
||||
signal_parts.append(f"放量{vol_ratio:.1f}x")
|
||||
elif volume_score < 8:
|
||||
signal_parts.append(f"量不足{vol_ratio:.1f}x")
|
||||
if magnitude_score >= 20:
|
||||
signal_parts.append(f"幅度{atr_ratio:.1f}ATR")
|
||||
elif magnitude_score < 8:
|
||||
signal_parts.append(f"幅度弱{atr_ratio:.1f}ATR")
|
||||
if prior_attempts >= 2:
|
||||
signal_parts.append(f"第{prior_attempts+1}次尝试")
|
||||
if timing_score <= 5:
|
||||
signal_parts.append("亚洲时段")
|
||||
|
||||
signal = f"{dir_label}质量{'高' if quality_tier == 'high' else '低' if quality_tier == 'low' else '中'}({', '.join(signal_parts)})" if signal_parts else ""
|
||||
|
||||
result.update({
|
||||
"quality_score": round(quality_score, 1),
|
||||
"quality_tier": quality_tier,
|
||||
"factors": {
|
||||
"volume_score": round(volume_score, 1),
|
||||
"volume_ratio": round(vol_ratio, 2),
|
||||
"magnitude_score": round(magnitude_score, 1),
|
||||
"atr_ratio": round(atr_ratio, 2),
|
||||
"timing_score": round(timing_score, 1),
|
||||
"prior_attempts_score": round(prior_score, 1),
|
||||
"prior_attempts": prior_attempts,
|
||||
"zone_clearance_score": round(zone_score, 1),
|
||||
},
|
||||
"fake_probability": round(fake_prob, 3),
|
||||
"signal": signal,
|
||||
"recommendation": recommendation,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factor scoring interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def breakout_quality_factor_score(quality_data: dict) -> tuple[float, str]:
|
||||
"""Convert breakout quality into a factor score.
|
||||
|
||||
Returns:
|
||||
(score_delta, signal_label)
|
||||
"""
|
||||
cfg = _breakout_quality_config()
|
||||
tier = quality_data.get("quality_tier", "medium")
|
||||
signal = quality_data.get("signal", "")
|
||||
|
||||
if tier == "high":
|
||||
return cfg["weight_high_quality"], signal
|
||||
elif tier == "low":
|
||||
return cfg["weight_low_quality_penalty"], signal
|
||||
else:
|
||||
return 0.0, ""
|
||||
@ -55,6 +55,28 @@ SIGNAL_CODE_LABELS = {
|
||||
"false_breakout": "假突破",
|
||||
"high_position_reject": "高位拒绝",
|
||||
"risk_reward_bad": "盈亏比不合格",
|
||||
# --- 新增因子 v1.8 ---
|
||||
"rs_strong": "RS相对强势",
|
||||
"rs_weak": "RS相对弱势",
|
||||
"rs_independent_strength": "BTC回调中独立走强",
|
||||
"oi_buildup": "OI蓄力",
|
||||
"oi_healthy_trend": "OI健康增长",
|
||||
"oi_divergence_risk": "OI背离风险",
|
||||
"funding_negative_contrarian": "资金费率负值反向看多",
|
||||
"funding_positive_risk": "资金费率过高风险",
|
||||
"tf_alignment_full": "多周期三重对齐",
|
||||
"tf_alignment_double": "多周期双重确认",
|
||||
"tf_alignment_single_penalty": "仅单周期支持",
|
||||
"tf_alignment_conflict_penalty": "多周期方向矛盾",
|
||||
# --- 新增因子 v1.8.1: VCP / Volume Profile / 突破质量 ---
|
||||
"vcp_bull_breakout": "VCP多头突破",
|
||||
"vcp_bull_forming": "VCP多头蓄力",
|
||||
"vcp_bear_breakdown": "顶部分配破位",
|
||||
"vcp_bear_forming": "顶部分配蓄力",
|
||||
"vp_path_clear": "VP路径清晰",
|
||||
"vp_path_blocked": "VP路径受阻",
|
||||
"breakout_quality_high": "突破质量高",
|
||||
"breakout_quality_low": "突破质量低",
|
||||
"unknown": "未分类信号",
|
||||
}
|
||||
|
||||
@ -102,6 +124,28 @@ _PATTERNS = [
|
||||
("false_breakout", ("假突破", "冲高回落")),
|
||||
("high_position_reject", ("高位", "追高")),
|
||||
("risk_reward_bad", ("risk_reward_ok=false", "rr1=", "盈亏比")),
|
||||
# --- 新增因子 v1.8 ---
|
||||
("rs_strong", ("RS强势",)),
|
||||
("rs_weak", ("RS弱势",)),
|
||||
("rs_independent_strength", ("BTC回调", "独立走强")),
|
||||
("oi_buildup", ("OI蓄力",)),
|
||||
("oi_healthy_trend", ("OI健康增长",)),
|
||||
("oi_divergence_risk", ("OI背离风险",)),
|
||||
("funding_negative_contrarian", ("资金费率负值", "反向看多")),
|
||||
("funding_positive_risk", ("资金费率过高",)),
|
||||
("tf_alignment_full", ("三重对齐",)),
|
||||
("tf_alignment_double", ("双重确认",)),
|
||||
("tf_alignment_single_penalty", ("仅单周期",)),
|
||||
("tf_alignment_conflict_penalty", ("方向矛盾",)),
|
||||
# --- 新增因子 v1.8.1 ---
|
||||
("vcp_bull_breakout", ("VCP突破",)),
|
||||
("vcp_bull_forming", ("VCP蓄力",)),
|
||||
("vcp_bear_breakdown", ("顶部分配破位",)),
|
||||
("vcp_bear_forming", ("顶部分配蓄力",)),
|
||||
("vp_path_clear", ("VP路径清晰",)),
|
||||
("vp_path_blocked", ("VP路径受阻",)),
|
||||
("breakout_quality_high", ("突破质量高", "破位质量高")),
|
||||
("breakout_quality_low", ("突破质量低", "破位质量低")),
|
||||
]
|
||||
|
||||
|
||||
|
||||
398
app/core/timeframe_alignment.py
Normal file
398
app/core/timeframe_alignment.py
Normal file
@ -0,0 +1,398 @@
|
||||
"""多时间框架对齐度评分 — Timeframe Alignment Score。
|
||||
|
||||
核心逻辑:
|
||||
- 分别判断 D1 / 4H / 1H 三个级别的方向倾向
|
||||
- 统计方向一致的级别数量(对齐度 0-3)
|
||||
- 对齐度 3 = 三重共振,最高信心
|
||||
- 对齐度 2 = 双重确认,正常交易
|
||||
- 对齐度 1 = 单级别信号,降级为观察
|
||||
- 对齐度 0 = 方向矛盾,不建议入场
|
||||
|
||||
方向判断依据(不用滞后指标,纯 PA):
|
||||
- 最近 N 根 K 线的高低点趋势(higher highs/higher lows vs lower highs/lower lows)
|
||||
- 最近动K方向
|
||||
- 当前价格相对近期高低点的位置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from app.config.config_loader import _get_section as _get_cfg_section
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _alignment_config() -> dict:
|
||||
"""Load config from rules.yaml -> timeframe_alignment section."""
|
||||
try:
|
||||
cfg = _get_cfg_section("timeframe_alignment") or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return {
|
||||
# How many candles to look back for trend detection
|
||||
"d1_lookback": int(cfg.get("d1_lookback", 10)),
|
||||
"h4_lookback": int(cfg.get("h4_lookback", 15)),
|
||||
"h1_lookback": int(cfg.get("h1_lookback", 20)),
|
||||
# Minimum swing size as % of price to count as valid swing
|
||||
"min_swing_pct": float(cfg.get("min_swing_pct", 1.0)),
|
||||
# Alignment score thresholds
|
||||
"min_alignment_buy_now": int(cfg.get("min_alignment_buy_now", 2)),
|
||||
"min_alignment_wait": int(cfg.get("min_alignment_wait", 2)),
|
||||
"min_alignment_observe": int(cfg.get("min_alignment_observe", 1)),
|
||||
# Factor weights
|
||||
"weight_full_alignment": float(cfg.get("weight_full_alignment", 4.0)),
|
||||
"weight_double_alignment": float(cfg.get("weight_double_alignment", 2.0)),
|
||||
"weight_no_alignment_penalty": float(cfg.get("weight_no_alignment_penalty", -3.0)),
|
||||
"weight_single_penalty": float(cfg.get("weight_single_penalty", -1.5)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single timeframe direction detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _detect_swing_points(df: pd.DataFrame, min_swing_pct: float = 1.0) -> list[dict]:
|
||||
"""Detect significant swing highs and lows in a price series.
|
||||
|
||||
Returns list of {"type": "high"/"low", "price": float, "index": int}
|
||||
sorted by index (oldest first).
|
||||
"""
|
||||
if df is None or len(df) < 5:
|
||||
return []
|
||||
|
||||
highs = df["high"].values
|
||||
lows = df["low"].values
|
||||
closes = df["close"].values
|
||||
n = len(df)
|
||||
|
||||
swings = []
|
||||
min_move = closes[-1] * min_swing_pct / 100 if closes[-1] > 0 else 0
|
||||
|
||||
# Simple swing detection: local max/min with at least 2 bars on each side
|
||||
for i in range(2, n - 2):
|
||||
# Swing high
|
||||
if (
|
||||
highs[i] >= highs[i - 1]
|
||||
and highs[i] >= highs[i - 2]
|
||||
and highs[i] >= highs[i + 1]
|
||||
and highs[i] >= highs[i + 2]
|
||||
):
|
||||
swings.append({"type": "high", "price": float(highs[i]), "index": i})
|
||||
# Swing low
|
||||
if (
|
||||
lows[i] <= lows[i - 1]
|
||||
and lows[i] <= lows[i - 2]
|
||||
and lows[i] <= lows[i + 1]
|
||||
and lows[i] <= lows[i + 2]
|
||||
):
|
||||
swings.append({"type": "low", "price": float(lows[i]), "index": i})
|
||||
|
||||
# Filter out insignificant swings
|
||||
if min_move > 0 and len(swings) >= 2:
|
||||
filtered = [swings[0]]
|
||||
for s in swings[1:]:
|
||||
if abs(s["price"] - filtered[-1]["price"]) >= min_move:
|
||||
filtered.append(s)
|
||||
swings = filtered
|
||||
|
||||
return sorted(swings, key=lambda x: x["index"])
|
||||
|
||||
|
||||
def detect_timeframe_direction(df: pd.DataFrame, lookback: int = 15, min_swing_pct: float = 1.0) -> dict:
|
||||
"""Detect the directional bias of a single timeframe.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"direction": 1 (bullish) / -1 (bearish) / 0 (neutral/unclear),
|
||||
"confidence": float 0-1,
|
||||
"reason": str,
|
||||
"higher_highs": bool,
|
||||
"higher_lows": bool,
|
||||
"lower_highs": bool,
|
||||
"lower_lows": bool,
|
||||
"price_position": float (0-1, where in recent range),
|
||||
}
|
||||
"""
|
||||
if df is None or len(df) < lookback:
|
||||
return {
|
||||
"direction": 0,
|
||||
"confidence": 0.0,
|
||||
"reason": "数据不足",
|
||||
"higher_highs": False,
|
||||
"higher_lows": False,
|
||||
"lower_highs": False,
|
||||
"lower_lows": False,
|
||||
"price_position": 0.5,
|
||||
}
|
||||
|
||||
recent = df.tail(lookback).copy()
|
||||
swings = _detect_swing_points(recent, min_swing_pct)
|
||||
|
||||
# Separate swing highs and lows
|
||||
swing_highs = [s for s in swings if s["type"] == "high"]
|
||||
swing_lows = [s for s in swings if s["type"] == "low"]
|
||||
|
||||
# Check for higher highs / higher lows (bullish structure)
|
||||
higher_highs = False
|
||||
higher_lows = False
|
||||
lower_highs = False
|
||||
lower_lows = False
|
||||
|
||||
if len(swing_highs) >= 2:
|
||||
last_two_highs = swing_highs[-2:]
|
||||
higher_highs = last_two_highs[1]["price"] > last_two_highs[0]["price"]
|
||||
lower_highs = last_two_highs[1]["price"] < last_two_highs[0]["price"]
|
||||
|
||||
if len(swing_lows) >= 2:
|
||||
last_two_lows = swing_lows[-2:]
|
||||
higher_lows = last_two_lows[1]["price"] > last_two_lows[0]["price"]
|
||||
lower_lows = last_two_lows[1]["price"] < last_two_lows[0]["price"]
|
||||
|
||||
# Price position within recent range (0 = at low, 1 = at high)
|
||||
range_high = float(recent["high"].max())
|
||||
range_low = float(recent["low"].min())
|
||||
current_close = float(recent["close"].iloc[-1])
|
||||
price_range = range_high - range_low
|
||||
price_position = (current_close - range_low) / price_range if price_range > 0 else 0.5
|
||||
|
||||
# Recent candle momentum (last 3 candles net direction)
|
||||
last_3 = recent.tail(3)
|
||||
net_change = float(last_3["close"].iloc[-1]) - float(last_3["open"].iloc[0])
|
||||
recent_bullish = net_change > 0
|
||||
|
||||
# Linear-regression slope of closes (works even without clean swing points,
|
||||
# e.g. strong monotonic trends where pullback-based swings never form).
|
||||
closes = recent["close"].values.astype(float)
|
||||
slope_dir = 0
|
||||
slope_pct = 0.0
|
||||
if len(closes) >= 3:
|
||||
x = np.arange(len(closes))
|
||||
try:
|
||||
slope = np.polyfit(x, closes, 1)[0]
|
||||
mean_close = float(np.mean(closes))
|
||||
# Normalize slope to % move per bar relative to mean price
|
||||
slope_pct = (slope / mean_close * 100) if mean_close > 0 else 0.0
|
||||
if slope_pct > 0.15:
|
||||
slope_dir = 1
|
||||
elif slope_pct < -0.15:
|
||||
slope_dir = -1
|
||||
except Exception:
|
||||
slope_dir = 0
|
||||
|
||||
# Determine direction
|
||||
bull_score = 0
|
||||
bear_score = 0
|
||||
|
||||
if higher_highs:
|
||||
bull_score += 1
|
||||
if higher_lows:
|
||||
bull_score += 1.5 # Higher lows are more important for trend
|
||||
if lower_highs:
|
||||
bear_score += 1
|
||||
if lower_lows:
|
||||
bear_score += 1.5
|
||||
if slope_dir == 1:
|
||||
bull_score += 1.5 # regression trend confirms direction
|
||||
elif slope_dir == -1:
|
||||
bear_score += 1.5
|
||||
if price_position > 0.65:
|
||||
bull_score += 0.5
|
||||
elif price_position < 0.35:
|
||||
bear_score += 0.5
|
||||
if recent_bullish:
|
||||
bull_score += 0.5
|
||||
else:
|
||||
bear_score += 0.5
|
||||
|
||||
# Decision
|
||||
if bull_score >= 2.5 and bull_score > bear_score + 1:
|
||||
direction = 1
|
||||
confidence = min(1.0, bull_score / 4.0)
|
||||
if higher_highs and higher_lows:
|
||||
reason = "高点抬高+低点抬高"
|
||||
elif slope_dir == 1:
|
||||
reason = f"趋势斜率向上({slope_pct:+.2f}%/bar)"
|
||||
else:
|
||||
reason = "结构偏多"
|
||||
elif bear_score >= 2.5 and bear_score > bull_score + 1:
|
||||
direction = -1
|
||||
confidence = min(1.0, bear_score / 4.0)
|
||||
if lower_highs and lower_lows:
|
||||
reason = "高点降低+低点降低"
|
||||
elif slope_dir == -1:
|
||||
reason = f"趋势斜率向下({slope_pct:+.2f}%/bar)"
|
||||
else:
|
||||
reason = "结构偏空"
|
||||
else:
|
||||
direction = 0
|
||||
confidence = 0.3
|
||||
reason = "方向不明确"
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"confidence": round(confidence, 2),
|
||||
"reason": reason,
|
||||
"higher_highs": higher_highs,
|
||||
"higher_lows": higher_lows,
|
||||
"lower_highs": lower_highs,
|
||||
"lower_lows": lower_lows,
|
||||
"slope_dir": slope_dir,
|
||||
"slope_pct": round(slope_pct, 3),
|
||||
"price_position": round(price_position, 3),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-timeframe alignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_timeframe_alignment(
|
||||
df_d1: Optional[pd.DataFrame] = None,
|
||||
df_4h: Optional[pd.DataFrame] = None,
|
||||
df_1h: Optional[pd.DataFrame] = None,
|
||||
trade_direction: str = "long",
|
||||
) -> dict:
|
||||
"""Compute alignment score across D1/4H/1H timeframes.
|
||||
|
||||
Args:
|
||||
df_d1: Daily kline DataFrame
|
||||
df_4h: 4-hour kline DataFrame
|
||||
df_1h: 1-hour kline DataFrame
|
||||
trade_direction: "long" or "short" — the intended trade direction
|
||||
|
||||
Returns:
|
||||
{
|
||||
"alignment_score": int (0-3),
|
||||
"alignment_label": str,
|
||||
"d1_direction": dict,
|
||||
"h4_direction": dict,
|
||||
"h1_direction": dict,
|
||||
"aligned_timeframes": list[str],
|
||||
"conflicting_timeframes": list[str],
|
||||
"entry_allowed": bool,
|
||||
"max_entry_action": str, # "可即刻买入" / "等回踩" / "观察"
|
||||
}
|
||||
"""
|
||||
cfg = _alignment_config()
|
||||
target_dir = 1 if trade_direction == "long" else -1
|
||||
|
||||
# Detect direction for each timeframe
|
||||
d1_dir = detect_timeframe_direction(df_d1, cfg["d1_lookback"], cfg["min_swing_pct"]) if df_d1 is not None else None
|
||||
h4_dir = detect_timeframe_direction(df_4h, cfg["h4_lookback"], cfg["min_swing_pct"]) if df_4h is not None else None
|
||||
h1_dir = detect_timeframe_direction(df_1h, cfg["h1_lookback"], cfg["min_swing_pct"]) if df_1h is not None else None
|
||||
|
||||
# Count alignment
|
||||
aligned = []
|
||||
conflicting = []
|
||||
timeframes = [("D1", d1_dir), ("4H", h4_dir), ("1H", h1_dir)]
|
||||
|
||||
for tf_name, tf_result in timeframes:
|
||||
if tf_result is None:
|
||||
continue
|
||||
if tf_result["direction"] == target_dir:
|
||||
aligned.append(tf_name)
|
||||
elif tf_result["direction"] == -target_dir:
|
||||
conflicting.append(tf_name)
|
||||
# direction == 0 (neutral) doesn't count as aligned or conflicting
|
||||
|
||||
alignment_score = len(aligned)
|
||||
|
||||
# Determine entry permission based on alignment
|
||||
if alignment_score >= 3:
|
||||
alignment_label = "三重对齐"
|
||||
max_entry_action = "可即刻买入"
|
||||
entry_allowed = True
|
||||
elif alignment_score >= cfg["min_alignment_buy_now"]:
|
||||
alignment_label = "双重确认"
|
||||
max_entry_action = "可即刻买入"
|
||||
entry_allowed = True
|
||||
elif alignment_score >= cfg["min_alignment_observe"]:
|
||||
alignment_label = "单级别信号"
|
||||
max_entry_action = "观察"
|
||||
entry_allowed = True
|
||||
else:
|
||||
alignment_label = "方向矛盾"
|
||||
max_entry_action = "观察"
|
||||
entry_allowed = False
|
||||
|
||||
# If higher timeframe (D1) is conflicting, be more conservative
|
||||
if "D1" in conflicting:
|
||||
if max_entry_action == "可即刻买入":
|
||||
max_entry_action = "等回踩"
|
||||
alignment_label += "(日线逆向)"
|
||||
|
||||
return {
|
||||
"alignment_score": alignment_score,
|
||||
"alignment_label": alignment_label,
|
||||
"d1_direction": d1_dir or {"direction": 0, "confidence": 0, "reason": "无数据"},
|
||||
"h4_direction": h4_dir or {"direction": 0, "confidence": 0, "reason": "无数据"},
|
||||
"h1_direction": h1_dir or {"direction": 0, "confidence": 0, "reason": "无数据"},
|
||||
"aligned_timeframes": aligned,
|
||||
"conflicting_timeframes": conflicting,
|
||||
"entry_allowed": entry_allowed,
|
||||
"max_entry_action": max_entry_action,
|
||||
"trade_direction": trade_direction,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factor scoring interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def alignment_factor_score(alignment_data: dict) -> tuple[float, str]:
|
||||
"""Convert alignment data into a factor score.
|
||||
|
||||
Returns:
|
||||
(score_delta, signal_label)
|
||||
"""
|
||||
cfg = _alignment_config()
|
||||
score_val = alignment_data.get("alignment_score", 0)
|
||||
label = alignment_data.get("alignment_label", "")
|
||||
|
||||
if score_val >= 3:
|
||||
return cfg["weight_full_alignment"], f"多周期三重对齐({'/'.join(alignment_data.get('aligned_timeframes', []))})"
|
||||
elif score_val == 2:
|
||||
return cfg["weight_double_alignment"], f"多周期双重确认({'/'.join(alignment_data.get('aligned_timeframes', []))})"
|
||||
elif score_val == 1:
|
||||
return cfg["weight_single_penalty"], f"仅单周期支持({'/'.join(alignment_data.get('aligned_timeframes', []))})"
|
||||
else:
|
||||
conflicting = alignment_data.get("conflicting_timeframes", [])
|
||||
return cfg["weight_no_alignment_penalty"], f"多周期方向矛盾({'/'.join(conflicting)}冲突)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry action gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def gate_entry_by_alignment(
|
||||
current_entry_action: str,
|
||||
alignment_data: dict,
|
||||
) -> tuple[str, str]:
|
||||
"""Downgrade entry action if alignment is insufficient.
|
||||
|
||||
Args:
|
||||
current_entry_action: the entry action determined by other logic
|
||||
alignment_data: result from compute_timeframe_alignment
|
||||
|
||||
Returns:
|
||||
(final_entry_action, reason_if_downgraded)
|
||||
"""
|
||||
max_allowed = alignment_data.get("max_entry_action", "观察")
|
||||
|
||||
# Define action hierarchy
|
||||
action_rank = {"可即刻买入": 3, "即刻买入": 3, "等回踩": 2, "观察": 1}
|
||||
current_rank = action_rank.get(current_entry_action, 1)
|
||||
max_rank = action_rank.get(max_allowed, 1)
|
||||
|
||||
if current_rank <= max_rank:
|
||||
return current_entry_action, ""
|
||||
|
||||
# Downgrade
|
||||
reason = f"多周期对齐度不足({alignment_data.get('alignment_label', '')}), 降级为{max_allowed}"
|
||||
return max_allowed, reason
|
||||
367
app/core/vcp_detector.py
Normal file
367
app/core/vcp_detector.py
Normal file
@ -0,0 +1,367 @@
|
||||
"""VCP (Volatility Contraction Pattern) 精细化检测 — 多空双向。
|
||||
|
||||
做多 VCP:
|
||||
- 价格从高点回调形成多次 swing low,每次回调幅度递减
|
||||
- 同时成交量逐步萎缩到地量
|
||||
- 最终一根放量 K 线突破整个收缩区间上沿
|
||||
- 止损放在最后一次收缩低点,天然 RR 3:1+
|
||||
|
||||
做空 VCP(顶部分配 / Distribution):
|
||||
- 价格从低点反弹形成多次 swing high,每次反弹幅度递减
|
||||
- 成交量在反弹时递减(买盘衰竭)
|
||||
- 最终一根放量阴线跌破收缩区间下沿
|
||||
- 止损放在最后一次反弹高点
|
||||
|
||||
核心参数:
|
||||
- 至少 2 次收缩(3 次更佳)
|
||||
- 每次收缩幅度 < 前一次的 75%
|
||||
- 最后一次收缩的成交量 < 前一次的 80%
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app.config.config_loader import _get_section as _get_cfg_section
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _vcp_config() -> dict:
|
||||
try:
|
||||
cfg = _get_cfg_section("vcp") or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return {
|
||||
"min_contractions": int(cfg.get("min_contractions", 2)),
|
||||
"max_contractions": int(cfg.get("max_contractions", 5)),
|
||||
"contraction_decay_max": float(cfg.get("contraction_decay_max", 0.75)),
|
||||
"volume_decay_max": float(cfg.get("volume_decay_max", 0.85)),
|
||||
"min_first_swing_pct": float(cfg.get("min_first_swing_pct", 5.0)),
|
||||
"breakout_vol_ratio_min": float(cfg.get("breakout_vol_ratio_min", 2.0)),
|
||||
"lookback_bars": int(cfg.get("lookback_bars", 60)),
|
||||
"swing_window": int(cfg.get("swing_window", 3)),
|
||||
# Factor weights
|
||||
"weight_vcp_bull": float(cfg.get("weight_vcp_bull", 5.0)),
|
||||
"weight_vcp_bear": float(cfg.get("weight_vcp_bear", 5.0)),
|
||||
"weight_vcp_forming": float(cfg.get("weight_vcp_forming", 3.0)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Swing detection (simplified for VCP — needs clear pivots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_swing_highs(df: pd.DataFrame, window: int = 3) -> list[dict]:
|
||||
"""Find swing highs: local maxima with `window` bars on each side."""
|
||||
highs = df["high"].values.astype(float)
|
||||
n = len(highs)
|
||||
swings = []
|
||||
for i in range(window, n - window):
|
||||
if all(highs[i] >= highs[i - j] for j in range(1, window + 1)) and \
|
||||
all(highs[i] >= highs[i + j] for j in range(1, window + 1)):
|
||||
swings.append({"index": i, "price": float(highs[i])})
|
||||
return swings
|
||||
|
||||
|
||||
def _find_swing_lows(df: pd.DataFrame, window: int = 3) -> list[dict]:
|
||||
"""Find swing lows: local minima with `window` bars on each side."""
|
||||
lows = df["low"].values.astype(float)
|
||||
n = len(lows)
|
||||
swings = []
|
||||
for i in range(window, n - window):
|
||||
if all(lows[i] <= lows[i - j] for j in range(1, window + 1)) and \
|
||||
all(lows[i] <= lows[i + j] for j in range(1, window + 1)):
|
||||
swings.append({"index": i, "price": float(lows[i])})
|
||||
return swings
|
||||
|
||||
|
||||
def _avg_volume_around(df: pd.DataFrame, index: int, window: int = 3) -> float:
|
||||
"""Average volume around a swing point."""
|
||||
start = max(0, index - window)
|
||||
end = min(len(df), index + window + 1)
|
||||
return float(df["volume"].iloc[start:end].mean())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VCP Detection — Bullish (做多)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_vcp_bull(df: pd.DataFrame) -> dict:
|
||||
"""Detect bullish VCP: swing lows with decreasing depth + volume contraction.
|
||||
|
||||
Looks for a series of pullbacks from a resistance level where each pullback
|
||||
is shallower than the last, indicating sellers are exhausting.
|
||||
|
||||
Args:
|
||||
df: 4H or 1D kline DataFrame (recommend 60+ bars)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"detected": bool,
|
||||
"forming": bool, # pattern forming but not yet broken out
|
||||
"contractions": int, # number of valid contractions
|
||||
"depths_pct": list, # each contraction depth as %
|
||||
"volume_ratios": list, # volume at each contraction relative to first
|
||||
"pivot_high": float, # resistance level (breakout target)
|
||||
"last_low": float, # last contraction low (stop loss level)
|
||||
"breakout": bool, # has price broken above pivot_high with volume
|
||||
"rr_estimate": float, # estimated risk/reward if entering now
|
||||
"score": float,
|
||||
"signal": str,
|
||||
}
|
||||
"""
|
||||
cfg = _vcp_config()
|
||||
result = {"detected": False, "forming": False, "contractions": 0,
|
||||
"depths_pct": [], "volume_ratios": [], "pivot_high": 0,
|
||||
"last_low": 0, "breakout": False, "rr_estimate": 0, "score": 0, "signal": ""}
|
||||
|
||||
if df is None or len(df) < cfg["lookback_bars"]:
|
||||
return result
|
||||
|
||||
recent = df.tail(cfg["lookback_bars"]).reset_index(drop=True)
|
||||
swing_highs = _find_swing_highs(recent, cfg["swing_window"])
|
||||
swing_lows = _find_swing_lows(recent, cfg["swing_window"])
|
||||
|
||||
if len(swing_highs) < 1 or len(swing_lows) < cfg["min_contractions"]:
|
||||
return result
|
||||
|
||||
# Find the resistance level (highest swing high in the lookback)
|
||||
pivot_high = max(s["price"] for s in swing_highs)
|
||||
result["pivot_high"] = round(pivot_high, 6)
|
||||
|
||||
# Measure contraction depths: distance from pivot_high to each swing low
|
||||
depths = []
|
||||
volumes = []
|
||||
for sl in swing_lows:
|
||||
depth_pct = (pivot_high - sl["price"]) / pivot_high * 100 if pivot_high > 0 else 0
|
||||
if depth_pct > 0:
|
||||
vol = _avg_volume_around(recent, sl["index"], cfg["swing_window"])
|
||||
depths.append({"depth_pct": depth_pct, "price": sl["price"], "index": sl["index"], "volume": vol})
|
||||
|
||||
if len(depths) < cfg["min_contractions"]:
|
||||
return result
|
||||
|
||||
# Filter: keep only the last N contractions that show decay
|
||||
# Start from the deepest (first major pullback) and check subsequent ones decay
|
||||
depths.sort(key=lambda x: x["index"])
|
||||
|
||||
# Find the first significant pullback
|
||||
valid_sequence = []
|
||||
for d in depths:
|
||||
if d["depth_pct"] >= cfg["min_first_swing_pct"]:
|
||||
valid_sequence = [d]
|
||||
break
|
||||
|
||||
if not valid_sequence:
|
||||
return result
|
||||
|
||||
# Build contraction sequence: each subsequent must be shallower
|
||||
for d in depths:
|
||||
if d["index"] <= valid_sequence[-1]["index"]:
|
||||
continue
|
||||
if d["depth_pct"] < valid_sequence[-1]["depth_pct"]:
|
||||
valid_sequence.append(d)
|
||||
|
||||
if len(valid_sequence) < cfg["min_contractions"]:
|
||||
return result
|
||||
|
||||
# Verify decay ratios
|
||||
depth_ratios = []
|
||||
volume_ratios = []
|
||||
first_vol = valid_sequence[0]["volume"]
|
||||
|
||||
for i in range(1, len(valid_sequence)):
|
||||
ratio = valid_sequence[i]["depth_pct"] / valid_sequence[i - 1]["depth_pct"]
|
||||
depth_ratios.append(ratio)
|
||||
vol_ratio = valid_sequence[i]["volume"] / first_vol if first_vol > 0 else 1
|
||||
volume_ratios.append(vol_ratio)
|
||||
|
||||
# Check all depth ratios are decaying
|
||||
all_decaying = all(r <= cfg["contraction_decay_max"] for r in depth_ratios)
|
||||
# Volume should also be contracting (at least the last one)
|
||||
vol_contracting = volume_ratios[-1] <= cfg["volume_decay_max"] if volume_ratios else False
|
||||
|
||||
if not all_decaying:
|
||||
return result
|
||||
|
||||
# Pattern is forming
|
||||
last_low = valid_sequence[-1]["price"]
|
||||
result["forming"] = True
|
||||
result["contractions"] = len(valid_sequence)
|
||||
result["depths_pct"] = [round(d["depth_pct"], 2) for d in valid_sequence]
|
||||
result["volume_ratios"] = [round(v, 2) for v in volume_ratios]
|
||||
result["last_low"] = round(last_low, 6)
|
||||
|
||||
# Check for breakout: current price above pivot_high with volume
|
||||
current_close = float(recent["close"].iloc[-1])
|
||||
current_vol = float(recent["volume"].iloc[-1])
|
||||
avg_vol = float(recent["volume"].rolling(20).mean().iloc[-1]) if len(recent) >= 20 else float(recent["volume"].mean())
|
||||
vol_breakout = current_vol / avg_vol if avg_vol > 0 else 0
|
||||
|
||||
if current_close > pivot_high and vol_breakout >= cfg["breakout_vol_ratio_min"]:
|
||||
result["breakout"] = True
|
||||
result["detected"] = True
|
||||
# RR estimate: risk = entry to last_low, reward = depth of first contraction projected up
|
||||
risk = current_close - last_low
|
||||
reward = valid_sequence[0]["depth_pct"] / 100 * pivot_high # project first swing depth as target
|
||||
result["rr_estimate"] = round(reward / risk, 2) if risk > 0 else 0
|
||||
result["score"] = cfg["weight_vcp_bull"]
|
||||
result["signal"] = f"VCP突破({len(valid_sequence)}次收缩, 量{vol_breakout:.1f}x, RR≈{result['rr_estimate']})"
|
||||
elif current_close > last_low and current_close < pivot_high:
|
||||
# Forming but not broken out yet — still valuable as watch signal
|
||||
result["detected"] = True
|
||||
risk = current_close - last_low
|
||||
reward = pivot_high - current_close + (valid_sequence[0]["depth_pct"] / 100 * pivot_high * 0.5)
|
||||
result["rr_estimate"] = round(reward / risk, 2) if risk > 0 else 0
|
||||
result["score"] = cfg["weight_vcp_forming"]
|
||||
result["signal"] = f"VCP蓄力中({len(valid_sequence)}次收缩{'量缩' if vol_contracting else ''}, 待突破${pivot_high:.4f})"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VCP Detection — Bearish / Distribution (做空)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_vcp_bear(df: pd.DataFrame) -> dict:
|
||||
"""Detect bearish VCP (distribution): swing highs with decreasing bounce height.
|
||||
|
||||
Looks for a series of rallies from a support level where each rally is
|
||||
weaker than the last, indicating buyers are exhausting.
|
||||
|
||||
Args:
|
||||
df: 4H or 1D kline DataFrame (recommend 60+ bars)
|
||||
|
||||
Returns:
|
||||
Same structure as detect_vcp_bull but for short side.
|
||||
"""
|
||||
cfg = _vcp_config()
|
||||
result = {"detected": False, "forming": False, "contractions": 0,
|
||||
"depths_pct": [], "volume_ratios": [], "pivot_low": 0,
|
||||
"last_high": 0, "breakdown": False, "rr_estimate": 0, "score": 0, "signal": ""}
|
||||
|
||||
if df is None or len(df) < cfg["lookback_bars"]:
|
||||
return result
|
||||
|
||||
recent = df.tail(cfg["lookback_bars"]).reset_index(drop=True)
|
||||
swing_highs = _find_swing_highs(recent, cfg["swing_window"])
|
||||
swing_lows = _find_swing_lows(recent, cfg["swing_window"])
|
||||
|
||||
if len(swing_lows) < 1 or len(swing_highs) < cfg["min_contractions"]:
|
||||
return result
|
||||
|
||||
# Find the support level (lowest swing low in the lookback)
|
||||
pivot_low = min(s["price"] for s in swing_lows)
|
||||
result["pivot_low"] = round(pivot_low, 6)
|
||||
|
||||
# Measure bounce heights: distance from pivot_low to each swing high
|
||||
bounces = []
|
||||
for sh in swing_highs:
|
||||
bounce_pct = (sh["price"] - pivot_low) / pivot_low * 100 if pivot_low > 0 else 0
|
||||
if bounce_pct > 0:
|
||||
vol = _avg_volume_around(recent, sh["index"], cfg["swing_window"])
|
||||
bounces.append({"bounce_pct": bounce_pct, "price": sh["price"], "index": sh["index"], "volume": vol})
|
||||
|
||||
if len(bounces) < cfg["min_contractions"]:
|
||||
return result
|
||||
|
||||
bounces.sort(key=lambda x: x["index"])
|
||||
|
||||
# Find first significant bounce
|
||||
valid_sequence = []
|
||||
for b in bounces:
|
||||
if b["bounce_pct"] >= cfg["min_first_swing_pct"]:
|
||||
valid_sequence = [b]
|
||||
break
|
||||
|
||||
if not valid_sequence:
|
||||
return result
|
||||
|
||||
# Build sequence: each subsequent bounce must be weaker
|
||||
for b in bounces:
|
||||
if b["index"] <= valid_sequence[-1]["index"]:
|
||||
continue
|
||||
if b["bounce_pct"] < valid_sequence[-1]["bounce_pct"]:
|
||||
valid_sequence.append(b)
|
||||
|
||||
if len(valid_sequence) < cfg["min_contractions"]:
|
||||
return result
|
||||
|
||||
# Verify decay
|
||||
bounce_ratios = []
|
||||
volume_ratios = []
|
||||
first_vol = valid_sequence[0]["volume"]
|
||||
|
||||
for i in range(1, len(valid_sequence)):
|
||||
ratio = valid_sequence[i]["bounce_pct"] / valid_sequence[i - 1]["bounce_pct"]
|
||||
bounce_ratios.append(ratio)
|
||||
vol_ratio = valid_sequence[i]["volume"] / first_vol if first_vol > 0 else 1
|
||||
volume_ratios.append(vol_ratio)
|
||||
|
||||
all_decaying = all(r <= cfg["contraction_decay_max"] for r in bounce_ratios)
|
||||
vol_contracting = volume_ratios[-1] <= cfg["volume_decay_max"] if volume_ratios else False
|
||||
|
||||
if not all_decaying:
|
||||
return result
|
||||
|
||||
last_high = valid_sequence[-1]["price"]
|
||||
result["forming"] = True
|
||||
result["contractions"] = len(valid_sequence)
|
||||
result["depths_pct"] = [round(b["bounce_pct"], 2) for b in valid_sequence]
|
||||
result["volume_ratios"] = [round(v, 2) for v in volume_ratios]
|
||||
result["last_high"] = round(last_high, 6)
|
||||
|
||||
# Check for breakdown
|
||||
current_close = float(recent["close"].iloc[-1])
|
||||
current_vol = float(recent["volume"].iloc[-1])
|
||||
avg_vol = float(recent["volume"].rolling(20).mean().iloc[-1]) if len(recent) >= 20 else float(recent["volume"].mean())
|
||||
vol_breakout = current_vol / avg_vol if avg_vol > 0 else 0
|
||||
|
||||
if current_close < pivot_low and vol_breakout >= cfg["breakout_vol_ratio_min"]:
|
||||
result["breakdown"] = True
|
||||
result["detected"] = True
|
||||
risk = last_high - current_close
|
||||
reward = valid_sequence[0]["bounce_pct"] / 100 * pivot_low
|
||||
result["rr_estimate"] = round(reward / risk, 2) if risk > 0 else 0
|
||||
result["score"] = cfg["weight_vcp_bear"]
|
||||
result["signal"] = f"顶部分配破位({len(valid_sequence)}次反弹递减, 量{vol_breakout:.1f}x, RR≈{result['rr_estimate']})"
|
||||
elif current_close < last_high and current_close > pivot_low:
|
||||
result["detected"] = True
|
||||
risk = last_high - current_close
|
||||
reward = current_close - pivot_low + (valid_sequence[0]["bounce_pct"] / 100 * pivot_low * 0.5)
|
||||
result["rr_estimate"] = round(reward / risk, 2) if risk > 0 else 0
|
||||
result["score"] = cfg["weight_vcp_forming"]
|
||||
result["signal"] = f"顶部分配蓄力({len(valid_sequence)}次反弹递减{'量缩' if vol_contracting else ''}, 待破位${pivot_low:.4f})"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_vcp(df: pd.DataFrame, direction: str = "both") -> dict:
|
||||
"""Detect VCP pattern for given direction.
|
||||
|
||||
Args:
|
||||
df: 4H or 1D kline DataFrame
|
||||
direction: "long", "short", or "both"
|
||||
|
||||
Returns:
|
||||
{"bull": {...}, "bear": {...}} or single direction result
|
||||
"""
|
||||
if direction == "long":
|
||||
return detect_vcp_bull(df)
|
||||
elif direction == "short":
|
||||
return detect_vcp_bear(df)
|
||||
else:
|
||||
return {
|
||||
"bull": detect_vcp_bull(df),
|
||||
"bear": detect_vcp_bear(df),
|
||||
}
|
||||
335
app/core/volume_profile.py
Normal file
335
app/core/volume_profile.py
Normal file
@ -0,0 +1,335 @@
|
||||
"""简易 Volume Profile — 价格维度的成交量分布(HVN/LVN 识别)。
|
||||
|
||||
核心逻辑:
|
||||
- 把过去 N 根 K 线的价格区间等分为若干 bin
|
||||
- 统计每个 bin 内的累计成交量
|
||||
- High Volume Node (HVN):成交量显著高于均值的价格区 → 阻力/支撑
|
||||
- Low Volume Node (LVN):成交量显著低于均值的价格区 → 价格快速穿越区
|
||||
|
||||
使用场景(多空双向):
|
||||
做多:
|
||||
- 突破后上方是 LVN → TP 可以设远(价格会快速穿越)
|
||||
- 突破后上方紧挨 HVN → TP 保守(价格容易卡住)
|
||||
- 当前价格在 HVN 内 → 支撑较强
|
||||
|
||||
做空:
|
||||
- 破位后下方是 LVN → TP 可以设远
|
||||
- 破位后下方紧挨 HVN → TP 保守
|
||||
- 当前价格在 HVN 下方 → 阻力较强
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app.config.config_loader import _get_section as _get_cfg_section
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _vp_config() -> dict:
|
||||
try:
|
||||
cfg = _get_cfg_section("volume_profile") or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return {
|
||||
"num_bins": int(cfg.get("num_bins", 30)),
|
||||
"lookback_bars": int(cfg.get("lookback_bars", 50)),
|
||||
"hvn_threshold_mult": float(cfg.get("hvn_threshold_mult", 1.5)),
|
||||
"lvn_threshold_mult": float(cfg.get("lvn_threshold_mult", 0.5)),
|
||||
"poc_weight": float(cfg.get("poc_weight", 2.0)),
|
||||
"tp_lvn_bonus": float(cfg.get("tp_lvn_bonus", 1.5)),
|
||||
"tp_hvn_penalty": float(cfg.get("tp_hvn_penalty", -1.0)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core Volume Profile computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_volume_profile(df: pd.DataFrame, num_bins: int = 30, lookback: int = 50) -> dict:
|
||||
"""Compute volume profile from kline data.
|
||||
|
||||
Args:
|
||||
df: OHLCV DataFrame
|
||||
num_bins: number of price bins
|
||||
lookback: number of bars to analyze
|
||||
|
||||
Returns:
|
||||
{
|
||||
"bins": [{"price_low": f, "price_high": f, "price_mid": f, "volume": f, "type": str}],
|
||||
"poc": float, # Point of Control (price with highest volume)
|
||||
"value_area_high": float, # 70% volume area upper bound
|
||||
"value_area_low": float, # 70% volume area lower bound
|
||||
"hvn_zones": [{"low": f, "high": f, "volume": f}],
|
||||
"lvn_zones": [{"low": f, "high": f, "volume": f}],
|
||||
"range_high": float,
|
||||
"range_low": float,
|
||||
"available": bool,
|
||||
}
|
||||
"""
|
||||
if df is None or len(df) < 10:
|
||||
return {"bins": [], "poc": 0, "value_area_high": 0, "value_area_low": 0,
|
||||
"hvn_zones": [], "lvn_zones": [], "range_high": 0, "range_low": 0, "available": False}
|
||||
|
||||
recent = df.tail(lookback)
|
||||
range_high = float(recent["high"].max())
|
||||
range_low = float(recent["low"].min())
|
||||
|
||||
if range_high <= range_low or range_low <= 0:
|
||||
return {"bins": [], "poc": 0, "value_area_high": 0, "value_area_low": 0,
|
||||
"hvn_zones": [], "lvn_zones": [], "range_high": range_high, "range_low": range_low, "available": False}
|
||||
|
||||
# Create price bins
|
||||
bin_edges = np.linspace(range_low, range_high, num_bins + 1)
|
||||
bin_volumes = np.zeros(num_bins)
|
||||
|
||||
# Distribute volume across bins for each candle
|
||||
# Each candle's volume is distributed proportionally across the bins it spans
|
||||
for _, row in recent.iterrows():
|
||||
candle_low = float(row["low"])
|
||||
candle_high = float(row["high"])
|
||||
candle_vol = float(row["volume"])
|
||||
|
||||
if candle_high <= candle_low or candle_vol <= 0:
|
||||
continue
|
||||
|
||||
for b in range(num_bins):
|
||||
bin_low = bin_edges[b]
|
||||
bin_high = bin_edges[b + 1]
|
||||
|
||||
# Calculate overlap between candle range and bin
|
||||
overlap_low = max(candle_low, bin_low)
|
||||
overlap_high = min(candle_high, bin_high)
|
||||
|
||||
if overlap_high > overlap_low:
|
||||
# Proportion of candle range that falls in this bin
|
||||
proportion = (overlap_high - overlap_low) / (candle_high - candle_low)
|
||||
bin_volumes[b] += candle_vol * proportion
|
||||
|
||||
# Build bin list
|
||||
avg_vol = float(np.mean(bin_volumes)) if np.sum(bin_volumes) > 0 else 1.0
|
||||
cfg = _vp_config()
|
||||
hvn_thresh = avg_vol * cfg["hvn_threshold_mult"]
|
||||
lvn_thresh = avg_vol * cfg["lvn_threshold_mult"]
|
||||
|
||||
bins = []
|
||||
for b in range(num_bins):
|
||||
vol = float(bin_volumes[b])
|
||||
if vol >= hvn_thresh:
|
||||
bin_type = "hvn"
|
||||
elif vol <= lvn_thresh:
|
||||
bin_type = "lvn"
|
||||
else:
|
||||
bin_type = "normal"
|
||||
bins.append({
|
||||
"price_low": round(float(bin_edges[b]), 6),
|
||||
"price_high": round(float(bin_edges[b + 1]), 6),
|
||||
"price_mid": round(float((bin_edges[b] + bin_edges[b + 1]) / 2), 6),
|
||||
"volume": round(vol, 2),
|
||||
"type": bin_type,
|
||||
})
|
||||
|
||||
# Point of Control (POC): bin with highest volume
|
||||
poc_idx = int(np.argmax(bin_volumes))
|
||||
poc = float((bin_edges[poc_idx] + bin_edges[poc_idx + 1]) / 2)
|
||||
|
||||
# Value Area (70% of total volume centered on POC)
|
||||
total_vol = float(np.sum(bin_volumes))
|
||||
target_vol = total_vol * 0.70
|
||||
accumulated = float(bin_volumes[poc_idx])
|
||||
va_low_idx = poc_idx
|
||||
va_high_idx = poc_idx
|
||||
|
||||
while accumulated < target_vol and (va_low_idx > 0 or va_high_idx < num_bins - 1):
|
||||
expand_low = float(bin_volumes[va_low_idx - 1]) if va_low_idx > 0 else 0
|
||||
expand_high = float(bin_volumes[va_high_idx + 1]) if va_high_idx < num_bins - 1 else 0
|
||||
|
||||
if expand_low >= expand_high and va_low_idx > 0:
|
||||
va_low_idx -= 1
|
||||
accumulated += expand_low
|
||||
elif va_high_idx < num_bins - 1:
|
||||
va_high_idx += 1
|
||||
accumulated += expand_high
|
||||
else:
|
||||
va_low_idx -= 1
|
||||
accumulated += expand_low
|
||||
|
||||
value_area_low = float(bin_edges[va_low_idx])
|
||||
value_area_high = float(bin_edges[va_high_idx + 1])
|
||||
|
||||
# Collect HVN and LVN zones (merge adjacent bins of same type)
|
||||
hvn_zones = _merge_zones(bins, "hvn")
|
||||
lvn_zones = _merge_zones(bins, "lvn")
|
||||
|
||||
return {
|
||||
"bins": bins,
|
||||
"poc": round(poc, 6),
|
||||
"value_area_high": round(value_area_high, 6),
|
||||
"value_area_low": round(value_area_low, 6),
|
||||
"hvn_zones": hvn_zones,
|
||||
"lvn_zones": lvn_zones,
|
||||
"range_high": round(range_high, 6),
|
||||
"range_low": round(range_low, 6),
|
||||
"available": True,
|
||||
}
|
||||
|
||||
|
||||
def _merge_zones(bins: list[dict], zone_type: str) -> list[dict]:
|
||||
"""Merge adjacent bins of the same type into zones."""
|
||||
zones = []
|
||||
current = None
|
||||
for b in bins:
|
||||
if b["type"] == zone_type:
|
||||
if current is None:
|
||||
current = {"low": b["price_low"], "high": b["price_high"], "volume": b["volume"]}
|
||||
else:
|
||||
current["high"] = b["price_high"]
|
||||
current["volume"] += b["volume"]
|
||||
else:
|
||||
if current is not None:
|
||||
zones.append({k: round(v, 6) if isinstance(v, float) else v for k, v in current.items()})
|
||||
current = None
|
||||
if current is not None:
|
||||
zones.append({k: round(v, 6) if isinstance(v, float) else v for k, v in current.items()})
|
||||
return zones
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TP optimization based on Volume Profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def find_nearest_node(price: float, direction: str, vp_data: dict) -> dict:
|
||||
"""Find the nearest HVN or LVN in the given direction from current price.
|
||||
|
||||
Args:
|
||||
price: current price
|
||||
direction: "above" or "below"
|
||||
vp_data: result from compute_volume_profile
|
||||
|
||||
Returns:
|
||||
{
|
||||
"nearest_hvn": {"low": f, "high": f, "distance_pct": f} or None,
|
||||
"nearest_lvn": {"low": f, "high": f, "distance_pct": f} or None,
|
||||
"path_type": "clear" / "blocked" / "mixed",
|
||||
"tp_adjustment": str,
|
||||
}
|
||||
"""
|
||||
if not vp_data.get("available"):
|
||||
return {"nearest_hvn": None, "nearest_lvn": None, "path_type": "unknown", "tp_adjustment": ""}
|
||||
|
||||
hvn_zones = vp_data.get("hvn_zones", [])
|
||||
lvn_zones = vp_data.get("lvn_zones", [])
|
||||
|
||||
nearest_hvn = None
|
||||
nearest_lvn = None
|
||||
|
||||
if direction == "above":
|
||||
# Find first HVN above price
|
||||
for zone in hvn_zones:
|
||||
if zone["low"] > price:
|
||||
dist = (zone["low"] - price) / price * 100
|
||||
nearest_hvn = {"low": zone["low"], "high": zone["high"], "distance_pct": round(dist, 2)}
|
||||
break
|
||||
# Find first LVN above price
|
||||
for zone in lvn_zones:
|
||||
if zone["low"] > price:
|
||||
dist = (zone["low"] - price) / price * 100
|
||||
nearest_lvn = {"low": zone["low"], "high": zone["high"], "distance_pct": round(dist, 2)}
|
||||
break
|
||||
else: # below
|
||||
# Find first HVN below price (search from high to low)
|
||||
for zone in reversed(hvn_zones):
|
||||
if zone["high"] < price:
|
||||
dist = (price - zone["high"]) / price * 100
|
||||
nearest_hvn = {"low": zone["low"], "high": zone["high"], "distance_pct": round(dist, 2)}
|
||||
break
|
||||
# Find first LVN below price
|
||||
for zone in reversed(lvn_zones):
|
||||
if zone["high"] < price:
|
||||
dist = (price - zone["high"]) / price * 100
|
||||
nearest_lvn = {"low": zone["low"], "high": zone["high"], "distance_pct": round(dist, 2)}
|
||||
break
|
||||
|
||||
# Determine path type
|
||||
if nearest_lvn and (not nearest_hvn or nearest_lvn["distance_pct"] < nearest_hvn["distance_pct"]):
|
||||
path_type = "clear"
|
||||
tp_adjustment = "TP可设远(前方低量区,价格易快速穿越)"
|
||||
elif nearest_hvn and (not nearest_lvn or nearest_hvn["distance_pct"] < nearest_lvn["distance_pct"]):
|
||||
path_type = "blocked"
|
||||
tp_adjustment = f"TP应保守(前方{nearest_hvn['distance_pct']:.1f}%处有高量区阻力)"
|
||||
else:
|
||||
path_type = "mixed"
|
||||
tp_adjustment = ""
|
||||
|
||||
return {
|
||||
"nearest_hvn": nearest_hvn,
|
||||
"nearest_lvn": nearest_lvn,
|
||||
"path_type": path_type,
|
||||
"tp_adjustment": tp_adjustment,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factor scoring interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def vp_factor_context(
|
||||
df: pd.DataFrame,
|
||||
current_price: float,
|
||||
trade_direction: str = "long",
|
||||
) -> dict:
|
||||
"""Compute Volume Profile context for factor scoring.
|
||||
|
||||
Args:
|
||||
df: 4H kline DataFrame
|
||||
current_price: current price
|
||||
trade_direction: "long" or "short"
|
||||
|
||||
Returns:
|
||||
{
|
||||
"vp": volume profile data,
|
||||
"path_analysis": nearest node analysis,
|
||||
"score_delta": float,
|
||||
"signal": str,
|
||||
"poc": float,
|
||||
"in_value_area": bool,
|
||||
}
|
||||
"""
|
||||
cfg = _vp_config()
|
||||
vp = compute_volume_profile(df, cfg["num_bins"], cfg["lookback_bars"])
|
||||
|
||||
if not vp.get("available"):
|
||||
return {"vp": vp, "path_analysis": {}, "score_delta": 0, "signal": "", "poc": 0, "in_value_area": False}
|
||||
|
||||
# Determine which direction to look for obstacles
|
||||
look_dir = "above" if trade_direction == "long" else "below"
|
||||
path = find_nearest_node(current_price, look_dir, vp)
|
||||
|
||||
# Score adjustment
|
||||
score_delta = 0.0
|
||||
signal = ""
|
||||
|
||||
if path["path_type"] == "clear":
|
||||
score_delta = cfg["tp_lvn_bonus"]
|
||||
signal = f"VP路径清晰({path['tp_adjustment']})"
|
||||
elif path["path_type"] == "blocked":
|
||||
score_delta = cfg["tp_hvn_penalty"]
|
||||
signal = f"VP路径受阻({path['tp_adjustment']})"
|
||||
|
||||
# Check if price is in value area (support/resistance context)
|
||||
in_value_area = vp["value_area_low"] <= current_price <= vp["value_area_high"]
|
||||
|
||||
return {
|
||||
"vp": vp,
|
||||
"path_analysis": path,
|
||||
"score_delta": round(score_delta, 2),
|
||||
"signal": signal,
|
||||
"poc": vp["poc"],
|
||||
"in_value_area": in_value_area,
|
||||
}
|
||||
@ -1273,6 +1273,99 @@ def confirm_burst(symbol, cand):
|
||||
else:
|
||||
onchain_context = onchain_context or {"has_data": False}
|
||||
|
||||
# ---- v1.8 新增因子:RS + OI/Funding + 多周期对齐 ----
|
||||
rs_context = {}
|
||||
oi_funding_context = {}
|
||||
alignment_context = {}
|
||||
try:
|
||||
from app.core.relative_strength import compute_relative_strength, rs_factor_score
|
||||
rs_context = compute_relative_strength(symbol)
|
||||
rs_delta, rs_label = rs_factor_score(rs_context)
|
||||
if rs_delta != 0 and rs_label:
|
||||
signals.append(rs_label)
|
||||
rs_code = "rs_strong" if rs_delta > 0 else "rs_weak"
|
||||
if rs_context.get("independent_strength"):
|
||||
rs_code = "rs_independent_strength"
|
||||
score += factor_scorer.delta(rs_code, rs_delta, evidence=rs_label, value=rs_context.get("rs_score"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from app.core.oi_funding import oi_funding_factor_scores
|
||||
# Determine price position for funding analysis
|
||||
_price_pos = "neutral"
|
||||
if cand_change_24h > 8:
|
||||
_price_pos = "high"
|
||||
elif cand_change_24h < -3:
|
||||
_price_pos = "low"
|
||||
oi_funding_result = oi_funding_factor_scores(
|
||||
symbol,
|
||||
price_change_pct=cand_change_24h,
|
||||
price_position=_price_pos,
|
||||
)
|
||||
oi_funding_context = oi_funding_result
|
||||
for factor in oi_funding_result.get("factors", []):
|
||||
signals.append(factor["label"])
|
||||
score += factor_scorer.delta(
|
||||
factor["code"], factor["score"],
|
||||
evidence=factor["label"],
|
||||
value=factor.get("score"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from app.core.timeframe_alignment import compute_timeframe_alignment, alignment_factor_score
|
||||
_trade_dir = "short" if trade_side == "short" else "long"
|
||||
alignment_context = compute_timeframe_alignment(
|
||||
df_d1=d1_df, df_4h=h4_df, df_1h=h1_df,
|
||||
trade_direction=_trade_dir,
|
||||
)
|
||||
align_delta, align_label = alignment_factor_score(alignment_context)
|
||||
if align_delta != 0 and align_label:
|
||||
signals.append(align_label)
|
||||
align_code = "tf_alignment_full" if alignment_context.get("alignment_score", 0) >= 3 else \
|
||||
"tf_alignment_double" if alignment_context.get("alignment_score", 0) == 2 else \
|
||||
"tf_alignment_single_penalty" if alignment_context.get("alignment_score", 0) == 1 else \
|
||||
"tf_alignment_conflict_penalty"
|
||||
score += factor_scorer.delta(align_code, align_delta, evidence=align_label, value=alignment_context.get("alignment_score"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- v1.8.1 新增因子:VCP + Volume Profile + 突破质量 ----
|
||||
vcp_context = {}
|
||||
vp_context = {}
|
||||
try:
|
||||
from app.core.vcp_detector import detect_vcp
|
||||
_vcp_dir = "short" if trade_side == "short" else "long"
|
||||
vcp_result = detect_vcp(h4_df, direction=_vcp_dir)
|
||||
if isinstance(vcp_result, dict) and vcp_result.get("detected"):
|
||||
vcp_context = vcp_result
|
||||
vcp_signal = vcp_result.get("signal", "")
|
||||
vcp_score = vcp_result.get("score", 0)
|
||||
if vcp_signal:
|
||||
signals.append(vcp_signal)
|
||||
if trade_side == "short":
|
||||
vcp_code = "vcp_bear_breakdown" if vcp_result.get("breakdown") else "vcp_bear_forming"
|
||||
else:
|
||||
vcp_code = "vcp_bull_breakout" if vcp_result.get("breakout") else "vcp_bull_forming"
|
||||
score += factor_scorer.delta(vcp_code, vcp_score, evidence=vcp_signal, value=vcp_result.get("contractions"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from app.core.volume_profile import vp_factor_context
|
||||
_vp_dir = "short" if trade_side == "short" else "long"
|
||||
vp_context = vp_factor_context(h4_df, price, trade_direction=_vp_dir)
|
||||
vp_delta = vp_context.get("score_delta", 0)
|
||||
vp_signal = vp_context.get("signal", "")
|
||||
if vp_delta != 0 and vp_signal:
|
||||
signals.append(vp_signal)
|
||||
vp_code = "vp_path_clear" if vp_delta > 0 else "vp_path_blocked"
|
||||
score += factor_scorer.delta(vp_code, vp_delta, evidence=vp_signal, value=vp_context.get("path_analysis"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- 1H量价行为(核心前瞻信号) ----
|
||||
vol_avg = float(h1_df["volume"].rolling(20).mean().iloc[-1])
|
||||
vol_latest = float(h1_df["volume"].iloc[-1])
|
||||
@ -1368,6 +1461,38 @@ def confirm_burst(symbol, cand):
|
||||
if t and _safe_age_bars(bp_4h.get("pullback_age_bars")) <= 1:
|
||||
current_trigger_times.append(t)
|
||||
|
||||
# ---- v1.8.1 突破/破位质量评估 ----
|
||||
breakout_quality_context = {}
|
||||
try:
|
||||
from app.core.signal_quality import estimate_breakout_quality, breakout_quality_factor_score
|
||||
# Determine breakout level from detected patterns
|
||||
_bq_level = 0
|
||||
_bq_dir = "short" if trade_side == "short" else "long"
|
||||
_bq_zones = h4_zones if 'h4_zones' in dir() else []
|
||||
if bp_4h.get("detected") and _bq_dir == "long":
|
||||
_bq_level = float(bp_4h.get("box_top") or bp_4h.get("breakout_level") or 0)
|
||||
elif bp_1h.get("detected") and _bq_dir == "long":
|
||||
_bq_level = float(bp_1h.get("box_top") or bp_1h.get("breakout_level") or 0)
|
||||
elif short_1h.get("detected") and _bq_dir == "short":
|
||||
_bq_level = float(short_1h.get("breakdown_level") or short_1h.get("box_bottom") or 0)
|
||||
|
||||
if _bq_level > 0 and h1_df is not None:
|
||||
breakout_quality_context = estimate_breakout_quality(
|
||||
df=h1_df,
|
||||
breakout_bar_index=-1,
|
||||
breakout_level=_bq_level,
|
||||
direction=_bq_dir,
|
||||
atr=atr_1h,
|
||||
nearby_zones=_bq_zones,
|
||||
)
|
||||
bq_delta, bq_signal = breakout_quality_factor_score(breakout_quality_context)
|
||||
if bq_delta != 0 and bq_signal:
|
||||
signals.append(bq_signal)
|
||||
bq_code = "breakout_quality_high" if bq_delta > 0 else "breakout_quality_low"
|
||||
score += factor_scorer.delta(bq_code, bq_delta, evidence=bq_signal, value=breakout_quality_context.get("quality_score"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- v1.7.7: 日线 PA 全分析(供需区 + 起爆点 + 动K,高权重)----
|
||||
# 日线是最大的时间框架,信号强度远高于小时级
|
||||
pa_1d = {}
|
||||
@ -1797,6 +1922,17 @@ def confirm_burst(symbol, cand):
|
||||
entry_method = f"{entry_method}(入场价已修正为当前价${price:.4f})"
|
||||
signals.append("⚠️ 入场方案价高于现价,已修正为当前市价")
|
||||
|
||||
# ---- v1.8 多周期对齐度入场降级 ----
|
||||
try:
|
||||
if alignment_context and alignment_context.get("alignment_score", 3) < 2:
|
||||
from app.core.timeframe_alignment import gate_entry_by_alignment
|
||||
gated_action, gate_reason = gate_entry_by_alignment(entry_action, alignment_context)
|
||||
if gated_action != entry_action:
|
||||
signals.append(f"⚠️ {gate_reason}")
|
||||
entry_action = gated_action
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# === ATR动态止损 (v1.6.8 → v1.7.1) ===
|
||||
# 止损% = max(2×ATR_1h/price, 5%地板),min(止损%, 10%天花板)
|
||||
atr_stop_pct = (atr_1h * stop_cfg.get("atr_mult", 2.0)) / price
|
||||
@ -2044,6 +2180,12 @@ def confirm_burst(symbol, cand):
|
||||
"fresh_reason": fresh_reason if 'fresh_reason' in locals() else "",
|
||||
"fresh_events": fresh_events if 'fresh_events' in locals() else [],
|
||||
"trigger_context": trigger_context if 'trigger_context' in locals() else {},
|
||||
"rs_context": rs_context if 'rs_context' in locals() else {},
|
||||
"oi_funding_context": oi_funding_context if 'oi_funding_context' in locals() else {},
|
||||
"alignment_context": alignment_context if 'alignment_context' in locals() else {},
|
||||
"vcp_context": vcp_context if 'vcp_context' in locals() else {},
|
||||
"vp_context": vp_context if 'vp_context' in locals() else {},
|
||||
"breakout_quality_context": breakout_quality_context if 'breakout_quality_context' in locals() else {},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -222,7 +222,7 @@ def build_router(repo_root: Path):
|
||||
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT symbol, name, trend_rank, trend_score, market_cap_rank, detected_at, extra_json
|
||||
SELECT id, symbol, name, trend_rank, trend_score, market_cap_rank, detected_at, extra_json
|
||||
FROM sentiment_events
|
||||
WHERE detected_at = (SELECT MAX(detected_at) FROM sentiment_events WHERE source='coingecko')
|
||||
ORDER BY trend_rank
|
||||
|
||||
143
scripts/deploy_server.sh
Executable file
143
scripts/deploy_server.sh
Executable file
@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Incremental deployment for an existing Dockerized AlphaX server.
|
||||
#
|
||||
# Required:
|
||||
# DEPLOY_HOST="user@server"
|
||||
# REMOTE_DIR="/path/to/existing/alphax-docker"
|
||||
#
|
||||
# Optional:
|
||||
# DEPLOY_BRANCH="main"
|
||||
# DEPLOY_MESSAGE="deploy: update alphax"
|
||||
# DEPLOY_REMOTE="origin"
|
||||
# DEPLOY_SERVICES="alphax-web alphax-scheduler alphax-price-streamer"
|
||||
# DEPLOY_RUN_MIGRATIONS=1
|
||||
# DEPLOY_HEALTHCHECK_URL="http://127.0.0.1:8191/api/stats"
|
||||
# DEPLOY_SKIP_COMMIT=1
|
||||
# DEPLOY_SKIP_PUSH=1
|
||||
# DEPLOY_SKIP_BUILD=1
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DEPLOY_HOST="${DEPLOY_HOST:-}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-}"
|
||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
|
||||
DEPLOY_REMOTE="${DEPLOY_REMOTE:-origin}"
|
||||
DEPLOY_MESSAGE="${DEPLOY_MESSAGE:-}"
|
||||
DEPLOY_SERVICES="${DEPLOY_SERVICES:-alphax-web alphax-scheduler alphax-price-streamer}"
|
||||
DEPLOY_RUN_MIGRATIONS="${DEPLOY_RUN_MIGRATIONS:-1}"
|
||||
DEPLOY_HEALTHCHECK_URL="${DEPLOY_HEALTHCHECK_URL:-http://127.0.0.1:8191/api/stats}"
|
||||
DEPLOY_SKIP_COMMIT="${DEPLOY_SKIP_COMMIT:-0}"
|
||||
DEPLOY_SKIP_PUSH="${DEPLOY_SKIP_PUSH:-0}"
|
||||
DEPLOY_SKIP_BUILD="${DEPLOY_SKIP_BUILD:-0}"
|
||||
|
||||
die() {
|
||||
echo "[deploy] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
info() {
|
||||
echo "[deploy] $*"
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
|
||||
}
|
||||
|
||||
quote_remote() {
|
||||
printf "%q" "$1"
|
||||
}
|
||||
|
||||
if [[ -z "$DEPLOY_HOST" || -z "$REMOTE_DIR" ]]; then
|
||||
cat >&2 <<'USAGE'
|
||||
Usage:
|
||||
DEPLOY_HOST="user@server" REMOTE_DIR="/path/to/alphax-docker" DEPLOY_MESSAGE="deploy: xxx" bash scripts/deploy_server.sh
|
||||
|
||||
What it does on the existing server directory:
|
||||
git fetch + git pull --ff-only
|
||||
docker compose build
|
||||
docker compose up -d postgres alphax-web alphax-scheduler alphax-price-streamer
|
||||
|
||||
Useful options:
|
||||
DEPLOY_BRANCH=main
|
||||
DEPLOY_SERVICES="alphax-web alphax-scheduler alphax-price-streamer"
|
||||
DEPLOY_RUN_MIGRATIONS=1
|
||||
DEPLOY_SKIP_COMMIT=1
|
||||
DEPLOY_SKIP_PUSH=1
|
||||
USAGE
|
||||
exit 2
|
||||
fi
|
||||
|
||||
require_cmd git
|
||||
require_cmd ssh
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
current_branch="$(git branch --show-current)"
|
||||
if [[ "$current_branch" != "$DEPLOY_BRANCH" ]]; then
|
||||
die "current branch is '$current_branch', expected '$DEPLOY_BRANCH'. Set DEPLOY_BRANCH or switch branch first."
|
||||
fi
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
if [[ "$DEPLOY_SKIP_COMMIT" == "1" ]]; then
|
||||
die "working tree has changes, but DEPLOY_SKIP_COMMIT=1. Commit or stash first."
|
||||
fi
|
||||
if [[ -z "$DEPLOY_MESSAGE" ]]; then
|
||||
die "working tree has changes. Provide DEPLOY_MESSAGE='deploy: ...' to commit them."
|
||||
fi
|
||||
info "staging local changes"
|
||||
git add -A
|
||||
info "creating local commit"
|
||||
git commit -m "$DEPLOY_MESSAGE"
|
||||
else
|
||||
info "working tree clean; no local commit needed"
|
||||
fi
|
||||
|
||||
if [[ "$DEPLOY_SKIP_PUSH" != "1" ]]; then
|
||||
info "pushing $DEPLOY_REMOTE/$DEPLOY_BRANCH"
|
||||
git push "$DEPLOY_REMOTE" "$DEPLOY_BRANCH"
|
||||
else
|
||||
info "skip git push"
|
||||
fi
|
||||
|
||||
remote_dir_q="$(quote_remote "$REMOTE_DIR")"
|
||||
branch_q="$(quote_remote "$DEPLOY_BRANCH")"
|
||||
remote_q="$(quote_remote "$DEPLOY_REMOTE")"
|
||||
services_q="$DEPLOY_SERVICES"
|
||||
health_q="$(quote_remote "$DEPLOY_HEALTHCHECK_URL")"
|
||||
|
||||
remote_cmd="
|
||||
set -Eeuo pipefail
|
||||
cd $remote_dir_q
|
||||
test -d .git || { echo '[deploy:remote] ERROR: REMOTE_DIR is not an existing git checkout'; exit 1; }
|
||||
test -f docker-compose.yml || { echo '[deploy:remote] ERROR: docker-compose.yml not found in REMOTE_DIR'; exit 1; }
|
||||
echo '[deploy:remote] fetching code'
|
||||
git fetch $remote_q $branch_q
|
||||
echo '[deploy:remote] checking out and pulling branch'
|
||||
git checkout $branch_q
|
||||
git pull --ff-only $remote_q $branch_q
|
||||
echo '[deploy:remote] docker compose config check'
|
||||
docker compose config >/dev/null
|
||||
if [[ '$DEPLOY_SKIP_BUILD' != '1' ]]; then
|
||||
echo '[deploy:remote] docker compose build'
|
||||
docker compose build $services_q
|
||||
else
|
||||
echo '[deploy:remote] skip docker build'
|
||||
fi
|
||||
if [[ '$DEPLOY_RUN_MIGRATIONS' == '1' ]]; then
|
||||
echo '[deploy:remote] running PostgreSQL migrations'
|
||||
docker compose run --rm alphax-web python scripts/postgres/run_migrations.py
|
||||
fi
|
||||
echo '[deploy:remote] docker compose up'
|
||||
docker compose up -d postgres $services_q
|
||||
echo '[deploy:remote] service status'
|
||||
docker compose ps
|
||||
if [[ -n $health_q ]]; then
|
||||
echo '[deploy:remote] healthcheck'
|
||||
curl -fsS $health_q >/dev/null
|
||||
fi
|
||||
"
|
||||
|
||||
info "deploying on $DEPLOY_HOST:$REMOTE_DIR"
|
||||
ssh "$DEPLOY_HOST" "$remote_cmd"
|
||||
info "deployment complete"
|
||||
Loading…
Reference in New Issue
Block a user