feat: 备份 crypto/ + stocks/ 子目录到 skill 仓库

【备份】cron 已迁到 ~/.hermes/scripts/symlink, 旧副本 ~/.hermes/scripts/crypto/ 和 stocks/ 即将删, 先备份
- crypto-t-monitor/scripts/backtest.py + okx_t_monitor.py
- intraday-trading/scripts/{hk,us}_intraday_cli.py + hk_intraday_cli_runner.sh
- strategy-management/scripts/backtest.py (与 crypto-t-monitor 重复, 备份占位)

【未删本地】等用户确认
This commit is contained in:
2026-07-24 12:06:22 +08:00
parent 80063a1cac
commit d331b4e682
6 changed files with 1877 additions and 0 deletions
+216
View File
@@ -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()
+563
View File
@@ -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()