diff --git a/README_DOCKER.md b/README_DOCKER.md index 0fb4331..51818af 100644 --- a/README_DOCKER.md +++ b/README_DOCKER.md @@ -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 diff --git a/app/core/factor_scoring.py b/app/core/factor_scoring.py index baf5895..aaf63a0 100644 --- a/app/core/factor_scoring.py +++ b/app/core/factor_scoring.py @@ -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": ("突破质量低", "破位质量低"), } diff --git a/app/core/oi_funding.py b/app/core/oi_funding.py new file mode 100644 index 0000000..6017903 --- /dev/null +++ b/app/core/oi_funding.py @@ -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, + } diff --git a/app/core/relative_strength.py b/app/core/relative_strength.py new file mode 100644 index 0000000..aea5ded --- /dev/null +++ b/app/core/relative_strength.py @@ -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 diff --git a/app/core/signal_quality.py b/app/core/signal_quality.py new file mode 100644 index 0000000..9c3d7ec --- /dev/null +++ b/app/core/signal_quality.py @@ -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, "" diff --git a/app/core/signal_taxonomy.py b/app/core/signal_taxonomy.py index a3115e3..878d6e2 100644 --- a/app/core/signal_taxonomy.py +++ b/app/core/signal_taxonomy.py @@ -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", ("突破质量低", "破位质量低")), ] diff --git a/app/core/timeframe_alignment.py b/app/core/timeframe_alignment.py new file mode 100644 index 0000000..a8e2ad0 --- /dev/null +++ b/app/core/timeframe_alignment.py @@ -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 diff --git a/app/core/vcp_detector.py b/app/core/vcp_detector.py new file mode 100644 index 0000000..472b971 --- /dev/null +++ b/app/core/vcp_detector.py @@ -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), + } diff --git a/app/core/volume_profile.py b/app/core/volume_profile.py new file mode 100644 index 0000000..853655d --- /dev/null +++ b/app/core/volume_profile.py @@ -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, + } diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index ba45dda..9c6dc44 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -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 {}, } diff --git a/app/web/routes_content.py b/app/web/routes_content.py index 3f6ac69..1966c63 100644 --- a/app/web/routes_content.py +++ b/app/web/routes_content.py @@ -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 diff --git a/scripts/deploy_server.sh b/scripts/deploy_server.sh new file mode 100755 index 0000000..1046927 --- /dev/null +++ b/scripts/deploy_server.sh @@ -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"