From b979f8ff4242cee1440ce95260a79d7faf814cf9 Mon Sep 17 00:00:00 2001 From: ddrwode <34234@3来 34> Date: Fri, 6 Feb 2026 15:40:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=98=B4=E7=BA=BF=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E6=AD=A2=E7=9B=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bitmart/保守模式参数优化/README.md | 186 -- bitmart/保守模式参数优化/backtest_engine.py | 789 -------- bitmart/保守模式参数优化/current_params.json | 41 - bitmart/保守模式参数优化/optimize_params.py | 746 ------- .../四分之一,五分钟,反手条件充足_保守模式.py | 1746 ----------------- bitmart/四分之一_新反手策略.py | 604 ------ .../四分之一,五分钟,反手条件充足修改版.py | 752 +++++++ 7 files changed, 752 insertions(+), 4112 deletions(-) delete mode 100644 bitmart/保守模式参数优化/README.md delete mode 100644 bitmart/保守模式参数优化/backtest_engine.py delete mode 100644 bitmart/保守模式参数优化/current_params.json delete mode 100644 bitmart/保守模式参数优化/optimize_params.py delete mode 100644 bitmart/保守模式参数优化/四分之一,五分钟,反手条件充足_保守模式.py delete mode 100644 bitmart/四分之一_新反手策略.py create mode 100644 bitmart/四分之一,五分钟,反手条件充足修改版.py diff --git a/bitmart/保守模式参数优化/README.md b/bitmart/保守模式参数优化/README.md deleted file mode 100644 index 24f3ac0..0000000 --- a/bitmart/保守模式参数优化/README.md +++ /dev/null @@ -1,186 +0,0 @@ -# 保守模式参数优化 - -这个目录包含两部分: -- 实盘脚本:`四分之一,五分钟,反手条件充足_保守模式.py` -- 参数优化:`optimize_params.py` + `backtest_engine.py` - -`current_params.json` 是优化结果文件,实盘脚本启动时会自动读取并覆盖默认参数。 - -## 1. 使用 CSV 做 30 天参数优化(推荐) - -CSV 至少包含: -- `id`(秒级时间戳) -- `open` -- `high` -- `low` -- `close` - -运行示例: - -```bash -python3 "/Users/ddrwode/code/lm_code/bitmart/保守模式参数优化/optimize_params.py" \ - --data-file "/Users/ddrwode/code/lm_code/bitmart/数据/your_5m_30days.csv" \ - --days 30 \ - --train-days 20 \ - --valid-days 10 \ - --n-trials 300 -``` - -优化完成后会写入: -- `/Users/ddrwode/code/lm_code/bitmart/保守模式参数优化/current_params.json` - -如果你想做更稳健的参数(推荐实盘前使用): - -```bash -python3 "/Users/ddrwode/code/lm_code/bitmart/保守模式参数优化/optimize_params.py" \ - --data-file "/Users/ddrwode/code/lm_code/bitmart/数据/your_5m_90days.csv" \ - --days 90 \ - --train-days 45 \ - --valid-days 15 \ - --window-step-days 7 \ - --method optuna \ - --n-trials 1200 \ - --valid-score-weight 0.9 \ - --drawdown-guard 8 \ - --stability-weight 0.6 \ - --stress-slippage-multipliers "0.8,1.0,1.2,1.4" \ - --stress-fee-multipliers "1.0,1.15,1.3" -``` - -## 2. 不提供 CSV,直接从 API 自动抓取 30 天数据并优化 - -最简单一条命令(会自动抓取并计算): - -```bash -python3 "/Users/ddrwode/code/lm_code/bitmart/保守模式参数优化/optimize_params.py" \ - --days 30 \ - --step 5 \ - --n-trials 300 -``` - -脚本会按以下顺序找凭证: -- 命令行 `--api-key/--secret-key` -- 环境变量 `BITMART_API_KEY/BITMART_SECRET_KEY` -- 保守模式脚本里的 `self.api_key/self.secret_key` - -抓取到的K线会自动保存为: -- `/Users/ddrwode/code/lm_code/bitmart/保守模式参数优化/auto_ethusdt_5m_30d.csv` - -你也可以显式指定保存位置: - -```bash -BITMART_API_KEY="xxx" BITMART_SECRET_KEY="xxx" \ -python3 "/Users/ddrwode/code/lm_code/bitmart/保守模式参数优化/optimize_params.py" \ - --days 30 \ - --step 5 \ - --n-trials 300 \ - --save-data-file "/Users/ddrwode/code/lm_code/bitmart/数据/eth_5m_30days.csv" -``` - -## 3. 运行保守模式实盘脚本 - -```bash -python3 "/Users/ddrwode/code/lm_code/bitmart/保守模式参数优化/四分之一,五分钟,反手条件充足_保守模式.py" -``` - -如果你想加载其他参数文件: - -```bash -BITMART_PARAMS_PATH="/absolute/path/to/current_params.json" \ -python3 "/Users/ddrwode/code/lm_code/bitmart/保守模式参数优化/四分之一,五分钟,反手条件充足_保守模式.py" -``` - -注意: -- 实盘下单数量现在会直接使用 `default_order_size`(不再固定写死 25),与回测/优化口径一致。 -- `current_params.json` 新增支持趋势过滤和 AI 门控参数(见第 7 节)。 - -### 启动时自动优化与加速(不减少计算数据) - -数据量、试验数、窗口数、压力场景均与配置一致,加速仅通过**代码并行**实现: - -- **并行试验**(Optuna):`BITMART_AUTO_OPT_N_JOBS=4` 或 优化脚本 `--n-jobs 4`,多组试验同时跑。 -- **并行压力场景**:每个窗口内多组 slip×fee 同时回测。环境变量 `BITMART_AUTO_OPT_STRESS_WORKERS=4` 或 优化脚本 `--n-stress-workers 4`。 -- **跳过近期已优化**:`BITMART_AUTO_OPT_SKIP_IF_FRESH_HOURS=24` 表示若 `current_params.json` 24 小时内已更新则跳过本次优化,直接加载。 - -单独运行优化脚本时: -- `--n-jobs 4`:Optuna 并行 4 个试验。 -- `--n-stress-workers 4`:每窗口 4 个进程并行跑压力场景(不减少 slip/fee 组数)。 - -## 4. 实时价格(WebSocket) - -保守模式脚本已支持: -- WebSocket 实时价优先 -- 自动重连 -- 超时自动回退 API 价格 - -默认 WebSocket 订阅: -- `wss://openapi-ws-v2.bitmart.com/api?protocol=1.1` -- topic: `futures/bookticker:ETHUSDT`(实时推送,默认更快) - -如果你本机没有 WebSocket 依赖,会自动回退 API。安装方式: - -```bash -pip3 install websocket-client -``` - -可选环境变量: -- `BITMART_WS_ENABLED=0`:禁用 WebSocket -- `BITMART_WS_URL=...`:自定义 WS 地址 -- `BITMART_WS_TOPIC=...`:自定义订阅 topic -- `BITMART_WS_PRICE_TTL=2.0`:价格新鲜度阈值(秒) - -## 5. 动态止盈止损 - -保守模式已支持按近期波动率自动调整: -- `take_profit_usd` -- `stop_loss_usd` -- `trailing_activation_usd` -- `trailing_distance_usd` -- `break_even_activation_usd` -- `break_even_floor_usd` - -触发方式: -- 每进入一根新的 5 分钟 K 线,自动计算近 N 根K线平均振幅(trimmed mean) -- 用当前波动率和目标波动率比值计算缩放系数,再更新上述参数 - -可选环境变量: -- `BITMART_DYNAMIC_RISK_ENABLED=1`:开启/关闭动态风控 -- `BITMART_DYNAMIC_RISK_WINDOW_BARS=36`:波动率窗口(5m K线根数) -- `BITMART_DYNAMIC_VOL_TARGET_PCT=0.25`:目标波动率(%) -- `BITMART_DYNAMIC_SCALE_MIN=0.7`:最小缩放倍数 -- `BITMART_DYNAMIC_SCALE_MAX=1.8`:最大缩放倍数 - -这些参数也可写到 `current_params.json` 的 `params` 中。 - -## 6. 费用模型说明 - -优化器会按下面公式计入手续费返佣: - -- `effective_fee_rate = raw_fee_rate * (1 - rebate_ratio)` - -默认: -- `raw_fee_rate = 0.0006` -- `rebate_ratio = 0.90` -- `effective_fee_rate = 0.00006` - -可通过命令行改: -- `--raw-fee-rate` -- `--rebate-ratio` -- `--stress-slippage-multipliers` -- `--stress-fee-multipliers` - -## 7. 趋势过滤 + AI 门控(新) - -实盘与回测都支持以下参数(可由优化器自动搜索): -- 趋势过滤:`enable_trend_filter`、`trend_ema_fast`、`trend_ema_slow`、`trend_strength_threshold_pct`、`trend_slope_lookback`、`trend_slope_threshold_pct` -- AI 门控:`enable_ai_filter`、`ai_min_confidence`、`ai_bias`、`ai_w_trend_align`、`ai_w_breakout_strength`、`ai_w_entity_strength`、`ai_w_shadow_balance`、`ai_w_volatility_fit` - -说明: -- 趋势过滤只影响新开仓,不阻断风控平仓和反手。 -- AI 门控是轻量打分器(logistic 风格),用于 `trade/skip`,不是黑盒大模型。 - -## 8. 重要提示 - -- 回测撮合属于简化模型,不等于实盘撮合。 -- 参数应周期性重训(例如每天或每周)。 -- 若出现交易次数过低,适当降低 `open_breakout_buffer_pct` 或冷却时间。 diff --git a/bitmart/保守模式参数优化/backtest_engine.py b/bitmart/保守模式参数优化/backtest_engine.py deleted file mode 100644 index 65ec8c9..0000000 --- a/bitmart/保守模式参数优化/backtest_engine.py +++ /dev/null @@ -1,789 +0,0 @@ -from __future__ import annotations - -import csv -import math -import time -from dataclasses import asdict, dataclass -from datetime import datetime, timezone -from pathlib import Path -from statistics import mean -from typing import Any, Dict, List, Optional, Sequence, Tuple - - -DEFAULT_PARAMS: Dict[str, Any] = { - "leverage": "20", - "open_type": "cross", - "default_order_size": 10, - "take_profit_usd": 3.0, - "stop_loss_usd": -2.0, - "trailing_activation_usd": 1.2, - "trailing_distance_usd": 0.6, - "break_even_activation_usd": 1.0, - "break_even_floor_usd": 0.2, - "open_breakout_buffer_pct": 0.03, - "enable_shadow_reverse": False, - "shadow_reverse_threshold_pct": 0.15, - "enable_trend_filter": True, - "trend_ema_fast": 21, - "trend_ema_slow": 55, - "trend_strength_threshold_pct": 0.02, - "trend_slope_lookback": 3, - "trend_slope_threshold_pct": 0.005, - "enable_ai_filter": True, - "ai_min_confidence": 0.58, - "ai_bias": -0.1, - "ai_w_trend_align": 1.1, - "ai_w_breakout_strength": 0.8, - "ai_w_entity_strength": 0.55, - "ai_w_shadow_balance": 0.35, - "ai_w_volatility_fit": 0.45, - "dynamic_vol_target_pct": 0.25, - "open_cooldown_seconds": 300, - "reverse_cooldown_seconds": 300, - "reverse_min_move_pct": 0.15, - # Backtest-only calibration parameters (not used by live runtime script) - "order_notional_usd": 100.0, - "pnl_per_usd_move": 1.0, - "slippage_pct": 0.01, -} - - -def _as_float(value: Any, default: float = 0.0) -> float: - try: - return float(value) - except Exception: - return default - - -def _as_bool(value: Any) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, (int, float)): - return bool(value) - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y", "on"} - return False - - -def _parse_timestamp(value: Any) -> int: - text = str(value).strip() - if not text: - raise ValueError("empty timestamp") - - if text.isdigit() or (text.startswith("-") and text[1:].isdigit()): - ts = int(text) - if ts > 10**12: - ts //= 1000 - return ts - - try: - ts = int(float(text)) - if ts > 10**12: - ts //= 1000 - return ts - except ValueError: - pass - - dt_text = text.replace("Z", "+00:00") - dt = datetime.fromisoformat(dt_text) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return int(dt.timestamp()) - - -def load_klines_from_csv(csv_path: str | Path, last_n_days: Optional[int] = 30) -> List[Dict[str, float]]: - """ - Load kline data from CSV. Supports these field names (case-insensitive): - - timestamp: id / timestamp / time / ts / datetime / date - - open: open / open_price - - high: high / high_price - - low: low / low_price - - close: close / close_price - - volume (optional): volume / vol - """ - path = Path(csv_path) - if not path.exists(): - raise FileNotFoundError(f"CSV not found: {path}") - - records: Dict[int, Dict[str, float]] = {} - with path.open("r", encoding="utf-8") as f: - reader = csv.DictReader(f) - if not reader.fieldnames: - raise ValueError(f"CSV has no header: {path}") - - field_map = {name.strip().lower(): name for name in reader.fieldnames if name} - - def find_key(candidates: Sequence[str]) -> Optional[str]: - for candidate in candidates: - if candidate in field_map: - return field_map[candidate] - return None - - ts_key = find_key(("id", "timestamp", "time", "ts", "datetime", "date")) - open_key = find_key(("open", "open_price")) - high_key = find_key(("high", "high_price")) - low_key = find_key(("low", "low_price")) - close_key = find_key(("close", "close_price")) - volume_key = find_key(("volume", "vol")) - - if not ts_key or not open_key or not high_key or not low_key or not close_key: - raise ValueError( - "CSV is missing required columns. Need timestamp/open/high/low/close (or aliases)." - ) - - for row in reader: - try: - ts = _parse_timestamp(row.get(ts_key, "")) - kline = { - "id": ts, - "open": _as_float(row.get(open_key), 0.0), - "high": _as_float(row.get(high_key), 0.0), - "low": _as_float(row.get(low_key), 0.0), - "close": _as_float(row.get(close_key), 0.0), - } - if volume_key and row.get(volume_key) not in (None, ""): - kline["volume"] = _as_float(row.get(volume_key), 0.0) - records[ts] = kline - except Exception: - continue - - klines = sorted(records.values(), key=lambda x: x["id"]) - if last_n_days is not None and klines: - cutoff = klines[-1]["id"] - int(last_n_days * 24 * 3600) - klines = [k for k in klines if k["id"] >= cutoff] - return klines - - -def save_klines_to_csv(klines: Sequence[Dict[str, float]], output_path: str | Path) -> Path: - path = Path(output_path) - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8", newline="") as f: - writer = csv.writer(f) - writer.writerow(["id", "open", "high", "low", "close", "volume"]) - for k in klines: - writer.writerow([ - int(k["id"]), - k["open"], - k["high"], - k["low"], - k["close"], - k.get("volume", ""), - ]) - return path - - -def fetch_klines_from_api( - contract_api: Any, - contract_symbol: str = "ETHUSDT", - step: int = 5, - days: int = 30, - batch_hours: int = 12, - sleep_seconds: float = 0.05, -) -> List[Dict[str, float]]: - """Fetch recent kline data from Bitmart APIContract-compatible client.""" - end_ts = int(time.time()) - start_ts = end_ts - days * 24 * 3600 - cursor = start_ts - all_rows: Dict[int, Dict[str, float]] = {} - - while cursor < end_ts: - batch_end = min(cursor + batch_hours * 3600, end_ts) - response = contract_api.get_kline( - contract_symbol=contract_symbol, - step=step, - start_time=cursor, - end_time=batch_end, - )[0] - if response.get("code") != 1000: - cursor = batch_end + 1 - continue - - for item in response.get("data", []): - try: - ts = int(item["timestamp"]) - all_rows[ts] = { - "id": ts, - "open": float(item["open_price"]), - "high": float(item["high_price"]), - "low": float(item["low_price"]), - "close": float(item["close_price"]), - "volume": float(item.get("volume", 0) or 0), - } - except Exception: - continue - - cursor = batch_end + 1 - if sleep_seconds > 0: - time.sleep(sleep_seconds) - - return sorted(all_rows.values(), key=lambda x: x["id"]) - - -def split_rolling_windows( - klines: Sequence[Dict[str, float]], - train_days: int = 20, - valid_days: int = 10, - step_days: int = 5, -) -> List[Tuple[List[Dict[str, float]], List[Dict[str, float]]]]: - """Build rolling (train, valid) windows by timestamp ranges.""" - if not klines: - return [] - - train_secs = train_days * 24 * 3600 - valid_secs = valid_days * 24 * 3600 - step_secs = max(1, step_days) * 24 * 3600 - - first_ts = int(klines[0]["id"]) - last_ts = int(klines[-1]["id"]) - required_span = train_secs + valid_secs - - windows: List[Tuple[List[Dict[str, float]], List[Dict[str, float]]]] = [] - - def slice_range(start_ts: int, end_ts: int) -> List[Dict[str, float]]: - return [k for k in klines if start_ts <= int(k["id"]) < end_ts] - - cursor = first_ts - while cursor + required_span <= last_ts: - train_start = cursor - train_end = train_start + train_secs - valid_end = train_end + valid_secs - - train_part = slice_range(train_start, train_end) - valid_part = slice_range(train_end, valid_end) - if len(train_part) >= 50 and len(valid_part) >= 30: - windows.append((train_part, valid_part)) - - cursor += step_secs - - if not windows and (last_ts - first_ts) >= required_span: - valid_end = last_ts - valid_start = valid_end - valid_secs - train_start = valid_start - train_secs - train_part = slice_range(train_start, valid_start) - valid_part = slice_range(valid_start, valid_end) - if len(train_part) >= 50 and len(valid_part) >= 30: - windows.append((train_part, valid_part)) - - return windows - - -@dataclass -class BacktestResult: - net_pnl: float - gross_pnl: float - total_fees: float - trades: int - win_rate: float - max_drawdown: float - loss_days: int - start_ts: int - end_ts: int - - def to_dict(self) -> Dict[str, Any]: - return asdict(self) - - -class ConservativeBacktester: - """Backtester for the conservative quarter-breakout + reverse strategy.""" - - def __init__( - self, - params: Optional[Dict[str, Any]] = None, - raw_fee_rate: float = 0.0006, - rebate_ratio: float = 0.90, - ) -> None: - self.params: Dict[str, Any] = dict(DEFAULT_PARAMS) - if params: - self.params.update(params) - - self.raw_fee_rate = float(raw_fee_rate) - self.rebate_ratio = float(rebate_ratio) - self.effective_fee_rate = self.raw_fee_rate * (1.0 - self.rebate_ratio) - - size_scale = max(_as_float(self.params.get("default_order_size", 10), 10.0) / 10.0, 0.05) - self.order_notional_usd = _as_float(self.params.get("order_notional_usd", 100.0), 100.0) * size_scale - self.pnl_per_usd_move = _as_float(self.params.get("pnl_per_usd_move", 1.0), 1.0) * size_scale - self.slippage_pct = _as_float(self.params.get("slippage_pct", 0.01), 0.01) - - @staticmethod - def _entity_size(kline: Dict[str, float]) -> float: - return abs(kline["close"] - kline["open"]) - - @staticmethod - def _entity_edges(kline: Dict[str, float]) -> Tuple[float, float]: - upper = max(kline["open"], kline["close"]) - lower = min(kline["open"], kline["close"]) - return upper, lower - - @staticmethod - def _upper_shadow_pct(kline: Dict[str, float]) -> float: - body_top = max(kline["open"], kline["close"]) - if body_top <= 0: - return 0.0 - return (kline["high"] - body_top) / body_top * 100.0 - - @staticmethod - def _lower_shadow_pct(kline: Dict[str, float]) -> float: - body_bottom = min(kline["open"], kline["close"]) - if body_bottom <= 0: - return 0.0 - return (body_bottom - kline["low"]) / body_bottom * 100.0 - - def _calc_fee(self) -> float: - return self.order_notional_usd * self.effective_fee_rate - - def _apply_slippage(self, price: float, is_buy: bool) -> float: - if self.slippage_pct <= 0: - return price - shift = self.slippage_pct / 100.0 - return price * (1.0 + shift) if is_buy else price * (1.0 - shift) - - @staticmethod - def _clip(value: float, low: float, high: float) -> float: - return max(low, min(high, value)) - - def _signal_confidence( - self, - side: str, - current_price: float, - entry_price: float, - prev_kline: Dict[str, float], - levels: Dict[str, float], - trend_bias: str, - trend_strength_pct: float, - target_volatility_pct: float, - ) -> float: - prev_entity = self._entity_size(prev_kline) - prev_close = max(float(prev_kline.get("close", 0.0)), 0.000001) - entity_pct = prev_entity / prev_close * 100.0 - - upper_shadow_pct = levels["upper_shadow_pct"] - lower_shadow_pct = levels["lower_shadow_pct"] - - if entry_price <= 0: - breakout_move_pct = 0.0 - elif side == "long": - breakout_move_pct = max(0.0, (current_price - entry_price) / entry_price * 100.0) - else: - breakout_move_pct = max(0.0, (entry_price - current_price) / entry_price * 100.0) - - if side == "long": - trend_align = 1.0 if trend_bias == "bull" else (-1.0 if trend_bias == "bear" else 0.0) - shadow_balance = lower_shadow_pct - upper_shadow_pct - else: - trend_align = 1.0 if trend_bias == "bear" else (-1.0 if trend_bias == "bull" else 0.0) - shadow_balance = upper_shadow_pct - lower_shadow_pct - - trend_strength_threshold = max(_as_float(self.params.get("trend_strength_threshold_pct", 0.02), 0.02), 0.01) - trend_strength_scale = self._clip(abs(trend_strength_pct) / trend_strength_threshold, 0.0, 2.5) - trend_component = trend_align * trend_strength_scale - breakout_strength = self._clip(breakout_move_pct / 0.25, 0.0, 2.5) - entity_strength = self._clip(entity_pct / 0.50, 0.0, 2.5) - shadow_component = self._clip(shadow_balance / 0.30, -2.5, 2.5) - - current_vol = 0.0 - current_close = float(current_price) - if current_close > 0: - current_vol = abs(float(prev_kline["high"]) - float(prev_kline["low"])) / current_close * 100.0 - - target_vol = max(float(target_volatility_pct), 0.05) - vol_fit = 1.0 - abs(current_vol - target_vol) / (target_vol * 2.0) - vol_fit = self._clip(vol_fit, -1.5, 1.5) - - score = ( - _as_float(self.params.get("ai_bias", -0.1), -0.1) - + _as_float(self.params.get("ai_w_trend_align", 1.1), 1.1) * trend_component - + _as_float(self.params.get("ai_w_breakout_strength", 0.8), 0.8) * breakout_strength - + _as_float(self.params.get("ai_w_entity_strength", 0.55), 0.55) * entity_strength - + _as_float(self.params.get("ai_w_shadow_balance", 0.35), 0.35) * shadow_component - + _as_float(self.params.get("ai_w_volatility_fit", 0.45), 0.45) * vol_fit - ) - score = self._clip(score, -60.0, 60.0) - return 1.0 / (1.0 + math.exp(-score)) - - def _calc_levels(self, prev_kline: Dict[str, float], current_kline: Dict[str, float]) -> Optional[Dict[str, float]]: - prev_entity = self._entity_size(prev_kline) - if prev_entity < 0.1: - return None - - prev_upper, prev_lower = self._entity_edges(prev_kline) - - prev_bull_for_calc = prev_kline["close"] > prev_kline["open"] - prev_bear_for_calc = prev_kline["close"] < prev_kline["open"] - current_open_above_prev_close = current_kline["open"] > prev_kline["close"] - current_open_below_prev_close = current_kline["open"] < prev_kline["close"] - use_current_open_as_base = ( - (prev_bull_for_calc and current_open_above_prev_close) - or (prev_bear_for_calc and current_open_below_prev_close) - ) - - if use_current_open_as_base: - base = current_kline["open"] - long_trigger = base + prev_entity / 4.0 - short_trigger = base - prev_entity / 4.0 - long_breakout = base + prev_entity / 4.0 - short_breakout = base - prev_entity / 4.0 - else: - long_trigger = prev_lower + prev_entity / 4.0 - short_trigger = prev_upper - prev_entity / 4.0 - long_breakout = prev_upper + prev_entity / 4.0 - short_breakout = prev_lower - prev_entity / 4.0 - - open_buffer_pct = _as_float(self.params.get("open_breakout_buffer_pct", 0.03), 0.03) - long_entry_price = long_breakout * (1.0 + open_buffer_pct / 100.0) - short_entry_price = short_breakout * (1.0 - open_buffer_pct / 100.0) - - prev_bearish = prev_kline["close"] < prev_kline["open"] - current_bullish = current_kline["close"] > current_kline["open"] - skip_short_by_upper_third = prev_bearish and current_bullish - - prev_bullish = prev_kline["close"] > prev_kline["open"] - current_bearish = current_kline["close"] < current_kline["open"] - skip_long_by_lower_third = prev_bullish and current_bearish - - return { - "prev_entity_upper": prev_upper, - "prev_entity_lower": prev_lower, - "long_trigger": long_trigger, - "short_trigger": short_trigger, - "long_entry_price": long_entry_price, - "short_entry_price": short_entry_price, - "skip_short_by_upper_third": float(skip_short_by_upper_third), - "skip_long_by_lower_third": float(skip_long_by_lower_third), - "upper_shadow_pct": self._upper_shadow_pct(prev_kline), - "lower_shadow_pct": self._lower_shadow_pct(prev_kline), - } - - def run(self, klines: Sequence[Dict[str, float]]) -> BacktestResult: - if len(klines) < 3: - start_ts = int(klines[0]["id"]) if klines else 0 - end_ts = int(klines[-1]["id"]) if klines else 0 - return BacktestResult( - net_pnl=0.0, - gross_pnl=0.0, - total_fees=0.0, - trades=0, - win_rate=0.0, - max_drawdown=0.0, - loss_days=0, - start_ts=start_ts, - end_ts=end_ts, - ) - - open_cooldown = int(_as_float(self.params.get("open_cooldown_seconds", 300), 300)) - reverse_cooldown = int(_as_float(self.params.get("reverse_cooldown_seconds", 300), 300)) - reverse_min_move_pct = _as_float(self.params.get("reverse_min_move_pct", 0.15), 0.15) - - take_profit = _as_float(self.params.get("take_profit_usd", 3.0), 3.0) - stop_loss = _as_float(self.params.get("stop_loss_usd", -2.0), -2.0) - trailing_activation = _as_float(self.params.get("trailing_activation_usd", 1.2), 1.2) - trailing_distance = _as_float(self.params.get("trailing_distance_usd", 0.6), 0.6) - be_activation = _as_float(self.params.get("break_even_activation_usd", 1.0), 1.0) - be_floor = _as_float(self.params.get("break_even_floor_usd", 0.2), 0.2) - - enable_shadow_reverse = _as_bool(self.params.get("enable_shadow_reverse", False)) - shadow_reverse_threshold = _as_float(self.params.get("shadow_reverse_threshold_pct", 0.15), 0.15) - enable_trend_filter = _as_bool(self.params.get("enable_trend_filter", True)) - trend_ema_fast = int(max(_as_float(self.params.get("trend_ema_fast", 21), 21), 2)) - trend_ema_slow = int(max(_as_float(self.params.get("trend_ema_slow", 55), 55), trend_ema_fast + 1)) - trend_strength_threshold = _as_float(self.params.get("trend_strength_threshold_pct", 0.02), 0.02) - trend_slope_lookback = int(max(_as_float(self.params.get("trend_slope_lookback", 3), 3), 1)) - trend_slope_threshold = _as_float(self.params.get("trend_slope_threshold_pct", 0.005), 0.005) - enable_ai_filter = _as_bool(self.params.get("enable_ai_filter", True)) - ai_min_confidence = _as_float(self.params.get("ai_min_confidence", 0.58), 0.58) - target_volatility_pct = _as_float(self.params.get("dynamic_vol_target_pct", 0.25), 0.25) - - position = 0 # 1 long, -1 short, 0 flat - entry_price = 0.0 - entry_ts = 0 - current_trade_open_fee = 0.0 - - last_open_ts: Optional[int] = None - last_open_kline_id: Optional[int] = None - last_reverse_ts: Optional[int] = None - - max_unrealized_pnl_seen: Optional[float] = None - - gross_pnl = 0.0 - total_fees = 0.0 - equity = 0.0 - - peak_equity = 0.0 - max_drawdown = 0.0 - - trades = 0 - wins = 0 - daily_pnl: Dict[str, float] = {} - ema_fast = float(klines[0]["close"]) - ema_slow = float(klines[0]["close"]) - alpha_fast = 2.0 / (trend_ema_fast + 1.0) - alpha_slow = 2.0 / (trend_ema_slow + 1.0) - slow_history: List[float] = [ema_slow] - - def mark_to_market(mark_price: float) -> float: - if position == 0: - return 0.0 - return (mark_price - entry_price) * position * self.pnl_per_usd_move - - def update_drawdown(mark_price: float) -> None: - nonlocal peak_equity, max_drawdown - mtm = mark_to_market(mark_price) - equity_mtm = equity + mtm - if equity_mtm > peak_equity: - peak_equity = equity_mtm - drawdown = peak_equity - equity_mtm - if drawdown > max_drawdown: - max_drawdown = drawdown - - def can_open(current_kline_id: int) -> bool: - if last_open_kline_id is not None and current_kline_id == last_open_kline_id: - return False - if last_open_ts is not None and current_kline_id - last_open_ts < open_cooldown: - return False - return True - - def can_reverse(current_price: float, trigger_price: float, now_ts: int) -> bool: - if last_reverse_ts is not None and now_ts - last_reverse_ts < reverse_cooldown: - return False - if trigger_price > 0: - move_pct = abs(current_price - trigger_price) / trigger_price * 100.0 - if move_pct < reverse_min_move_pct: - return False - return True - - def open_position(direction: int, raw_price: float, now_ts: int) -> None: - nonlocal position, entry_price, entry_ts - nonlocal last_open_ts, last_open_kline_id - nonlocal total_fees, equity, current_trade_open_fee - nonlocal max_unrealized_pnl_seen - - is_buy = direction == 1 - fill_price = self._apply_slippage(raw_price, is_buy=is_buy) - fee = self._calc_fee() - - position = direction - entry_price = fill_price - entry_ts = now_ts - last_open_ts = now_ts - last_open_kline_id = now_ts - max_unrealized_pnl_seen = 0.0 - - total_fees += fee - equity -= fee - current_trade_open_fee = fee - - def close_position(raw_price: float, now_ts: int) -> float: - nonlocal position, entry_price, entry_ts - nonlocal total_fees, equity, gross_pnl, trades, wins - nonlocal current_trade_open_fee, max_unrealized_pnl_seen - - if position == 0: - return 0.0 - - is_buy = position == -1 - fill_price = self._apply_slippage(raw_price, is_buy=is_buy) - - realized = (fill_price - entry_price) * position * self.pnl_per_usd_move - close_fee = self._calc_fee() - trade_net = realized - close_fee - current_trade_open_fee - - gross_pnl += realized - total_fees += close_fee - equity += realized - close_fee - - day_key = datetime.utcfromtimestamp(now_ts).date().isoformat() - daily_pnl[day_key] = daily_pnl.get(day_key, 0.0) + trade_net - - trades += 1 - if trade_net > 0: - wins += 1 - - position = 0 - entry_price = 0.0 - entry_ts = 0 - current_trade_open_fee = 0.0 - max_unrealized_pnl_seen = None - return trade_net - - for idx in range(1, len(klines)): - prev_kline = klines[idx - 1] - current_kline = klines[idx] - current_ts = int(current_kline["id"]) - close_price = float(current_kline["close"]) - ema_fast = ema_fast + alpha_fast * (close_price - ema_fast) - ema_slow = ema_slow + alpha_slow * (close_price - ema_slow) - slow_history.append(ema_slow) - max_slow_history = trend_slope_lookback + 5 - if len(slow_history) > max_slow_history: - slow_history = slow_history[-max_slow_history:] - - trend_strength_pct = 0.0 - trend_slope_pct = 0.0 - trend_bias = "neutral" - if ema_slow > 0: - trend_strength_pct = (ema_fast - ema_slow) / ema_slow * 100.0 - if len(slow_history) > trend_slope_lookback: - ref_slow = slow_history[-1 - trend_slope_lookback] - if ref_slow > 0: - trend_slope_pct = (ema_slow - ref_slow) / ref_slow * 100.0 - if trend_strength_pct >= trend_strength_threshold and trend_slope_pct >= trend_slope_threshold: - trend_bias = "bull" - elif trend_strength_pct <= -trend_strength_threshold and trend_slope_pct <= -trend_slope_threshold: - trend_bias = "bear" - - levels = self._calc_levels(prev_kline, current_kline) - if not levels: - update_drawdown(current_kline["close"]) - continue - - skip_short = bool(levels["skip_short_by_upper_third"]) - skip_long = bool(levels["skip_long_by_lower_third"]) - - # Risk controls come first, matching live behavior. - if position != 0: - pnl_close = mark_to_market(current_kline["close"]) - if max_unrealized_pnl_seen is None: - max_unrealized_pnl_seen = pnl_close - else: - max_unrealized_pnl_seen = max(max_unrealized_pnl_seen, pnl_close) - - should_close = False - if pnl_close <= stop_loss: - should_close = True - elif max_unrealized_pnl_seen >= be_activation and pnl_close <= be_floor: - should_close = True - elif max_unrealized_pnl_seen >= trailing_activation and pnl_close < max_unrealized_pnl_seen - trailing_distance: - should_close = True - elif pnl_close >= take_profit: - should_close = True - - if should_close: - close_position(current_kline["close"], current_ts) - update_drawdown(current_kline["close"]) - continue - - signal_type: Optional[str] = None - signal_price = 0.0 - - if position == 0: - long_hit = current_kline["high"] >= levels["long_entry_price"] and not skip_long - short_hit = current_kline["low"] <= levels["short_entry_price"] and not skip_short - - # Conservative tie-break: if both sides touched in one bar, skip. - if long_hit and not short_hit: - signal_type = "long" - signal_price = levels["long_entry_price"] - elif short_hit and not long_hit: - signal_type = "short" - signal_price = levels["short_entry_price"] - - if signal_type and can_open(current_ts): - allow_signal = True - if enable_trend_filter: - if signal_type == "long" and trend_bias == "bear": - allow_signal = False - elif signal_type == "short" and trend_bias == "bull": - allow_signal = False - - if allow_signal and enable_ai_filter: - confidence = self._signal_confidence( - side=signal_type, - current_price=close_price, - entry_price=signal_price, - prev_kline=prev_kline, - levels=levels, - trend_bias=trend_bias, - trend_strength_pct=trend_strength_pct, - target_volatility_pct=target_volatility_pct, - ) - if confidence < ai_min_confidence: - allow_signal = False - - if allow_signal: - open_position(1 if signal_type == "long" else -1, signal_price, current_ts) - - elif position == 1: - reverse_hit = current_kline["low"] <= levels["short_trigger"] and not skip_short - shadow_hit = ( - enable_shadow_reverse - and levels["upper_shadow_pct"] > shadow_reverse_threshold - and current_kline["low"] <= levels["prev_entity_lower"] - ) - if reverse_hit or shadow_hit: - trigger = levels["short_trigger"] if reverse_hit else levels["prev_entity_lower"] - if can_reverse(current_kline["close"], trigger, current_ts): - close_position(trigger, current_ts) - open_position(-1, trigger, current_ts) - last_reverse_ts = current_ts - - elif position == -1: - reverse_hit = current_kline["high"] >= levels["long_trigger"] and not skip_long - shadow_hit = ( - enable_shadow_reverse - and levels["lower_shadow_pct"] > shadow_reverse_threshold - and current_kline["high"] >= levels["prev_entity_upper"] - ) - if reverse_hit or shadow_hit: - trigger = levels["long_trigger"] if reverse_hit else levels["prev_entity_upper"] - if can_reverse(current_kline["close"], trigger, current_ts): - close_position(trigger, current_ts) - open_position(1, trigger, current_ts) - last_reverse_ts = current_ts - - update_drawdown(current_kline["close"]) - - if position != 0: - last_bar = klines[-1] - close_position(last_bar["close"], int(last_bar["id"])) - update_drawdown(last_bar["close"]) - - win_rate = (wins / trades * 100.0) if trades > 0 else 0.0 - loss_days = sum(1 for pnl in daily_pnl.values() if pnl < 0) - - return BacktestResult( - net_pnl=equity, - gross_pnl=gross_pnl, - total_fees=total_fees, - trades=trades, - win_rate=win_rate, - max_drawdown=max_drawdown, - loss_days=loss_days, - start_ts=int(klines[0]["id"]), - end_ts=int(klines[-1]["id"]), - ) - - -def score_result( - result: BacktestResult, - drawdown_weight: float = 1.4, - loss_day_weight: float = 0.8, - min_trades: int = 6, - undertrade_penalty: float = 0.5, -) -> float: - score = result.net_pnl - drawdown_weight * result.max_drawdown - loss_day_weight * result.loss_days - if result.trades < min_trades: - score -= (min_trades - result.trades) * undertrade_penalty - return score - - -def aggregate_results(results: Sequence[BacktestResult]) -> Dict[str, float]: - if not results: - return { - "net_pnl": 0.0, - "gross_pnl": 0.0, - "total_fees": 0.0, - "trades": 0.0, - "win_rate": 0.0, - "max_drawdown": 0.0, - "loss_days": 0.0, - } - - return { - "net_pnl": mean([r.net_pnl for r in results]), - "gross_pnl": mean([r.gross_pnl for r in results]), - "total_fees": mean([r.total_fees for r in results]), - "trades": mean([r.trades for r in results]), - "win_rate": mean([r.win_rate for r in results]), - "max_drawdown": mean([r.max_drawdown for r in results]), - "loss_days": mean([r.loss_days for r in results]), - } diff --git a/bitmart/保守模式参数优化/current_params.json b/bitmart/保守模式参数优化/current_params.json deleted file mode 100644 index 732e8f2..0000000 --- a/bitmart/保守模式参数优化/current_params.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "updated_at": "2026-02-06T00:00:00+00:00", - "source": { - "type": "manual_default" - }, - "optimization": { - "method": "manual_default", - "n_trials": 0, - "windows": 0 - }, - "fee_model": { - "raw_fee_rate": 0.0006, - "rebate_ratio": 0.9, - "effective_fee_rate": 0.00006 - }, - "score": 0, - "params": { - "leverage": "20", - "take_profit_usd": 3.0, - "stop_loss_usd": -2.0, - "trailing_activation_usd": 1.2, - "trailing_distance_usd": 0.6, - "break_even_activation_usd": 1.0, - "break_even_floor_usd": 0.2, - "default_order_size": 10, - "open_breakout_buffer_pct": 0.03, - "enable_shadow_reverse": false, - "shadow_reverse_threshold_pct": 0.15, - "open_cooldown_seconds": 300, - "reverse_cooldown_seconds": 300, - "reverse_min_move_pct": 0.15, - "dynamic_risk_enabled": true, - "dynamic_risk_window_bars": 36, - "dynamic_vol_target_pct": 0.25, - "dynamic_scale_min": 0.7, - "dynamic_scale_max": 1.8, - "order_notional_usd": 100.0, - "pnl_per_usd_move": 1.0, - "slippage_pct": 0.01 - } -} diff --git a/bitmart/保守模式参数优化/optimize_params.py b/bitmart/保守模式参数优化/optimize_params.py deleted file mode 100644 index 962c5ca..0000000 --- a/bitmart/保守模式参数优化/optimize_params.py +++ /dev/null @@ -1,746 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import os -import random -import re -import sys -from concurrent.futures import ProcessPoolExecutor -from datetime import datetime, timezone -from pathlib import Path -from statistics import mean, pstdev -from typing import Any, Dict, List, Optional, Tuple - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from backtest_engine import ( - DEFAULT_PARAMS, - ConservativeBacktester, - aggregate_results, - fetch_klines_from_api, - load_klines_from_csv, - save_klines_to_csv, - score_result, - split_rolling_windows, -) - - -STRATEGY_FILENAME = "四分之一,五分钟,反手条件充足_保守模式.py" - - -def _as_float(value: Any, default: float) -> float: - try: - return float(value) - except Exception: - return default - - -def _as_bool(value: Any, default: bool = False) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, (int, float)): - return bool(value) - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y", "on"} - return default - - -def _clamp(value: float, low: float, high: float) -> float: - return max(low, min(high, value)) - - -def _parse_float_list(text: str, default: List[float]) -> List[float]: - if not text: - return list(default) - values: List[float] = [] - for part in text.split(","): - raw = part.strip() - if not raw: - continue - try: - values.append(float(raw)) - except Exception: - continue - return values or list(default) - - -def normalize_params(params: Dict[str, Any]) -> Dict[str, Any]: - p = dict(params) - - p["take_profit_usd"] = round(max(_as_float(p.get("take_profit_usd", 3.0), 3.0), 0.5), 4) - p["stop_loss_usd"] = round(min(_as_float(p.get("stop_loss_usd", -2.0), -2.0), -0.1), 4) - - p["trailing_activation_usd"] = round(max(_as_float(p.get("trailing_activation_usd", 1.2), 1.2), 0.2), 4) - p["trailing_distance_usd"] = round(max(_as_float(p.get("trailing_distance_usd", 0.6), 0.6), 0.1), 4) - - p["break_even_activation_usd"] = round(max(_as_float(p.get("break_even_activation_usd", 1.0), 1.0), 0.2), 4) - p["break_even_floor_usd"] = round(max(_as_float(p.get("break_even_floor_usd", 0.2), 0.2), 0.0), 4) - - p["open_breakout_buffer_pct"] = round(max(_as_float(p.get("open_breakout_buffer_pct", 0.03), 0.03), 0.0), 4) - p["shadow_reverse_threshold_pct"] = round(max(_as_float(p.get("shadow_reverse_threshold_pct", 0.15), 0.15), 0.01), 4) - p["reverse_min_move_pct"] = round(max(_as_float(p.get("reverse_min_move_pct", 0.15), 0.15), 0.01), 4) - - p["open_cooldown_seconds"] = int(max(_as_float(p.get("open_cooldown_seconds", 300), 300), 1)) - p["reverse_cooldown_seconds"] = int(max(_as_float(p.get("reverse_cooldown_seconds", 300), 300), 1)) - - p["default_order_size"] = int(max(_as_float(p.get("default_order_size", 10), 10), 1)) - p["order_notional_usd"] = round(max(_as_float(p.get("order_notional_usd", 100.0), 100.0), 5.0), 4) - p["pnl_per_usd_move"] = round(max(_as_float(p.get("pnl_per_usd_move", 1.0), 1.0), 0.01), 4) - p["slippage_pct"] = round(max(_as_float(p.get("slippage_pct", 0.01), 0.01), 0.0), 5) - p["dynamic_vol_target_pct"] = round(_clamp(_as_float(p.get("dynamic_vol_target_pct", 0.25), 0.25), 0.05, 1.5), 6) - - p["enable_shadow_reverse"] = _as_bool(p.get("enable_shadow_reverse", False), False) - p["enable_trend_filter"] = _as_bool(p.get("enable_trend_filter", True), True) - p["trend_ema_fast"] = int(max(_as_float(p.get("trend_ema_fast", 21), 21), 2)) - p["trend_ema_slow"] = int(max(_as_float(p.get("trend_ema_slow", 55), 55), p["trend_ema_fast"] + 1)) - p["trend_strength_threshold_pct"] = round(_clamp(_as_float(p.get("trend_strength_threshold_pct", 0.02), 0.02), 0.0, 0.2), 6) - p["trend_slope_lookback"] = int(max(_as_float(p.get("trend_slope_lookback", 3), 3), 1)) - p["trend_slope_threshold_pct"] = round(_clamp(_as_float(p.get("trend_slope_threshold_pct", 0.005), 0.005), 0.0, 0.1), 6) - - p["enable_ai_filter"] = _as_bool(p.get("enable_ai_filter", True), True) - p["ai_min_confidence"] = round(_clamp(_as_float(p.get("ai_min_confidence", 0.58), 0.58), 0.5, 0.95), 6) - p["ai_bias"] = round(_clamp(_as_float(p.get("ai_bias", -0.1), -0.1), -3.0, 3.0), 6) - p["ai_w_trend_align"] = round(_clamp(_as_float(p.get("ai_w_trend_align", 1.1), 1.1), 0.0, 3.0), 6) - p["ai_w_breakout_strength"] = round(_clamp(_as_float(p.get("ai_w_breakout_strength", 0.8), 0.8), 0.0, 3.0), 6) - p["ai_w_entity_strength"] = round(_clamp(_as_float(p.get("ai_w_entity_strength", 0.55), 0.55), 0.0, 3.0), 6) - p["ai_w_shadow_balance"] = round(_clamp(_as_float(p.get("ai_w_shadow_balance", 0.35), 0.35), 0.0, 3.0), 6) - p["ai_w_volatility_fit"] = round(_clamp(_as_float(p.get("ai_w_volatility_fit", 0.45), 0.45), 0.0, 3.0), 6) - - # Keep relationships sane. - if p["trailing_activation_usd"] <= p["break_even_activation_usd"]: - p["trailing_activation_usd"] = round(p["break_even_activation_usd"] + 0.2, 4) - - if p["take_profit_usd"] <= p["trailing_activation_usd"]: - p["take_profit_usd"] = round(p["trailing_activation_usd"] + 0.4, 4) - - max_floor = p["break_even_activation_usd"] * 0.9 - if p["break_even_floor_usd"] > max_floor: - p["break_even_floor_usd"] = round(max_floor, 4) - - if p["trailing_distance_usd"] >= p["trailing_activation_usd"]: - p["trailing_distance_usd"] = round(max(0.1, p["trailing_activation_usd"] * 0.6), 4) - - return p - - -def load_credentials_from_strategy_file(strategy_path: Path) -> Dict[str, str]: - """ - 从保守模式策略文件里提取 self.api_key/self.secret_key/self.memo,作为自动抓取兜底。 - """ - if not strategy_path.exists(): - return {} - - try: - content = strategy_path.read_text(encoding="utf-8") - except Exception: - return {} - - def extract(name: str) -> str: - pattern = rf'self\.{name}\s*=\s*["\']([^"\']+)["\']' - m = re.search(pattern, content) - return m.group(1).strip() if m else "" - - api_key = extract("api_key") - secret_key = extract("secret_key") - memo = extract("memo") - - result: Dict[str, str] = {} - if api_key: - result["api_key"] = api_key - if secret_key: - result["secret_key"] = secret_key - if memo: - result["memo"] = memo - return result - - -def _run_one_stress_scenario( - task: Tuple[ - Dict[str, Any], - float, - float, - List[Dict[str, float]], - List[Dict[str, float]], - float, - float, - float, - float, - int, - float, - ] -) -> Tuple[float, float, float, Any, Any]: - """ - 单压力场景回测(供多进程调用,不减少计算量)。 - 返回: (train_score, valid_score, valid_drawdown, base_train_res, base_valid_res) - base_* 仅当 slip≈1 且 fee≈1 时非 None。 - """ - ( - params, - slip_mult, - fee_mult, - train_data, - valid_data, - raw_fee_rate, - rebate_ratio, - drawdown_weight, - loss_day_weight, - min_trades, - undertrade_penalty, - ) = task - scenario_params = dict(params) - scenario_params["slippage_pct"] = max( - 0.0, - _as_float(params.get("slippage_pct", 0.01), 0.01) * float(slip_mult), - ) - scenario_params = normalize_params(scenario_params) - scenario_raw_fee_rate = max(0.0, raw_fee_rate * float(fee_mult)) - backtester = ConservativeBacktester( - params=scenario_params, - raw_fee_rate=scenario_raw_fee_rate, - rebate_ratio=rebate_ratio, - ) - train_res = backtester.run(train_data) - valid_res = backtester.run(valid_data) - train_score = score_result( - train_res, - drawdown_weight=drawdown_weight, - loss_day_weight=loss_day_weight, - min_trades=min_trades, - undertrade_penalty=undertrade_penalty, - ) - valid_score = score_result( - valid_res, - drawdown_weight=drawdown_weight, - loss_day_weight=loss_day_weight, - min_trades=min_trades, - undertrade_penalty=undertrade_penalty, - ) - is_base = abs(slip_mult - 1.0) < 1e-9 and abs(fee_mult - 1.0) < 1e-9 - return ( - train_score, - valid_score, - valid_res.max_drawdown, - train_res if is_base else None, - valid_res if is_base else None, - ) - - -def sample_random_params(rng: random.Random) -> Dict[str, Any]: - params = dict(DEFAULT_PARAMS) - params.update( - { - "take_profit_usd": rng.uniform(1.2, 6.0), - "stop_loss_usd": -rng.uniform(0.8, 4.0), - "trailing_activation_usd": rng.uniform(0.5, 3.0), - "trailing_distance_usd": rng.uniform(0.2, 1.8), - "break_even_activation_usd": rng.uniform(0.3, 2.2), - "break_even_floor_usd": rng.uniform(0.0, 1.0), - "open_breakout_buffer_pct": rng.uniform(0.0, 0.25), - "reverse_min_move_pct": rng.uniform(0.05, 0.5), - "open_cooldown_seconds": rng.choice([60, 120, 180, 240, 300, 360, 420, 480, 600, 900]), - "reverse_cooldown_seconds": rng.choice([60, 120, 180, 240, 300, 360, 420, 480, 600, 900]), - "enable_shadow_reverse": rng.random() < 0.15, - "shadow_reverse_threshold_pct": rng.uniform(0.08, 0.5), - "enable_trend_filter": rng.random() < 0.90, - "trend_ema_fast": rng.randint(8, 34), - "trend_ema_slow": rng.randint(35, 120), - "trend_strength_threshold_pct": rng.uniform(0.0, 0.08), - "trend_slope_lookback": rng.randint(1, 8), - "trend_slope_threshold_pct": rng.uniform(0.0, 0.03), - "enable_ai_filter": rng.random() < 0.90, - "ai_min_confidence": rng.uniform(0.50, 0.85), - "ai_bias": rng.uniform(-1.8, 1.0), - "ai_w_trend_align": rng.uniform(0.1, 2.5), - "ai_w_breakout_strength": rng.uniform(0.1, 2.5), - "ai_w_entity_strength": rng.uniform(0.1, 2.5), - "ai_w_shadow_balance": rng.uniform(0.0, 2.2), - "ai_w_volatility_fit": rng.uniform(0.0, 2.2), - "dynamic_vol_target_pct": rng.uniform(0.10, 0.80), - "slippage_pct": rng.uniform(0.005, 0.05), - } - ) - return normalize_params(params) - - -def evaluate_candidate( - params: Dict[str, Any], - windows: List[Tuple[List[Dict[str, float]], List[Dict[str, float]]]], - raw_fee_rate: float, - rebate_ratio: float, - drawdown_weight: float, - loss_day_weight: float, - min_trades: int, - undertrade_penalty: float, - valid_score_weight: float, - drawdown_guard: float, - stability_weight: float, - stress_slippage_multipliers: List[float], - stress_fee_multipliers: List[float], - n_stress_workers: int = 0, -) -> Tuple[float, Dict[str, float], Dict[str, float]]: - train_results = [] - valid_results = [] - window_scores = [] - num_stress = len(stress_slippage_multipliers) * len(stress_fee_multipliers) - use_parallel = n_stress_workers > 0 and num_stress > 1 - - for train_data, valid_data in windows: - scenario_train_scores: List[float] = [] - scenario_valid_scores: List[float] = [] - scenario_valid_drawdowns: List[float] = [] - base_train_res = None - base_valid_res = None - - if use_parallel: - tasks = [ - ( - params, - slip_mult, - fee_mult, - train_data, - valid_data, - raw_fee_rate, - rebate_ratio, - drawdown_weight, - loss_day_weight, - min_trades, - undertrade_penalty, - ) - for slip_mult in stress_slippage_multipliers - for fee_mult in stress_fee_multipliers - ] - max_workers = min(n_stress_workers, len(tasks)) - with ProcessPoolExecutor(max_workers=max_workers) as executor: - for res in executor.map(_run_one_stress_scenario, tasks): - train_score, valid_score, valid_dd, tr, vr = res - scenario_train_scores.append(train_score) - scenario_valid_scores.append(valid_score) - scenario_valid_drawdowns.append(valid_dd) - if tr is not None and vr is not None: - base_train_res, base_valid_res = tr, vr - else: - for slip_mult in stress_slippage_multipliers: - for fee_mult in stress_fee_multipliers: - scenario_params = dict(params) - scenario_params["slippage_pct"] = max( - 0.0, - _as_float(params.get("slippage_pct", 0.01), 0.01) * float(slip_mult), - ) - scenario_params = normalize_params(scenario_params) - scenario_raw_fee_rate = max(0.0, raw_fee_rate * float(fee_mult)) - backtester = ConservativeBacktester( - params=scenario_params, - raw_fee_rate=scenario_raw_fee_rate, - rebate_ratio=rebate_ratio, - ) - - train_res = backtester.run(train_data) - valid_res = backtester.run(valid_data) - - train_score = score_result( - train_res, - drawdown_weight=drawdown_weight, - loss_day_weight=loss_day_weight, - min_trades=min_trades, - undertrade_penalty=undertrade_penalty, - ) - valid_score = score_result( - valid_res, - drawdown_weight=drawdown_weight, - loss_day_weight=loss_day_weight, - min_trades=min_trades, - undertrade_penalty=undertrade_penalty, - ) - - scenario_train_scores.append(train_score) - scenario_valid_scores.append(valid_score) - scenario_valid_drawdowns.append(valid_res.max_drawdown) - - if base_train_res is None and abs(slip_mult - 1.0) < 1e-9 and abs(fee_mult - 1.0) < 1e-9: - base_train_res = train_res - base_valid_res = valid_res - - if base_train_res is None: - base_train_res = train_res - base_valid_res = valid_res - - if base_train_res is None or base_valid_res is None: - fallback_backtester = ConservativeBacktester( - params=params, - raw_fee_rate=raw_fee_rate, - rebate_ratio=rebate_ratio, - ) - base_train_res = fallback_backtester.run(train_data) - base_valid_res = fallback_backtester.run(valid_data) - - train_results.append(base_train_res) - valid_results.append(base_valid_res) - - train_score_mean = mean(scenario_train_scores) if scenario_train_scores else -1e9 - valid_score_mean = mean(scenario_valid_scores) if scenario_valid_scores else -1e9 - valid_score_min = min(scenario_valid_scores) if scenario_valid_scores else -1e9 - robustness_penalty = max(0.0, valid_score_mean - valid_score_min) * 0.35 - combined = valid_score_weight * valid_score_mean + (1.0 - valid_score_weight) * train_score_mean - robustness_penalty - - valid_drawdown_max = max(scenario_valid_drawdowns) if scenario_valid_drawdowns else 0.0 - if valid_drawdown_max > drawdown_guard: - combined -= (valid_drawdown_max - drawdown_guard) * 2.0 - - window_scores.append(combined) - - mean_score = mean(window_scores) if window_scores else -1e9 - - if len(valid_results) > 1: - stability_penalty = pstdev([r.net_pnl for r in valid_results]) * stability_weight - mean_score -= stability_penalty - - train_agg = aggregate_results(train_results) - valid_agg = aggregate_results(valid_results) - return mean_score, train_agg, valid_agg - - -def optimize_with_optuna( - windows: List[Tuple[List[Dict[str, float]], List[Dict[str, float]]]], - args: argparse.Namespace, -) -> Dict[str, Any]: - import optuna # type: ignore - - sampler = optuna.samplers.TPESampler(seed=args.seed) - study = optuna.create_study(direction="maximize", sampler=sampler) - - best: Dict[str, Any] = {} - - def objective(trial: Any) -> float: - params = dict(DEFAULT_PARAMS) - params.update( - { - "take_profit_usd": trial.suggest_float("take_profit_usd", 1.2, 6.0), - "stop_loss_usd": -trial.suggest_float("stop_loss_abs", 0.8, 4.0), - "trailing_activation_usd": trial.suggest_float("trailing_activation_usd", 0.5, 3.0), - "trailing_distance_usd": trial.suggest_float("trailing_distance_usd", 0.2, 1.8), - "break_even_activation_usd": trial.suggest_float("break_even_activation_usd", 0.3, 2.2), - "break_even_floor_usd": trial.suggest_float("break_even_floor_usd", 0.0, 1.0), - "open_breakout_buffer_pct": trial.suggest_float("open_breakout_buffer_pct", 0.0, 0.25), - "reverse_min_move_pct": trial.suggest_float("reverse_min_move_pct", 0.05, 0.5), - "open_cooldown_seconds": trial.suggest_int("open_cooldown_seconds", 60, 900, step=60), - "reverse_cooldown_seconds": trial.suggest_int("reverse_cooldown_seconds", 60, 900, step=60), - "enable_shadow_reverse": trial.suggest_categorical("enable_shadow_reverse", [False, True]), - "shadow_reverse_threshold_pct": trial.suggest_float("shadow_reverse_threshold_pct", 0.08, 0.5), - "enable_trend_filter": trial.suggest_categorical("enable_trend_filter", [False, True]), - "trend_ema_fast": trial.suggest_int("trend_ema_fast", 8, 34), - "trend_ema_slow": trial.suggest_int("trend_ema_slow", 35, 120), - "trend_strength_threshold_pct": trial.suggest_float("trend_strength_threshold_pct", 0.0, 0.08), - "trend_slope_lookback": trial.suggest_int("trend_slope_lookback", 1, 8), - "trend_slope_threshold_pct": trial.suggest_float("trend_slope_threshold_pct", 0.0, 0.03), - "enable_ai_filter": trial.suggest_categorical("enable_ai_filter", [False, True]), - "ai_min_confidence": trial.suggest_float("ai_min_confidence", 0.50, 0.85), - "ai_bias": trial.suggest_float("ai_bias", -1.8, 1.0), - "ai_w_trend_align": trial.suggest_float("ai_w_trend_align", 0.1, 2.5), - "ai_w_breakout_strength": trial.suggest_float("ai_w_breakout_strength", 0.1, 2.5), - "ai_w_entity_strength": trial.suggest_float("ai_w_entity_strength", 0.1, 2.5), - "ai_w_shadow_balance": trial.suggest_float("ai_w_shadow_balance", 0.0, 2.2), - "ai_w_volatility_fit": trial.suggest_float("ai_w_volatility_fit", 0.0, 2.2), - "dynamic_vol_target_pct": trial.suggest_float("dynamic_vol_target_pct", 0.10, 0.80), - "slippage_pct": trial.suggest_float("slippage_pct", 0.005, 0.05), - } - ) - params = normalize_params(params) - - score, train_agg, valid_agg = evaluate_candidate( - params=params, - windows=windows, - raw_fee_rate=args.raw_fee_rate, - rebate_ratio=args.rebate_ratio, - drawdown_weight=args.drawdown_weight, - loss_day_weight=args.loss_day_weight, - min_trades=args.min_trades, - undertrade_penalty=args.undertrade_penalty, - valid_score_weight=args.valid_score_weight, - drawdown_guard=args.drawdown_guard, - stability_weight=args.stability_weight, - stress_slippage_multipliers=args.stress_slippage_multipliers, - stress_fee_multipliers=args.stress_fee_multipliers, - n_stress_workers=getattr(args, "n_stress_workers", 0), - ) - - trial.set_user_attr("train_agg", train_agg) - trial.set_user_attr("valid_agg", valid_agg) - - nonlocal best - if not best or score > best["score"]: - best = { - "score": score, - "params": params, - "train_agg": train_agg, - "valid_agg": valid_agg, - } - return score - - n_jobs = max(1, int(getattr(args, "n_jobs", 1))) - study.optimize(objective, n_trials=args.n_trials, n_jobs=n_jobs, show_progress_bar=(n_jobs == 1)) - if best: - return best - - trial = study.best_trial - return { - "score": trial.value, - "params": normalize_params(dict(DEFAULT_PARAMS)), - "train_agg": trial.user_attrs.get("train_agg", {}), - "valid_agg": trial.user_attrs.get("valid_agg", {}), - } - - -def optimize_with_random( - windows: List[Tuple[List[Dict[str, float]], List[Dict[str, float]]]], - args: argparse.Namespace, -) -> Dict[str, Any]: - rng = random.Random(args.seed) - best: Optional[Dict[str, Any]] = None - - for i in range(args.n_trials): - params = sample_random_params(rng) - score, train_agg, valid_agg = evaluate_candidate( - params=params, - windows=windows, - raw_fee_rate=args.raw_fee_rate, - rebate_ratio=args.rebate_ratio, - drawdown_weight=args.drawdown_weight, - loss_day_weight=args.loss_day_weight, - min_trades=args.min_trades, - undertrade_penalty=args.undertrade_penalty, - valid_score_weight=args.valid_score_weight, - drawdown_guard=args.drawdown_guard, - stability_weight=args.stability_weight, - stress_slippage_multipliers=args.stress_slippage_multipliers, - stress_fee_multipliers=args.stress_fee_multipliers, - n_stress_workers=getattr(args, "n_stress_workers", 0), - ) - - if best is None or score > best["score"]: - best = { - "score": score, - "params": params, - "train_agg": train_agg, - "valid_agg": valid_agg, - "trial": i + 1, - } - - if (i + 1) % 20 == 0: - print(f"[random] trial {i + 1}/{args.n_trials}, best_score={best['score']:.6f}") - - if best is None: - raise RuntimeError("random optimization failed: no trial executed") - return best - - -def load_klines(args: argparse.Namespace) -> Tuple[List[Dict[str, float]], Dict[str, Any]]: - if args.data_file: - csv_path = Path(args.data_file).expanduser() - klines = load_klines_from_csv(csv_path, last_n_days=args.days) - source_meta = {"type": "csv", "path": str(csv_path)} - return klines, source_meta - - api_key = args.api_key or os.getenv("BITMART_API_KEY") - secret_key = args.secret_key or os.getenv("BITMART_SECRET_KEY") - memo = args.memo or os.getenv("BITMART_MEMO", "参数优化") - credential_source = "args_or_env" - - if not api_key or not secret_key: - strategy_path = Path(__file__).with_name(STRATEGY_FILENAME) - extracted = load_credentials_from_strategy_file(strategy_path) - if extracted: - api_key = api_key or extracted.get("api_key", "") - secret_key = secret_key or extracted.get("secret_key", "") - memo = memo or extracted.get("memo", "参数优化") - credential_source = f"strategy_file:{strategy_path.name}" - - if not api_key or not secret_key: - raise ValueError( - "No --data-file provided and API credentials missing. " - "Provide --api-key/--secret-key or env BITMART_API_KEY/BITMART_SECRET_KEY. " - f"Fallback strategy file attempted: {STRATEGY_FILENAME}" - ) - - from bitmart.api_contract import APIContract - - print(f"Using API credentials source: {credential_source}") - - client = APIContract(api_key, secret_key, memo, timeout=(5, 15)) - klines = fetch_klines_from_api( - contract_api=client, - contract_symbol=args.symbol, - step=args.step, - days=args.days, - batch_hours=args.batch_hours, - sleep_seconds=args.api_sleep, - ) - - source_meta = { - "type": "api", - "symbol": args.symbol, - "step": args.step, - "days": args.days, - "credential_source": credential_source, - } - - save_path = args.save_data_file - if not save_path: - save_path = str(Path(__file__).with_name(f"auto_{args.symbol.lower()}_{args.step}m_{args.days}d.csv")) - - if save_path: - output_path = save_klines_to_csv(klines, save_path) - source_meta["saved_csv"] = str(output_path) - print(f"Fetched klines saved to: {output_path}") - - return klines, source_meta - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Optimize conservative strategy params using last N days of kline data.") - - parser.add_argument("--data-file", type=str, default="", help="CSV path for kline data (recommended).") - parser.add_argument("--save-data-file", type=str, default="", help="When fetching from API, also save CSV here.") - parser.add_argument("--days", type=int, default=30, help="Use recent N days of data.") - parser.add_argument("--symbol", type=str, default="ETHUSDT", help="Bitmart contract symbol for API mode.") - parser.add_argument("--step", type=int, default=5, help="Kline step in minutes for API mode.") - parser.add_argument("--batch-hours", type=int, default=12, help="API fetch window size in hours.") - parser.add_argument("--api-sleep", type=float, default=0.05, help="Sleep seconds between API windows.") - - parser.add_argument("--api-key", type=str, default="", help="Bitmart API key (optional if using env).") - parser.add_argument("--secret-key", type=str, default="", help="Bitmart secret key (optional if using env).") - parser.add_argument("--memo", type=str, default="", help="Bitmart memo (optional).") - - parser.add_argument("--train-days", type=int, default=20, help="Rolling train window in days.") - parser.add_argument("--valid-days", type=int, default=10, help="Rolling validation window in days.") - parser.add_argument("--window-step-days", type=int, default=5, help="Rolling window step in days.") - - parser.add_argument("--method", type=str, default="auto", choices=["auto", "optuna", "random"], help="Search method.") - parser.add_argument("--n-trials", type=int, default=300, help="Number of optimization trials.") - parser.add_argument("--n-jobs", type=int, default=1, help="Parallel trials (Optuna only). 1=sequential, 2+=parallel.") - parser.add_argument("--n-stress-workers", type=int, default=0, help="Parallel stress scenarios per window (0=sequential). e.g. 4 runs slip×fee in parallel.") - parser.add_argument("--max-windows", type=int, default=0, help="Cap rolling windows (0=no cap). Speeds up when set to 2-3.") - parser.add_argument("--seed", type=int, default=42, help="Random seed.") - - parser.add_argument("--raw-fee-rate", type=float, default=0.0006, help="Raw taker fee rate (e.g. 0.0006).") - parser.add_argument("--rebate-ratio", type=float, default=0.90, help="Fee rebate ratio (e.g. 0.90 means 90%% rebate).") - - parser.add_argument("--drawdown-weight", type=float, default=1.4, help="Penalty weight for max drawdown.") - parser.add_argument("--loss-day-weight", type=float, default=0.8, help="Penalty weight for losing days.") - parser.add_argument("--min-trades", type=int, default=6, help="Minimum trades per window before penalty.") - parser.add_argument("--undertrade-penalty", type=float, default=0.5, help="Penalty per missing trade.") - parser.add_argument("--valid-score-weight", type=float, default=0.8, help="Weight of validation score in combined score.") - parser.add_argument("--drawdown-guard", type=float, default=10.0, help="Extra hard penalty beyond this validation drawdown.") - parser.add_argument("--stability-weight", type=float, default=0.3, help="Penalty for net-pnl variance across windows.") - parser.add_argument( - "--stress-slippage-multipliers", - type=str, - default="0.8,1.0,1.2", - help="Comma-separated slippage multipliers used in robust scoring.", - ) - parser.add_argument( - "--stress-fee-multipliers", - type=str, - default="1.0,1.15", - help="Comma-separated raw-fee multipliers used in robust scoring.", - ) - - parser.add_argument( - "--output", - type=str, - default=str(Path(__file__).with_name("current_params.json")), - help="Output JSON path used by live conservative script.", - ) - - return parser.parse_args() - - -def main() -> None: - args = parse_args() - args.stress_slippage_multipliers = _parse_float_list(args.stress_slippage_multipliers, [0.8, 1.0, 1.2]) - args.stress_fee_multipliers = _parse_float_list(args.stress_fee_multipliers, [1.0, 1.15]) - - klines, source_meta = load_klines(args) - if len(klines) < 200: - raise RuntimeError(f"Not enough klines for optimization: {len(klines)}") - - windows = split_rolling_windows( - klines, - train_days=args.train_days, - valid_days=args.valid_days, - step_days=args.window_step_days, - ) - if not windows: - raise RuntimeError( - "Could not build rolling windows. Increase data days or reduce train/valid window sizes." - ) - - max_windows = max(0, int(getattr(args, "max_windows", 0))) - if max_windows > 0 and len(windows) > max_windows: - windows = windows[:max_windows] - print(f"Capped rolling windows to {max_windows} (--max-windows)") - print(f"Loaded klines: {len(klines)}, rolling windows: {len(windows)}") - - use_method = args.method - if use_method == "auto": - try: - import optuna # noqa: F401 - - use_method = "optuna" - except Exception: - use_method = "random" - - if use_method == "optuna": - best = optimize_with_optuna(windows, args) - else: - best = optimize_with_random(windows, args) - - best_params = normalize_params(best["params"]) - - result_payload = { - "updated_at": datetime.now(timezone.utc).isoformat(), - "source": { - **source_meta, - "bars": len(klines), - "start_ts": int(klines[0]["id"]), - "end_ts": int(klines[-1]["id"]), - }, - "optimization": { - "method": use_method, - "n_trials": args.n_trials, - "train_days": args.train_days, - "valid_days": args.valid_days, - "window_step_days": args.window_step_days, - "windows": len(windows), - "seed": args.seed, - "stress_slippage_multipliers": args.stress_slippage_multipliers, - "stress_fee_multipliers": args.stress_fee_multipliers, - }, - "fee_model": { - "raw_fee_rate": args.raw_fee_rate, - "rebate_ratio": args.rebate_ratio, - "effective_fee_rate": args.raw_fee_rate * (1 - args.rebate_ratio), - }, - "score": best["score"], - "train_metrics_avg": best.get("train_agg", {}), - "valid_metrics_avg": best.get("valid_agg", {}), - "params": best_params, - } - - output_path = Path(args.output).expanduser() - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("w", encoding="utf-8") as f: - json.dump(result_payload, f, ensure_ascii=False, indent=2) - - print("Optimization done.") - print(f"Best score: {best['score']:.6f}") - print(f"Saved params to: {output_path}") - print("Best params:") - for k in sorted(best_params): - print(f" {k}: {best_params[k]}") - - -if __name__ == "__main__": - main() diff --git a/bitmart/保守模式参数优化/四分之一,五分钟,反手条件充足_保守模式.py b/bitmart/保守模式参数优化/四分之一,五分钟,反手条件充足_保守模式.py deleted file mode 100644 index 013f046..0000000 --- a/bitmart/保守模式参数优化/四分之一,五分钟,反手条件充足_保守模式.py +++ /dev/null @@ -1,1746 +0,0 @@ -import json -import math -import os -import subprocess -import sys -import threading -import time -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from tqdm import tqdm -from loguru import logger -from bit_tools import openBrowser -from DrissionPage import ChromiumPage -from DrissionPage import ChromiumOptions - -from bitmart.api_contract import APIContract - -try: - import websocket -except Exception: - websocket = None - - -class BitmartFuturesTransactionConservative: - def __init__(self, bit_id): - - self.page: ChromiumPage | None = None - - self.api_key = "a0fb7b98464fd9bcce67e7c519d58ec10d0c38a8" - self.secret_key = "4eaeba78e77aeaab1c2027f846a276d164f264a44c2c1bb1c5f3be50c8de1ca5" - self.memo = "合约交易" - - self.contract_symbol = "ETHUSDT" - - self.contractAPI = APIContract(self.api_key, self.secret_key, self.memo, timeout=(5, 15)) - - self.start = 0 # 持仓状态: -1 空, 0 无, 1 多 - - self.pbar = tqdm(total=30, desc="等待K线", ncols=80) # 可选:用于长时间等待时展示进度 - - self.last_kline_time = None # 上一次处理的K线时间戳,用于判断是否是新K线 - - # 保守模式:反手频率控制(降低来回打脸) - self.reverse_cooldown_seconds = 5 * 60 # 反手冷却时间(秒) - self.reverse_min_move_pct = 0.15 # 反手最小价差过滤(百分比) - self.last_reverse_time = None # 上次反手时间 - - # 保守模式:开仓频率控制 - self.open_cooldown_seconds = 5 * 60 # 开仓冷却时间(秒),两次开仓至少间隔此时长 - self.last_open_time = None # 上次开仓时间 - self.last_open_kline_id = None # 上次开仓所在 K 线 id,同一根 K 线只允许开仓一次 - - self.leverage = "20" # 保守杠杆 - self.open_type = "cross" # 全仓模式 - self.risk_percent = 0 # 未使用;若启用则可为每次开仓占可用余额的百分比 - self.take_profit_usd = 3 # 仓位盈利达到此金额(美元)时平仓止盈 - self.stop_loss_usd = -2 # 固定止损:亏损达到 2 美元平仓 - self.trailing_activation_usd = 1.2 # 盈利达到此金额后启动移动止损 - self.trailing_distance_usd = 0.6 # 从最高盈利回撤此金额则平仓 - self.break_even_activation_usd = 1.0 # 盈利达到该值后启用保本锁盈 - self.break_even_floor_usd = 0.2 # 回落到该值及以下则平仓保本 - self.max_unrealized_pnl_seen = None # 持仓期间见过的最大盈利(用于移动止损) - - self.open_avg_price = None # 开仓价格 - self.current_amount = None # 持仓量 - - self.bit_id = bit_id - self.default_order_size = 10 # 开仓/反手张数,统一在此修改 - self.open_breakout_buffer_pct = 0.03 # 开仓突破附加缓冲(百分比),减少假突破 - self.enable_shadow_reverse = False # 保守模式默认关闭影线反手 - self.shadow_reverse_threshold_pct = 0.15 # 影线反手阈值(百分比) - # 趋势过滤:仅限制新开仓,不影响持仓风控与反手 - self.enable_trend_filter = True - self.trend_ema_fast = 21 - self.trend_ema_slow = 55 - self.trend_strength_threshold_pct = 0.02 - self.trend_slope_lookback = 3 - self.trend_slope_threshold_pct = 0.005 - # 轻量 AI 门控:对候选开仓信号打分,低置信度跳过 - self.enable_ai_filter = True - self.ai_min_confidence = 0.58 - self.ai_bias = -0.1 - self.ai_w_trend_align = 1.1 - self.ai_w_breakout_strength = 0.8 - self.ai_w_entity_strength = 0.55 - self.ai_w_shadow_balance = 0.35 - self.ai_w_volatility_fit = 0.45 - self.last_trend_bias = "neutral" - self.last_trend_strength_pct = 0.0 - self.last_trend_slope_pct = 0.0 - self.recent_klines_cache = [] - - # 策略相关变量 - self.prev_kline = None # 上一根K线 - self.current_kline = None # 当前K线 - self.prev_entity = None # 上一根K线实体大小 - self.current_open = None # 当前K线开盘价 - - # WebSocket 实时价格(优先使用,失败自动回退 API) - self.ws_enabled = os.getenv("BITMART_WS_ENABLED", "1").strip().lower() not in {"0", "false", "off", "no"} - self.ws_url = os.getenv("BITMART_WS_URL", "wss://openapi-ws-v2.bitmart.com/api?protocol=1.1") - # 使用 bookticker 实时推送(比 ticker 的 1s 推送更及时) - self.ws_topic = os.getenv("BITMART_WS_TOPIC", f"futures/bookticker:{self.contract_symbol}") - self.ws_price_ttl_seconds = float(os.getenv("BITMART_WS_PRICE_TTL", "2.0")) - self.ws_reconnect_seconds = float(os.getenv("BITMART_WS_RECONNECT_SECONDS", "2.0")) - self.ws_last_price = None - self.ws_last_price_time = 0.0 - self.ws_last_stale_log_time = 0.0 - self.ws_app = None - self.ws_thread = None - self.ws_stop_event = threading.Event() - self.ws_lock = threading.Lock() - - # 动态风控:按近期波动率自动调整 TP/SL/移动止损 - self.dynamic_risk_enabled = os.getenv("BITMART_DYNAMIC_RISK_ENABLED", "1").strip().lower() not in {"0", "false", "off", "no"} - self.dynamic_risk_window_bars = int(os.getenv("BITMART_DYNAMIC_RISK_WINDOW_BARS", "36")) # 5m * 36 = 3小时 - self.dynamic_vol_target_pct = float(os.getenv("BITMART_DYNAMIC_VOL_TARGET_PCT", "0.25")) # 目标波动率(%) - self.dynamic_scale_min = float(os.getenv("BITMART_DYNAMIC_SCALE_MIN", "0.7")) - self.dynamic_scale_max = float(os.getenv("BITMART_DYNAMIC_SCALE_MAX", "1.8")) - self.last_dynamic_risk_kline_id = None - self.base_risk_params = {} - - # 启动前自动参数优化(按你的命令默认开启) - self.auto_optimize_before_trade = os.getenv( - "BITMART_AUTO_OPTIMIZE_BEFORE_TRADE", "1" - ).strip().lower() not in {"0", "false", "off", "no"} - self.auto_optimize_require_success = os.getenv( - "BITMART_AUTO_OPTIMIZE_REQUIRE_SUCCESS", "1" - ).strip().lower() not in {"0", "false", "off", "no"} - self.auto_optimize_days = int(os.getenv("BITMART_AUTO_OPT_DAYS", "90")) - self.auto_optimize_train_days = int(os.getenv("BITMART_AUTO_OPT_TRAIN_DAYS", "45")) - self.auto_optimize_valid_days = int(os.getenv("BITMART_AUTO_OPT_VALID_DAYS", "15")) - self.auto_optimize_window_step_days = int(os.getenv("BITMART_AUTO_OPT_WINDOW_STEP_DAYS", "7")) - self.auto_optimize_method = os.getenv("BITMART_AUTO_OPT_METHOD", "optuna") - self.auto_optimize_n_trials = int(os.getenv("BITMART_AUTO_OPT_N_TRIALS", "1200")) - self.auto_optimize_valid_score_weight = float(os.getenv("BITMART_AUTO_OPT_VALID_SCORE_WEIGHT", "0.9")) - self.auto_optimize_drawdown_guard = float(os.getenv("BITMART_AUTO_OPT_DRAWDOWN_GUARD", "8")) - self.auto_optimize_stability_weight = float(os.getenv("BITMART_AUTO_OPT_STABILITY_WEIGHT", "0.6")) - self.auto_optimize_stress_slippage = os.getenv("BITMART_AUTO_OPT_STRESS_SLIPPAGE", "0.8,1.0,1.2,1.4") - self.auto_optimize_stress_fee = os.getenv("BITMART_AUTO_OPT_STRESS_FEE", "1.0,1.15,1.3") - self.auto_optimize_allow_method_fallback = os.getenv( - "BITMART_AUTO_OPT_ALLOW_METHOD_FALLBACK", "1" - ).strip().lower() not in {"0", "false", "off", "no"} - self.auto_optimize_data_file = os.getenv("BITMART_AUTO_OPT_DATA_FILE", "").strip() - self.auto_optimize_script_path = Path(__file__).with_name("optimize_params.py") - self.auto_optimized_this_run = False - # 若参数文件在 N 小时内更新过则跳过优化(0=不跳过) - self.auto_optimize_skip_if_fresh_hours = float(os.getenv("BITMART_AUTO_OPT_SKIP_IF_FRESH_HOURS", "0")) - # 并行:试验数(Optuna 有效)、压力场景数(每窗口 slip×fee 并行) - self.auto_optimize_n_jobs = int(os.getenv("BITMART_AUTO_OPT_N_JOBS", "0")) - self.auto_optimize_stress_workers = int(os.getenv("BITMART_AUTO_OPT_STRESS_WORKERS", "0")) - - # 方案B:智能动态模式 - self.enable_smart_mode = os.getenv("BITMART_SMART_MODE_ENABLED", "1").strip().lower() not in {"0", "false", "off", "no"} - # 波动率分档阈值(单位:百分比) - self.vol_regime_low_threshold_pct = float(os.getenv("BITMART_VOL_REGIME_LOW_PCT", "0.22")) - self.vol_regime_high_threshold_pct = float(os.getenv("BITMART_VOL_REGIME_HIGH_PCT", "0.45")) - self.mid_vol_order_size_multiplier = float(os.getenv("BITMART_MID_VOL_SIZE_MULT", "0.7")) - self.high_vol_order_size_multiplier = float(os.getenv("BITMART_HIGH_VOL_SIZE_MULT", "0.3")) - self.high_vol_pause_trading = os.getenv("BITMART_HIGH_VOL_PAUSE", "1").strip().lower() not in {"0", "false", "off", "no"} - self.mid_vol_stop_loss_multiplier = float(os.getenv("BITMART_MID_VOL_SL_MULT", "0.85")) - self.high_vol_stop_loss_multiplier = float(os.getenv("BITMART_HIGH_VOL_SL_MULT", "0.70")) - # 时间过滤:亚洲时段 + 跳过换线小时 - self.trade_asia_session_only = os.getenv("BITMART_ASIA_SESSION_ONLY", "1").strip().lower() not in {"0", "false", "off", "no"} - self.asia_session_start_hour_utc = int(os.getenv("BITMART_ASIA_START_HOUR_UTC", "1")) - self.asia_session_end_hour_utc = int(os.getenv("BITMART_ASIA_END_HOUR_UTC", "10")) - self.blocked_utc_hours_utc = self._parse_int_set_csv(os.getenv("BITMART_BLOCKED_UTC_HOURS", "0,8,16"), {0, 8, 16}) - # 日内盈亏管理 - self.daily_profit_reduce_size_usd = float(os.getenv("BITMART_DAILY_PROFIT_REDUCE_SIZE_USD", "8")) - self.daily_reduced_size_multiplier = float(os.getenv("BITMART_DAILY_REDUCED_SIZE_MULT", "0.5")) - self.daily_max_loss_usd = float(os.getenv("BITMART_DAILY_MAX_LOSS_USD", "-10")) - self.daily_halt_on_loss = os.getenv("BITMART_DAILY_HALT_ON_LOSS", "1").strip().lower() not in {"0", "false", "off", "no"} - self.daily_balance_refresh_seconds = float(os.getenv("BITMART_DAILY_BALANCE_REFRESH_SECONDS", "15")) - self.daily_stat_day = None - self.daily_start_balance = None - self.daily_current_balance = None - self.daily_pnl_usd = 0.0 - self.daily_halt = False - self.last_daily_balance_refresh_time = 0.0 - self.last_smart_status_log_time = 0.0 - - # 启动时尝试读取动态参数(可由优化脚本自动生成) - self.load_runtime_params() - self.capture_base_risk_params() - - def get_runtime_params_path(self): - params_path_env = os.getenv("BITMART_PARAMS_PATH") - return Path(params_path_env).expanduser() if params_path_env else Path(__file__).with_name("current_params.json") - - def load_runtime_params(self): - """ - 从 current_params.json 或环境变量 BITMART_PARAMS_PATH 指向的文件加载参数。 - 文件格式支持两种: - 1) {"params": {...}} - 2) {...} 直接是参数字典 - """ - params_path = self.get_runtime_params_path() - if not params_path.exists(): - logger.info(f"未找到动态参数文件,使用内置保守参数: {params_path}") - return - - allowed_keys = { - "leverage", - "take_profit_usd", - "stop_loss_usd", - "trailing_activation_usd", - "trailing_distance_usd", - "break_even_activation_usd", - "break_even_floor_usd", - "default_order_size", - "open_breakout_buffer_pct", - "enable_shadow_reverse", - "shadow_reverse_threshold_pct", - "enable_trend_filter", - "trend_ema_fast", - "trend_ema_slow", - "trend_strength_threshold_pct", - "trend_slope_lookback", - "trend_slope_threshold_pct", - "enable_ai_filter", - "ai_min_confidence", - "ai_bias", - "ai_w_trend_align", - "ai_w_breakout_strength", - "ai_w_entity_strength", - "ai_w_shadow_balance", - "ai_w_volatility_fit", - "open_cooldown_seconds", - "reverse_cooldown_seconds", - "reverse_min_move_pct", - "dynamic_risk_enabled", - "dynamic_risk_window_bars", - "dynamic_vol_target_pct", - "dynamic_scale_min", - "dynamic_scale_max", - "enable_smart_mode", - "vol_regime_low_threshold_pct", - "vol_regime_high_threshold_pct", - "mid_vol_order_size_multiplier", - "high_vol_order_size_multiplier", - "high_vol_pause_trading", - "mid_vol_stop_loss_multiplier", - "high_vol_stop_loss_multiplier", - "trade_asia_session_only", - "asia_session_start_hour_utc", - "asia_session_end_hour_utc", - "blocked_utc_hours_utc", - "daily_profit_reduce_size_usd", - "daily_reduced_size_multiplier", - "daily_max_loss_usd", - "daily_halt_on_loss", - } - - try: - with params_path.open("r", encoding="utf-8") as f: - loaded = json.load(f) - params = loaded.get("params", loaded) - if not isinstance(params, dict): - logger.warning(f"参数文件格式不正确,忽略: {params_path}") - return - - for key, value in params.items(): - if key not in allowed_keys: - continue - if key == "leverage": - setattr(self, key, str(value)) - elif key in { - "dynamic_risk_enabled", - "enable_shadow_reverse", - "enable_trend_filter", - "enable_ai_filter", - "enable_smart_mode", - "high_vol_pause_trading", - "trade_asia_session_only", - "daily_halt_on_loss", - }: - setattr(self, key, self._to_bool(value)) - elif key == "blocked_utc_hours_utc": - if isinstance(value, (list, tuple, set)): - parsed = {int(v) for v in value if str(v).strip().isdigit()} - setattr(self, key, {h for h in parsed if 0 <= h <= 23} or {0, 8, 16}) - else: - setattr(self, key, self._parse_int_set_csv(str(value), {0, 8, 16})) - else: - setattr(self, key, value) - - logger.success(f"已加载动态参数文件: {params_path}") - logger.info( - "动态参数生效: " - f"TP={self.take_profit_usd}, SL={self.stop_loss_usd}, " - f"TrailAct={self.trailing_activation_usd}, TrailDist={self.trailing_distance_usd}, " - f"BEAct={self.break_even_activation_usd}, BEFloor={self.break_even_floor_usd}, " - f"BreakoutBuf={self.open_breakout_buffer_pct}%, " - f"OpenCD={self.open_cooldown_seconds}s, ReverseCD={self.reverse_cooldown_seconds}s, " - f"ReverseMove={self.reverse_min_move_pct}%, " - f"TrendFilter={self.enable_trend_filter}, AIFilter={self.enable_ai_filter}, " - f"SmartMode={self.enable_smart_mode}" - ) - except Exception as e: - logger.error(f"加载动态参数文件失败: {e} | path={params_path}") - - def run_auto_optimization_before_trade(self): - """ - 启动前自动运行 optimize_params.py,完成后重新加载参数。 - 支持快速启动(少天数/少试验/单压力场景)与“参数文件若为新则跳过优化”。 - """ - if not self.auto_optimize_before_trade: - logger.info("启动前自动优化已禁用(BITMART_AUTO_OPTIMIZE_BEFORE_TRADE=0)") - return True - if self.auto_optimized_this_run: - return True - - output_path = self.get_runtime_params_path() - if self.auto_optimize_skip_if_fresh_hours > 0 and output_path.exists(): - try: - mtime = output_path.stat().st_mtime - age_hours = (time.time() - mtime) / 3600.0 - if age_hours <= self.auto_optimize_skip_if_fresh_hours: - logger.info( - f"参数文件 {output_path.name} {age_hours:.1f}h 内已更新,跳过本次优化(BITMART_AUTO_OPT_SKIP_IF_FRESH_HOURS={self.auto_optimize_skip_if_fresh_hours})" - ) - self.load_runtime_params() - self.capture_base_risk_params() - return True - except Exception as e: - logger.debug(f"检查参数文件新鲜度失败: {e}") - - script_path = self.auto_optimize_script_path - if not script_path.exists(): - msg = f"未找到参数优化脚本: {script_path}" - if self.auto_optimize_require_success: - logger.error(msg) - return False - logger.warning(f"{msg},继续使用当前参数") - return True - - output_path.parent.mkdir(parents=True, exist_ok=True) - - # 使用完整数据与试验数(不缩减);加速依赖优化脚本内部并行(n_jobs / 压力场景并行) - days = self.auto_optimize_days - train_days = self.auto_optimize_train_days - valid_days = self.auto_optimize_valid_days - window_step_days = self.auto_optimize_window_step_days - n_trials = self.auto_optimize_n_trials - stress_slippage = self.auto_optimize_stress_slippage - stress_fee = self.auto_optimize_stress_fee - - method = str(self.auto_optimize_method).strip().lower() or "auto" - if method not in {"auto", "optuna", "random"}: - logger.warning(f"未知优化方法 {method},自动改为 auto") - method = "auto" - if method == "optuna" and self.auto_optimize_allow_method_fallback: - try: - import optuna # noqa: F401 - except Exception: - logger.warning( - "当前环境未安装 optuna,启动前优化自动降级为 random。" - "如需 optuna,请执行: pip install optuna" - ) - method = "random" - - cmd = [ - sys.executable, - str(script_path), - "--days", - str(days), - "--symbol", - self.contract_symbol, - "--step", - "5", - "--train-days", - str(train_days), - "--valid-days", - str(valid_days), - "--window-step-days", - str(window_step_days), - "--method", - method, - "--n-trials", - str(n_trials), - "--valid-score-weight", - str(self.auto_optimize_valid_score_weight), - "--drawdown-guard", - str(self.auto_optimize_drawdown_guard), - "--stability-weight", - str(self.auto_optimize_stability_weight), - "--stress-slippage-multipliers", - stress_slippage, - "--stress-fee-multipliers", - stress_fee, - "--output", - str(output_path), - ] - if self.auto_optimize_n_jobs > 0: - cmd.extend(["--n-jobs", str(self.auto_optimize_n_jobs)]) - if self.auto_optimize_stress_workers > 0: - cmd.extend(["--n-stress-workers", str(self.auto_optimize_stress_workers)]) - if self.auto_optimize_data_file: - cmd.extend(["--data-file", self.auto_optimize_data_file]) - - logger.info("启动前自动参数优化开始(可能耗时较长)") - logger.info(f"优化输出参数文件: {output_path}") - logger.info("优化命令: " + " ".join(cmd)) - - try: - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - if process.stdout is not None: - for line in process.stdout: - text = line.strip() - if text: - logger.info(f"[参数优化] {text}") - return_code = process.wait() - except Exception as e: - if self.auto_optimize_require_success: - logger.error(f"启动前自动优化异常: {e}") - return False - logger.warning(f"启动前自动优化异常: {e},继续使用当前参数") - return True - - if return_code != 0: - if self.auto_optimize_require_success: - logger.error(f"启动前自动优化失败,退出码: {return_code}") - return False - logger.warning(f"启动前自动优化失败(退出码={return_code}),继续使用当前参数") - return True - - self.auto_optimized_this_run = True - self.load_runtime_params() - self.capture_base_risk_params() - self.last_dynamic_risk_kline_id = None - logger.success("启动前自动优化完成,已加载最新参数") - return True - - def capture_base_risk_params(self): - """记录参数基线,动态风控在此基线之上按波动率缩放。""" - self.base_risk_params = { - "take_profit_usd": float(self.take_profit_usd), - "stop_loss_usd": float(self.stop_loss_usd), - "trailing_activation_usd": float(self.trailing_activation_usd), - "trailing_distance_usd": float(self.trailing_distance_usd), - "break_even_activation_usd": float(self.break_even_activation_usd), - "break_even_floor_usd": float(self.break_even_floor_usd), - } - - def get_recent_volatility_pct(self, bars=None): - """ - 计算近 N 根 5m K线的平均振幅百分比(trimmed mean)。 - 返回: 波动率百分比,如 0.25 表示 0.25% - """ - bars = bars or self.dynamic_risk_window_bars - bars = max(12, int(bars)) - now_ts = int(time.time()) - window_seconds = (bars + 10) * 5 * 60 - try: - response = self.contractAPI.get_kline( - contract_symbol=self.contract_symbol, - step=5, - start_time=now_ts - window_seconds, - end_time=now_ts, - )[0] - if response.get("code") != 1000: - return None - data = response.get("data", []) - if len(data) < 12: - return None - - ranges = [] - for k in data[-bars:]: - high = self._safe_float(k.get("high_price")) - low = self._safe_float(k.get("low_price")) - close = self._safe_float(k.get("close_price")) - if high is None or low is None or close is None or close <= 0: - continue - ranges.append((high - low) / close * 100) - - if len(ranges) < 12: - return None - - ranges.sort() - trim = int(len(ranges) * 0.1) - core = ranges[trim: len(ranges) - trim] if len(ranges) > trim * 2 else ranges - if not core: - return None - return sum(core) / len(core) - except Exception: - return None - - def update_dynamic_risk_params(self, current_kline_id): - """ - 每根新K线按近期波动率动态更新风险参数,避免固定 TP/SL 不适配行情。 - """ - if not self.dynamic_risk_enabled: - return - if self.last_dynamic_risk_kline_id == current_kline_id: - return - if not self.base_risk_params: - self.capture_base_risk_params() - if not self.base_risk_params: - return - - vol_pct = self.get_recent_volatility_pct(self.dynamic_risk_window_bars) - if vol_pct is None or vol_pct <= 0: - logger.warning("动态风控: 波动率计算失败,继续使用当前风险参数") - self.last_dynamic_risk_kline_id = current_kline_id - return - - target = max(self.dynamic_vol_target_pct, 0.01) - scale = vol_pct / target - scale = max(self.dynamic_scale_min, min(self.dynamic_scale_max, scale)) - - base = self.base_risk_params - self.take_profit_usd = round(base["take_profit_usd"] * scale, 4) - self.stop_loss_usd = round(-abs(base["stop_loss_usd"]) * scale, 4) - self.trailing_activation_usd = round(base["trailing_activation_usd"] * scale, 4) - self.trailing_distance_usd = round(base["trailing_distance_usd"] * scale, 4) - self.break_even_activation_usd = round(base["break_even_activation_usd"] * scale, 4) - self.break_even_floor_usd = round(base["break_even_floor_usd"] * scale, 4) - - # 保持参数关系合理,防止逻辑冲突 - if self.trailing_activation_usd <= self.break_even_activation_usd: - self.trailing_activation_usd = round(self.break_even_activation_usd + 0.2, 4) - if self.take_profit_usd <= self.trailing_activation_usd: - self.take_profit_usd = round(self.trailing_activation_usd + 0.4, 4) - max_floor = self.break_even_activation_usd * 0.9 - if self.break_even_floor_usd > max_floor: - self.break_even_floor_usd = round(max_floor, 4) - if self.trailing_distance_usd >= self.trailing_activation_usd: - self.trailing_distance_usd = round(max(0.1, self.trailing_activation_usd * 0.6), 4) - - self.last_dynamic_risk_kline_id = current_kline_id - logger.info( - "动态风控更新: " - f"vol={vol_pct:.4f}% scale={scale:.3f} " - f"TP={self.take_profit_usd} SL={self.stop_loss_usd} " - f"TrailAct={self.trailing_activation_usd} TrailDist={self.trailing_distance_usd} " - f"BEAct={self.break_even_activation_usd} BEFloor={self.break_even_floor_usd}" - ) - - def _safe_float(self, value): - try: - f = float(value) - if f > 0: - return f - return None - except Exception: - return None - - def _to_bool(self, value): - if isinstance(value, bool): - return value - if isinstance(value, (int, float)): - return bool(value) - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y", "on"} - return False - - def _parse_int_set_csv(self, text, default): - if not text: - return set(default) - result = set() - for item in str(text).split(","): - token = item.strip() - if not token: - continue - try: - value = int(token) - except Exception: - continue - if 0 <= value <= 23: - result.add(value) - return result or set(default) - - def _clip(self, value, low, high): - return max(low, min(high, value)) - - def _normalize_hour(self, hour): - return int(hour) % 24 - - def _utc_hour(self, ts=None): - return int(time.strftime("%H", time.gmtime(ts if ts is not None else time.time()))) - - def _utc_day(self, ts=None): - return time.strftime("%Y-%m-%d", time.gmtime(ts if ts is not None else time.time())) - - def _is_hour_in_range(self, hour, start, end): - hour = self._normalize_hour(hour) - start = self._normalize_hour(start) - end = self._normalize_hour(end) - if start <= end: - return start <= hour <= end - return hour >= start or hour <= end - - def infer_volatility_regime(self): - vol_pct = self.get_recent_volatility_pct_from_cache(20) - if vol_pct is None: - return "unknown", None - - low_th = max(0.0, float(self.vol_regime_low_threshold_pct)) - high_th = max(low_th, float(self.vol_regime_high_threshold_pct)) - if vol_pct >= high_th: - return "high", vol_pct - if vol_pct <= low_th: - return "low", vol_pct - return "mid", vol_pct - - def refresh_daily_pnl_state(self, force=False): - now = time.time() - day = self._utc_day(now) - - if self.daily_stat_day != day: - self.daily_stat_day = day - self.daily_start_balance = None - self.daily_current_balance = None - self.daily_pnl_usd = 0.0 - self.daily_halt = False - self.last_daily_balance_refresh_time = 0.0 - logger.info(f"日内风控重置: day={day}") - - if not force and (now - self.last_daily_balance_refresh_time) < self.daily_balance_refresh_seconds: - return - - balance = self.get_available_balance() - self.last_daily_balance_refresh_time = now - if balance is None: - return - - self.daily_current_balance = float(balance) - if self.daily_start_balance is None: - self.daily_start_balance = float(balance) - logger.info(f"日内风控基准余额: {self.daily_start_balance:.4f} USDT") - - self.daily_pnl_usd = float(self.daily_current_balance - self.daily_start_balance) - if self.daily_halt_on_loss and self.daily_pnl_usd <= float(self.daily_max_loss_usd): - if not self.daily_halt: - logger.warning( - f"触发日内亏损熔断: DailyPnL={self.daily_pnl_usd:.2f} <= {self.daily_max_loss_usd:.2f}" - ) - self.daily_halt = True - - def get_smart_trade_controls(self): - controls = { - "vol_regime": "unknown", - "vol_pct": None, - "size_multiplier": 1.0, - "stop_loss_multiplier": 1.0, - "allow_new_trade": True, - "block_reason": "", - "daily_pnl_usd": self.daily_pnl_usd, - "daily_halt": self.daily_halt, - } - if not self.enable_smart_mode: - return controls - - # 1) 波动率分档 - vol_regime, vol_pct = self.infer_volatility_regime() - controls["vol_regime"] = vol_regime - controls["vol_pct"] = vol_pct - if vol_regime == "mid": - controls["size_multiplier"] *= max(0.05, float(self.mid_vol_order_size_multiplier)) - controls["stop_loss_multiplier"] *= max(0.05, float(self.mid_vol_stop_loss_multiplier)) - elif vol_regime == "high": - controls["size_multiplier"] *= max(0.05, float(self.high_vol_order_size_multiplier)) - controls["stop_loss_multiplier"] *= max(0.05, float(self.high_vol_stop_loss_multiplier)) - if self.high_vol_pause_trading: - controls["allow_new_trade"] = False - controls["block_reason"] = "高波动暂停开新仓" - - # 2) 时间段过滤 - hour = self._utc_hour() - if hour in self.blocked_utc_hours_utc: - controls["allow_new_trade"] = False - controls["block_reason"] = f"UTC {hour}:00 属于换线禁交易时段" - elif self.trade_asia_session_only: - if not self._is_hour_in_range(hour, self.asia_session_start_hour_utc, self.asia_session_end_hour_utc): - controls["allow_new_trade"] = False - controls["block_reason"] = ( - f"非亚洲交易时段(UTC {hour}:00,不在 {self.asia_session_start_hour_utc}-{self.asia_session_end_hour_utc})" - ) - - # 3) 日内盈亏管理 - self.refresh_daily_pnl_state(force=False) - controls["daily_pnl_usd"] = self.daily_pnl_usd - controls["daily_halt"] = self.daily_halt - if self.daily_pnl_usd >= float(self.daily_profit_reduce_size_usd): - controls["size_multiplier"] *= max(0.05, float(self.daily_reduced_size_multiplier)) - if self.daily_halt: - controls["allow_new_trade"] = False - controls["block_reason"] = f"日内亏损达到阈值 {self.daily_max_loss_usd:.2f},暂停开新仓" - - return controls - - def log_smart_trade_controls(self, controls): - now = time.time() - if now - self.last_smart_status_log_time < 30: - return - self.last_smart_status_log_time = now - vol_text = "n/a" if controls["vol_pct"] is None else f"{controls['vol_pct']:.4f}%" - logger.info( - "智能模式状态: " - f"regime={controls['vol_regime']} vol={vol_text} " - f"sizeMult={controls['size_multiplier']:.3f} slMult={controls['stop_loss_multiplier']:.3f} " - f"dailyPnL={controls['daily_pnl_usd']:.2f} allowNew={controls['allow_new_trade']} " - f"reason={controls['block_reason'] or '-'}" - ) - - def _ema_series(self, closes, period): - period = max(1, int(period)) - if not closes: - return [] - alpha = 2.0 / (period + 1.0) - ema_values = [float(closes[0])] - ema = float(closes[0]) - for close in closes[1:]: - ema = ema + alpha * (float(close) - ema) - ema_values.append(ema) - return ema_values - - def infer_trend_state(self): - """ - 根据缓存K线计算趋势状态。 - 返回: (bias, strength_pct, slope_pct) - bias: bull / bear / neutral - """ - klines = self.recent_klines_cache or [] - min_len = max(int(self.trend_ema_slow) + 2, int(self.trend_slope_lookback) + 3) - if len(klines) < min_len: - return "neutral", 0.0, 0.0 - - closes = [float(k["close"]) for k in klines if k.get("close") is not None] - if len(closes) < min_len: - return "neutral", 0.0, 0.0 - - fast_series = self._ema_series(closes, self.trend_ema_fast) - slow_series = self._ema_series(closes, self.trend_ema_slow) - if not fast_series or not slow_series: - return "neutral", 0.0, 0.0 - - fast = fast_series[-1] - slow = slow_series[-1] - if slow <= 0: - return "neutral", 0.0, 0.0 - - lookback = max(1, int(self.trend_slope_lookback)) - ref_idx = max(0, len(slow_series) - 1 - lookback) - slow_ref = slow_series[ref_idx] - slope_pct = (slow - slow_ref) / slow_ref * 100 if slow_ref > 0 else 0.0 - strength_pct = (fast - slow) / slow * 100 - - strength_th = max(0.0, float(self.trend_strength_threshold_pct)) - slope_th = max(0.0, float(self.trend_slope_threshold_pct)) - bias = "neutral" - if strength_pct >= strength_th and slope_pct >= slope_th: - bias = "bull" - elif strength_pct <= -strength_th and slope_pct <= -slope_th: - bias = "bear" - - return bias, strength_pct, slope_pct - - def get_recent_volatility_pct_from_cache(self, bars=20): - klines = self.recent_klines_cache or [] - bars = max(8, int(bars)) - if len(klines) < bars: - return None - - ranges = [] - for k in klines[-bars:]: - close = float(k["close"]) - if close <= 0: - continue - ranges.append((float(k["high"]) - float(k["low"])) / close * 100) - if not ranges: - return None - return sum(ranges) / len(ranges) - - def estimate_signal_confidence( - self, - side, - current_price, - entry_price, - prev_kline, - trend_bias, - trend_strength_pct, - ): - prev_entity = self.calculate_entity(prev_kline) - prev_close = max(float(prev_kline.get("close") or 0.0), 0.000001) - entity_pct = prev_entity / prev_close * 100.0 - upper_shadow_pct = self.calculate_upper_shadow(prev_kline) - lower_shadow_pct = self.calculate_lower_shadow(prev_kline) - - if entry_price <= 0: - breakout_move_pct = 0.0 - elif side == "long": - breakout_move_pct = max(0.0, (current_price - entry_price) / entry_price * 100.0) - else: - breakout_move_pct = max(0.0, (entry_price - current_price) / entry_price * 100.0) - - if side == "long": - trend_align = 1.0 if trend_bias == "bull" else (-1.0 if trend_bias == "bear" else 0.0) - shadow_balance = lower_shadow_pct - upper_shadow_pct - else: - trend_align = 1.0 if trend_bias == "bear" else (-1.0 if trend_bias == "bull" else 0.0) - shadow_balance = upper_shadow_pct - lower_shadow_pct - - trend_strength_scale = self._clip( - abs(trend_strength_pct) / max(float(self.trend_strength_threshold_pct), 0.01), - 0.0, - 2.5, - ) - trend_component = trend_align * trend_strength_scale - breakout_strength = self._clip(breakout_move_pct / 0.25, 0.0, 2.5) - entity_strength = self._clip(entity_pct / 0.50, 0.0, 2.5) - shadow_component = self._clip(shadow_balance / 0.30, -2.5, 2.5) - - vol_pct = self.get_recent_volatility_pct_from_cache(20) - target_vol = max(float(self.dynamic_vol_target_pct), 0.05) - if vol_pct is None: - vol_fit = 0.0 - else: - vol_fit = 1.0 - abs(vol_pct - target_vol) / (target_vol * 2.0) - vol_fit = self._clip(vol_fit, -1.5, 1.5) - - score = ( - float(self.ai_bias) - + float(self.ai_w_trend_align) * trend_component - + float(self.ai_w_breakout_strength) * breakout_strength - + float(self.ai_w_entity_strength) * entity_strength - + float(self.ai_w_shadow_balance) * shadow_component - + float(self.ai_w_volatility_fit) * vol_fit - ) - score = self._clip(score, -60.0, 60.0) - confidence = 1.0 / (1.0 + math.exp(-score)) - return confidence - - def should_take_open_signal( - self, - side, - current_price, - entry_price, - prev_kline, - trend_bias, - trend_strength_pct, - ): - if self.enable_trend_filter: - if side == "long" and trend_bias == "bear": - logger.info("趋势过滤拒绝做多:当前趋势偏空") - return False - if side == "short" and trend_bias == "bull": - logger.info("趋势过滤拒绝做空:当前趋势偏多") - return False - - if self.enable_ai_filter: - confidence = self.estimate_signal_confidence( - side=side, - current_price=current_price, - entry_price=entry_price, - prev_kline=prev_kline, - trend_bias=trend_bias, - trend_strength_pct=trend_strength_pct, - ) - logger.info( - f"AI信号打分: side={side}, confidence={confidence:.3f}, threshold={self.ai_min_confidence:.3f}" - ) - if confidence < float(self.ai_min_confidence): - logger.info("AI门控拒绝开仓:置信度不足") - return False - - return True - - def _extract_price_from_ws_payload(self, payload): - """ - 尽量从 WS 消息中提取价格,兼容不同字段结构。 - """ - key_priority = ("last_price", "last", "price", "close_price", "close", "mark_price") - stack = [payload] - while stack: - node = stack.pop(0) - if isinstance(node, dict): - for key in key_priority: - price = self._safe_float(node.get(key)) - if price is not None: - return price - - bid = self._safe_float(node.get("best_bid_price") or node.get("bid_price") or node.get("bid")) - ask = self._safe_float(node.get("best_ask_price") or node.get("ask_price") or node.get("ask")) - if bid is not None and ask is not None: - return (bid + ask) / 2.0 - - for value in node.values(): - if isinstance(value, (dict, list)): - stack.append(value) - elif isinstance(node, list): - for value in node: - if isinstance(value, (dict, list)): - stack.append(value) - return None - - def _on_ws_open(self, ws): - subscribe_msg = {"action": "subscribe", "args": [self.ws_topic]} - ws.send(json.dumps(subscribe_msg)) - logger.success(f"WebSocket 已连接并订阅: {self.ws_topic}") - - def _on_ws_message(self, ws, message): - try: - if isinstance(message, bytes): - message = message.decode("utf-8", errors="ignore") - if not message: - return - if message == "pong": - return - if message == "ping": - try: - ws.send("pong") - except Exception: - pass - return - - payload = json.loads(message) - if isinstance(payload, dict) and payload.get("success") is False: - logger.warning( - f"WebSocket 订阅失败: group={payload.get('group')} error={payload.get('error')}" - ) - return - - price = self._extract_price_from_ws_payload(payload) - if price is not None: - with self.ws_lock: - self.ws_last_price = price - self.ws_last_price_time = time.time() - except Exception: - return - - def _on_ws_error(self, ws, error): - if not self.ws_stop_event.is_set(): - logger.warning(f"WebSocket 价格流异常: {error}") - - def _on_ws_close(self, ws, close_status_code, close_msg): - if not self.ws_stop_event.is_set(): - logger.warning(f"WebSocket 价格流断开: code={close_status_code}, msg={close_msg}") - - def _ws_price_loop(self): - while not self.ws_stop_event.is_set(): - try: - self.ws_app = websocket.WebSocketApp( - self.ws_url, - on_open=self._on_ws_open, - on_message=self._on_ws_message, - on_error=self._on_ws_error, - on_close=self._on_ws_close, - ) - self.ws_app.run_forever(ping_interval=20, ping_timeout=10) - except Exception as e: - if not self.ws_stop_event.is_set(): - logger.warning(f"WebSocket 启动/运行失败: {e}") - finally: - self.ws_app = None - - if not self.ws_stop_event.is_set(): - time.sleep(self.ws_reconnect_seconds) - - def start_price_stream(self): - if not self.ws_enabled: - logger.info("WebSocket 实时价格已禁用(BITMART_WS_ENABLED=0)") - return False - if websocket is None: - logger.warning("未安装 websocket-client,实时价格不可用,将回退到 API 轮询") - return False - if self.ws_thread and self.ws_thread.is_alive(): - return True - - self.ws_stop_event.clear() - self.ws_thread = threading.Thread(target=self._ws_price_loop, name="bitmart-price-ws", daemon=True) - self.ws_thread.start() - return True - - def stop_price_stream(self): - self.ws_stop_event.set() - if self.ws_app is not None: - try: - self.ws_app.close() - except Exception: - pass - if self.ws_thread and self.ws_thread.is_alive(): - self.ws_thread.join(timeout=3) - - def get_ws_price(self): - with self.ws_lock: - price = self.ws_last_price - ts = self.ws_last_price_time - if price is None: - return None - - age = time.time() - ts - if age <= self.ws_price_ttl_seconds: - return price - - now = time.time() - if now - self.ws_last_stale_log_time > 30: - logger.info(f"WebSocket 价格超时({age:.1f}s),回退 API 获取价格") - self.ws_last_stale_log_time = now - return None - - def get_klines(self): - """获取最近2根K线(当前K线和上一根K线)""" - try: - end_time = int(time.time()) - window_hours = max( - 3, - int((max(int(self.dynamic_risk_window_bars), int(self.trend_ema_slow) + int(self.trend_slope_lookback) + 30) * 5) / 60) + 1, - ) - # 获取足够多的条目确保有最新的K线 - response = self.contractAPI.get_kline( - contract_symbol=self.contract_symbol, - step=5, # 5分钟 - start_time=end_time - 3600 * window_hours, - end_time=end_time - )[0]["data"] - - # 每根: [timestamp, open, high, low, close, volume] - formatted = [] - for k in response: - formatted.append({ - 'id': int(k["timestamp"]), - 'open': float(k["open_price"]), - 'high': float(k["high_price"]), - 'low': float(k["low_price"]), - 'close': float(k["close_price"]) - }) - formatted.sort(key=lambda x: x['id']) - self.recent_klines_cache = formatted - - # 返回最近2根K线:倒数第二根(上一根)和最后一根(当前) - if len(formatted) >= 2: - return formatted[-2], formatted[-1] - return None, None - except Exception as e: - logger.error(f"获取K线异常: {e}") - self.ding(text="获取K线异常", error=True) - return None, None - - def get_current_price(self): - """获取当前最新价格""" - ws_price = self.get_ws_price() - if ws_price is not None: - return ws_price - - try: - end_time = int(time.time()) - response = self.contractAPI.get_kline( - contract_symbol=self.contract_symbol, - step=1, # 1分钟 - start_time=end_time - 3600 * 1, # 取最近1小时 - end_time=end_time - )[0] - if response['code'] == 1000: - return float(response['data'][-1]["close_price"]) - return None - except Exception as e: - logger.error(f"获取价格异常: {e}") - return None - - def get_available_balance(self): - """获取合约账户可用USDT余额""" - try: - response = self.contractAPI.get_assets_detail()[0] - if response['code'] == 1000: - data = response['data'] - if isinstance(data, dict): - return float(data.get('available_balance', 0)) - elif isinstance(data, list): - for asset in data: - if asset.get('currency') == 'USDT': - return float(asset.get('available_balance', 0)) - return None - except Exception as e: - logger.error(f"余额查询异常: {e}") - return None - - def get_position_status(self): - """获取当前持仓方向""" - try: - response = self.contractAPI.get_position(contract_symbol=self.contract_symbol)[0] - if response['code'] == 1000: - positions = response['data'] - if not positions: - self.start = 0 - self.open_avg_price = None - self.current_amount = None - self.unrealized_pnl = None - return True - pos = positions[0] - self.start = 1 if pos['position_type'] == 1 else -1 - self.open_avg_price = float(pos['open_avg_price']) - self.current_amount = float(pos['current_amount']) - self.position_cross = pos["position_cross"] - # 直接从API获取未实现盈亏(Bitmart返回的是 unrealized_value 字段) - self.unrealized_pnl = float(pos.get('unrealized_value', 0)) - logger.debug(f"持仓详情: 方向={self.start}, 开仓均价={self.open_avg_price}, " - f"持仓量={self.current_amount}, 未实现盈亏={self.unrealized_pnl:.2f}") - return True - else: - return False - except Exception as e: - logger.error(f"持仓查询异常: {e}") - return False - - def get_unrealized_pnl_usd(self): - """ - 获取当前持仓未实现盈亏(美元),直接使用API返回值 - """ - if self.start == 0 or self.unrealized_pnl is None: - return None - return self.unrealized_pnl - - def set_leverage(self): - """程序启动时设置全仓 + 高杠杆""" - try: - response = self.contractAPI.post_submit_leverage( - contract_symbol=self.contract_symbol, - leverage=self.leverage, - open_type=self.open_type - )[0] - if response['code'] == 1000: - logger.success(f"全仓模式 + {self.leverage}x 杠杆设置成功") - return True - else: - logger.error(f"杠杆设置失败: {response}") - return False - except Exception as e: - logger.error(f"设置杠杆异常: {e}") - return False - - def openBrowser(self): - """打开 TGE 对应浏览器实例""" - try: - bit_port = openBrowser(id=self.bit_id) - co = ChromiumOptions() - co.set_local_port(port=bit_port) - self.page = ChromiumPage(addr_or_opts=co) - return True - except: - return False - - def click_safe(self, xpath, sleep=0.5): - """安全点击""" - try: - ele = self.page.ele(xpath) - if not ele: - return False - # ele.scroll.to_see(center=True) - # time.sleep(sleep) - ele.click(by_js=True) - return True - except: - return False - - def 平仓(self): - """平仓操作""" - self.click_safe('x://span[normalize-space(text()) ="市价"]') - - def set_order_size(self, size): - """设置下单数量,确保实盘与参数一致。""" - try: - qty = float(size if size is not None else self.default_order_size) - if qty <= 0: - qty = float(self.default_order_size) - qty_text = str(int(qty)) if float(qty).is_integer() else f"{qty:.4f}".rstrip("0").rstrip(".") - ele = self.page.ele('x://*[@id="size_0"]') - if not ele: - logger.warning("未找到数量输入框,无法设置下单数量") - return False - ele.input(vals=qty_text, clear=True) - return True - except Exception as e: - logger.warning(f"设置下单数量失败: {e}") - return False - - def 开单(self, marketPriceLongOrder=0, limitPriceShortOrder=0, size=None, price=None): - """ - marketPriceLongOrder 市价做多或者做空,1是做多,-1是做空 - limitPriceShortOrder 限价做多或者做空 - """ - size = self.default_order_size if size is None else size - if marketPriceLongOrder == -1: - self.click_safe('x://button[normalize-space(text()) ="市价"]') - self.set_order_size(size) - self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]') - elif marketPriceLongOrder == 1: - self.click_safe('x://button[normalize-space(text()) ="市价"]') - self.set_order_size(size) - self.click_safe('x://span[normalize-space(text()) ="买入/做多"]') - - if limitPriceShortOrder == -1: - self.click_safe('x://button[normalize-space(text()) ="限价"]') - self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True) - time.sleep(1) - self.set_order_size(size) - self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]') - elif limitPriceShortOrder == 1: - self.click_safe('x://button[normalize-space(text()) ="限价"]') - self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True) - time.sleep(1) - self.set_order_size(size) - self.click_safe('x://span[normalize-space(text()) ="买入/做多"]') - - def ding(self, text, error=False): - """日志通知""" - if error: - logger.error(text) - else: - logger.info(text) - - def calculate_entity(self, kline): - """计算K线实体大小(绝对值)""" - return abs(kline['close'] - kline['open']) - - def calculate_upper_shadow(self, kline): - """计算上阴线(上影线)涨幅百分比""" - # 上阴线 = (最高价 - max(开盘价, 收盘价)) / max(开盘价, 收盘价) - body_top = max(kline['open'], kline['close']) - if body_top == 0: - return 0 - return (kline['high'] - body_top) / body_top * 100 - - def calculate_lower_shadow(self, kline): - """计算下阴线(下影线)跌幅百分比""" - # 下阴线 = (min(开盘价, 收盘价) - 最低价) / min(开盘价, 收盘价) - body_bottom = min(kline['open'], kline['close']) - if body_bottom == 0: - return 0 - return (body_bottom - kline['low']) / body_bottom * 100 - - def get_entity_edge(self, kline): - """获取K线实体边(收盘价或开盘价,取决于是阳线还是阴线)""" - # 阳线(收盘>开盘):实体上边=收盘价,实体下边=开盘价 - # 阴线(收盘<开盘):实体上边=开盘价,实体下边=收盘价 - return { - 'upper': max(kline['open'], kline['close']), # 实体上边 - 'lower': min(kline['open'], kline['close']) # 实体下边 - } - - def check_signal(self, current_price, prev_kline, current_kline): - """ - 检查交易信号 - 返回: ('long', trigger_price) / ('short', trigger_price) / None - """ - # 计算上一根K线实体 - prev_entity = self.calculate_entity(prev_kline) - - # 实体过小不交易(实体 < 0.1) - if prev_entity < 0.1: - logger.info(f"上一根K线实体过小: {prev_entity:.4f},跳过信号检测") - return None - - # 获取上一根K线的实体上下边 - prev_entity_edge = self.get_entity_edge(prev_kline) - prev_entity_upper = prev_entity_edge['upper'] # 实体上边 - prev_entity_lower = prev_entity_edge['lower'] # 实体下边 - - # 优化:以下两种情况以当前这根的开盘价作为计算基准 - # 1) 上一根阳线 且 当前开盘价 > 上一根收盘价(跳空高开) - # 2) 上一根阴线 且 当前开盘价 < 上一根收盘价(跳空低开) - prev_is_bullish_for_calc = prev_kline['close'] > prev_kline['open'] - prev_is_bearish_for_calc = prev_kline['close'] < prev_kline['open'] - current_open_above_prev_close = current_kline['open'] > prev_kline['close'] - current_open_below_prev_close = current_kline['open'] < prev_kline['close'] - use_current_open_as_base = (prev_is_bullish_for_calc and current_open_above_prev_close) or (prev_is_bearish_for_calc and current_open_below_prev_close) - - if use_current_open_as_base: - # 以当前K线开盘价为基准计算(跳空时用当前开盘价参与计算) - calc_lower = current_kline['open'] - calc_upper = current_kline['open'] # 同一基准,上下四分之一对称 - long_trigger = calc_lower + prev_entity / 4 - short_trigger = calc_upper - prev_entity / 4 - long_breakout = calc_upper + prev_entity / 4 - short_breakout = calc_lower - prev_entity / 4 - else: - # 原有计算方式 - long_trigger = prev_entity_lower + prev_entity / 4 # 做多触发价 = 实体下边 + 实体/4(下四分之一处) - short_trigger = prev_entity_upper - prev_entity / 4 # 做空触发价 = 实体上边 - 实体/4(上四分之一处) - long_breakout = prev_entity_upper + prev_entity / 4 # 做多突破价 = 实体上边 + 实体/4 - short_breakout = prev_entity_lower - prev_entity / 4 # 做空突破价 = 实体下边 - 实体/4 - - # 保守模式:开仓需要超过突破位一定缓冲,减少假突破 - long_entry_price = long_breakout * (1 + self.open_breakout_buffer_pct / 100) - short_entry_price = short_breakout * (1 - self.open_breakout_buffer_pct / 100) - - # 上一根阴线 + 当前阳线:做多形态,不按上一根K线上三分之一做空 - prev_is_bearish = prev_kline['close'] < prev_kline['open'] - current_is_bullish = current_kline['close'] > current_kline['open'] - skip_short_by_upper_third = prev_is_bearish and current_is_bullish - # 上一根阳线 + 当前阴线:做空形态,不按上一根K线下三分之一做多 - prev_is_bullish = prev_kline['close'] > prev_kline['open'] - current_is_bearish = current_kline['close'] < current_kline['open'] - skip_long_by_lower_third = prev_is_bullish and current_is_bearish - - if use_current_open_as_base: - if prev_is_bullish_for_calc and current_open_above_prev_close: - logger.info(f"上一根阳线且当前开盘价({current_kline['open']:.2f})>上一根收盘价({prev_kline['close']:.2f}),以当前开盘价为基准计算") - else: - logger.info(f"上一根阴线且当前开盘价({current_kline['open']:.2f})<上一根收盘价({prev_kline['close']:.2f}),以当前开盘价为基准计算") - logger.info(f"当前价格: {current_price:.2f}, 上一根实体: {prev_entity:.4f}") - logger.info(f"上一根实体上边: {prev_entity_upper:.2f}, 下边: {prev_entity_lower:.2f}") - logger.info(f"做多触发价(下1/4): {long_trigger:.2f}, 做空触发价(上1/4): {short_trigger:.2f}") - logger.info(f"突破做多价(上1/4外): {long_breakout:.2f}, 突破做空价(下1/4外): {short_breakout:.2f}") - logger.info( - f"保守开仓价(加缓冲): 做多 {long_entry_price:.2f}, 做空 {short_entry_price:.2f}, 缓冲 {self.open_breakout_buffer_pct:.3f}%" - ) - if skip_short_by_upper_third: - logger.info("上一根阴线+当前阳线(做多形态),不按上四分之一做空") - if skip_long_by_lower_third: - logger.info("上一根阳线+当前阴线(做空形态),不按下四分之一做多") - - trend_bias, trend_strength_pct, trend_slope_pct = self.infer_trend_state() - self.last_trend_bias = trend_bias - self.last_trend_strength_pct = trend_strength_pct - self.last_trend_slope_pct = trend_slope_pct - logger.info( - f"趋势状态: bias={trend_bias}, strength={trend_strength_pct:.4f}%, slope={trend_slope_pct:.4f}%" - ) - - # 无持仓时检查开仓信号 - if self.start == 0: - if current_price >= long_entry_price and not skip_long_by_lower_third: - if self.should_take_open_signal( - side="long", - current_price=current_price, - entry_price=long_entry_price, - prev_kline=prev_kline, - trend_bias=trend_bias, - trend_strength_pct=trend_strength_pct, - ): - logger.info(f"触发做多信号!价格 {current_price:.2f} >= 保守开仓价 {long_entry_price:.2f}") - return ('long', long_entry_price) - elif current_price <= short_entry_price and not skip_short_by_upper_third: - if self.should_take_open_signal( - side="short", - current_price=current_price, - entry_price=short_entry_price, - prev_kline=prev_kline, - trend_bias=trend_bias, - trend_strength_pct=trend_strength_pct, - ): - logger.info(f"触发做空信号!价格 {current_price:.2f} <= 保守开仓价 {short_entry_price:.2f}") - return ('short', short_entry_price) - - # 持仓时检查反手信号 - elif self.start == 1: # 持多仓 - # 反手条件1: 价格跌到上一根K线的上三分之一处(做空触发价);上一根阴线+当前阳线做多时跳过 - if current_price <= short_trigger and not skip_short_by_upper_third: - logger.info(f"持多反手做空!价格 {current_price:.2f} <= 触发价(上1/4) {short_trigger:.2f}") - return ('reverse_short', short_trigger) - - # 反手条件2: 可选影线反手(保守模式默认关闭) - if self.enable_shadow_reverse: - upper_shadow_pct = self.calculate_upper_shadow(prev_kline) - if upper_shadow_pct > self.shadow_reverse_threshold_pct and current_price <= prev_entity_lower: - logger.info( - f"持多反手做空!上影线涨幅 {upper_shadow_pct:.4f}% > {self.shadow_reverse_threshold_pct}%," - f"价格 {current_price:.2f} <= 实体下边 {prev_entity_lower:.2f}" - ) - return ('reverse_short', prev_entity_lower) - - elif self.start == -1: # 持空仓 - # 反手条件1: 价格涨到上一根K线的下三分之一处(做多触发价);上一根阳线+当前阴线做空时跳过 - if current_price >= long_trigger and not skip_long_by_lower_third: - logger.info(f"持空反手做多!价格 {current_price:.2f} >= 触发价(下1/4) {long_trigger:.2f}") - return ('reverse_long', long_trigger) - - # 反手条件2: 可选影线反手(保守模式默认关闭) - if self.enable_shadow_reverse: - lower_shadow_pct = self.calculate_lower_shadow(prev_kline) - if lower_shadow_pct > self.shadow_reverse_threshold_pct and current_price >= prev_entity_upper: - logger.info( - f"持空反手做多!下影线跌幅 {lower_shadow_pct:.4f}% > {self.shadow_reverse_threshold_pct}%," - f"价格 {current_price:.2f} >= 实体上边 {prev_entity_upper:.2f}" - ) - return ('reverse_long', prev_entity_upper) - - return None - - def can_open(self, current_kline_id): - """开仓前过滤:同一根 K 线只开一次 + 开仓冷却时间。仅用于 long/short 新开仓。""" - now = time.time() - if self.last_open_kline_id is not None and self.last_open_kline_id == current_kline_id: - logger.info(f"开仓频率控制:本 K 线({current_kline_id})已开过仓,跳过") - return False - if self.last_open_time is not None and now - self.last_open_time < self.open_cooldown_seconds: - remain = self.open_cooldown_seconds - (now - self.last_open_time) - logger.info(f"开仓冷却中,剩余 {remain:.0f} 秒") - return False - return True - - def can_reverse(self, current_price, trigger_price): - """反手前过滤:冷却时间 + 最小价差""" - now = time.time() - if self.last_reverse_time and now - self.last_reverse_time < self.reverse_cooldown_seconds: - remain = self.reverse_cooldown_seconds - (now - self.last_reverse_time) - logger.info(f"反手冷却中,剩余 {remain:.0f} 秒") - return False - - if trigger_price and trigger_price > 0: - move_pct = abs(current_price - trigger_price) / trigger_price * 100 - if move_pct < self.reverse_min_move_pct: - logger.info(f"反手价差不足: {move_pct:.4f}% < {self.reverse_min_move_pct}%") - return False - - return True - - def verify_no_position(self, max_retries=5, retry_interval=3): - """ - 验证当前无持仓 - 返回: True 表示无持仓可以开仓,False 表示有持仓不能开仓 - """ - for i in range(max_retries): - if self.get_position_status(): - if self.start == 0: - logger.info(f"确认无持仓,可以开仓") - return True - else: - logger.warning( - f"仍有持仓 (方向: {self.start}),等待 {retry_interval} 秒后重试 ({i + 1}/{max_retries})") - time.sleep(retry_interval) - else: - logger.warning(f"查询持仓状态失败,等待 {retry_interval} 秒后重试 ({i + 1}/{max_retries})") - time.sleep(retry_interval) - - logger.error(f"经过 {max_retries} 次重试仍有持仓或查询失败,放弃开仓") - return False - - def verify_position_direction(self, expected_direction): - """ - 验证当前持仓方向是否与预期一致 - expected_direction: 1 多仓, -1 空仓 - 返回: True 表示持仓方向正确,False 表示不正确 - """ - if self.get_position_status(): - if self.start == expected_direction: - logger.info(f"持仓方向验证成功: {self.start}") - return True - else: - logger.warning(f"持仓方向不符: 期望 {expected_direction}, 实际 {self.start}") - return False - else: - logger.error("查询持仓状态失败") - return False - - def execute_trade(self, signal, size=None): - """执行交易。size 不传或为 None 时使用 default_order_size。""" - signal_type, trigger_price = signal - size = self.default_order_size if size is None else size - - if signal_type == 'long': - # 开多前先确认无持仓 - logger.info(f"准备开多,触发价: {trigger_price:.2f}") - if not self.get_position_status(): - logger.error("开仓前查询持仓状态失败,放弃开仓") - return False - if self.start != 0: - logger.warning(f"开多前发现已有持仓 (方向: {self.start}),放弃开仓避免双向持仓") - return False - - logger.info(f"确认无持仓,执行开多") - self.开单(marketPriceLongOrder=1, size=size) - time.sleep(3) # 等待订单执行 - - # 验证开仓是否成功 - if self.verify_position_direction(1): - self.max_unrealized_pnl_seen = None # 新仓位重置移动止损记录 - self.last_open_time = time.time() - self.last_open_kline_id = getattr(self, "_current_kline_id_for_open", None) - logger.success("开多成功") - return True - else: - logger.error("开多后持仓验证失败") - return False - - elif signal_type == 'short': - # 开空前先确认无持仓 - logger.info(f"准备开空,触发价: {trigger_price:.2f}") - if not self.get_position_status(): - logger.error("开仓前查询持仓状态失败,放弃开仓") - return False - if self.start != 0: - logger.warning(f"开空前发现已有持仓 (方向: {self.start}),放弃开仓避免双向持仓") - return False - - logger.info(f"确认无持仓,执行开空") - self.开单(marketPriceLongOrder=-1, size=size) - time.sleep(3) # 等待订单执行 - - # 验证开仓是否成功 - if self.verify_position_direction(-1): - self.max_unrealized_pnl_seen = None # 新仓位重置移动止损记录 - self.last_open_time = time.time() - self.last_open_kline_id = getattr(self, "_current_kline_id_for_open", None) - logger.success("开空成功") - return True - else: - logger.error("开空后持仓验证失败") - return False - - elif signal_type == 'reverse_long': - # 平空 + 开多(反手做多):先平仓,确认无仓后再开多,避免双向持仓 - logger.info(f"执行反手做多,触发价: {trigger_price:.2f}") - self.平仓() - time.sleep(1) # 给交易所处理平仓的时间 - # 轮询确认已无持仓再开多(最多等约 10 秒) - for _ in range(10): - if self.get_position_status() and self.start == 0: - break - time.sleep(1) - if self.start != 0: - logger.warning("反手做多:平仓后仍有持仓,放弃本次开多") - return False - logger.info("已确认无持仓,执行开多") - self.开单(marketPriceLongOrder=1, size=size) - time.sleep(3) - - if self.verify_position_direction(1): - self.max_unrealized_pnl_seen = None - logger.success("反手做多成功") - self.last_reverse_time = time.time() - time.sleep(20) - return True - else: - logger.error("反手做多后持仓验证失败") - return False - - elif signal_type == 'reverse_short': - # 平多 + 开空(反手做空):先平仓,确认无仓后再开空 - logger.info(f"执行反手做空,触发价: {trigger_price:.2f}") - self.平仓() - time.sleep(1) - for _ in range(10): - if self.get_position_status() and self.start == 0: - break - time.sleep(1) - if self.start != 0: - logger.warning("反手做空:平仓后仍有持仓,放弃本次开空") - return False - logger.info("已确认无持仓,执行开空") - self.开单(marketPriceLongOrder=-1, size=size) - time.sleep(3) - - if self.verify_position_direction(-1): - self.max_unrealized_pnl_seen = None - logger.success("反手做空成功") - self.last_reverse_time = time.time() - time.sleep(20) - return True - else: - logger.error("反手做空后持仓验证失败") - return False - - return False - - def action(self): - """主循环""" - - logger.info("开始运行四分之一策略交易(保守模式)...") - - # 启动前先自动优化参数,再进入交易 - if not self.run_auto_optimization_before_trade(): - logger.error("自动参数优化失败,策略停止启动") - return - - # 启动时设置全仓高杠杆 - if not self.set_leverage(): - logger.error("杠杆设置失败,程序继续运行但可能下单失败") - return - - # 初始化日内风控状态(方案B) - self.refresh_daily_pnl_state(force=True) - - if self.start_price_stream(): - logger.success("实时价格流已启动(WebSocket 优先)") - else: - logger.warning("实时价格流未启动,当前将使用 API 轮询价格") - - page_start = True - - try: - while True: - - if page_start: - # 打开浏览器 - for i in range(5): - if self.openBrowser(): - logger.info("浏览器打开成功") - break - else: - self.ding("打开浏览器失败!", error=True) - return - - # 进入交易页面 - self.page.get("https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT") - self.click_safe('x://button[normalize-space(text()) ="市价"]') - self.set_order_size(self.default_order_size) - - page_start = False - - try: - # 1. 获取K线数据(当前K线和上一根K线) - prev_kline, current_kline = self.get_klines() - if not prev_kline or not current_kline: - logger.warning("获取K线失败,等待重试...") - time.sleep(5) - continue - - # 记录进入新的K线 - current_kline_time = current_kline['id'] - if self.last_kline_time != current_kline_time: - self.last_kline_time = current_kline_time - logger.info(f"进入新K线: {current_kline_time}") - self.update_dynamic_risk_params(current_kline_time) - - # 2. 获取当前价格 - current_price = self.get_current_price() - if not current_price: - logger.warning("获取价格失败,等待重试...") - time.sleep(2) - continue - - # 3. 每次循环都通过SDK获取真实持仓状态(避免状态不同步导致双向持仓) - if not self.get_position_status(): - logger.warning("获取持仓状态失败,等待重试...") - time.sleep(2) - continue - - logger.debug(f"当前持仓状态: {self.start} (0=无, 1=多, -1=空)") - smart_controls = self.get_smart_trade_controls() - self.log_smart_trade_controls(smart_controls) - effective_stop_loss = self.stop_loss_usd - sl_mult = max(0.05, float(smart_controls.get("stop_loss_multiplier", 1.0))) - if effective_stop_loss < 0: - effective_stop_loss = -abs(float(effective_stop_loss)) * sl_mult - - # 3.5 止损/止盈/保本锁盈/移动止损 - if self.start != 0: - pnl_usd = self.get_unrealized_pnl_usd() - if pnl_usd is not None: - # 固定止损:亏损达到 stop_loss_usd 平仓 - if pnl_usd <= effective_stop_loss: - logger.info(f"仓位亏损 {pnl_usd:.2f} 美元 <= 止损 {effective_stop_loss:.2f} 美元,执行止损平仓") - self.平仓() - self.max_unrealized_pnl_seen = None - time.sleep(3) - continue - # 更新持仓期间最大盈利(用于移动止损) - if self.max_unrealized_pnl_seen is None: - self.max_unrealized_pnl_seen = pnl_usd - else: - self.max_unrealized_pnl_seen = max(self.max_unrealized_pnl_seen, pnl_usd) - # 保本锁盈:盈利达到 break_even_activation_usd 后,回落到 floor 即平仓 - if self.max_unrealized_pnl_seen >= self.break_even_activation_usd and pnl_usd <= self.break_even_floor_usd: - logger.info( - f"保本锁盈:最高盈利 {self.max_unrealized_pnl_seen:.2f} >= {self.break_even_activation_usd}," - f"当前盈利回落到 {pnl_usd:.2f} <= {self.break_even_floor_usd},执行平仓" - ) - self.平仓() - self.max_unrealized_pnl_seen = None - time.sleep(3) - continue - # 移动止损:盈利曾达到 activation 后,从最高盈利回撤 trailing_distance 则平仓 - if self.max_unrealized_pnl_seen >= self.trailing_activation_usd: - if pnl_usd < self.max_unrealized_pnl_seen - self.trailing_distance_usd: - logger.info(f"移动止损:当前盈利 {pnl_usd:.2f} 从最高 {self.max_unrealized_pnl_seen:.2f} 回撤 >= {self.trailing_distance_usd} 美元,平仓") - self.平仓() - self.max_unrealized_pnl_seen = None - time.sleep(3) - continue - # 止盈:盈利达到 take_profit_usd 平仓 - if pnl_usd >= self.take_profit_usd: - logger.info(f"仓位盈利 {pnl_usd:.2f} 美元 >= {self.take_profit_usd} 美元,执行止盈平仓") - self.平仓() - self.max_unrealized_pnl_seen = None - time.sleep(3) - continue - - # 4. 检查信号 - signal = self.check_signal(current_price, prev_kline, current_kline) - - # 4.5 智能模式拦截:高波动暂停/时间过滤/日内亏损熔断 - if signal and not smart_controls["allow_new_trade"]: - logger.info(f"智能模式阻止交易: {signal[0]} | {smart_controls['block_reason']}") - signal = None - - # 5. 反手过滤:冷却时间 + 最小价差 - if signal and signal[0].startswith('reverse_'): - if not self.can_reverse(current_price, signal[1]): - signal = None - - # 5.5 开仓频率过滤:同一根 K 线只开一次 + 开仓冷却 - if signal and signal[0] in ('long', 'short'): - if not self.can_open(current_kline_time): - signal = None - else: - self._current_kline_id_for_open = current_kline_time # 供 execute_trade 成功后记录 - - # 6. 有信号则执行交易 - if signal: - dynamic_size = max(1.0, float(self.default_order_size) * float(smart_controls["size_multiplier"])) - dynamic_size = round(dynamic_size, 4) - trade_success = self.execute_trade(signal, size=dynamic_size) - if trade_success: - logger.success( - f"交易执行完成: {signal[0]}, 下单数量={dynamic_size}, 当前持仓状态: {self.start}" - ) - page_start = True - else: - logger.warning(f"交易执行失败或被阻止: {signal[0]}") - - # 短暂等待后继续循环(同一根K线遇到信号就操作) - time.sleep(0.1) - - if page_start and self.page: - self.page.close() - time.sleep(5) - - except KeyboardInterrupt: - logger.info("用户中断,程序退出") - break - except Exception as e: - logger.error(f"主循环异常: {e}") - time.sleep(5) - finally: - self.stop_price_stream() - if self.page: - try: - self.page.close() - except Exception: - pass - - -if __name__ == '__main__': - BitmartFuturesTransactionConservative(bit_id="f2320f57e24c45529a009e1541e25961").action() diff --git a/bitmart/四分之一_新反手策略.py b/bitmart/四分之一_新反手策略.py deleted file mode 100644 index 308dbfd..0000000 --- a/bitmart/四分之一_新反手策略.py +++ /dev/null @@ -1,604 +0,0 @@ -import random -import time - -from tqdm import tqdm -from loguru import logger -from bit_tools import openBrowser -from DrissionPage import ChromiumPage -from DrissionPage import ChromiumOptions - -from bitmart.api_contract import APIContract - - -class BitmartFuturesTransaction: - def __init__(self, bit_id): - - self.page: ChromiumPage | None = None - - self.api_key = "a0fb7b98464fd9bcce67e7c519d58ec10d0c38a8" - self.secret_key = "4eaeba78e77aeaab1c2027f846a276d164f264a44c2c1bb1c5f3be50c8de1ca5" - self.memo = "合约交易" - - self.contract_symbol = "ETHUSDT" - - self.contractAPI = APIContract(self.api_key, self.secret_key, self.memo, timeout=(5, 15)) - - self.start = 0 # 持仓状态: -1 空, 0 无, 1 多 - self.direction = None - - self.pbar = tqdm(total=30, desc="等待K线", ncols=80) - - self.last_kline_time = None # 上一次处理的K线时间戳,用于判断是否是新K线 - - self.leverage = "100" # 高杠杆(全仓模式下可开更大仓位) - self.open_type = "cross" # 全仓模式 - self.risk_percent = 0.01 # 每次开仓使用可用余额的 1% - - self.open_avg_price = None # 开仓价格 - self.current_amount = None # 持仓量 - - self.bit_id = bit_id - - # 策略相关变量 - self.prev_kline = None # 上一根K线 - self.current_kline = None # 当前K线 - self.prev_entity = None # 上一根K线实体大小 - self.current_open = None # 当前K线开盘价 - - # 反手信号控制:记录当前K线是否已执行过反手(每根K线只执行一次) - self.reverse_executed_kline_id = None # 已执行反手的K线时间戳 - - def get_klines(self): - """获取最近2根K线(当前K线和上一根K线)""" - try: - end_time = int(time.time()) - # 获取足够多的条目确保有最新的K线 - response = self.contractAPI.get_kline( - contract_symbol=self.contract_symbol, - step=5, # 5分钟 - start_time=end_time - 3600 * 3, # 取最近3小时 - end_time=end_time - )[0]["data"] - - # 每根: [timestamp, open, high, low, close, volume] - formatted = [] - for k in response: - formatted.append({ - 'id': int(k["timestamp"]), - 'open': float(k["open_price"]), - 'high': float(k["high_price"]), - 'low': float(k["low_price"]), - 'close': float(k["close_price"]) - }) - formatted.sort(key=lambda x: x['id']) - - # 返回最近2根K线:倒数第二根(上一根)和最后一根(当前) - if len(formatted) >= 2: - return formatted[-2], formatted[-1] - return None, None - except Exception as e: - logger.error(f"获取K线异常: {e}") - self.ding(text="获取K线异常", error=True) - return None, None - - def get_current_price(self): - """获取当前最新价格""" - try: - end_time = int(time.time()) - response = self.contractAPI.get_kline( - contract_symbol=self.contract_symbol, - step=1, # 1分钟 - start_time=end_time - 3600 * 1, # 取最近1小时 - end_time=end_time - )[0] - if response['code'] == 1000: - return float(response['data'][-1]["close_price"]) - return None - except Exception as e: - logger.error(f"获取价格异常: {e}") - return None - - def get_available_balance(self): - """获取合约账户可用USDT余额""" - try: - response = self.contractAPI.get_assets_detail()[0] - if response['code'] == 1000: - data = response['data'] - if isinstance(data, dict): - return float(data.get('available_balance', 0)) - elif isinstance(data, list): - for asset in data: - if asset.get('currency') == 'USDT': - return float(asset.get('available_balance', 0)) - return None - except Exception as e: - logger.error(f"余额查询异常: {e}") - return None - - def get_position_status(self): - """获取当前持仓方向""" - try: - response = self.contractAPI.get_position(contract_symbol=self.contract_symbol)[0] - if response['code'] == 1000: - positions = response['data'] - if not positions: - self.start = 0 - return True - self.start = 1 if positions[0]['position_type'] == 1 else -1 - self.open_avg_price = float(positions[0]['open_avg_price']) - self.current_amount = positions[0]['current_amount'] - self.position_cross = positions[0]["position_cross"] - return True - else: - return False - except Exception as e: - logger.error(f"持仓查询异常: {e}") - return False - - def set_leverage(self): - """程序启动时设置全仓 + 高杠杆""" - try: - response = self.contractAPI.post_submit_leverage( - contract_symbol=self.contract_symbol, - leverage=self.leverage, - open_type=self.open_type - )[0] - if response['code'] == 1000: - logger.success(f"全仓模式 + {self.leverage}x 杠杆设置成功") - return True - else: - logger.error(f"杠杆设置失败: {response}") - return False - except Exception as e: - logger.error(f"设置杠杆异常: {e}") - return False - - def openBrowser(self): - """打开 TGE 对应浏览器实例""" - try: - bit_port = openBrowser(id=self.bit_id) - co = ChromiumOptions() - co.set_local_port(port=bit_port) - self.page = ChromiumPage(addr_or_opts=co) - return True - except: - return False - - def click_safe(self, xpath, sleep=0.5): - """安全点击""" - try: - ele = self.page.ele(xpath) - if not ele: - return False - # ele.scroll.to_see(center=True) - # time.sleep(sleep) - ele.click(by_js=True) - return True - except: - return False - - def 平仓(self): - """平仓操作""" - self.click_safe('x://span[normalize-space(text()) ="市价"]') - - def 开单(self, marketPriceLongOrder=0, limitPriceShortOrder=0, size=None, price=None): - """ - marketPriceLongOrder 市价做多或者做空,1是做多,-1是做空 - limitPriceShortOrder 限价做多或者做空 - """ - if marketPriceLongOrder == -1: - # self.click_safe('x://button[normalize-space(text()) ="市价"]') - # self.page.ele('x://*[@id="size_0"]').input(vals=size, clear=True) - self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]') - elif marketPriceLongOrder == 1: - # self.click_safe('x://button[normalize-space(text()) ="市价"]') - # self.page.ele('x://*[@id="size_0"]').input(vals=size, clear=True) - self.click_safe('x://span[normalize-space(text()) ="买入/做多"]') - - if limitPriceShortOrder == -1: - self.click_safe('x://button[normalize-space(text()) ="限价"]') - self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True) - time.sleep(1) - self.page.ele('x://*[@id="size_0"]').input(1) - self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]') - elif limitPriceShortOrder == 1: - self.click_safe('x://button[normalize-space(text()) ="限价"]') - self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True) - time.sleep(1) - self.page.ele('x://*[@id="size_0"]').input(1) - self.click_safe('x://span[normalize-space(text()) ="买入/做多"]') - - def ding(self, text, error=False): - """日志通知""" - if error: - logger.error(text) - else: - logger.info(text) - - def calculate_entity(self, kline): - """计算K线实体大小(绝对值)""" - return abs(kline['close'] - kline['open']) - - def calculate_upper_shadow(self, kline): - """计算上影线涨幅百分比""" - # 上影线 = (最高价 - max(开盘价, 收盘价)) / max(开盘价, 收盘价) - body_top = max(kline['open'], kline['close']) - if body_top == 0: - return 0 - return (kline['high'] - body_top) / body_top * 100 - - def calculate_lower_shadow(self, kline): - """计算下影线跌幅百分比""" - # 下影线 = (min(开盘价, 收盘价) - 最低价) / min(开盘价, 收盘价) - body_bottom = min(kline['open'], kline['close']) - if body_bottom == 0: - return 0 - return (body_bottom - kline['low']) / body_bottom * 100 - - def get_entity_edge(self, kline): - """获取K线实体边(收盘价或开盘价,取决于是阳线还是阴线)""" - # 阳线(收盘>开盘):实体上边=收盘价,实体下边=开盘价 - # 阴线(收盘<开盘):实体上边=开盘价,实体下边=收盘价 - return { - 'upper': max(kline['open'], kline['close']), # 实体上边 - 'lower': min(kline['open'], kline['close']) # 实体下边 - } - - def check_signal(self, current_price, prev_kline, current_kline): - """ - 检查交易信号 - 返回: ('long', trigger_price) / ('short', trigger_price) / None - """ - # 计算上一根K线实体 - prev_entity = self.calculate_entity(prev_kline) - - # 实体过小不交易(实体 < 0.1) - if prev_entity < 0.1: - logger.info(f"上一根K线实体过小: {prev_entity:.4f},跳过信号检测") - return None - - # 获取上一根K线的实体上下边 - prev_entity_edge = self.get_entity_edge(prev_kline) - prev_entity_upper = prev_entity_edge['upper'] # 实体上边 - prev_entity_lower = prev_entity_edge['lower'] # 实体下边 - - # 计算触发价(基于上一根K线实体位置) - long_trigger = prev_entity_lower + prev_entity / 4 # 做多触发价 = 实体下边 + 实体/4(下四分之一处) - short_trigger = prev_entity_upper - prev_entity / 4 # 做空触发价 = 实体上边 - 实体/4(上四分之一处) - - # 上一根阴线 + 当前阳线:做多形态,不按上一根K线上三分之一做空 - prev_is_bearish = prev_kline['close'] < prev_kline['open'] - current_is_bullish = current_kline['close'] > current_kline['open'] - skip_short_by_upper_third = prev_is_bearish and current_is_bullish - # 上一根阳线 + 当前阴线:做空形态,不按上一根K线下三分之一做多 - prev_is_bullish = prev_kline['close'] > prev_kline['open'] - current_is_bearish = current_kline['close'] < current_kline['open'] - skip_long_by_lower_third = prev_is_bullish and current_is_bearish - - logger.info(f"当前价格: {current_price:.2f}, 上一根实体: {prev_entity:.4f}") - logger.info(f"上一根实体上边: {prev_entity_upper:.2f}, 下边: {prev_entity_lower:.2f}") - logger.info(f"做多触发价(下1/4): {long_trigger:.2f}, 做空触发价(上1/4): {short_trigger:.2f}") - if skip_short_by_upper_third: - logger.info("上一根阴线+当前阳线(做多形态),不按上四分之一做空") - if skip_long_by_lower_third: - logger.info("上一根阳线+当前阴线(做空形态),不按下四分之一做多") - - # 无持仓时检查开仓信号(保留原有开仓逻辑) - if self.start == 0: - if current_price >= long_trigger and not skip_long_by_lower_third: - logger.info(f"触发做多信号!价格 {current_price:.2f} >= 触发价(下1/4) {long_trigger:.2f}") - return ('long', long_trigger) - elif current_price <= short_trigger and not skip_short_by_upper_third: - logger.info(f"触发做空信号!价格 {current_price:.2f} <= 触发价(上1/4) {short_trigger:.2f}") - return ('short', short_trigger) - - # ========== 止盈/止损逻辑(四分之一触发价)========== - # 计算基于当前K线开盘价的止盈止损触发价 - current_open = current_kline['open'] - stop_long_trigger = current_open + prev_entity / 4 # 止盈做多触发价 = 当前开盘价 + 上一根实体/4 - stop_short_trigger = current_open - prev_entity / 4 # 止损做空触发价 = 当前开盘价 - 上一根实体/4 - - logger.info(f"止盈止损触发价: 做多止盈={stop_long_trigger:.2f}, 做空止损={stop_short_trigger:.2f}") - - # 持多仓时检查止损信号(只平仓,不反手) - if self.start == 1: - if current_price <= stop_short_trigger and not skip_short_by_upper_third: - logger.info(f"【止损平仓】持多仓, 价格 {current_price:.2f} <= 止损价 {stop_short_trigger:.2f}") - return ('close_long', stop_short_trigger) - - # 持空仓时检查止盈信号(只平仓,不反手) - elif self.start == -1: - if current_price >= stop_long_trigger and not skip_long_by_lower_third: - logger.info(f"【止盈平仓】持空仓, 价格 {current_price:.2f} >= 止盈价 {stop_long_trigger:.2f}") - return ('close_short', stop_long_trigger) - - # ========== 反手逻辑(影线条件)========== - # 检查当前K线是否已执行过反手(每根K线只执行一次) - current_kline_id = current_kline['id'] - if self.reverse_executed_kline_id == current_kline_id: - logger.debug(f"当前K线 {current_kline_id} 已执行过反手,跳过反手检测") - return None - - # 持多仓时检查反手做空信号 - if self.start == 1: - # 反手条件: 上一根阳线 + 下影线>=0.1% + 当前先涨后跌到上一根最低价 - if prev_is_bullish: # 上一根是阳线 - lower_shadow_pct = self.calculate_lower_shadow(prev_kline) - current_went_up_first = current_kline['high'] > current_kline['open'] - - logger.debug(f"反手做空检测: 上一根阳线, 下影线跌幅={lower_shadow_pct:.4f}%, " - f"当前K线先涨过={current_went_up_first}, " - f"当前价格={current_price:.2f}, 上一根最低价={prev_kline['low']:.2f}") - - if lower_shadow_pct >= 0.1 and current_went_up_first and current_price <= prev_kline['low']: - logger.info(f"【反手做空】上一根阳线, 下影线跌幅 {lower_shadow_pct:.4f}% >= 0.1%, " - f"当前K线先涨过, 价格 {current_price:.2f} <= 上一根最低价 {prev_kline['low']:.2f}") - return ('reverse_short', prev_kline['low']) - - # 持空仓时检查反手做多信号 - elif self.start == -1: - # 反手条件: 上一根阴线 + 上影线>=0.1% + 当前先跌后涨到上一根最高价 - if prev_is_bearish: # 上一根是阴线 - upper_shadow_pct = self.calculate_upper_shadow(prev_kline) - current_went_down_first = current_kline['low'] < current_kline['open'] - - logger.debug(f"反手做多检测: 上一根阴线, 上影线涨幅={upper_shadow_pct:.4f}%, " - f"当前K线先跌过={current_went_down_first}, " - f"当前价格={current_price:.2f}, 上一根最高价={prev_kline['high']:.2f}") - - if upper_shadow_pct >= 0.1 and current_went_down_first and current_price >= prev_kline['high']: - logger.info(f"【反手做多】上一根阴线, 上影线涨幅 {upper_shadow_pct:.4f}% >= 0.1%, " - f"当前K线先跌过, 价格 {current_price:.2f} >= 上一根最高价 {prev_kline['high']:.2f}") - return ('reverse_long', prev_kline['high']) - - return None - - def verify_no_position(self, max_retries=5, retry_interval=3): - """ - 验证当前无持仓 - 返回: True 表示无持仓可以开仓,False 表示有持仓不能开仓 - """ - for i in range(max_retries): - if self.get_position_status(): - if self.start == 0: - logger.info(f"确认无持仓,可以开仓") - return True - else: - logger.warning( - f"仍有持仓 (方向: {self.start}),等待 {retry_interval} 秒后重试 ({i + 1}/{max_retries})") - time.sleep(retry_interval) - else: - logger.warning(f"查询持仓状态失败,等待 {retry_interval} 秒后重试 ({i + 1}/{max_retries})") - time.sleep(retry_interval) - - logger.error(f"经过 {max_retries} 次重试仍有持仓或查询失败,放弃开仓") - return False - - def verify_position_direction(self, expected_direction): - """ - 验证当前持仓方向是否与预期一致 - expected_direction: 1 多仓, -1 空仓 - 返回: True 表示持仓方向正确,False 表示不正确 - """ - if self.get_position_status(): - if self.start == expected_direction: - logger.info(f"持仓方向验证成功: {self.start}") - return True - else: - logger.warning(f"持仓方向不符: 期望 {expected_direction}, 实际 {self.start}") - return False - else: - logger.error("查询持仓状态失败") - return False - - def execute_trade(self, signal, current_kline_id, size=1): - """执行交易""" - signal_type, trigger_price = signal - size = 25 - - if signal_type == 'long': - # 开多前先确认无持仓 - logger.info(f"准备开多,触发价: {trigger_price:.2f}") - if not self.get_position_status(): - logger.error("开仓前查询持仓状态失败,放弃开仓") - return False - if self.start != 0: - logger.warning(f"开多前发现已有持仓 (方向: {self.start}),放弃开仓避免双向持仓") - return False - - logger.info(f"确认无持仓,执行开多") - self.开单(marketPriceLongOrder=1, size=size) - time.sleep(3) # 等待订单执行 - - # 验证开仓是否成功 - if self.verify_position_direction(1): - logger.success("开多成功") - return True - else: - logger.error("开多后持仓验证失败") - return False - - elif signal_type == 'short': - # 开空前先确认无持仓 - logger.info(f"准备开空,触发价: {trigger_price:.2f}") - if not self.get_position_status(): - logger.error("开仓前查询持仓状态失败,放弃开仓") - return False - if self.start != 0: - logger.warning(f"开空前发现已有持仓 (方向: {self.start}),放弃开仓避免双向持仓") - return False - - logger.info(f"确认无持仓,执行开空") - self.开单(marketPriceLongOrder=-1, size=size) - time.sleep(3) # 等待订单执行 - - # 验证开仓是否成功 - if self.verify_position_direction(-1): - logger.success("开空成功") - return True - else: - logger.error("开空后持仓验证失败") - return False - - elif signal_type == 'close_long': - # 止损平多仓(只平仓,不反手) - logger.info(f"执行止损平多仓,触发价: {trigger_price:.2f}") - self.平仓() - time.sleep(3) # 等待订单执行 - - # 验证平仓是否成功 - if self.get_position_status() and self.start == 0: - logger.success("止损平多仓成功") - time.sleep(20) # 额外等待避免频繁交易 - return True - else: - logger.error("止损平多仓后验证失败") - return False - - elif signal_type == 'close_short': - # 止盈平空仓(只平仓,不反手) - logger.info(f"执行止盈平空仓,触发价: {trigger_price:.2f}") - self.平仓() - time.sleep(3) # 等待订单执行 - - # 验证平仓是否成功 - if self.get_position_status() and self.start == 0: - logger.success("止盈平空仓成功") - time.sleep(20) # 额外等待避免频繁交易 - return True - else: - logger.error("止盈平空仓后验证失败") - return False - - elif signal_type == 'reverse_long': - # 平空 + 开多(反手做多)- 优化:平仓后立即开仓 - logger.info(f"执行反手做多,触发价: {trigger_price:.2f}") - self.平仓() - # time.sleep(1) # 等待1秒让平仓订单提交并更新UI - - # 立即执行开多,不等待平仓验证完成(市价单通常毫秒级成交) - logger.info("平仓已提交,立即执行开多") - self.开单(marketPriceLongOrder=1, size=size) - time.sleep(3) # 等待订单执行 - - # 验证开仓是否成功 - if self.verify_position_direction(1): - logger.success("反手做多成功") - # 记录当前K线已执行反手 - self.reverse_executed_kline_id = current_kline_id - time.sleep(20) # 额外等待避免频繁交易 - return True - else: - logger.error("反手做多后持仓验证失败") - return False - - elif signal_type == 'reverse_short': - # 平多 + 开空(反手做空)- 优化:平仓后立即开仓 - logger.info(f"执行反手做空,触发价: {trigger_price:.2f}") - self.平仓() - # time.sleep(1) # 等待1秒让平仓订单提交并更新UI - - # 立即执行开空,不等待平仓验证完成(市价单通常毫秒级成交) - logger.info("平仓已提交,立即执行开空") - self.开单(marketPriceLongOrder=-1, size=size) - time.sleep(3) # 等待订单执行 - - # 验证开仓是否成功 - if self.verify_position_direction(-1): - logger.success("反手做空成功") - # 记录当前K线已执行反手 - self.reverse_executed_kline_id = current_kline_id - time.sleep(20) # 额外等待避免频繁交易 - return True - else: - logger.error("反手做空后持仓验证失败") - return False - - return False - - def action(self): - """主循环""" - - logger.info("开始运行四分之一策略交易(新反手逻辑)...") - - # 启动时设置全仓高杠杆 - if not self.set_leverage(): - logger.error("杠杆设置失败,程序继续运行但可能下单失败") - return - - page_start = True - - while True: - - if page_start: - # 打开浏览器 - for i in range(5): - if self.openBrowser(): - logger.info("浏览器打开成功") - break - else: - self.ding("打开浏览器失败!", error=True) - return - - # 进入交易页面 - self.page.get("https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT") - self.click_safe('x://button[normalize-space(text()) ="市价"]') - size = 25 - self.page.ele('x://*[@id="size_0"]').input(vals=size, clear=True) - - page_start = False - - try: - # 1. 获取K线数据(当前K线和上一根K线) - prev_kline, current_kline = self.get_klines() - if not prev_kline or not current_kline: - logger.warning("获取K线失败,等待重试...") - time.sleep(5) - continue - - # 2. 获取当前价格 - current_price = self.get_current_price() - if not current_price: - logger.warning("获取价格失败,等待重试...") - time.sleep(2) - continue - - # 3. 每次循环都通过SDK获取真实持仓状态(避免状态不同步导致双向持仓) - if not self.get_position_status(): - logger.warning("获取持仓状态失败,等待重试...") - time.sleep(2) - continue - - logger.debug(f"当前持仓状态: {self.start} (0=无, 1=多, -1=空)") - - # 4. 检查信号 - signal = self.check_signal(current_price, prev_kline, current_kline) - - # 5. 有信号则执行交易 - if signal: - trade_success = self.execute_trade(signal, current_kline['id'], size=1) - if trade_success: - logger.success(f"交易执行完成: {signal[0]}, 当前持仓状态: {self.start}") - page_start = True - else: - logger.warning(f"交易执行失败或被阻止: {signal[0]}") - - # 6. 短暂等待后继续循环(同一根K线遇到信号就操作) - time.sleep(0.5) - - if page_start: - self.page.close() - time.sleep(25) - - page_start = True - - except KeyboardInterrupt: - logger.info("用户中断,程序退出") - break - except Exception as e: - logger.error(f"主循环异常: {e}") - time.sleep(5) - - -if __name__ == '__main__': - BitmartFuturesTransaction(bit_id="f2320f57e24c45529a009e1541e25961").action() diff --git a/bitmart/四分之一,五分钟,反手条件充足修改版.py b/bitmart/四分之一,五分钟,反手条件充足修改版.py new file mode 100644 index 0000000..8c35284 --- /dev/null +++ b/bitmart/四分之一,五分钟,反手条件充足修改版.py @@ -0,0 +1,752 @@ +import time + +from tqdm import tqdm +from loguru import logger +from bit_tools import openBrowser +from DrissionPage import ChromiumPage +from DrissionPage import ChromiumOptions + +from bitmart.api_contract import APIContract + + +class BitmartFuturesTransaction: + def __init__(self, bit_id): + + self.page: ChromiumPage | None = None + + self.api_key = "a0fb7b98464fd9bcce67e7c519d58ec10d0c38a8" + self.secret_key = "4eaeba78e77aeaab1c2027f846a276d164f264a44c2c1bb1c5f3be50c8de1ca5" + self.memo = "合约交易" + + self.contract_symbol = "ETHUSDT" + + self.contractAPI = APIContract(self.api_key, self.secret_key, self.memo, timeout=(5, 15)) + + self.start = 0 # 持仓状态: -1 空, 0 无, 1 多 + + self.pbar = tqdm(total=30, desc="等待K线", ncols=80) # 可选:用于长时间等待时展示进度 + + self.last_kline_time = None # 上一次处理的K线时间戳,用于判断是否是新K线 + + # 反手频率控制 + self.reverse_cooldown_seconds = 1.5 * 60 # 反手冷却时间(秒) + self.reverse_min_move_pct = 0.05 # 反手最小价差过滤(百分比) + self.last_reverse_time = None # 上次反手时间 + + # 开仓频率控制 + self.open_cooldown_seconds = 60 # 开仓冷却时间(秒),两次开仓至少间隔此时长 + self.last_open_time = None # 上次开仓时间 + self.last_open_kline_id = None # 上次开仓所在 K 线 id,同一根 K 线只允许开仓一次 + + self.leverage = "100" # 高杠杆(全仓模式下可开更大仓位) + self.open_type = "cross" # 全仓模式 + self.risk_percent = 0 # 未使用;若启用则可为每次开仓占可用余额的百分比 + self.take_profit_usd = 5 # 仓位盈利达到此金额(美元)时平仓止盈 + self.stop_loss_usd = -3 # 固定止损:亏损达到 3 美元平仓 + self.trailing_activation_usd = 2 # 盈利达到此金额后启动移动止损 + self.trailing_distance_usd = 1.5 # 从最高盈利回撤此金额则平仓 + self.max_unrealized_pnl_seen = None # 持仓期间见过的最大盈利(用于移动止损) + # 当前K线从极值回落平仓 + # 模式: 'fixed'=固定点数(drop_from_high_to_close);'pct_retrace'=按本K线涨幅比例动态算回撤 + self.drop_from_high_mode = 'pct_retrace' # 'fixed' | 'pct_retrace' + self.drop_from_high_to_close = 2 # fixed 模式下:回落/反弹超过此价格(点数)则平仓,0 表示关闭 + # pct_retrace 模式:本K线涨幅 = (最高-开盘)/开盘*100;允许回撤% = 涨幅% * retrace_ratio,从最高点回撤超过则平仓 + self.retrace_ratio = 0.5 # 回撤系数,如 0.5 表示允许回撤涨幅的 50%(类似斐波那契 50% 回撤) + self.min_rise_pct_to_activate = 0.02 # 至少涨/跌这么多才启用动态回撤,避免噪音 + self.min_drop_pct_from_high = 0.03 # 至少从最高点回撤这么多%才平仓(保底,避免过于敏感) + self._candle_high_seen = None # 当前K线内见过的最高价(多头用) + self._candle_low_seen = None # 当前K线内见过的最低价(空头用) + self._candle_id_for_high_low = None # 记录高低对应的K线 id,换线则重置 + + self.open_avg_price = None # 开仓价格 + self.current_amount = None # 持仓量 + + self.bit_id = bit_id + self.default_order_size = 25 # 开仓/反手张数,统一在此修改 + + # 策略相关变量 + self.prev_kline = None # 上一根K线 + self.current_kline = None # 当前K线 + self.prev_entity = None # 上一根K线实体大小 + self.current_open = None # 当前K线开盘价 + + def get_klines(self): + """获取最近2根K线(当前K线和上一根K线)""" + try: + end_time = int(time.time()) + # 获取足够多的条目确保有最新的K线 + response = self.contractAPI.get_kline( + contract_symbol=self.contract_symbol, + step=5, # 5分钟 + start_time=end_time - 3600 * 3, # 取最近3小时 + end_time=end_time + )[0]["data"] + + # 每根: [timestamp, open, high, low, close, volume] + formatted = [] + for k in response: + formatted.append({ + 'id': int(k["timestamp"]), + 'open': float(k["open_price"]), + 'high': float(k["high_price"]), + 'low': float(k["low_price"]), + 'close': float(k["close_price"]) + }) + formatted.sort(key=lambda x: x['id']) + + # 返回最近2根K线:倒数第二根(上一根)和最后一根(当前) + if len(formatted) >= 2: + return formatted[-2], formatted[-1] + return None, None + except Exception as e: + logger.error(f"获取K线异常: {e}") + self.ding(text="获取K线异常", error=True) + return None, None + + def get_current_price(self): + """获取当前最新价格""" + try: + end_time = int(time.time()) + response = self.contractAPI.get_kline( + contract_symbol=self.contract_symbol, + step=1, # 1分钟 + start_time=end_time - 3600 * 1, # 取最近1小时 + end_time=end_time + )[0] + if response['code'] == 1000: + return float(response['data'][-1]["close_price"]) + return None + except Exception as e: + logger.error(f"获取价格异常: {e}") + return None + + def get_available_balance(self): + """获取合约账户可用USDT余额""" + try: + response = self.contractAPI.get_assets_detail()[0] + if response['code'] == 1000: + data = response['data'] + if isinstance(data, dict): + return float(data.get('available_balance', 0)) + elif isinstance(data, list): + for asset in data: + if asset.get('currency') == 'USDT': + return float(asset.get('available_balance', 0)) + return None + except Exception as e: + logger.error(f"余额查询异常: {e}") + return None + + def get_position_status(self): + """获取当前持仓方向""" + try: + response = self.contractAPI.get_position(contract_symbol=self.contract_symbol)[0] + if response['code'] == 1000: + positions = response['data'] + if not positions: + self.start = 0 + self.open_avg_price = None + self.current_amount = None + self.unrealized_pnl = None + return True + pos = positions[0] + self.start = 1 if pos['position_type'] == 1 else -1 + self.open_avg_price = float(pos['open_avg_price']) + self.current_amount = float(pos['current_amount']) + self.position_cross = pos["position_cross"] + # 直接从API获取未实现盈亏(Bitmart返回的是 unrealized_value 字段) + self.unrealized_pnl = float(pos.get('unrealized_value', 0)) + logger.debug(f"持仓详情: 方向={self.start}, 开仓均价={self.open_avg_price}, " + f"持仓量={self.current_amount}, 未实现盈亏={self.unrealized_pnl:.2f}") + return True + else: + return False + except Exception as e: + logger.error(f"持仓查询异常: {e}") + return False + + def get_unrealized_pnl_usd(self): + """ + 获取当前持仓未实现盈亏(美元),直接使用API返回值 + """ + if self.start == 0 or self.unrealized_pnl is None: + return None + return self.unrealized_pnl + + def set_leverage(self): + """程序启动时设置全仓 + 高杠杆""" + try: + response = self.contractAPI.post_submit_leverage( + contract_symbol=self.contract_symbol, + leverage=self.leverage, + open_type=self.open_type + )[0] + if response['code'] == 1000: + logger.success(f"全仓模式 + {self.leverage}x 杠杆设置成功") + return True + else: + logger.error(f"杠杆设置失败: {response}") + return False + except Exception as e: + logger.error(f"设置杠杆异常: {e}") + return False + + def openBrowser(self): + """打开 TGE 对应浏览器实例""" + try: + bit_port = openBrowser(id=self.bit_id) + co = ChromiumOptions() + co.set_local_port(port=bit_port) + self.page = ChromiumPage(addr_or_opts=co) + return True + except: + return False + + def click_safe(self, xpath, sleep=0.5): + """安全点击""" + try: + ele = self.page.ele(xpath) + if not ele: + return False + # ele.scroll.to_see(center=True) + # time.sleep(sleep) + ele.click(by_js=True) + return True + except: + return False + + def 平仓(self): + """平仓操作""" + self.click_safe('x://span[normalize-space(text()) ="市价"]') + + def 开单(self, marketPriceLongOrder=0, limitPriceShortOrder=0, size=None, price=None): + """ + marketPriceLongOrder 市价做多或者做空,1是做多,-1是做空 + limitPriceShortOrder 限价做多或者做空 + """ + if marketPriceLongOrder == -1: + # self.click_safe('x://button[normalize-space(text()) ="市价"]') + # self.page.ele('x://*[@id="size_0"]').input(vals=size, clear=True) + self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]') + elif marketPriceLongOrder == 1: + # self.click_safe('x://button[normalize-space(text()) ="市价"]') + # self.page.ele('x://*[@id="size_0"]').input(vals=size, clear=True) + self.click_safe('x://span[normalize-space(text()) ="买入/做多"]') + + if limitPriceShortOrder == -1: + self.click_safe('x://button[normalize-space(text()) ="限价"]') + self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True) + time.sleep(1) + self.page.ele('x://*[@id="size_0"]').input(1) + self.click_safe('x://span[normalize-space(text()) ="卖出/做空"]') + elif limitPriceShortOrder == 1: + self.click_safe('x://button[normalize-space(text()) ="限价"]') + self.page.ele('x://*[@id="price_0"]').input(vals=price, clear=True) + time.sleep(1) + self.page.ele('x://*[@id="size_0"]').input(1) + self.click_safe('x://span[normalize-space(text()) ="买入/做多"]') + + def ding(self, text, error=False): + """日志通知""" + if error: + logger.error(text) + else: + logger.info(text) + + def calculate_entity(self, kline): + """计算K线实体大小(绝对值)""" + return abs(kline['close'] - kline['open']) + + def calculate_upper_shadow(self, kline): + """计算上阴线(上影线)涨幅百分比""" + # 上阴线 = (最高价 - max(开盘价, 收盘价)) / max(开盘价, 收盘价) + body_top = max(kline['open'], kline['close']) + if body_top == 0: + return 0 + return (kline['high'] - body_top) / body_top * 100 + + def calculate_lower_shadow(self, kline): + """计算下阴线(下影线)跌幅百分比""" + # 下阴线 = (min(开盘价, 收盘价) - 最低价) / min(开盘价, 收盘价) + body_bottom = min(kline['open'], kline['close']) + if body_bottom == 0: + return 0 + return (body_bottom - kline['low']) / body_bottom * 100 + + def get_entity_edge(self, kline): + """获取K线实体边(收盘价或开盘价,取决于是阳线还是阴线)""" + # 阳线(收盘>开盘):实体上边=收盘价,实体下边=开盘价 + # 阴线(收盘<开盘):实体上边=开盘价,实体下边=收盘价 + return { + 'upper': max(kline['open'], kline['close']), # 实体上边 + 'lower': min(kline['open'], kline['close']) # 实体下边 + } + + def check_signal(self, current_price, prev_kline, current_kline): + """ + 检查交易信号 + 返回: ('long', trigger_price) / ('short', trigger_price) / None + """ + # 计算上一根K线实体 + prev_entity = self.calculate_entity(prev_kline) + + # 实体过小不交易(实体 < 0.1) + if prev_entity < 0.1: + logger.info(f"上一根K线实体过小: {prev_entity:.4f},跳过信号检测") + return None + + # 获取上一根K线的实体上下边 + prev_entity_edge = self.get_entity_edge(prev_kline) + prev_entity_upper = prev_entity_edge['upper'] # 实体上边 + prev_entity_lower = prev_entity_edge['lower'] # 实体下边 + + # 优化:以下两种情况以当前这根的开盘价作为计算基准 + # 1) 上一根阳线 且 当前开盘价 > 上一根收盘价(跳空高开) + # 2) 上一根阴线 且 当前开盘价 < 上一根收盘价(跳空低开) + prev_is_bullish_for_calc = prev_kline['close'] > prev_kline['open'] + prev_is_bearish_for_calc = prev_kline['close'] < prev_kline['open'] + current_open_above_prev_close = current_kline['open'] > prev_kline['close'] + current_open_below_prev_close = current_kline['open'] < prev_kline['close'] + use_current_open_as_base = (prev_is_bullish_for_calc and current_open_above_prev_close) or (prev_is_bearish_for_calc and current_open_below_prev_close) + + if use_current_open_as_base: + # 以当前K线开盘价为基准计算(跳空时用当前开盘价参与计算) + calc_lower = current_kline['open'] + calc_upper = current_kline['open'] # 同一基准,上下四分之一对称 + long_trigger = calc_lower + prev_entity / 4 + short_trigger = calc_upper - prev_entity / 4 + long_breakout = calc_upper + prev_entity / 4 + short_breakout = calc_lower - prev_entity / 4 + else: + # 原有计算方式 + long_trigger = prev_entity_lower + prev_entity / 4 # 做多触发价 = 实体下边 + 实体/4(下四分之一处) + short_trigger = prev_entity_upper - prev_entity / 4 # 做空触发价 = 实体上边 - 实体/4(上四分之一处) + long_breakout = prev_entity_upper + prev_entity / 4 # 做多突破价 = 实体上边 + 实体/4 + short_breakout = prev_entity_lower - prev_entity / 4 # 做空突破价 = 实体下边 - 实体/4 + + # 上一根阴线 + 当前阳线:做多形态,不按上一根K线上三分之一做空 + prev_is_bearish = prev_kline['close'] < prev_kline['open'] + current_is_bullish = current_kline['close'] > current_kline['open'] + skip_short_by_upper_third = prev_is_bearish and current_is_bullish + # 上一根阳线 + 当前阴线:做空形态,不按上一根K线下三分之一做多 + prev_is_bullish = prev_kline['close'] > prev_kline['open'] + current_is_bearish = current_kline['close'] < current_kline['open'] + skip_long_by_lower_third = prev_is_bullish and current_is_bearish + + if use_current_open_as_base: + if prev_is_bullish_for_calc and current_open_above_prev_close: + logger.info(f"上一根阳线且当前开盘价({current_kline['open']:.2f})>上一根收盘价({prev_kline['close']:.2f}),以当前开盘价为基准计算") + else: + logger.info(f"上一根阴线且当前开盘价({current_kline['open']:.2f})<上一根收盘价({prev_kline['close']:.2f}),以当前开盘价为基准计算") + logger.info(f"当前价格: {current_price:.2f}, 上一根实体: {prev_entity:.4f}") + logger.info(f"上一根实体上边: {prev_entity_upper:.2f}, 下边: {prev_entity_lower:.2f}") + logger.info(f"做多触发价(下1/4): {long_trigger:.2f}, 做空触发价(上1/4): {short_trigger:.2f}") + logger.info(f"突破做多价(上1/4外): {long_breakout:.2f}, 突破做空价(下1/4外): {short_breakout:.2f}") + if skip_short_by_upper_third: + logger.info("上一根阴线+当前阳线(做多形态),不按上四分之一做空") + if skip_long_by_lower_third: + logger.info("上一根阳线+当前阴线(做空形态),不按下四分之一做多") + + # 无持仓时检查开仓信号 + if self.start == 0: + if current_price >= long_breakout and not skip_long_by_lower_third: + logger.info(f"触发做多信号!价格 {current_price:.2f} >= 突破价(上1/4外) {long_breakout:.2f}") + return ('long', long_breakout) + elif current_price <= short_breakout and not skip_short_by_upper_third: + logger.info(f"触发做空信号!价格 {current_price:.2f} <= 突破价(下1/4外) {short_breakout:.2f}") + return ('short', short_breakout) + + # 持仓时检查反手信号 + elif self.start == 1: # 持多仓 + # 反手条件1: 价格跌到上一根K线的上三分之一处(做空触发价);上一根阴线+当前阳线做多时跳过 + if current_price <= short_trigger and not skip_short_by_upper_third: + logger.info(f"持多反手做空!价格 {current_price:.2f} <= 触发价(上1/4) {short_trigger:.2f}") + return ('reverse_short', short_trigger) + + # 反手条件2: 上一根K线上阴线涨幅>0.01%,当前跌到上一根实体下边 + upper_shadow_pct = self.calculate_upper_shadow(prev_kline) + if upper_shadow_pct > 0.01 and current_price <= prev_entity_lower: + logger.info(f"持多反手做空!上阴线涨幅 {upper_shadow_pct:.4f}% > 0.01%," + f"价格 {current_price:.2f} <= 实体下边 {prev_entity_lower:.2f}") + return ('reverse_short', prev_entity_lower) + + elif self.start == -1: # 持空仓 + # 反手条件1: 价格涨到上一根K线的下三分之一处(做多触发价);上一根阳线+当前阴线做空时跳过 + if current_price >= long_trigger and not skip_long_by_lower_third: + logger.info(f"持空反手做多!价格 {current_price:.2f} >= 触发价(下1/4) {long_trigger:.2f}") + return ('reverse_long', long_trigger) + + # 反手条件2: 上一根K线下阴线跌幅>0.01%,当前涨到上一根实体上边 + lower_shadow_pct = self.calculate_lower_shadow(prev_kline) + if lower_shadow_pct > 0.01 and current_price >= prev_entity_upper: + logger.info(f"持空反手做多!下阴线跌幅 {lower_shadow_pct:.4f}% > 0.01%," + f"价格 {current_price:.2f} >= 实体上边 {prev_entity_upper:.2f}") + return ('reverse_long', prev_entity_upper) + + return None + + def can_open(self, current_kline_id): + """开仓前过滤:同一根 K 线只开一次 + 开仓冷却时间。仅用于 long/short 新开仓。""" + now = time.time() + if self.last_open_kline_id is not None and self.last_open_kline_id == current_kline_id: + logger.info(f"开仓频率控制:本 K 线({current_kline_id})已开过仓,跳过") + return False + if self.last_open_time is not None and now - self.last_open_time < self.open_cooldown_seconds: + remain = self.open_cooldown_seconds - (now - self.last_open_time) + logger.info(f"开仓冷却中,剩余 {remain:.0f} 秒") + return False + return True + + def can_reverse(self, current_price, trigger_price): + """反手前过滤:冷却时间 + 最小价差""" + now = time.time() + if self.last_reverse_time and now - self.last_reverse_time < self.reverse_cooldown_seconds: + remain = self.reverse_cooldown_seconds - (now - self.last_reverse_time) + logger.info(f"反手冷却中,剩余 {remain:.0f} 秒") + return False + + if trigger_price and trigger_price > 0: + move_pct = abs(current_price - trigger_price) / trigger_price * 100 + if move_pct < self.reverse_min_move_pct: + logger.info(f"反手价差不足: {move_pct:.4f}% < {self.reverse_min_move_pct}%") + return False + + return True + + def verify_no_position(self, max_retries=5, retry_interval=3): + """ + 验证当前无持仓 + 返回: True 表示无持仓可以开仓,False 表示有持仓不能开仓 + """ + for i in range(max_retries): + if self.get_position_status(): + if self.start == 0: + logger.info(f"确认无持仓,可以开仓") + return True + else: + logger.warning( + f"仍有持仓 (方向: {self.start}),等待 {retry_interval} 秒后重试 ({i + 1}/{max_retries})") + time.sleep(retry_interval) + else: + logger.warning(f"查询持仓状态失败,等待 {retry_interval} 秒后重试 ({i + 1}/{max_retries})") + time.sleep(retry_interval) + + logger.error(f"经过 {max_retries} 次重试仍有持仓或查询失败,放弃开仓") + return False + + def verify_position_direction(self, expected_direction): + """ + 验证当前持仓方向是否与预期一致 + expected_direction: 1 多仓, -1 空仓 + 返回: True 表示持仓方向正确,False 表示不正确 + """ + if self.get_position_status(): + if self.start == expected_direction: + logger.info(f"持仓方向验证成功: {self.start}") + return True + else: + logger.warning(f"持仓方向不符: 期望 {expected_direction}, 实际 {self.start}") + return False + else: + logger.error("查询持仓状态失败") + return False + + def execute_trade(self, signal, size=None): + """执行交易。size 不传或为 None 时使用 default_order_size。""" + signal_type, trigger_price = signal + size = self.default_order_size if size is None else size + + if signal_type == 'long': + # 开多前先确认无持仓 + logger.info(f"准备开多,触发价: {trigger_price:.2f}") + if not self.get_position_status(): + logger.error("开仓前查询持仓状态失败,放弃开仓") + return False + if self.start != 0: + logger.warning(f"开多前发现已有持仓 (方向: {self.start}),放弃开仓避免双向持仓") + return False + + logger.info(f"确认无持仓,执行开多") + self.开单(marketPriceLongOrder=1, size=size) + time.sleep(3) # 等待订单执行 + + # 验证开仓是否成功 + if self.verify_position_direction(1): + self.max_unrealized_pnl_seen = None # 新仓位重置移动止损记录 + self.last_open_time = time.time() + self.last_open_kline_id = getattr(self, "_current_kline_id_for_open", None) + logger.success("开多成功") + return True + else: + logger.error("开多后持仓验证失败") + return False + + elif signal_type == 'short': + # 开空前先确认无持仓 + logger.info(f"准备开空,触发价: {trigger_price:.2f}") + if not self.get_position_status(): + logger.error("开仓前查询持仓状态失败,放弃开仓") + return False + if self.start != 0: + logger.warning(f"开空前发现已有持仓 (方向: {self.start}),放弃开仓避免双向持仓") + return False + + logger.info(f"确认无持仓,执行开空") + self.开单(marketPriceLongOrder=-1, size=size) + time.sleep(3) # 等待订单执行 + + # 验证开仓是否成功 + if self.verify_position_direction(-1): + self.max_unrealized_pnl_seen = None # 新仓位重置移动止损记录 + self.last_open_time = time.time() + self.last_open_kline_id = getattr(self, "_current_kline_id_for_open", None) + logger.success("开空成功") + return True + else: + logger.error("开空后持仓验证失败") + return False + + elif signal_type == 'reverse_long': + # 平空 + 开多(反手做多):先平仓,确认无仓后再开多,避免双向持仓 + logger.info(f"执行反手做多,触发价: {trigger_price:.2f}") + self.平仓() + time.sleep(1) # 给交易所处理平仓的时间 + # 轮询确认已无持仓再开多(最多等约 10 秒) + for _ in range(10): + if self.get_position_status() and self.start == 0: + break + time.sleep(1) + if self.start != 0: + logger.warning("反手做多:平仓后仍有持仓,放弃本次开多") + return False + logger.info("已确认无持仓,执行开多") + self.开单(marketPriceLongOrder=1, size=size) + time.sleep(3) + + if self.verify_position_direction(1): + self.max_unrealized_pnl_seen = None + logger.success("反手做多成功") + self.last_reverse_time = time.time() + time.sleep(20) + return True + else: + logger.error("反手做多后持仓验证失败") + return False + + elif signal_type == 'reverse_short': + # 平多 + 开空(反手做空):先平仓,确认无仓后再开空 + logger.info(f"执行反手做空,触发价: {trigger_price:.2f}") + self.平仓() + time.sleep(1) + for _ in range(10): + if self.get_position_status() and self.start == 0: + break + time.sleep(1) + if self.start != 0: + logger.warning("反手做空:平仓后仍有持仓,放弃本次开空") + return False + logger.info("已确认无持仓,执行开空") + self.开单(marketPriceLongOrder=-1, size=size) + time.sleep(3) + + if self.verify_position_direction(-1): + self.max_unrealized_pnl_seen = None + logger.success("反手做空成功") + self.last_reverse_time = time.time() + time.sleep(20) + return True + else: + logger.error("反手做空后持仓验证失败") + return False + + return False + + def action(self): + """主循环""" + + logger.info("开始运行四分之一策略交易...") + + # 启动时设置全仓高杠杆 + if not self.set_leverage(): + logger.error("杠杆设置失败,程序继续运行但可能下单失败") + return + + page_start = True + + while True: + + if page_start: + # 打开浏览器 + for i in range(5): + if self.openBrowser(): + logger.info("浏览器打开成功") + break + else: + self.ding("打开浏览器失败!", error=True) + return + + # 进入交易页面 + self.page.get("https://derivatives.bitmart.com/zh-CN/futures/ETHUSDT") + self.click_safe('x://button[normalize-space(text()) ="市价"]') + + self.page.ele('x://*[@id="size_0"]').input(vals=25, clear=True) + + page_start = False + + try: + # 1. 获取K线数据(当前K线和上一根K线) + prev_kline, current_kline = self.get_klines() + if not prev_kline or not current_kline: + logger.warning("获取K线失败,等待重试...") + time.sleep(5) + continue + + # 记录进入新的K线 + current_kline_time = current_kline['id'] + if self.last_kline_time != current_kline_time: + self.last_kline_time = current_kline_time + logger.info(f"进入新K线: {current_kline_time}") + + # 2. 获取当前价格 + current_price = self.get_current_price() + if not current_price: + logger.warning("获取价格失败,等待重试...") + time.sleep(2) + continue + + # 3. 每次循环都通过SDK获取真实持仓状态(避免状态不同步导致双向持仓) + if not self.get_position_status(): + logger.warning("获取持仓状态失败,等待重试...") + time.sleep(2) + continue + + logger.debug(f"当前持仓状态: {self.start} (0=无, 1=多, -1=空)") + + # 3.5 止损/止盈/移动止损 + 当前K线从极值回落平仓 + if self.start != 0: + # 当前K线从最高/最低点回落平仓:换线重置跟踪,有持仓时更新本K线内最高/最低价并检查 + if self._candle_id_for_high_low != current_kline_time: + self._candle_high_seen = None + self._candle_low_seen = None + self._candle_id_for_high_low = current_kline_time + use_fixed = self.drop_from_high_mode == 'fixed' and self.drop_from_high_to_close and self.drop_from_high_to_close > 0 + use_pct = self.drop_from_high_mode == 'pct_retrace' + if use_fixed or use_pct: + if self.start == 1: # 多头:跟踪当前K线最高价,从最高点回落超过阈值则平仓 + self._candle_high_seen = max(self._candle_high_seen or 0, current_price) + do_close = False + if use_fixed and self._candle_high_seen and current_price <= self._candle_high_seen - self.drop_from_high_to_close: + do_close = True + reason = f"固定回落 {self.drop_from_high_to_close}" + elif use_pct and self._candle_high_seen and current_kline.get('open'): + candle_open = float(current_kline['open']) + rise_pct = (self._candle_high_seen - candle_open) / candle_open * 100 if candle_open > 0 else 0 + if rise_pct >= self.min_rise_pct_to_activate: + drop_trigger_pct = max(self.min_drop_pct_from_high, rise_pct * self.retrace_ratio) + drop_pct = (self._candle_high_seen - current_price) / self._candle_high_seen * 100 if self._candle_high_seen else 0 + if drop_pct >= drop_trigger_pct: + do_close = True + reason = f"涨幅 {rise_pct:.3f}% → 允许回撤 {drop_trigger_pct:.3f}%,实际回撤 {drop_pct:.3f}%" + else: + pass # 涨幅不足,不启用动态回撤 + if do_close: + logger.info(f"当前K线从最高点回落平仓:最高 {self._candle_high_seen:.2f},当前 {current_price:.2f},{reason}") + self.平仓() + self.max_unrealized_pnl_seen = None + self._candle_high_seen = None + time.sleep(3) + continue + elif self.start == -1: # 空头:跟踪当前K线最低价,从最低点反弹超过阈值则平仓 + self._candle_low_seen = min(self._candle_low_seen or float('inf'), current_price) + do_close = False + if use_fixed and self._candle_low_seen and current_price >= self._candle_low_seen + self.drop_from_high_to_close: + do_close = True + reason = f"固定反弹 {self.drop_from_high_to_close}" + elif use_pct and self._candle_low_seen and current_kline.get('open'): + candle_open = float(current_kline['open']) + rise_pct = (candle_open - self._candle_low_seen) / candle_open * 100 if candle_open > 0 else 0 # 对空头是“跌幅” + if rise_pct >= self.min_rise_pct_to_activate: + drop_trigger_pct = max(self.min_drop_pct_from_high, rise_pct * self.retrace_ratio) + bounce_pct = (current_price - self._candle_low_seen) / self._candle_low_seen * 100 if self._candle_low_seen else 0 + if bounce_pct >= drop_trigger_pct: + do_close = True + reason = f"跌幅 {rise_pct:.3f}% → 允许反弹 {drop_trigger_pct:.3f}%,实际反弹 {bounce_pct:.3f}%" + if do_close: + logger.info(f"当前K线从最低点反弹平仓:最低 {self._candle_low_seen:.2f},当前 {current_price:.2f},{reason}") + self.平仓() + self.max_unrealized_pnl_seen = None + self._candle_low_seen = None + time.sleep(3) + continue + + pnl_usd = self.get_unrealized_pnl_usd() + if pnl_usd is not None: + # 固定止损:亏损达到 3 美元平仓 + if pnl_usd <= self.stop_loss_usd: + logger.info(f"仓位亏损 {pnl_usd:.2f} 美元 <= 止损 {self.stop_loss_usd} 美元,执行止损平仓") + self.平仓() + self.max_unrealized_pnl_seen = None + time.sleep(3) + continue + # 更新持仓期间最大盈利(用于移动止损) + if self.max_unrealized_pnl_seen is None: + self.max_unrealized_pnl_seen = pnl_usd + else: + self.max_unrealized_pnl_seen = max(self.max_unrealized_pnl_seen, pnl_usd) + # 移动止损:盈利曾达到 activation 后,从最高盈利回撤 trailing_distance 则平仓 + if self.max_unrealized_pnl_seen >= self.trailing_activation_usd: + if pnl_usd < self.max_unrealized_pnl_seen - self.trailing_distance_usd: + logger.info(f"移动止损:当前盈利 {pnl_usd:.2f} 从最高 {self.max_unrealized_pnl_seen:.2f} 回撤 >= {self.trailing_distance_usd} 美元,平仓") + self.平仓() + self.max_unrealized_pnl_seen = None + time.sleep(3) + continue + # 止盈:盈利达到 take_profit_usd 平仓 + if pnl_usd >= self.take_profit_usd: + logger.info(f"仓位盈利 {pnl_usd:.2f} 美元 >= {self.take_profit_usd} 美元,执行止盈平仓") + self.平仓() + self.max_unrealized_pnl_seen = None + time.sleep(3) + continue + + # 4. 检查信号 + signal = self.check_signal(current_price, prev_kline, current_kline) + + # 5. 反手过滤:冷却时间 + 最小价差 + if signal and signal[0].startswith('reverse_'): + if not self.can_reverse(current_price, signal[1]): + signal = None + + # 5.5 开仓频率过滤:同一根 K 线只开一次 + 开仓冷却 + if signal and signal[0] in ('long', 'short'): + if not self.can_open(current_kline_time): + signal = None + else: + self._current_kline_id_for_open = current_kline_time # 供 execute_trade 成功后记录 + + # 6. 有信号则执行交易 + if signal: + trade_success = self.execute_trade(signal) + if trade_success: + logger.success(f"交易执行完成: {signal[0]}, 当前持仓状态: {self.start}") + page_start = True + else: + logger.warning(f"交易执行失败或被阻止: {signal[0]}") + + # 短暂等待后继续循环(同一根K线遇到信号就操作) + time.sleep(0.1) + + if page_start: + self.page.close() + time.sleep(5) + + except KeyboardInterrupt: + logger.info("用户中断,程序退出") + break + except Exception as e: + logger.error(f"主循环异常: {e}") + time.sleep(5) + + +if __name__ == '__main__': + BitmartFuturesTransaction(bit_id="f2320f57e24c45529a009e1541e25961").action()