#!/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()