This commit is contained in:
aaron 2026-06-01 00:16:01 +08:00
parent ec96f9c012
commit 4459af481b
12 changed files with 2678 additions and 2 deletions

View File

@ -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

View File

@ -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
View 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,
}

View 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
View 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, ""

View File

@ -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", ("突破质量低", "破位质量低")),
]

View 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
View 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
View 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,
}

View File

@ -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 {},
}

View File

@ -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
View 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"