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()
+279
View File
@@ -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=== 完成 ===")
+31
View File
@@ -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
+279
View File
@@ -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=== 完成 ===")
+509
View File
@@ -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()