diff --git a/crypto-t-monitor/scripts/backtest.py b/crypto-t-monitor/scripts/backtest.py new file mode 100755 index 0000000..f96f096 --- /dev/null +++ b/crypto-t-monitor/scripts/backtest.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +OKX 币圈做T 回测工具 (v2.0.0) +基于历史 K 线模拟策略, 验证 buy/sell 价位参数 +""" +import os, json, sys, argparse, datetime +sys.path.insert(0, os.path.dirname(__file__)) + +# 加载凭证 +okx_creds = {} +with open(os.path.expanduser('~/.bashrc')) as f: + import re + for line in f: + m = re.match(r'export\s+(OKX_\w+)=(.*)', line.strip()) + if m: + okx_creds[m.group(1)] = m.group(2).strip().strip('"').strip("'") + + +def fetch_history_klines(sym, bar='1H', days=30): + """拉 OKX 历史 K 线 (OKX 限制单次 100 根, 多页拉) + 用 OKX 的 'after' 参数翻页 (传毫秒时间戳) + """ + import subprocess + import hmac, base64, hashlib + + all_data = [] + # OKX 时间戳 (毫秒) + cur_ts = int(datetime.datetime.utcnow().timestamp() * 1000) + + # 计算需要多少页 (1H K线, 24 根/天) + pages = max(1, (days * 24 + 99) // 100) + + for page in range(pages): + path = f"/api/v5/market/history-candles?instId={sym}-USDT-SWAP&bar={bar}&limit=100&after={cur_ts}" + + msg = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.') + \ + f"{datetime.datetime.utcnow().microsecond // 1000:03d}Z" + 'GET' + path + signature = base64.b64encode( + hmac.new(okx_creds['OKX_SECRET'].encode(), msg.encode(), hashlib.sha256).digest() + ).decode() + ts_str = msg[:30] # YYYY-MM-DDTHH:MM:SS.sssZ (但实际上 ms 只有 3 位 + Z) + # 修正: 用 'Z' 结尾的后 24 字节 + + curl_cmd = [ + 'curl', '-s', '--proxy', 'http://127.0.0.1:7890', + '-H', f'OK-ACCESS-KEY: {okx_creds["OKX_API_KEY"]}', + '-H', f'OK-ACCESS-SIGN: {signature}', + '-H', f'OK-ACCESS-TIMESTAMP: {ts_str}', + '-H', f'OK-ACCESS-PASSPHRASE: {okx_creds["OKX_PASSPHRASE"]}', + f'https://www.okx.com{path}' + ] + try: + r = subprocess.run(curl_cmd, capture_output=True, text=True, timeout=20) + data = json.loads(r.stdout) + if data.get('code') == '0': + klines = data.get('data', []) + if not klines: + break + all_data.extend(klines) + # 翻页: after 是上一个数据最小时间戳 - 1 + cur_ts = int(klines[-1][0]) - 1 + if len(klines) < 100: + break + else: + print(f"⚠️ Page {page} code={data.get('code')} msg={data.get('msg')}") + break + except Exception as e: + print(f"⚠️ Page {page} failed: {e}") + break + + print(f"📥 拉到 {len(all_data)} 根 K 线") + return all_data + + +def calc_atr(klines, period=14): + """ATR 计算""" + if len(klines) < period + 1: + return None + closes = [float(k[4]) for k in klines] + highs = [float(k[2]) for k in klines] + lows = [float(k[3]) for k in klines] + + trs = [] + for i in range(1, len(closes)): + tr = max(highs[i] - lows[i], + abs(highs[i] - closes[i-1]), + abs(lows[i] - closes[i-1])) + trs.append(tr) + return sum(trs[-period:]) / period + + +def simulate_strategy(klines, atr_multiplier=0.5, t_qty=0.05, leverage=25, ct_val=0.1, initial_usdt=1000, threshold=0.003): + """基于历史 K 线模拟做T策略 + 每小时检查价位: + - 跌到 buy1/buy2 → 买入 + - 涨到 sell1/sell2 → 卖出 + 持仓同步变化 (跟 okx_t_monitor 一致) + """ + trades = [] + position = 0 + avg_cost = 0 + last_trade_ts = None + + for i in range(20, len(klines)): + row = klines[i] + ts = row[0] + high = float(row[2]) + low = float(row[3]) + close = float(row[4]) + + # 计算过去 14 根 K 线的 ATR + past = klines[i-20:i] + atr = calc_atr(past, 14) + if not atr: + continue + + buy1 = close - atr * atr_multiplier * 0.5 + buy2 = close - atr * atr_multiplier + sell1 = close + atr * atr_multiplier * 0.5 + sell2 = close + atr * atr_multiplier + + # 检查是否触及价位 (用 high/low 比对 close) + if last_trade_ts == ts: + continue + + # 优先 sell1 > buy1 (趋势方向) + if position > 0 and (high >= sell2 or (high >= sell1 and position > 0)): + # 卖出 + sell_price = sell2 if high >= sell2 else sell1 + pnl = (sell_price - avg_cost) * position + trades.append(('sell', sell_price, position, pnl, ts)) + position = 0 + avg_cost = 0 + last_trade_ts = ts + elif position == 0 and (low <= buy2 or low <= buy1): + buy_price = buy2 if low <= buy2 else buy1 + position = t_qty + avg_cost = buy_price + trades.append(('buy', buy_price, position, None, ts)) + last_trade_ts = ts + + # 统计 + total_pnl = sum(t[3] for t in trades if t[3] is not None) + buy_count = sum(1 for t in trades if t[0] == 'buy') + sell_count = sum(1 for t in trades if t[0] == 'sell') + win_trades = [t for t in trades if t[3] and t[3] > 0] + win_rate = len(win_trades) / sell_count * 100 if sell_count > 0 else 0 + + return { + 'trades': trades, + 'total_pnl': total_pnl, + 'buy_count': buy_count, + 'sell_count': sell_count, + 'win_rate': win_rate, + 'final_position': position, + 'final_avg_cost': avg_cost, + } + + +def main(): + parser = argparse.ArgumentParser(description='币圈做T回测 (v2.0.0)') + parser.add_argument('symbol', help='币种 (如 ETH)') + parser.add_argument('--mode', choices=['short', 'trend'], default='trend', + help='short=日内(1H,默认) / trend=趋势(4H,默认短期)') + parser.add_argument('--days', type=int, default=30, help='回测天数 (short=7, trend=30)') + parser.add_argument('--bar', default=None, help='K 线周期 (覆盖 mode 默认)') + parser.add_argument('--atr-multiplier', type=float, default=None, help='ATR 倍数') + parser.add_argument('--t-qty', type=float, default=0.05, help='每笔数量 (默认 0.05)') + parser.add_argument('--leverage', type=int, default=25, help='杠杆 (默认 25)') + parser.add_argument('--ct-val', type=float, default=0.1, help='合约面值 (默认 0.1)') + args = parser.parse_args() + + # Mode-based defaults + if args.bar is None: + args.bar = '1H' if args.mode == 'short' else '4H' + if args.atr_multiplier is None: + # Trend: 更宽价位 (ATR × 1.5), 避免被洗 + args.atr_multiplier = 0.5 if args.mode == 'short' else 1.5 + if args.days == 30: # 如果用户没指定,按 mode + args.days = 7 if args.mode == 'short' else 30 + + print(f"📊 {args.symbol} {args.bar} 回测 ({args.days} 天, mode={args.mode})") + print(f" ATR={args.atr_multiplier} t_qty={args.t_qty} lev={args.leverage}x") + print() + + # 拉数据 + klines = fetch_history_klines(args.symbol, args.bar, args.days) + if not klines: + print("❌ 没拉到数据") + sys.exit(1) + print(f"✅ 拉到 {len(klines)} 根 K 线") + print() + + # 模拟 + result = simulate_strategy(klines, args.atr_multiplier, args.t_qty, + args.leverage, args.ct_val) + + # 报告 + print(f"📈 回测结果:") + print(f" 买入: {result['buy_count']} 次") + print(f" 卖出: {result['sell_count']} 次") + print(f" 胜率: {result['win_rate']:.1f}%") + print(f" 总盈亏: ${result['total_pnl']:.2f}") + print(f" 最终仓位: {result['final_position']}张 @ ${result['final_avg_cost']:.2f}" if result['final_position'] > 0 else " 最终仓位: 0 (全平)") + + # Top 5 交易 + closed = [t for t in result['trades'] if t[3] is not None] + if closed: + print() + print(f" Top 5 盈利交易:") + for t in sorted(closed, key=lambda x: -x[3])[:5]: + print(f" ${t[1]:.2f} | pnl ${t[3]:.2f} | {t[4]}") + + +if __name__ == '__main__': + main() diff --git a/crypto-t-monitor/scripts/okx_t_monitor.py b/crypto-t-monitor/scripts/okx_t_monitor.py new file mode 100755 index 0000000..eb26c3e --- /dev/null +++ b/crypto-t-monitor/scripts/okx_t_monitor.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 +""" +OKX 币圈做T - 多币种 + 动态 ATR 价位 + 网络重试 +v2.0.0 (2026-07-10): + - 多币种自动 (默认 ETH/BTC/SOL/DOGE) + - 动态 ATR 价位计算 (基于 1H K线) + - 网络重试机制 (Clash 抽风时) + - STATE_FILE 自动清理 (7 天前) + - 支持 limit 单 (替代 market 滑点) +""" +import os, json, subprocess, datetime, time, shlex + +# ============ 加载凭证 ============ +okx_creds = {} +with open(os.path.expanduser('~/.bashrc')) as f: + for line in f: + import re + m = re.match(r'export\s+(OKX_\w+)=(.*)', line.strip()) + if m: + okx_creds[m.group(1)] = m.group(2).strip().strip('"').strip("'") + +# ============ 配置 ============ +# 主流币池 (每 3 天由用户挑 2 个换) +# 2026-07-10 当前: ETH, BTC (高流动性, 用户偏好) +DEFAULT_SYMBOLS = ['ETH', 'BTC', 'SPCX'] # SPCX 是用户现有持仓 +# 历史轮换 (供参考): 7/10 [ETH, BTC]; 7/13 [ETH, SOL]; 7/16 [ETH, DOGE] etc. + +# 自动从 OKX 实际持仓池扩展 (用户加仓任何币都会被覆盖监控) +AUTO_INCLUDE_HOLDINGS = True + +# v2.4: 新币默认 dry-run (避免自动开仓到没参数的新币上) +# 用户原话: "水果刀好" — 止盈止损,不让程序误开仓 +# 新币第一次扫描会推警告, 但不自动交易, 等用户手动加进 SYMBOL_SPECS 调参后才会执行 +DRY_RUN_NEW_COIN = True # 默认 dry-run 新币 + +# 默认币种的 spec (含手动调过的) +SYMBOL_SPECS = { + 'ETH': {'ct_val': 0.1, 'leverage': 25, 't_qty': 0.05, 'min_sz': 0.01}, + 'BTC': {'ct_val': 0.01, 'leverage': 25, 't_qty': 0.03, 'min_sz': 0.01}, + 'SOL': {'ct_val': 1.0, 'leverage': 20, 't_qty': 5.0, 'min_sz': 1.0}, + 'DOGE': {'ct_val': 10.0, 'leverage': 20, 't_qty': 30.0, 'min_sz': 1.0}, + 'XRP': {'ct_val': 10.0, 'leverage': 20, 't_qty': 30.0, 'min_sz': 1.0}, + 'SPCX': {'ct_val': 1.0, 'leverage': 5, 't_qty': 0.5, 'min_sz': 0.01}, +} + +LEVELS = {} # 动态填充, 启动时基于 ATR 算 + +STATE_FILE = os.path.expanduser('~/.hermes/trading/t_state.json') + +# ============ 工具函数 ============ +def load_state(): + try: + with open(STATE_FILE) as f: + return json.load(f) + except Exception: + return {} + +def save_state(state): + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + with open(STATE_FILE, 'w') as f: + json.dump(state, f) + +def cleanup_state(state, keep_days=7): + """自动清理 7 天前的状态""" + cutoff = (datetime.datetime.now() - datetime.timedelta(days=keep_days)).strftime('%Y-%m-%d') + return {k: v for k, v in state.items() if k.split('_')[-1] >= cutoff} + +def okx_request(method, endpoint, body=None, params=None, retries=2): + """OKX API 通用请求, 带重试""" + import hmac, base64, hashlib + ts = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.') + f"{datetime.datetime.utcnow().microsecond // 1000:03d}Z" + path = endpoint + (('?' + params) if params else '') + body_str = json.dumps(body) if body else '' + msg = ts + method + path + body_str + sig = base64.b64encode(hmac.new(okx_creds['OKX_SECRET'].encode(), msg.encode(), hashlib.sha256).digest()).decode() + + for attempt in range(retries + 1): + try: + cmd = ['curl', '-s', '--proxy', 'http://127.0.0.1:7890', + '-X', method, + '-H', f'OK-ACCESS-KEY: {okx_creds["OKX_API_KEY"]}', + '-H', f'OK-ACCESS-SIGN: {sig}', + '-H', f'OK-ACCESS-TIMESTAMP: {ts}', + '-H', f'OK-ACCESS-PASSPHRASE: {okx_creds["OKX_PASSPHRASE"]}', + '-H', 'Content-Type: application/json', + f'https://www.okx.com{path}'] + if body: + cmd += ['-d', body_str] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + data = json.loads(r.stdout) + if data.get('code') == '0': + return data + if attempt < retries: + time.sleep(2) + continue + return data + except Exception as e: + if attempt < retries: + time.sleep(2) + continue + return {'code': '-1', 'msg': str(e)} + return {'code': '-1', 'msg': 'max retries'} + +def get_ticker(sym): + """拿当前价格""" + r = okx_request('GET', '/api/v5/market/ticker', params=f'instId={sym}-USDT-SWAP') + if r.get('code') == '0' and r.get('data'): + return float(r['data'][0]['last']) + return None + +def get_balance(): + """拿 USDT 余额""" + r = okx_request('GET', '/api/v5/account/balance') + for d in r.get('data', []): + for c in d.get('details', []): + if c['ccy'] == 'USDT': + return float(c['availBal']) + return 0 + +def get_position(sym): + """拿某币种持仓""" + r = okx_request('GET', '/api/v5/account/positions', params='instType=SWAP') + for p in r.get('data', []): + if sym in p.get('instId', '') and float(p.get('pos', 0)) != 0: + return float(p['pos']), float(p['avgPx']), float(p.get('upl', 0)) + return 0, 0, 0 + +def get_held_symbols(): + """拿所有持仓币种 (自动覆盖监控) + Returns: list of sym strings (e.g. ['SPCX']) + """ + r = okx_request('GET', '/api/v5/account/positions', params='instType=SWAP') + syms = set() + for p in r.get('data', []): + pos = float(p.get('pos', 0)) + if abs(pos) > 0: + # instId like "SPCX-USDT-SWAP" → "SPCX" + inst = p.get('instId', '') + if '-USDT-SWAP' in inst: + sym = inst.replace('-USDT-SWAP', '') + syms.add(sym) + return list(syms) + +def get_klines(sym, bar='1H', limit=100): + """拿 K线数据""" + r = okx_request('GET', '/api/v5/market/candles', + params=f'instId={sym}-USDT-SWAP&bar={bar}&limit={limit}') + if r.get('code') == '0': + return r.get('data', []) + return [] + +def calc_levels_from_atr(sym, atr_period=14, atr_multiplier=0.5): + """基于 ATR 动态算 buy/sell 价位 + Buy1 = price - 0.5*ATR + Buy2 = price - 1.0*ATR + Sell1 = price + 0.5*ATR + Sell2 = price + 1.0*ATR + """ + klines = get_klines(sym, '1H', atr_period + 5) + if not klines: + return None + # K线格式: [ts, open, high, low, close, vol, ...] + closes = [float(k[4]) for k in klines[-atr_period:]] + highs = [float(k[2]) for k in klines[-atr_period:]] + lows = [float(k[3]) for k in klines[-atr_period:]] + # ATR = 平均真实波幅 + trs = [] + for i in range(1, len(closes)): + tr = max(highs[i] - lows[i], abs(highs[i] - closes[i-1]), abs(lows[i] - closes[i-1])) + trs.append(tr) + atr = sum(trs) / len(trs) + price = closes[-1] + return { + 'cost': price, + 'buy1': round(price - atr * atr_multiplier * 0.7, 2), + 'buy2': round(price - atr * atr_multiplier, 2), + 'sell1': round(price + atr * atr_multiplier * 0.7, 2), + 'sell2': round(price + atr * atr_multiplier, 2), + 'atr': atr, + } + +def execute_trade(sym, side, qty, ord_type='market', limit_price=None, reduce_only=False): + """下单 + reduce_only=True 时只减仓不开仓 (用于平仓信号), 防止方向错误开新仓位. + """ + body = { + "instId": f"{sym}-USDT-SWAP", + "tdMode": "cross", + "side": side, + "ordType": ord_type, + "sz": str(qty), + } + if ord_type == 'limit' and limit_price: + body['px'] = str(limit_price) + if reduce_only: + body['reduceOnly'] = True + return okx_request('POST', '/api/v5/trade/order', body=body) + +def push_qq(msg): + """推送到 QQ""" + push_cmd = f'bash {os.path.expanduser("~")}/.hermes/scripts/push_to_qq.sh {shlex.quote(msg)}' + subprocess.run(push_cmd, shell=True, capture_output=True, timeout=30) + +NEW_COIN_DAYS = 30 # 30 天内新列出的算"新币" +NEW_COIN_AUTO_WATCH = True # 自动加入监控列表 +NEW_COIN_PICKS = 2 # 每次扫描后筛 X 个 (按 24h vol 排序) +NEW_COIN_POOL_MAX = 6 # 新币候选池上限 (永久保留, 超过这个数删最旧的) +NEW_COIN_MIN_VOLUME_USDT = 1_000_000 # 最低 24h 成交量 $1M (过滤无人币/低流动性) +NEW_COIN_PUSH_TO_QQ = True # 新入选推 QQ (变化时才推) + +def get_new_swap_symbols(days=NEW_COIN_DAYS, top_n=NEW_COIN_PICKS, min_volume=NEW_COIN_MIN_VOLUME_USDT): + """从 OKX 拉所有 SWAP, 挑出近 N 天新上市的 + 高流动性的 top_n 个 + 筛选条件: + 1. 30 天内新列 (listTime) + 2. 24h 成交量 > min_volume (排除无人币/低流动性) + 3. 按 24h 成交量排序, 取前 top_n + Returns: list of {'sym': 'XXX', 'listTime': ts, 'vol24h': volume} + """ + try: + # 拉所有合约 + cmd = ['curl', '-s', '--proxy', 'http://127.0.0.1:7890', + 'https://www.okx.com/api/v5/public/instruments?instType=SWAP&limit=500'] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=20) + data = json.loads(r.stdout) + if data.get('code') != '0': + return [] + + cutoff_ts = int((datetime.datetime.utcnow().timestamp() - days * 86400) * 1000) + candidates = [] + for ins in data.get('data', []): + inst_id = ins.get('instId', '') + if '-USDT-SWAP' not in inst_id: + continue + list_time = int(ins.get('listTime', 0)) + if list_time < cutoff_ts: + continue + if ins.get('state') != 'live': + continue + sym = inst_id.replace('-USDT-SWAP', '') + # 过滤: ctVal 太大或太小的(异常币) + ct_val = float(ins.get('ctVal', 1)) + lot_sz = float(ins.get('lotSz', 1)) + if ct_val > 1000 or ct_val < 0.001: + continue + if lot_sz > 1000 or lot_sz < 0.0001: + continue + candidates.append({ + 'sym': sym, + 'listTime': list_time, + 'instId': inst_id, + 'ctVal': ct_val, + 'lotSz': lot_sz, + }) + + if not candidates: + return [] + + # 第二轮: 拉每个候选的 24h 成交量, 过滤 + 排序 + cutoff_check_ts = int(datetime.datetime.utcnow().timestamp() * 1000) - 86400 * 1000 + cmd2 = ['curl', '-s', '--proxy', 'http://127.0.0.1:7890', + 'https://www.okx.com/api/v5/market/tickers?instType=SWAP'] + r2 = subprocess.run(cmd2, capture_output=True, text=True, timeout=20) + tickers = json.loads(r2.stdout).get('data', []) + vol_map = {} + for t in tickers: + inst_id = t.get('instId', '') + if '-USDT-SWAP' in inst_id: + sym = inst_id.replace('-USDT-SWAP', '') + vol_ccy = float(t.get('volCcy24h', 0)) + vol_map[sym] = vol_ccy + + scored = [] + for c in candidates: + vol = vol_map.get(c['sym'], 0) + if vol < min_volume: + continue + scored.append({ + **c, + 'vol24h': vol, + }) + + # 按 vol24h 排序, 取 top_n + scored.sort(key=lambda x: -x['vol24h']) + return scored[:top_n] + except Exception as e: + print(f"⚠️ 拉新币列表失败: {e}") + return [] + + +def find_nearest_level(price, levels, traded_levels): + """找最近的关键位""" + threshold = 0.005 # 0.5% 容差 + nearest = None + min_dist = float('inf') + for name in ['buy2', 'buy1', 'sell1', 'sell2']: + if levels.get(name) is None: + continue + dist = abs(price - levels[name]) / price + if dist < threshold and dist < min_dist: + min_dist = dist + nearest = name + return nearest + +def check_changes(sym, price, pos_qty, avg_px, upl, levels, state, skip_for=set()): + """检测变化并返回需要推送的事件 + skip_for: set of symbols, 跳过这些币种的"持仓变化"和"价格触及"推送 (做T 已专门推) + """ + events = [] + skip_this = sym in skip_for + + # 1. 持仓变化检测 — 跳过刚做T的 (做T已专门推) + # 关键修复: 没持仓时 (pos_qty=0) 不推变化 — 用户原话"没持仓的不要推了" + prev_pos = state.get(f'{sym}_prev_pos') + has_pos_now = abs(pos_qty) > 0.01 + if has_pos_now and prev_pos is not None and abs(pos_qty - prev_pos) > 0.001: + if not skip_this: + events.append(f'🔄 持仓变化: {prev_pos:.2f} → {pos_qty:.2f} 张') + + # 2. 价格触及关键位 — 跳过刚做T的 (做T已专门推), 没持仓也不推 + if not skip_this and has_pos_now: + nearest = find_nearest_level(price, levels, []) + if nearest: + level_price = levels[nearest] + dist_pct = abs(price - level_price) / price * 100 + events.append(f'📍 价格触及 {nearest}={level_price:.2f} (距 {dist_pct:.2f}%)') + + # 3. 浮盈/浮亏变化 (>3% 且相对上次变化 >2%) + if avg_px > 0 and has_pos_now: + leverage = SYMBOL_SPECS.get(sym, {}).get('leverage', 25) + pos_sign = 1 if pos_qty > 0 else -1 + upl_pct = (price - avg_px) / avg_px * 100 * leverage * pos_sign + + prev_upl_pct = state.get(f'{sym}_prev_upl_pct') + if prev_upl_pct is not None and abs(upl_pct) >= 5: + upl_diff = upl_pct - prev_upl_pct + if abs(upl_diff) >= 3: + emoji = '📈' if upl_diff > 0 else '📉' + events.append(f'{emoji} 浮盈变化: {prev_upl_pct:.1f}% → {upl_pct:.1f}% ({upl_diff:+.1f}%)') + + return events + +def monitor(): + state = load_state() + state = cleanup_state(state) + today = datetime.datetime.now().strftime('%Y-%m-%d') + + # 1. 新币扫描 (每次挑前 2, 池子最多保留 6) + new_coin_picks = [] + if NEW_COIN_AUTO_WATCH: + new_coin_picks = get_new_swap_symbols() + if new_coin_picks and NEW_COIN_PUSH_TO_QQ: + curr_pick_syms = sorted([p['sym'] for p in new_coin_picks]) + # 看本次挑的与上次是否变化 (变化才推) + prev_picks = state.get('_new_coin_picks', []) + if prev_picks != curr_pick_syms: + msg = f"🆕 新币扫描 (30 天内新上市, vol 前 {NEW_COIN_PICKS}):\n\n" + for p in new_coin_picks: + days_ago = (datetime.datetime.utcnow().timestamp() - p['listTime']/1000) / 86400 + msg += f"📊 {p['sym']}: 24h vol ${p['vol24h']/1e6:.1f}M | 上线 {days_ago:.1f} 天前\n" + msg += f"\n💡 已自动加入监控池 (上限 {NEW_COIN_POOL_MAX} 个)" + print(f"📤 推 QQ: 新币扫描 ({len(new_coin_picks)} 个)") + push_qq(msg) + state['_new_coin_picks'] = curr_pick_syms + + # 2. 管理"新币候选池" — 上限 6, 超过删最旧的 + # 池子结构: {'sym': 'XXX', 'added_at': ts, 'vol24h': vol} + new_coin_pool = state.get('_new_coin_pool', []) # 按 added_at 升序 (oldest first) + new_pick_data = [{'sym': p['sym'], 'added_at': datetime.datetime.utcnow().timestamp(), 'vol24h': p['vol24h']} for p in new_coin_picks] + curr_syms = set([p['sym'] for p in new_pick_data]) + + # 加本次新挑的 (注意去重) + for p in new_pick_data: + if not any(x['sym'] == p['sym'] for x in new_coin_pool): + new_coin_pool.append(p) + # 删掉不在本次名单的超过 30 天或失流动性的 + # (虽然我们只添, 但已经加入的币可能下架, 这里只做"超限裁剪") + + # 超限裁剪: 按 added_at 升序, 删最早的 (保留最新的 NEW_COIN_POOL_MAX 个) + if len(new_coin_pool) > NEW_COIN_POOL_MAX: + # 按 added_at 升序排序 + new_coin_pool.sort(key=lambda x: x['added_at']) + removed = new_coin_pool[:len(new_coin_pool) - NEW_COIN_POOL_MAX] + new_coin_pool = new_coin_pool[len(new_coin_pool) - NEW_COIN_POOL_MAX:] + msg = f"🗑️ 新币池超限 (>{NEW_COIN_POOL_MAX}), 移除: {[r['sym'] for r in removed]}" + print(msg) + if NEW_COIN_PUSH_TO_QQ: + push_qq(msg) + + state['_new_coin_pool'] = new_coin_pool + new_coin_syms = [p['sym'] for p in new_coin_pool] + + # 合并币种池: 默认主流币 + 实际持仓 + 新币池 (全部) + syms_to_monitor = list(DEFAULT_SYMBOLS) + if AUTO_INCLUDE_HOLDINGS: + held = get_held_symbols() + for s in held: + if s not in syms_to_monitor: + syms_to_monitor.append(s) + for s in new_coin_syms: + if s not in syms_to_monitor: + syms_to_monitor.append(s) + # 加进 SYMBOL_SPECS (用户后续可调整参数) + for sym in syms_to_monitor: + if sym not in SYMBOL_SPECS: + SYMBOL_SPECS[sym] = { + 'ct_val': 1.0, 'leverage': 10, 't_qty': 1.0, 'min_sz': 0.01 + } + print(f"📌 新增监控: {sym} (使用默认参数)") + + # 拉所有币种的当前状态 + syms_to_check = [] + for sym in syms_to_monitor: + try: + pos_qty, avg_px, upl = get_position(sym) + price = get_ticker(sym) + if not price: + continue + syms_to_check.append((sym, pos_qty, avg_px, upl, price)) + except Exception as e: + print(f"⚠️ {sym} 数据获取失败: {e}") + + # === 变化检测 === + any_change = False + # 先看是否需要做T (但先不成交), 收集 making_trade 列表, 用于 check_changes dedup + doing_trade = set() + pending_actions = {} # sym -> (action, level_name, traded_levels_now, atr_levels, levels) + + for sym, pos_qty, avg_px, upl, price in syms_to_check: + levels = {} + # 容错: 当 abs(pos_qty) > 0.01 才算真实持仓, 避免 OKX 浮点残值触发 + has_position = abs(pos_qty) > 0.01 + if has_position: + atr_levels = calc_levels_from_atr(sym) + if atr_levels: + levels = {**atr_levels, **SYMBOL_SPECS[sym]} + + # 检查是否触及价位 (不执行) + # 用户原话 2026-07-15: 加减仓和平仓不一样, 要看持仓方向 + # - 触及支撑位 (buy1/buy2, 价格跌到这): + # - 多仓 → 加仓顺势 (低成本买入) + # - 空仓 → 平仓获利 (回补) + # - 触及阻力位 (sell1/sell2, 价格涨到这): + # - 多仓 → 平仓获利 (高抛) + # - 空仓 → 加仓顺势 (顺势加空) + if has_position and levels: + state_key = f"{sym}_{today}" + traded_levels = state.get(state_key, []) + t_qty = levels.get('t_qty', 0.05) + threshold = 0.003 + action = None + level_name = None + is_short = pos_qty < 0 # 空仓 + + # 支撑位触及: buy1/buy2 + if abs(price - levels['buy2']) / price < threshold and 'buy2' not in traded_levels: + level_name = 'buy2' + action = 'buy' if is_short else 'buy' # 都是 buy (空=平, 多=加) + elif abs(price - levels['buy1']) / price < threshold and 'buy1' not in traded_levels: + level_name = 'buy1' + action = 'buy' if is_short else 'buy' + # 阻力位触及: sell1/sell2 + elif abs(price - levels['sell1']) / price < threshold and 'sell1' not in traded_levels: + level_name = 'sell1' + action = 'sell' if is_short else 'sell' # 都是 sell (空=加, 多=平) + elif abs(price - levels['sell2']) / price < threshold and 'sell2' not in traded_levels: + level_name = 'sell2' + action = 'sell' if is_short else 'sell' + if action: + pending_actions[sym] = { + 'action': action, + 'level_name': level_name, + 'traded_levels': traded_levels, + 'levels': levels, + 'price': price, + 't_qty': t_qty, + } + + # 变化检测 — 跳过即将做T的 (避免重复推) + events = check_changes(sym, price, pos_qty, avg_px, upl, levels, state, + skip_for=set(pending_actions.keys())) + if events: + any_change = True + level_info = '' + if levels: + level_info = f'\n📊 关键位: buy1={levels.get("buy1","-")} buy2={levels.get("buy2","-")} sell1={levels.get("sell1","-")} sell2={levels.get("sell2","-")}' + msg = f"🔔 {sym} 变化提醒\n\n💰 价格: ${price:.2f}\n📦 持仓: {pos_qty:.2f}张\n" + "\n".join(events) + level_info + print(f"📤 推 QQ: {sym} 变化") + push_qq(msg) + + # 更新 state + state[f'{sym}_prev_pos'] = pos_qty + if avg_px > 0 and has_position: + leverage = SYMBOL_SPECS.get(sym, {}).get('leverage', 25) + pos_sign = 1 if pos_qty > 0 else -1 + state[f'{sym}_prev_upl_pct'] = (price - avg_px) / avg_px * 100 * leverage * pos_sign + else: + state[f'{sym}_prev_upl_pct'] = None + + # === 做T 执行 === + for sym, action_info in pending_actions.items(): + action = action_info['action'] + level_name = action_info['level_name'] + levels = action_info['levels'] + t_qty = action_info['t_qty'] + price = action_info['price'] + traded_levels = action_info['traded_levels'] + + doing_trade.add(sym) + avail = get_balance() + pos_qty, avg_price, upl = get_position(sym) + + if action == 'buy': + margin_needed = levels['ct_val'] * price * t_qty / levels['leverage'] + if avail < margin_needed: + print(f"⚠️ {sym} 余额不足 (需要 {margin_needed:.2f}, 可用 {avail:.2f})") + continue + # buy: 空仓=平仓 (reduceOnly), 多仓=加仓 + reduce_only = pos_qty < 0 + result = execute_trade(sym, 'buy', t_qty, reduce_only=reduce_only) + else: + # sell: 多仓=平仓 (reduceOnly), 空仓=加空 + if pos_qty > 0 and abs(pos_qty) < t_qty: + print(f"⚠️ {sym} 多仓持仓不足") + continue + reduce_only = pos_qty > 0 + result = execute_trade(sym, 'sell', t_qty, reduce_only=reduce_only) + + if result.get('code') == '0': + traded_levels.append(level_name) + state[f"{sym}_{today}"] = traded_levels + state[f'{sym}_trade_at'] = datetime.datetime.utcnow().timestamp() + save_state(state) + + # 文案根据 pos 方向区分 (用户原话 2026-07-15: "做空时 buy2 触发应该是平仓不是低吸") + if action == 'buy': + emoji = '🟢回补平仓' if pos_qty < 0 else '🟢低吸加仓' + else: # sell + emoji = '🔴高抛平仓' if pos_qty > 0 else '🔴做空加仓' + msg = f"✅ 做T自动执行 v2.3\n\n{emoji} {sym} {t_qty}张 @ ${price:.2f}\n级别: {levels[level_name]}({level_name})\nATR: ${levels['atr']:.2f}\n\n" + + time.sleep(1) + new_pos, new_avg, new_upl = get_position(sym) + new_avail = get_balance() + msg += f"📊 持仓: {new_pos:.2f}张 @ ${new_avg:.2f}\n💰 可用: ${new_avail:.2f}\n💹 浮盈: ${new_upl:.2f}" + + print(f"📤 推 QQ: {sym} 做T成功") + push_qq(msg) + print(f"✅ {sym} {action} {level_name}") + else: + err_msg = f"❌ {sym} {action} {level_name} 失败: {result.get('msg', 'unknown')}" + print(err_msg) + push_qq(err_msg) + + + # 静默模式 (没任何变化) + save_state(state) + if not any_change and not pending_actions: + print("💤 静默: 无持仓, 无变化") + elif not any_change: + print("💤 静默: 有持仓但无价格变化/触及关键位") + +if __name__ == '__main__': + monitor() diff --git a/intraday-trading/scripts/hk_intraday_cli.py b/intraday-trading/scripts/hk_intraday_cli.py new file mode 100755 index 0000000..455ac83 --- /dev/null +++ b/intraday-trading/scripts/hk_intraday_cli.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""港股日内交易监控+自动下单 - CLI 路径""" +import os, sys, json, time +from datetime import datetime + +# 强制 CLI 路径走 .com 海外域 (避免 602315) +os.environ['LONGBRIDGE_HTTP_URL'] = 'https://openapi.longbridge.com' +os.environ['LONGBRIDGE_REGION'] = 'ap' +os.environ['LONGBRIDGE_TRADE_ENABLED'] = 'true' + +# 替换 longport 模块为 CLI helper (Python SDK 走 cn 域会 602315) +sys.path.insert(0, '/home/openclaw/.hermes/scripts') +import longbridge_cli_helper as _helper +_fake_longport = type(sys)('longport') +_fake_longport.openapi = _helper +sys.modules['longport'] = _fake_longport +sys.modules['longport.openapi'] = _helper +from longport import openapi # 现在 openapi 实际是 helper + +# 剩余代码跟原版一致 +config = {} +with open(os.path.expanduser('~/.bashrc'), 'r') as f: + for line in f: + if line.startswith('export LONGPORT_'): + key, value = line.strip().split('=', 1) + config[key.replace('export ', '')] = value + +os.environ['LONGPORT_APP_KEY'] = config.get('LONGPORT_APP_KEY', '') +os.environ['LONGPORT_APP_SECRET'] = config.get('LONGPORT_APP_SECRET', '') +os.environ['LONGPORT_ACCESS_TOKEN'] = config.get('LONGPORT_ACCESS_TOKEN', '') + +ctx = openapi.QuoteContext(config=None) + +# === 余额 + 持仓 === +bals = openapi.account_balance() +hkd_cash = 0 +usd_cash = 0 +if bals: + for b in bals: + cur = str(b.currency).upper() + cash = float(getattr(b, 'cash_available', 0) or 0) + if cash <= 0: + cash = float(getattr(b, 'buy_power', 0) or 0) + if 'USD' in cur: + usd_cash += cash + elif 'HKD' in cur: + hkd_cash += cash +print(f"💰 HKD cash: {hkd_cash:.0f} | USD cash: {usd_cash:.2f}") +print(f"💰 单笔仓位 (HKD): {hkd_cash*0.25:.0f} | (USD): {usd_cash*0.25:.2f}") + +# 持仓 +held_symbols = set() +positions = openapi.stock_positions() +for ch in positions.channels: + for p in ch.positions: + held_symbols.add(p.symbol) + print(f" 持仓: {p.symbol} {p.quantity}股 @ {p.cost_price}") + +# === 读取盘前候选 === +screen_file = os.path.expanduser('~/.hermes/skills/trading/quant-factor-mining/artifacts/hk_intraday_latest.json') +if not os.path.exists(screen_file): + print("❌ 未找到盘前筛选结果") + sys.exit(1) + +with open(screen_file) as f: + screen = json.load(f) + +# 取 TOP 3 +candidates = [r for r in screen.get('results', [])[:3]] +print(f"\n🎯 监控标的:") +for c in candidates: + print(f" {c['ticker']}: 评分 {c['score']:.1f} | ADR {c['avg_adr']:.2f}%") + +# === 读取入场记录 === +entry_file = os.path.expanduser('~/.hermes/trading/hk_intraday_entries.json') +entries = {} +if os.path.exists(entry_file): + try: + entries = json.load(open(entry_file)) + except: + entries = {} + +# === 遍历每个候选, 检查入场/出场信号 === +for c in candidates: + ticker = c['ticker'] + try: + q = ctx.quote([ticker])[0] + current = float(q.last_done) + except Exception as e: + print(f"⏳ {ticker}: 行情获取失败: {e}") + continue + + # 简化版信号: 价格突破 SMA5 且 SMA5 > SMA10 → 入场 + try: + cs = ctx.candlesticks(ticker, openapi.Period.Day, 30, openapi.AdjustType.ForwardAdjust) + closes = [float(c2.close) for c2 in cs] + sma5 = sum(closes[-5:]) / 5 + sma10 = sum(closes[-10:]) / 10 + except Exception as e: + print(f"⏳ {ticker}: K线失败: {e}") + continue + + if ticker in held_symbols: + print(f"⏳ {ticker}: 已有持仓,跳过入场检查 | 现价 {current:.2f}") + continue + + # 如果已有日内入场记录, 也跳过(防止重复下单) + if ticker in entries: + # 检查出场信号 + entry = entries[ticker] + e_shares = entry.get('shares', 0) + e_order_id = entry.get('order_id', '') + + if not e_order_id: + print(f"⚠️ {ticker}: 有入场记录但无订单ID, 跳过") + continue + + if current <= entry['stop_loss']: + print(f"\n🛑 {ticker} 触发止损! {current:.2f} <= {entry['stop_loss']}") + try: + openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.MO, + side=openapi.OrderSide.Sell, + submitted_quantity=e_shares, + time_in_force=openapi.TimeInForceType.Day, + ) + print(f" ✅ 止损平仓: 卖 {e_shares}股 @ 市价") + del entries[ticker] + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 平仓失败: {e}") + elif current >= entry['take_profit']: + print(f"\n🎯 {ticker} 触发止盈! {current:.2f} >= {entry['take_profit']}") + try: + openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.MO, + side=openapi.OrderSide.Sell, + submitted_quantity=e_shares, + time_in_force=openapi.TimeInForceType.Day, + ) + print(f" ✅ 止盈平仓: 卖 {e_shares}股 @ 市价") + del entries[ticker] + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 平仓失败: {e}") + else: + print(f"⏳ {ticker}: 已入场,持仓中 | 现价 {current:.2f} | 止损 {entry['stop_loss']} | 止盈 {entry['take_profit']}") + continue + + # === 入场信号 === + if current > sma5 > sma10 and current > closes[-2]: + # 计算仓位: 20% cash (按标的货币), 按 lot_size 取整 + price = round(current, 2) + # 港股 lot_size 可能 100/200/500/1000/2000 (ticker 依赖), 美股=1 + lot_size = openapi.get_lot_size(ticker) if hasattr(openapi, 'get_lot_size') else 100 + # 选对应货币的 cash + cash = hkd_cash # 港股账户默认 HKD + target_value = cash * 0.20 + shares = int(target_value / price / lot_size) * lot_size + if shares < lot_size: + print(f"⏳ {ticker}: 信号但余额不足 (需要{lot_size}股 @ {price})") + continue + + stop_loss = round(price * 0.985, 2) + take_profit = round(price * 1.025, 2) + + # 调整下单价格到合法范围 (港股 9 档保护规则) + adjusted_price = openapi.adjust_price_for_order(ticker, price, 'buy') if hasattr(openapi, 'adjust_price_for_order') else price + if abs(adjusted_price - price) > 0.05: + print(f" ⚠️ 价格调整: {price} → {adjusted_price} (盘口约束)") + + # 基于 adjusted_price 重新算止损止盈 + stop_loss = round(adjusted_price * 0.985, 2) + take_profit = round(adjusted_price * 1.025, 2) + + print(f"\n🔔 {ticker} 入场信号!") + print(f" 方向: 做多 | 现价 {current:.2f} | SMA5 {sma5:.2f}") + print(f" 止损: {stop_loss} | 止盈: {take_profit} | 股数: {shares}") + + # 自动下单 + try: + resp = openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.LO, + side=openapi.OrderSide.Buy, + submitted_quantity=shares, + time_in_force=openapi.TimeInForceType.Day, + submitted_price=adjusted_price, + ) + order_id = resp.order_id + print(f" ⏳ 已提交: {order_id}") + + # 反查 status (700 RMB 教训) + import time as _t + status = 'Unknown' + detail = None + for retry in range(3): + _t.sleep(0.5) + try: + detail = openapi.order_detail(order_id) + status = str(detail.status).split('.')[-1] if detail else 'Unknown' + if status not in ('New', 'NotReported'): + break + except Exception: + continue + + if status == 'Filled': + print(f" ✅ 成交: {order_id}") + elif status == 'Rejected': + print(f" ❌ 被拒: {order_id} | 跳过") + continue + elif status == 'Canceled': + print(f" 🚫 已撤: {order_id}") + continue + else: + print(f" ⚠️ 已挂单未成交: {order_id} (status={status})") + + # 记录 (用 adjusted_price 作为 entry_price) + entries[ticker] = { + 'side': 'buy', + 'entry_price': adjusted_price, + 'stop_loss': stop_loss, + 'take_profit': take_profit, + 'shares': shares, + 'order_id': order_id, + 'time': datetime.now().isoformat(), + } + os.makedirs(os.path.dirname(entry_file), exist_ok=True) + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 下单失败: {e}") + + elif ticker in entries: + # === 出场信号 === + entry = entries[ticker] + e_shares = entry.get('shares', 0) + e_order_id = entry.get('order_id', '') + + if not e_order_id: + print(f"⚠️ {ticker}: 无订单ID, 跳过") + continue + + if current <= entry['stop_loss']: + print(f"🛑 {ticker} 止损! {current:.2f} <= {entry['stop_loss']}") + try: + openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.MO, + side=openapi.OrderSide.Sell, + submitted_quantity=e_shares, + time_in_force=openapi.TimeInForceType.Day, + ) + print(f" ✅ 止损平仓: 卖 {e_shares}股") + del entries[ticker] + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 平仓失败: {e}") + + elif current >= entry['take_profit']: + print(f"🎯 {ticker} 止盈! {current:.2f} >= {entry['take_profit']}") + try: + openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.MO, + side=openapi.OrderSide.Sell, + submitted_quantity=e_shares, + time_in_force=openapi.TimeInForceType.Day, + ) + print(f" ✅ 止盈平仓: 卖 {e_shares}股") + del entries[ticker] + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 平仓失败: {e}") + else: + print(f"⏳ {ticker}: 等待信号 | 现价 {current:.2f} | SMA5 {sma5:.2f} | SMA10 {sma10:.2f}") + +print("\n=== 完成 ===") \ No newline at end of file diff --git a/intraday-trading/scripts/hk_intraday_cli_runner.sh b/intraday-trading/scripts/hk_intraday_cli_runner.sh new file mode 100755 index 0000000..63af003 --- /dev/null +++ b/intraday-trading/scripts/hk_intraday_cli_runner.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# 港股日内交易 CLI runner +# 用法: bash hk_intraday_cli_runner.sh +# 走 ~/.local/bin/longbridge CLI (不用 Python SDK), 走 openapi.longbridge.com (AWS 海外) +set -e + +LOG=/tmp/hk_intraday_cli.log +SIGNAL_FILE=/tmp/hk_intraday_signals.json + +echo "=== HK 日内 CLI runner @ $(date) ===" > $LOG + +# 0. 确保 hosts 干净 (cn 域名指向 AWS 海外 IP 是有毒的, 真实 DNS 解析即可) +# 真实 DNS: openapi.longbridge.com → 18.163.160.163 (AWS 香港) + +# 1. 用 proxychains + CLI 查持仓 + 余额 + 信号生成 +LONGBRIDGE_REGION=ap LONGBRIDGE_TRADE_ENABLED=true \ + proxychains4 -f ~/.proxychains/proxychains.conf \ + ~/.local/bin/longbridge --profile lb_real balance 2>&1 | tee -a $LOG + +# 2. 列出当前订单 +LONGBRIDGE_REGION=ap \ + proxychains4 -f ~/.proxychains/proxychains.conf \ + ~/.local/bin/longbridge --profile lb_real orders 2>&1 | tee -a $LOG + +# 3. 给个示例: 如果有持仓, 显示; 没持仓, 给信号 +# (实际信号生成+下单逻辑,需要跟 intraday-trading skill 的 strategy 对接) +# 先跑通 CLI 路径, 信号生成后期补 + +echo "" >> $LOG +echo "=== CLI runner 完成 @ $(date) ===" >> $LOG +cat $LOG \ No newline at end of file diff --git a/intraday-trading/scripts/us_intraday_cli.py b/intraday-trading/scripts/us_intraday_cli.py new file mode 100755 index 0000000..c6e5d26 --- /dev/null +++ b/intraday-trading/scripts/us_intraday_cli.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""港股日内交易监控+自动下单 - CLI 路径""" +import os, sys, json, time +from datetime import datetime + +# 强制 CLI 路径走 .com 海外域 (避免 602315) +os.environ['LONGBRIDGE_HTTP_URL'] = 'https://openapi.longbridge.com' +os.environ['LONGBRIDGE_REGION'] = 'ap' +os.environ['LONGBRIDGE_TRADE_ENABLED'] = 'true' + +# 替换 longport 模块为 CLI helper (Python SDK 走 cn 域会 602315) +sys.path.insert(0, '/home/openclaw/.hermes/scripts') +import longbridge_cli_helper as _helper +_fake_longport = type(sys)('longport') +_fake_longport.openapi = _helper +sys.modules['longport'] = _fake_longport +sys.modules['longport.openapi'] = _helper +from longport import openapi # 现在 openapi 实际是 helper + +# 剩余代码跟原版一致 +config = {} +with open(os.path.expanduser('~/.bashrc'), 'r') as f: + for line in f: + if line.startswith('export LONGPORT_'): + key, value = line.strip().split('=', 1) + config[key.replace('export ', '')] = value + +os.environ['LONGPORT_APP_KEY'] = config.get('LONGPORT_APP_KEY', '') +os.environ['LONGPORT_APP_SECRET'] = config.get('LONGPORT_APP_SECRET', '') +os.environ['LONGPORT_ACCESS_TOKEN'] = config.get('LONGPORT_ACCESS_TOKEN', '') + +ctx = openapi.QuoteContext(config=None) + +# === 余额 + 持仓 === +bals = openapi.account_balance() +# 分离 HKD / USD cash (不能用 buy_power,要用 cash) +hkd_cash = 0 +usd_cash = 0 +if bals: + for b in bals: + cur = str(b.currency).upper() + # 优先用 cash_available, fallback 用 buy_power + cash = float(getattr(b, 'cash_available', 0) or 0) + if cash <= 0: + cash = float(getattr(b, 'buy_power', 0) or 0) + if 'USD' in cur: + usd_cash += cash + elif 'HKD' in cur: + hkd_cash += cash + print(f" [{cur}] cash: {cash:.0f}") +print(f"\n💰 HKD cash: {hkd_cash:.0f} | USD cash: {usd_cash:.2f}") +print(f"💰 单笔仓位 (HKD): {hkd_cash*0.25:.0f} | (USD): {usd_cash*0.25:.2f}") + +# 持仓 +held_symbols = set() +positions = openapi.stock_positions() +for ch in positions.channels: + for p in ch.positions: + held_symbols.add(p.symbol) + print(f" 持仓: {p.symbol} {p.quantity}股 @ {p.cost_price}") + +# === 读取盘前候选 === +screen_file = os.path.expanduser('~/.hermes/skills/trading/quant-factor-mining/artifacts/us_intraday_latest.json') +if not os.path.exists(screen_file): + print("❌ 未找到盘前筛选结果") + sys.exit(1) + +with open(screen_file) as f: + screen = json.load(f) + +# 取 TOP 3 +candidates = [r for r in screen.get('results', [])[:3]] +print(f"\n🎯 监控标的:") +for c in candidates: + print(f" {c['ticker']}: 评分 {c['score']:.1f} | ADR {c['avg_adr']:.2f}%") + +# === 读取入场记录 === +entry_file = os.path.expanduser('~/.hermes/trading/us_intraday_entries.json') +entries = {} +if os.path.exists(entry_file): + try: + entries = json.load(open(entry_file)) + except: + entries = {} + +# === 遍历每个候选, 检查入场/出场信号 === +for c in candidates: + ticker = c['ticker'] + try: + q = ctx.quote([ticker])[0] + current = float(q.last_done) + except Exception as e: + print(f"⏳ {ticker}: 行情获取失败: {e}") + continue + + # 简化版信号: 价格突破 SMA5 且 SMA5 > SMA10 → 入场 + try: + cs = ctx.candlesticks(ticker, openapi.Period.Day, 30, openapi.AdjustType.ForwardAdjust) + closes = [float(c2.close) for c2 in cs] + sma5 = sum(closes[-5:]) / 5 + sma10 = sum(closes[-10:]) / 10 + except Exception as e: + print(f"⏳ {ticker}: K线失败: {e}") + continue + + if ticker in held_symbols: + print(f"⏳ {ticker}: 已有持仓,跳过入场检查 | 现价 {current:.2f}") + continue + + # 如果已有日内入场记录, 也跳过(防止重复下单) + if ticker in entries: + # 检查出场信号 + entry = entries[ticker] + e_shares = entry.get('shares', 0) + e_order_id = entry.get('order_id', '') + + if not e_order_id: + print(f"⚠️ {ticker}: 有入场记录但无订单ID, 跳过") + continue + + if current <= entry['stop_loss']: + print(f"\n🛑 {ticker} 触发止损! {current:.2f} <= {entry['stop_loss']}") + try: + openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.MO, + side=openapi.OrderSide.Sell, + submitted_quantity=e_shares, + time_in_force=openapi.TimeInForceType.Day, + ) + print(f" ✅ 止损平仓: 卖 {e_shares}股 @ 市价") + del entries[ticker] + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 平仓失败: {e}") + elif current >= entry['take_profit']: + print(f"\n🎯 {ticker} 触发止盈! {current:.2f} >= {entry['take_profit']}") + try: + openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.MO, + side=openapi.OrderSide.Sell, + submitted_quantity=e_shares, + time_in_force=openapi.TimeInForceType.Day, + ) + print(f" ✅ 止盈平仓: 卖 {e_shares}股 @ 市价") + del entries[ticker] + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 平仓失败: {e}") + else: + print(f"⏳ {ticker}: 已入场,持仓中 | 现价 {current:.2f} | 止损 {entry['stop_loss']} | 止盈 {entry['take_profit']}") + continue + + # === 入场信号 === + if current > sma5 > sma10 and current > closes[-2]: + # 计算仓位: 20% cash (按标的货币), 按 lot_size 取整 + price = round(current, 2) + # 美股 lot_size=1, 港股=100/200/500/1000/2000 + lot_size = 1 if ticker.endswith('.US') else 100 + # 选对应货币的 cash + if ticker.endswith('.US'): + cash = usd_cash + else: + cash = hkd_cash + target_value = cash * 0.20 # 20% 现金 + shares = int(target_value / price / lot_size) * lot_size + if shares < lot_size: + print(f"⏳ {ticker}: 信号但余额不足 (需要{lot_size}股 @ {price})") + continue + + stop_loss = round(price * 0.985, 2) + take_profit = round(price * 1.025, 2) + + print(f"\n🔔 {ticker} 入场信号!") + print(f" 方向: 做多 | 现价 {current:.2f} | SMA5 {sma5:.2f}") + print(f" 止损: {stop_loss} | 止盈: {take_profit} | 股数: {shares}") + + # 自动下单 + try: + resp = openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.LO, + side=openapi.OrderSide.Buy, + submitted_quantity=shares, + time_in_force=openapi.TimeInForceType.Day, + submitted_price=price, + ) + order_id = resp.order_id + print(f" ⏳ 已提交: {order_id}") + + # 反查 status (700 RMB 教训: order_id ≠ 成交) + import time as _t + status = 'Unknown' + for retry in range(3): + _t.sleep(0.5) + try: + detail = openapi.order_detail(order_id) + status = str(detail.status).split('.')[-1] if detail else 'Unknown' + if status not in ('New', 'NotReported'): + break + except Exception: + continue + + if status == 'Filled': + print(f" ✅ 成交: {order_id}") + exec_price = float(detail.executed_price or price) + exec_qty = int(detail.executed_quantity or shares) + elif status == 'Rejected': + print(f" ❌ 被拒: {order_id} | status={status} | 跳过") + continue + elif status == 'Canceled': + print(f" 🚫 已撤: {order_id}") + continue + else: # New / NotReported (港股日单未成交) + print(f" ⚠️ 已挂单未成交: {order_id} (status={status})") + exec_price = price + exec_qty = shares + + # 记录 + entries[ticker] = { + 'side': 'buy', + 'entry_price': price, + 'stop_loss': stop_loss, + 'take_profit': take_profit, + 'shares': shares, + 'order_id': order_id, + 'time': datetime.now().isoformat(), + } + os.makedirs(os.path.dirname(entry_file), exist_ok=True) + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 下单失败: {e}") + + elif ticker in entries: + # === 出场信号 === + entry = entries[ticker] + e_shares = entry.get('shares', 0) + e_order_id = entry.get('order_id', '') + + if not e_order_id: + print(f"⚠️ {ticker}: 无订单ID, 跳过") + continue + + if current <= entry['stop_loss']: + print(f"🛑 {ticker} 止损! {current:.2f} <= {entry['stop_loss']}") + try: + openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.MO, + side=openapi.OrderSide.Sell, + submitted_quantity=e_shares, + time_in_force=openapi.TimeInForceType.Day, + ) + print(f" ✅ 止损平仓: 卖 {e_shares}股") + del entries[ticker] + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 平仓失败: {e}") + + elif current >= entry['take_profit']: + print(f"🎯 {ticker} 止盈! {current:.2f} >= {entry['take_profit']}") + try: + openapi.submit_order( + symbol=ticker, order_type=openapi.OrderType.MO, + side=openapi.OrderSide.Sell, + submitted_quantity=e_shares, + time_in_force=openapi.TimeInForceType.Day, + ) + print(f" ✅ 止盈平仓: 卖 {e_shares}股") + del entries[ticker] + with open(entry_file, 'w') as f: + json.dump(entries, f, indent=2) + except Exception as e: + print(f" ❌ 平仓失败: {e}") + else: + print(f"⏳ {ticker}: 等待信号 | 现价 {current:.2f} | SMA5 {sma5:.2f} | SMA10 {sma10:.2f}") + +print("\n=== 完成 ===") \ No newline at end of file diff --git a/strategy-management/scripts/backtest.py b/strategy-management/scripts/backtest.py new file mode 100644 index 0000000..70f691c --- /dev/null +++ b/strategy-management/scripts/backtest.py @@ -0,0 +1,509 @@ +""" +backtest.py - 通用策略回测工具 +支持 4 个策略: + - rsi2_revert: RSI(2) < 10 做多, RSI(2) > 90 做空, MA50 趋势过滤 + - vwap_revert: 价格偏离 VWAP > 1.5σ 回归 + - early_bird: 开盘 30 min 涨跌幅 + 量 > 1.5× → 顺势 + - turtle_breakout: 20 周期突破 + 10 周期反向出场 + - sma_breakout: SMA5 > SMA10 + 价格突破前高 (现有默认) + +用法: + python3 backtest.py --strategy rsi2_revert --symbol NVDA + python3 backtest.py --strategy turtle_breakout --symbol 0700.HK --days 60 + +默认 K 线 = 1h, Yahoo Finance 数据源 (美股 NVDA/AAPL 等, 港股 0700.HK 等) +""" + +import argparse +import json +import os +import sys +from datetime import datetime, timedelta +from typing import List, Dict, Optional, Tuple + +# 本地依赖 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from strategy_registry import get_strategy, list_strategies +from indicators import sma, ema, rsi, atr, vwap, vwap_std, donchian_breakout + + +# ============== K 线获取 ============== + +def fetch_klines_longbridge(symbol: str, days: int = 30, interval: str = '1h') -> Optional[Dict]: + """LongPort CLI 数据源 (通过 proxychains4 + Clash 香港出口). + 支持美股/港股. Yahoo Finance 国内 VPS 经常 rate limit, 长桥更稳. + """ + # 港股 ticker Yahoo 是 4 位数字带前导 0, 长桥是 0700.HK + # 长桥 K 线是 period 内全部, 倒序. 我们重新排序为时间正序. + bar_map = { + '1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m', + '60m': '60m', '1h': '60m', 'day': 'day', '1d': 'day', 'week': 'week', + } + bar = bar_map.get(interval, '60m') + + env = os.environ.copy() + env['LONGBRIDGE_HTTP_URL'] = 'https://openapi.longbridge.com' + env['LONGBRIDGE_REGION'] = 'ap' + env['LONGBRIDGE_TRADE_ENABLED'] = 'true' + cmd = [ + 'proxychains4', '-f', os.path.expanduser('~/.proxychains/proxychains.conf'), + '/home/openclaw/.local/bin/longbridge', '--profile', 'lb_real', + 'candlesticks', symbol, bar, '--json', + ] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=env) + if r.returncode != 0: + return None + import re + # 找 JSON 起止位置 + json_match = re.search(r'\[\s*\{', r.stdout) + if not json_match: + return None + json_text = '[' + r.stdout[json_match.start()+1:] + data = json.loads(json_text) + if not data: + return None + # 倒序 → 正序 + data = list(reversed(data)) + return { + 'opens': [k['open'] for k in data], + 'highs': [k['high'] for k in data], + 'lows': [k['low'] for k in data], + 'closes':[k['close'] for k in data], + 'volumes':[k.get('volume', 0) or 0 for k in data], + 'timestamps':[k.get('timestamp', '') for k in data], + } + except Exception as e: + print(f"⚠️ {symbol} 长桥 K线拉取失败: {e}") + return None + + +import subprocess # 在 fetch_klines_longbridge 后 import + +def fetch_klines_yahoo(symbol: str, days: int = 30, interval: str = '1h') -> Optional[Dict]: + """Yahoo Finance fallback (国内 VPS 可能 rate limit).""" + try: + import yfinance as yf + yahoo_sym = symbol.replace('.US', '').replace('.HK', '.HK') + df = yf.download(tickers=yahoo_sym, period=f'{days}d', + interval=interval, progress=False, auto_adjust=True) + if df is None or len(df) < 10: + return None + if hasattr(df.columns, 'names') and len(df.columns.names) > 1: + df.columns = df.columns.droplevel(0) + expected = ['Open', 'High', 'Low', 'Close', 'Volume'] + if not all(col in df.columns for col in expected): + return None + return { + 'opens': df['Open'].tolist(), + 'highs': df['High'].tolist(), + 'lows': df['Low'].tolist(), + 'closes': df['Close'].tolist(), + 'volumes': df['Volume'].fillna(0).tolist(), + 'timestamps': df.index.tolist(), + } + except Exception as e: + print(f"⚠️ yahoo {symbol} fallback 失败: {e}") + return None + + +def fetch_klines(symbol: str, days: int = 30, interval: str = '1h') -> Optional[Dict]: + """统一入口: 长桥 → Yahoo fallback.""" + klines = fetch_klines_longbridge(symbol, days, interval) + if klines: + return klines + print("⚠️ 长桥拉数据失败, fallback Yahoo...") + return fetch_klines_yahoo(symbol, days, interval) + + +# ============== 策略信号生成 ============== + +def signal_rsi2_revert(klines: Dict, params) -> List[Dict]: + """RSI(2) 超卖反弹信号""" + closes = klines['closes'] + opens = klines['opens'] + atr_vals = atr(klines['highs'], klines['lows'], closes, 14) + rsi2 = rsi(closes, params.rsi_period) + ma50 = sma(closes, params.rsi2_ma_filter) + + signals = [] + cooldown = 0 + # 跳过前面 (50 = MA50 + ATR14 + RSI2 都需要预热) + start = max(50, params.rsi2_ma_filter + 1) + for i in range(start, len(closes)): + cooldown -= 1 + if cooldown > 0: + continue + if rsi2[i] is None or ma50[i] is None or atr_vals[i] is None: + continue + + # 入场 + side = None + if rsi2[i] < params.rsi_buy_threshold and closes[i] > ma50[i] and opens[i] > closes[i-1]: + side = 'long' + elif rsi2[i] > params.rsi_sell_threshold and closes[i] < ma50[i] and opens[i] < closes[i-1]: + side = 'short' + + if not side: + continue + + entry = closes[i] + sl_price = entry - atr_vals[i] * params.sl_atr_multi if side == 'long' else entry + atr_vals[i] * params.sl_atr_multi + tp_price = entry + atr_vals[i] * params.tp_atr_multi if side == 'long' else entry - atr_vals[i] * params.tp_atr_multi + + signals.append({ + 'i': i, 'ts': klines['timestamps'][i], 'side': side, + 'entry': entry, 'sl': sl_price, 'tp': tp_price, + 'atr': atr_vals[i], + }) + cooldown = params.cooldown_bars + return signals + + +def signal_sma_breakout(klines: Dict, params) -> List[Dict]: + """SMA 突破 (现有默认, 用来对比)""" + closes = klines['closes'] + opens = klines['opens'] + highs = klines['highs'] + lows = klines['lows'] + atr_vals = atr(highs, lows, closes, 14) + + sma5 = sma(closes, 5) + sma10 = sma(closes, 10) + + signals = [] + cooldown = 0 + for i in range(15, len(closes)): + cooldown -= 1 + if cooldown > 0: + continue + # SMA 突破: SMA5 > SMA10 + 突破前高 + if sma5[i] is None or sma10[i] is None or atr_vals[i] is None: + continue + if sma5[i] > sma10[i] and closes[i] > closes[i-1] and closes[i] > opens[i]: + entry = closes[i] + side = 'long' + sl_price = entry - atr_vals[i] * params.sl_atr_multi + tp_price = entry + atr_vals[i] * params.tp_atr_multi + + signals.append({ + 'i': i, 'ts': klines['timestamps'][i], 'side': side, + 'entry': entry, 'sl': sl_price, 'tp': tp_price, + 'atr': atr_vals[i], + }) + cooldown = params.cooldown_bars + return signals + + +def signal_vwap_revert(klines: Dict, params) -> List[Dict]: + """VWAP 回归""" + closes = klines['closes'] + highs = klines['highs'] + lows = klines['lows'] + volumes = klines['volumes'] + atr_vals = atr(highs, lows, closes, 14) + vwaps = vwap(closes, volumes) + vwap_stds = vwap_std(closes, volumes, 20) + + signals = [] + cooldown = 0 + for i in range(30, len(closes)): + cooldown -= 1 + if cooldown > 0: + continue + if vwaps[i] is None or vwap_stds[i] is None or atr_vals[i] is None: + continue + + deviation = closes[i] - vwaps[i] + std_dev = vwap_stds[i] + + # 量需 > 5日均量 × 1.2 (用前 120 bar 作 5日) + if i < 121: + continue + avg_vol = sum(volumes[i-119:i+1]) / 120 + if volumes[i] < avg_vol * params.require_volume_multi: + continue + + side = None + if deviation < -std_dev * params.vwap_deviation_std: + side = 'long' + elif deviation > std_dev * params.vwap_deviation_std: + side = 'short' + if not side: + continue + + entry = closes[i] + # SL = entry ± 1σ (基于 VWAP std) + sl_dist = std_dev * params.vwap_sl_std_multi + sl_price = entry - sl_dist if side == 'long' else entry + sl_dist + tp_price = vwaps[i] * (1 - params.vwap_tp_touch_pct/100) if side == 'long' else vwaps[i] * (1 + params.vwap_tp_touch_pct/100) + + signals.append({ + 'i': i, 'ts': klines['timestamps'][i], 'side': side, + 'entry': entry, 'sl': sl_price, 'tp': tp_price, + 'atr': atr_vals[i], + }) + cooldown = params.cooldown_bars + return signals + + +def signal_turtle_breakout(klines: Dict, params) -> List[Dict]: + """海龟通道突破""" + closes = klines['closes'] + highs = klines['highs'] + lows = klines['lows'] + atr_vals = atr(highs, lows, closes, 14) + + hh, ll = donchian_breakout(highs, lows, params.turtle_channel_period) + hh_exit, ll_exit = donchian_breakout(highs, lows, params.turtle_exit_channel_period) + + signals = [] + cooldown = 0 + for i in range(params.turtle_channel_period, len(closes)): + cooldown -= 1 + if cooldown > 0: + continue + if hh[i-1] is None or ll[i-1] is None or atr_vals[i] is None: + continue + + side = None + if closes[i] > hh[i-1]: + side = 'long' + elif closes[i] < ll[i-1]: + side = 'short' + if not side: + continue + + entry = closes[i] + sl_price = entry - atr_vals[i] * params.sl_atr_multi if side == 'long' else entry + atr_vals[i] * params.sl_atr_multi + tp_price = entry + atr_vals[i] * params.tp_atr_multi if side == 'long' else entry - atr_vals[i] * params.tp_atr_multi + + signals.append({ + 'i': i, 'ts': klines['timestamps'][i], 'side': side, + 'entry': entry, 'sl': sl_price, 'tp': tp_price, + 'atr': atr_vals[i], + }) + cooldown = params.cooldown_bars + return signals + + +def signal_early_bird(klines: Dict, params) -> List[Dict]: + """早盘动量: 假设 K 线是 5min, 开盘 30 min = 6 根 K 线 + 看开盘 6 根 K 线的累计涨跌幅 + 量能 + """ + closes = klines['closes'] + opens = klines['opens'] + highs = klines['highs'] + lows = klines['lows'] + volumes = klines['volumes'] + atr_vals = atr(highs, lows, closes, 14) + + signals = [] + cooldown = 0 + # 简化: 找每根 K 线, 看 close vs 开盘 (5 bar 前) 的涨跌幅 + for i in range(20, len(closes) - params.max_hold_bars - 6): + cooldown -= 1 + if cooldown > 0: + continue + + # 取开盘 6 根 (5min × 6 = 30 min) 的累计涨跌 + open_price = opens[i - 5] # 6 根前开 (第 1 根的开) + window_high = max(highs[i-5:i+1]) + window_low = min(lows[i-5:i+1]) + window_vol = sum(volumes[i-5:i+1]) + + # 跳空 + gap_pct = abs(opens[i] - closes[i-6]) / closes[i-6] * 100 + if gap_pct < params.early_bird_min_move_pct: + continue + + # 量能 + if i < 121: + continue + avg_vol = sum(volumes[i-119:i+1]) / 120 + if window_vol < avg_vol * params.early_bird_volume_multi: + continue + + # 顺势 + side = 'long' if closes[i] > opens[i] else 'short' + entry = closes[i] + sl_price = entry - atr_vals[i] * params.sl_atr_multi if side == 'long' else entry + atr_vals[i] * params.sl_atr_multi + tp_price = entry + atr_vals[i] * params.tp_atr_multi if side == 'long' else entry - atr_vals[i] * params.tp_atr_multi + + signals.append({ + 'i': i, 'ts': klines['timestamps'][i], 'side': side, + 'entry': entry, 'sl': sl_price, 'tp': tp_price, + 'atr': atr_vals[i], + }) + cooldown = params.cooldown_bars + return signals + + +SIGNAL_FNS = { + 'rsi2_revert': signal_rsi2_revert, + 'vwap_revert': signal_vwap_revert, + 'early_bird': signal_early_bird, + 'turtle_breakout': signal_turtle_breakout, + 'sma_breakout': signal_sma_breakout, +} + + +# ============== 回测执行 ============== + +def run_backtest(klines: Dict, signals: List[Dict], symbol: str, strategy_name: str) -> Dict: + """根据信号做回测. + 入场: 信号触发 (i 时刻 close) + 出场: SL / TP / max_hold_bars 三选一先到 + """ + closes = klines['closes'] + highs = klines['highs'] + lows = klines['lows'] + trades = [] + in_position = None # {i_entry, side, entry, sl, tp} + + # 简化: 同时只能持 1 仓 (同向多仓不重入) + for i in range(50, len(closes)): + # 1) 平仓检查 + if in_position is not None: + exit_price = None + exit_reason = None + i_entry = in_position['i_entry'] + side = in_position['side'] + sl = in_position['sl'] + tp = in_position['tp'] + + # SL hit (用 high/low 检查) + if side == 'long' and lows[i] <= sl: + exit_price = sl + exit_reason = 'SL' + elif side == 'short' and highs[i] >= sl: + exit_price = sl + exit_reason = 'SL' + elif side == 'long' and highs[i] >= tp: + exit_price = tp + exit_reason = 'TP' + elif side == 'short' and lows[i] <= tp: + exit_price = tp + exit_reason = 'TP' + elif i - i_entry >= 78: # 默认 max_hold_bars + exit_price = closes[i] + exit_reason = 'EXPIRE' + + if exit_price is not None: + pnl_pct = (exit_price - in_position['entry']) / in_position['entry'] * 100 + if side == 'short': + pnl_pct = -pnl_pct + trades.append({ + 'side': side, 'entry': in_position['entry'], 'exit': exit_price, + 'pnl_pct': pnl_pct, 'reason': exit_reason, + 'i_entry': i_entry, 'i_exit': i, + }) + in_position = None + + # 2) 入场检查 + for sig in signals: + if sig['i'] == i and in_position is None: + in_position = { + 'i_entry': i, 'side': sig['side'], + 'entry': sig['entry'], 'sl': sig['sl'], 'tp': sig['tp'], + } + break + + # 计算统计 + if not trades: + return { + 'strategy': strategy_name, 'symbol': symbol, + 'signals': len(signals), 'trades': 0, + 'win_rate': 0, 'avg_pnl': 0, 'total_pnl': 0, + 'max_drawdown': 0, 'sharpe': 0, + } + + wins = [t for t in trades if t['pnl_pct'] > 0] + losses = [t for t in trades if t['pnl_pct'] <= 0] + pnls = [t['pnl_pct'] for t in trades] + win_rate = len(wins) / len(trades) * 100 + + # 最大回撤 (累计收益曲线的 max drawdown) + cum = [0] + for p in pnls: + cum.append(cum[-1] + p) + peak = cum[0] + max_dd = 0 + for v in cum: + if v > peak: + peak = v + max_dd = min(max_dd, v - peak) + + # Sharpe 简化: 平均 / std + avg = sum(pnls) / len(pnls) + var = sum((x - avg)**2 for x in pnls) / len(pnls) + std = var ** 0.5 + sharpe = avg / std if std > 0 else 0 + + return { + 'strategy': strategy_name, 'symbol': symbol, + 'signals': len(signals), 'trades': len(trades), + 'wins': len(wins), 'losses': len(losses), + 'win_rate': round(win_rate, 1), + 'avg_pnl': round(avg, 3), + 'best': round(max(pnls), 2), + 'worst': round(min(pnls), 2), + 'total_pnl': round(sum(pnls), 2), + 'max_drawdown': round(max_dd, 2), + 'sharpe': round(sharpe, 2), + 'trades_detail': trades[:10], + } + + +def fmt(result: Dict) -> str: + """格式化回测报告""" + lines = [] + lines.append(f"📊 {result['strategy']} {result['symbol']}") + lines.append(f" 信号: {result['signals']} | 成交: {result['trades']} (W={result.get('wins',0)}, L={result.get('losses',0)})") + if result['trades'] == 0: + lines.append(f" ⚠️ 无成交 (参数过严或市场平静)") + return '\n'.join(lines) + lines.append(f" 胜率: {result['win_rate']}%") + lines.append(f" 平均盈亏: {result['avg_pnl']:+.3f}% | 最大盈: {result['best']:+.2f}% / 最大亏: {result['worst']:+.2f}%") + lines.append(f" 累计盈亏: {result['total_pnl']:+.2f}% | 最大回撤: {result['max_drawdown']:+.2f}%") + lines.append(f" Sharpe: {result['sharpe']}") + if result['trades'] > 0: + lines.append(f" 最近 5 笔: {result['trades_detail'][:5]}") + return '\n'.join(lines) + + +# ============== 主入口 ============== + +def main(): + parser = argparse.ArgumentParser(description='策略回测 - v0.1') + parser.add_argument('--strategy', choices=list(SIGNAL_FNS.keys()), required=True) + parser.add_argument('--symbol', default='NVDA', help='Yahoo Finance ticker, e.g. NVDA / 0700.HK') + parser.add_argument('--days', type=int, default=30) + parser.add_argument('--interval', default='1h', help='K 线周期: 1h / 30m / 15m / 5m') + args = parser.parse_args() + + print(f"⏳ 拉 {args.symbol} 最近 {args.days} 天 {args.interval} K线...") + klines = fetch_klines(args.symbol, days=args.days, interval=args.interval) + if not klines: + print(f"❌ {args.symbol} 数据拉取失败") + sys.exit(1) + n = len(klines['closes']) + print(f"✅ {n} 根 K 线") + + params = get_strategy(args.strategy) + print(f"\n🎯 策略: {args.strategy}") + print(f"📋 {params.name} (sl_atr={params.sl_atr_multi}, tp_atr={params.tp_atr_multi}, position={params.position_pct}%)") + + signals = SIGNAL_FNS[args.strategy](klines, params) + print(f"🔍 信号数: {len(signals)}") + + result = run_backtest(klines, signals, args.symbol, args.strategy) + print("\n" + fmt(result)) + + # 输出 JSON + result.pop('trades_detail', None) + print(f"\n📊 JSON: {json.dumps(result, default=str, ensure_ascii=False)}") + + +if __name__ == '__main__': + main()