From 2b78639b9e6cd559b47646b285475f213b7baf9e Mon Sep 17 00:00:00 2001 From: mike Date: Thu, 23 Jul 2026 10:28:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20strategy-management=20=E5=8A=A0=20--str?= =?UTF-8?q?ategy=20=E5=8F=82=E6=95=B0=20+=20=E6=96=B0=E5=BB=BA=20A=20?= =?UTF-8?q?=E8=82=A1=20calc=5Fcn=5Flevels.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- strategy-management/scripts/calc_cn_levels.py | 240 +++++++++++++++ strategy-management/scripts/calc_hk_levels.py | 273 ++++++++++++++++++ strategy-management/scripts/calc_us_levels.py | 257 +++++++++++++++++ 3 files changed, 770 insertions(+) create mode 100644 strategy-management/scripts/calc_cn_levels.py create mode 100644 strategy-management/scripts/calc_hk_levels.py create mode 100644 strategy-management/scripts/calc_us_levels.py diff --git a/strategy-management/scripts/calc_cn_levels.py b/strategy-management/scripts/calc_cn_levels.py new file mode 100644 index 0000000..0b3ebe5 --- /dev/null +++ b/strategy-management/scripts/calc_cn_levels.py @@ -0,0 +1,240 @@ +"""--- +name: cn-scan +description: "A股日内做T点位扫描 — 高股息候选池 + 实时 quote + 日线/5min K, 算 SL/TP1/TP2 (不交易, 仅参考)" +---""" + +""" +A股日内做T点位扫描 (cron 模板) +- 高股息候选池 (预设, 与 scan_cn.py 共用) +- 拉实时 quote + 日线/5min K 线 (longport_http) +- 算 SL/TP1/TP2 用 calc_exit_levels() +- 三级输出: ✅ R:R≥1.5 / ⚠️ R:R 1.0 / ❌ 否决 +- 推 QQ (origin delivery) + +用法: + python3 calc_cn_levels.py # 跑 (默认) + python3 calc_cn_levels.py --top 3 # 只看 top 3 + python3 calc_cn_levels.py --period day # 用日线 (默认) + python3 calc_cn_levels.py --strategy turtle_breakout # 海龟 +""" +import argparse +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, '/home/openclaw/.hermes/scripts') +sys.path.insert(0, str(Path(__file__).parent)) +from longport_http import get_quote, get_candlesticks +from exit_levels import calc_exit_levels +from indicators import atr as calc_atr, vwap as calc_vwap + +# === A 股候选池 (与 scan_cn.py 共用) === +A_SHARE_POOL = { + "601088.SH": {"name": "中国神华", "yield": 6.7, "sector": "煤炭"}, + "601328.SH": {"name": "交通银行", "yield": 6.2, "sector": "银行"}, + "601398.SH": {"name": "工商银行", "yield": 5.9, "sector": "银行"}, + "601288.SH": {"name": "农业银行", "yield": 5.8, "sector": "银行"}, + "601939.SH": {"name": "建设银行", "yield": 6.0, "sector": "银行"}, + "601988.SH": {"name": "中国银行", "yield": 5.7, "sector": "银行"}, + "600900.SH": {"name": "长江电力", "yield": 3.8, "sector": "电力"}, + "601857.SH": {"name": "中国石油", "yield": 5.5, "sector": "能源"}, + "600028.SH": {"name": "中国石化", "yield": 5.2, "sector": "能源"}, + "601728.SH": {"name": "中国电信", "yield": 4.8, "sector": "电信"}, + "600036.SH": {"name": "招商银行", "yield": 4.5, "sector": "银行"}, + "601166.SH": {"name": "兴业银行", "yield": 5.8, "sector": "银行"}, + "601818.SH": {"name": "光大银行", "yield": 5.9, "sector": "银行"}, + "600377.SH": {"name": "宁沪高速", "yield": 6.2, "sector": "高速"}, + "601666.SH": {"name": "平煤股份", "yield": 6.2, "sector": "煤炭"}, + "600023.SH": {"name": "浙能电力", "yield": 5.5, "sector": "电力"}, + "000858.SZ": {"name": "五粮液", "yield": 10.5, "sector": "白酒"}, + "000568.SZ": {"name": "泸州老窖", "yield": 7.0, "sector": "白酒"}, + "000937.SZ": {"name": "冀中能源", "yield": 11.0, "sector": "煤炭"}, + "002304.SZ": {"name": "洋河股份", "yield": 10.8, "sector": "白酒"}, + "000596.SZ": {"name": "古井贡酒", "yield": 6.9, "sector": "白酒"}, + "000001.SZ": {"name": "平安银行", "yield": 5.4, "sector": "银行"}, + "600519.SH": {"name": "贵州茅台", "yield": 5.0, "sector": "白酒"}, +} + +MIN_YIELD = 5.0 # 股息率下限 + + +def calc_levels(symbol: str, klines: list, quote: dict, side: str, min_rr: float, + strategy: str = 'rsi2_revert'): + """ + strategy: 'rsi2_revert' | 'vwap_revert' | 'early_bird' | 'turtle_breakout' + A 股默认用 rsi2_revert (震荡回归, 适合高股息股) + """ + if not klines or not quote: + return None, '数据缺失' + + closes = [k['close'] for k in klines] + highs = [k['high'] for k in klines] + lows = [k['low'] for k in klines] + volumes = [k.get('volume', 0) for k in klines] + + atr_vals = calc_atr(highs, lows, closes, 14) + current_atr = atr_vals[-1] + if not current_atr: + return None, 'ATR 失败' + + vwaps = calc_vwap(closes, volumes) + current_vwap = vwaps[-1] + + current_price = quote.get('price') or quote.get('last_done', 0) + if not current_price: + return None, '价格缺失' + + # ── 策略参数映射 ── + if strategy == 'turtle_breakout': + vol_sl_multi, vol_tp1_multi, vol_tp2_multi = 2.0, 4.0, 8.0 + eff_min_rr = min(min_rr, 1.0) + use_vwap = False + elif strategy == 'vwap_revert': + vol_sl_multi, vol_tp1_multi, vol_tp2_multi = 1.0, 2.0, 3.0 + eff_min_rr = max(min_rr, 1.5) + use_vwap = True + elif strategy == 'early_bird': + vol_sl_multi, vol_tp1_multi, vol_tp2_multi = 1.5, 2.0, 3.0 + eff_min_rr = min_rr + use_vwap = False + else: # rsi2_revert + vol_sl_multi, vol_tp1_multi, vol_tp2_multi = 1.5, 2.0, 3.0 + eff_min_rr = min_rr + use_vwap = True + + eff_vwap = current_vwap if use_vwap else None + return calc_exit_levels( + entry=current_price, + atr=current_atr, + current_price=current_price, + day_high=quote.get('high') or max(highs), + day_low=quote.get('low') or min(lows), + prev_high=max(highs), + prev_low=min(lows), + vwap=eff_vwap, + side=side, + min_rr=eff_min_rr, + vol_sl_multi=vol_sl_multi, + vol_tp1_multi=vol_tp1_multi, + vol_tp2_multi=vol_tp2_multi, + ), strategy + + +def format_cn_output(levels, change_pct, current_price, side, symbol, info, mode): + """A 股格式: ¥ 符号, 股息率""" + name = info.get('name', symbol) + div_yield = info.get('yield', 0) + sector = info.get('sector', '') + if mode == 'strict': + return ( + f"\n📈 **{symbol}** {name} (股息率 {div_yield}% | {sector})\n" + f"现价 ¥{current_price:.2f} ({change_pct:+.2f}%) | {side.upper()}\n" + f"SL ¥{levels.sl:.2f}\n" + f"TP1 ¥{levels.tp1:.2f}\n" + f"TP2 ¥{levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ✅" + ) + elif mode == 'relaxed': + return ( + f"\n📈 **{symbol}** {name} (股息率 {div_yield}% | {sector})\n" + f"现价 ¥{current_price:.2f} ({change_pct:+.2f}%) | {side.upper()} [R:R 1.0 宽松]\n" + f"SL ¥{levels.sl:.2f}\n" + f"TP1 ¥{levels.tp1:.2f}\n" + f"TP2 ¥{levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ⚠️" + ) + else: # atr_adj + return ( + f"\n📈 **{symbol}** {name} (股息率 {div_yield}% | {sector})\n" + f"现价 ¥{current_price:.2f} ({change_pct:+.2f}%) | {side.upper()} [ATR 调整]\n" + f"SL ¥{levels.sl:.2f}\n" + f"TP1 ¥{levels.tp1:.2f}\n" + f"TP2 ¥{levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ⚠️" + ) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--top', type=int, default=5, help='top N (default 5)') + ap.add_argument('--period', default='day', + choices=['day', '5m', '15m', '1h'], + help='K线周期 (default day)') + ap.add_argument('--strategy', default='rsi2_revert', + choices=['rsi2_revert', 'vwap_revert', 'early_bird', 'turtle_breakout'], + help='策略 (default rsi2_revert, A 股推荐)') + args = ap.parse_args() + + # 过滤高股息 + candidates = {k: v for k, v in A_SHARE_POOL.items() if v.get('yield', 0) >= MIN_YIELD} + top = list(candidates.items())[:args.top] + + from datetime import date + today = date.today().isoformat() + + print(f"📊 A 股日内做T点位扫描 ({today}, top {args.top}, 策略={args.strategy})") + print(f"📋 共扫描 {len(top)} 支\n") + + output_lines = [] + + for symbol, info in top: + print(f"--- {symbol} {info['name']} ---") + + quote = get_quote(symbol) + if not quote: + print(f" ❌ quote 拉取失败") + continue + + klines = get_candlesticks(symbol, args.period, 30) + if not klines: + print(f" ❌ K线 拉取失败") + continue + + current_price = quote.get('price') or quote.get('last_done', 0) + change_pct = quote.get('change_pct', 0) + print(f" 现价: ¥{current_price:.2f} ({change_pct:+.2f}%)") + + side = 'long' if change_pct > 0 else 'short' + + levels, _ = calc_levels(symbol, klines, quote, side, min_rr=1.5, strategy=args.strategy) + if levels: + print(f" ✅ R:R 1.5 [{args.strategy}] → SL=¥{levels.sl:.2f} TP1=¥{levels.tp1:.2f} TP2=¥{levels.tp2:.2f} R:R=1:{levels.rr_ratio:.2f}") + output_lines.append(format_cn_output(levels, change_pct, current_price, side, symbol, info, 'strict')) + else: + levels_relaxed, _ = calc_levels(symbol, klines, quote, side, min_rr=1.0, strategy=args.strategy) + if levels_relaxed: + print(f" ⚠️ R:R 1.5 否决, 1.0 通过 → R:R=1:{levels_relaxed.rr_ratio:.2f}") + output_lines.append(format_cn_output(levels_relaxed, change_pct, current_price, side, symbol, info, 'relaxed')) + else: + closes = [k['close'] for k in klines] + highs = [k['high'] for k in klines] + lows = [k['low'] for k in klines] + volumes = [k.get('volume', 0) for k in klines] + atr_v = calc_atr(highs, lows, closes, 14)[-1] + vwaps_v = calc_vwap(closes, volumes) + levels_alt = calc_exit_levels( + entry=current_price, atr=atr_v, current_price=current_price, + day_high=max(highs), day_low=min(lows), + prev_high=max(highs), prev_low=min(lows), + vwap=vwaps_v[-1], side=side, min_rr=1.5, + vol_sl_multi=1.5, vol_tp1_multi=3.0, + ) + if levels_alt: + print(f" ⚠️ ATR 调整 → R:R=1:{levels_alt.rr_ratio:.2f}") + output_lines.append(format_cn_output(levels_alt, change_pct, current_price, side, symbol, info, 'atr_adj')) + else: + print(f" ❌ 全部场景否决") + + print() + + if output_lines: + header = f"📊 A 股日内做T点位 ({today})\n⚠️ 仅参考, 不交易\n" + print("\n=== QQ 推送内容 ===") + print(header + "\n---\n".join(output_lines)) + else: + print("\n💤 全部场景否决, 无输出") + + +if __name__ == '__main__': + main() diff --git a/strategy-management/scripts/calc_hk_levels.py b/strategy-management/scripts/calc_hk_levels.py new file mode 100644 index 0000000..74d0eab --- /dev/null +++ b/strategy-management/scripts/calc_hk_levels.py @@ -0,0 +1,273 @@ +--- +name: hk-scan +description: "港股日内做T点位扫描 — 拉候选池 top 5 + 实时 quote + 5min K, 算 SL/TP1/TP2 推 QQ (不交易, 仅参考)" +--- + +""" +港股日内做T点位扫描 (cron 模板) +- 拉候选池 top 5 (artifact hk_intraday_latest.json) +- 拉实时 quote + 5min K 线 +- 算 SL/TP1/TP2 用 calc_exit_levels() +- 三级输出: ✅ R:R≥1.5 / ⚠️ R:R 1.0 / ❌ 否决 +- 推 QQ (origin delivery) + +用法: + python3 calc_hk_levels.py # 跑 (cron 默认) + python3 calc_hk_levels.py --top 3 # 只看 top 3 + python3 calc_hk_levels.py --period 60m # 用 60min K +""" +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +# 添加 strategy-management scripts 到 path +sys.path.insert(0, str(Path(__file__).parent)) +from exit_levels import calc_exit_levels +from indicators import atr as calc_atr, vwap as calc_vwap + +# === 常量 === +HERMES_HOME = '/home/openclaw' +CANDIDATE_FILE = f'{HERMES_HOME}/.hermes/skills/trading/quant-factor-mining/artifacts/hk_intraday_latest.json' +PROXYCHAINS = ['proxychains4', '-f', f'{HERMES_HOME}/.proxychains/proxychains.conf'] +LONGBRIDGE = ['/home/openclaw/.local/bin/longbridge', '--profile', 'lb_real'] + + +# === 长桥数据拉取 === + +def fetch_quote(symbol: str) -> dict: + """港美股 quote, JSON 格式 (港美都支持)""" + result = subprocess.run( + PROXYCHAINS + LONGBRIDGE + ['quote', symbol, '--json'], + capture_output=True, text=True, timeout=30, + ) + start = result.stdout.find('[') + if start == -1: + return {} + try: + return json.loads(result.stdout[start:])[0] + except Exception: + return {} + + +def fetch_klines_hk(symbol: str, period: str = '5m', count: int = 30) -> list: + """ + 港股 K 线 (表格 parser, 6 列, 不用 --json) + - 分隔符 │ (U+2502), 不是 | + - 表头中文: 时间/开盘/最高/最低/收盘/成交量 + - 时间格式: '2026-07-10 10:30' (空格分隔) + - 数字带千分位逗号: '1,190,430' + """ + result = subprocess.run( + PROXYCHAINS + LONGBRIDGE + ['candlesticks', symbol, period, '--count', str(count)], + capture_output=True, text=True, timeout=30, + ) + klines = [] + pattern = re.compile( + r'│\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s*│' + r'\s*([\d,.]+)\s*│\s*([\d,.]+)\s*│\s*([\d,.]+)\s*│\s*([\d,.]+)\s*│\s*([\d,.]+)\s*│' + ) + for line in result.stdout.split('\n'): + m = pattern.search(line) + if m: + ts, o, h, l, c, v = m.groups() + def parse_num(s): + return float(s.replace(',', '')) + klines.append({ + 'timestamp': ts.replace(' ', 'T'), + 'open': parse_num(o), + 'high': parse_num(h), + 'low': parse_num(l), + 'close': parse_num(c), + 'volume': parse_num(v), + }) + return klines + + +# === 业务逻辑 === + +def calc_levels(symbol: str, klines: list, quote: dict, side: str, min_rr: float, + strategy: str = 'rsi2_revert'): + """ + strategy: 'rsi2_revert' | 'vwap_revert' | 'early_bird' | 'turtle_breakout' + 每个策略用不同的 vol_multi 参数组 + """ + if not klines or not quote: + return None, '数据缺失' + closes = [k['close'] for k in klines] + highs = [k['high'] for k in klines] + lows = [k['low'] for k in klines] + volumes = [k['volume'] for k in klines] + + atr_vals = calc_atr(highs, lows, closes, 14) + current_atr = atr_vals[-1] + if not current_atr: + return None, 'ATR 失败' + + vwaps = calc_vwap(closes, volumes) + current_vwap = vwaps[-1] + + current_price = quote['last_done'] + + # ── 策略参数映射 ── + if strategy == 'turtle_breakout': + vol_sl_multi, vol_tp1_multi, vol_tp2_multi = 2.0, 4.0, 8.0 + eff_min_rr = min(min_rr, 1.0) + use_vwap = False + elif strategy == 'vwap_revert': + vol_sl_multi, vol_tp1_multi, vol_tp2_multi = 1.0, 2.0, 3.0 + eff_min_rr = max(min_rr, 1.5) + use_vwap = True + elif strategy == 'early_bird': + vol_sl_multi, vol_tp1_multi, vol_tp2_multi = 1.5, 2.0, 3.0 + eff_min_rr = min_rr + use_vwap = False + else: # rsi2_revert + vol_sl_multi, vol_tp1_multi, vol_tp2_multi = 1.5, 2.0, 3.0 + eff_min_rr = min_rr + use_vwap = True + + eff_vwap = current_vwap if use_vwap else None + return calc_exit_levels( + entry=current_price, + atr=current_atr, + current_price=current_price, + day_high=quote['high'], + day_low=quote['low'], + prev_high=max(highs), + prev_low=min(lows), + vwap=eff_vwap, + side=side, + min_rr=eff_min_rr, + vol_sl_multi=vol_sl_multi, + vol_tp1_multi=vol_tp1_multi, + vol_tp2_multi=vol_tp2_multi, + ), strategy + + +def format_qq_output(levels, change, current_price, side, symbol, score, adr, mode): + """格式化为 QQ 推送文本 (单条)""" + if mode == 'strict': + return ( + f"\n📈 **{symbol}** (score {score}, ADR {adr}%)\n" + f"现价 ${current_price:.2f} ({change:+.2f}%) | {side.upper()}\n" + f"SL ${levels.sl:.2f} ({levels.sl_method})\n" + f"TP1 ${levels.tp1:.2f} ({levels.tp_method})\n" + f"TP2 ${levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ✅" + ) + elif mode == 'relaxed': + return ( + f"\n📈 **{symbol}** (score {score}, ADR {adr}%)\n" + f"现价 ${current_price:.2f} ({change:+.2f}%) | {side.upper()} [R:R 1.0 宽松]\n" + f"SL ${levels.sl:.2f}\n" + f"TP1 ${levels.tp1:.2f}\n" + f"TP2 ${levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ⚠️" + ) + else: # atr_adj + return ( + f"\n📈 **{symbol}** (score {score}, ADR {adr}%)\n" + f"现价 ${current_price:.2f} ({change:+.2f}%) | {side.upper()} [ATR 调整]\n" + f"SL ${levels.sl:.2f}\n" + f"TP1 ${levels.tp1:.2f}\n" + f"TP2 ${levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ⚠️" + ) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--top', type=int, default=5, help='候选池 top N (default 5)') + ap.add_argument('--period', default='5m', help='K 线周期 (default 5m)') + ap.add_argument('--strategy', default='rsi2_revert', + choices=['rsi2_revert', 'vwap_revert', 'early_bird', 'turtle_breakout'], + help='策略 (default rsi2_revert)') + args = ap.parse_args() + + if not os.path.exists(CANDIDATE_FILE): + print(f"[skip] 候选池不存在: {CANDIDATE_FILE}") + return + + with open(CANDIDATE_FILE) as f: + candidate_data = json.load(f) + + top = candidate_data.get('results', [])[:args.top] + date = candidate_data.get('date', '?')[:10] + + print(f"📊 港股日内做T点位扫描 (候选池 {date}, top {args.top})") + print(f"📋 共扫描 {len(top)} 支\n") + + output_lines = [] + + for entry in top: + symbol = entry['ticker'] + score = entry['score'] + avg_adr = entry['avg_adr'] + + print(f"--- {symbol} (score {score}, ADR {avg_adr}%) ---") + + quote = fetch_quote(symbol) + if not quote: + print(f" ❌ quote 拉取失败") + continue + + klines = fetch_klines_hk(symbol, args.period, 30) + if not klines: + print(f" ❌ K线 拉取失败") + continue + + current_price = quote['last_done'] + change = (current_price - quote['prev_close']) / quote['prev_close'] * 100 + print(f" 现价: ${current_price:.2f} ({change:+.2f}%)") + + # 顺势方向 + side = 'long' if change > 0 else 'short' + + # 三级尝试: 严格 / 宽松 / ATR 调整 + levels, _ = calc_levels(symbol, klines, quote, side, min_rr=1.5, strategy=args.strategy) + if levels: + print(f" ✅ R:R 1.5 [{args.strategy}] → SL=${levels.sl:.2f} TP1=${levels.tp1:.2f} TP2=${levels.tp2:.2f} R:R=1:{levels.rr_ratio:.2f}") + output_lines.append(format_qq_output(levels, change, current_price, side, symbol, score, avg_adr, 'strict')) + else: + # 场景 B: 宽松 + levels_relaxed, _ = calc_levels(symbol, klines, quote, side, min_rr=1.0, strategy=args.strategy) + if levels_relaxed: + print(f" ⚠️ R:R 1.5 否决, 1.0 通过 → R:R=1:{levels_relaxed.rr_ratio:.2f}") + output_lines.append(format_qq_output(levels_relaxed, change, current_price, side, symbol, score, avg_adr, 'relaxed')) + else: + # 场景 C: ATR 倍数调整 (SL=1.5 ATR, TP1=3.0 ATR) + closes = [k['close'] for k in klines] + highs = [k['high'] for k in klines] + lows = [k['low'] for k in klines] + volumes = [k['volume'] for k in klines] + atr_v = calc_atr(highs, lows, closes, 14)[-1] + vwaps = calc_vwap(closes, volumes) + levels_alt = calc_exit_levels( + entry=current_price, atr=atr_v, current_price=current_price, + day_high=quote['high'], day_low=quote['low'], + prev_high=max(highs), prev_low=min(lows), + vwap=vwaps[-1], side=side, min_rr=1.5, + vol_sl_multi=1.5, vol_tp1_multi=3.0, + ) + if levels_alt: + print(f" ⚠️ ATR 调整 → R:R=1:{levels_alt.rr_ratio:.2f}") + output_lines.append(format_qq_output(levels_alt, change, current_price, side, symbol, score, avg_adr, 'atr_adj')) + else: + print(f" ❌ 全部场景否决") + + print() + + if output_lines: + header = f"📊 港股日内做T点位 ({date})\n⚠️ 仅参考, 不交易\n" + print("\n=== QQ 推送内容 ===") + print(header + "\n---\n".join(output_lines)) + else: + print("\n💤 全部场景否决, 无输出") + + +if __name__ == '__main__': + main() diff --git a/strategy-management/scripts/calc_us_levels.py b/strategy-management/scripts/calc_us_levels.py new file mode 100644 index 0000000..d26a07a --- /dev/null +++ b/strategy-management/scripts/calc_us_levels.py @@ -0,0 +1,257 @@ +--- +name: us-scan +description: "美股日内做T点位扫描 — 拉候选池 top 5 + 实时 quote + 5min K, 算 SL/TP1/TP2 推 QQ (不交易, 仅参考)" +--- + +""" +美股日内做T点位扫描 (cron 模板) +- 拉候选池 top 5 (artifact us_intraday_latest.json) +- 拉实时 quote + 5min K 线 (JSON 输出) +- 算 SL/TP1/TP2 用 calc_exit_levels() +- 三级输出: ✅ R:R≥1.5 / ⚠️ R:R 1.0 / ❌ 否决 +- 推 QQ (origin delivery) + +用法: + python3 calc_us_levels.py # 跑 (cron 默认) + python3 calc_us_levels.py --top 3 # 只看 top 3 + python3 calc_us_levels.py --period 60m # 用 60min K +""" +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from exit_levels import calc_exit_levels +from indicators import atr as calc_atr, vwap as calc_vwap + +# === 常量 === +HERMES_HOME = '/home/openclaw' +CANDIDATE_FILE = f'{HERMES_HOME}/.hermes/skills/trading/quant-factor-mining/artifacts/us_intraday_latest.json' +PROXYCHAINS = ['proxychains4', '-f', f'{HERMES_HOME}/.proxychains/proxychains.conf'] +LONGBRIDGE = ['/home/openclaw/.local/bin/longbridge', '--profile', 'lb_real'] + + +# === 长桥数据拉取 (美股版) === + +def fetch_quote(symbol: str) -> dict: + """美股 quote, JSON""" + result = subprocess.run( + PROXYCHAINS + LONGBRIDGE + ['quote', symbol, '--json'], + capture_output=True, text=True, timeout=30, + ) + start = result.stdout.find('[') + if start == -1: + return {} + try: + return json.loads(result.stdout[start:])[0] + except Exception: + return {} + + +def fetch_klines_us(symbol: str, period: str = '5m', count: int = 30) -> list: + """ + 美股 K 线 (JSON, 7 列含 turnover) + - 支持 --json + - stdout 拼接表格提示 + JSON → find('[') 切 + - 时间格式: '2026-07-16T03:55:00' (T 分隔) + - 数字无千分位 + """ + result = subprocess.run( + PROXYCHAINS + LONGBRIDGE + ['candlesticks', symbol, period, '--count', str(count), '--json'], + capture_output=True, text=True, timeout=30, + ) + # 长桥把表格提示 (USOption/HK) + JSON 拼一起 + start = result.stdout.find('[') + if start == -1: + return [] + try: + data = json.loads(result.stdout[start:]) + return [k for k in data if 'timestamp' in k and 'close' in k] + except Exception: + return [] + + +# === 业务逻辑 (与 calc_hk_levels.py 完全一致, 只是 klines 函数不同) === + +def calc_levels(symbol: str, klines: list, quote: dict, side: str, min_rr: float, + strategy: str = 'rsi2_revert'): + """ + strategy: 'rsi2_revert' | 'vwap_revert' | 'early_bird' | 'turtle_breakout' + 每个策略用不同的 vol_multi 参数组 + """ + if not klines or not quote: + return None, '数据缺失' + closes = [k['close'] for k in klines] + highs = [k['high'] for k in klines] + lows = [k['low'] for k in klines] + volumes = [k.get('volume', 0) for k in klines] + atr_vals = calc_atr(highs, lows, closes, 14) + current_atr = atr_vals[-1] + if not current_atr: + return None, 'ATR 失败' + vwaps = calc_vwap(closes, volumes) + current_vwap = vwaps[-1] + current_price = quote['last_done'] + + # ── 策略参数映射 ── + if strategy == 'turtle_breakout': + # 海龟: SL=2ATR, TP1=4ATR(1R), TP2=8ATR(2R), 更宽松 min_rr=1.0 + vol_sl_multi = 2.0 + vol_tp1_multi = 4.0 + vol_tp2_multi = 8.0 + eff_min_rr = min(min_rr, 1.0) + use_vwap = False # 海龟不看 VWAP + elif strategy == 'vwap_revert': + # VWAP 回归: SL=1σ, TP1=VWAP±0.05%, TP2=2ATR + vol_sl_multi = 1.0 + vol_tp1_multi = 2.0 + vol_tp2_multi = 3.0 + eff_min_rr = max(min_rr, 1.5) + use_vwap = True + elif strategy == 'early_bird': + # 开盘缺口: SL=1.5ATR, TP1=2ATR, TP2=3ATR + vol_sl_multi = 1.5 + vol_tp1_multi = 2.0 + vol_tp2_multi = 3.0 + eff_min_rr = min_rr + use_vwap = False + else: + # rsi2_revert (默认): SL=1.5, TP1=2.0, TP2=3.0 + vol_sl_multi = 1.5 + vol_tp1_multi = 2.0 + vol_tp2_multi = 3.0 + eff_min_rr = min_rr + use_vwap = True + + eff_vwap = current_vwap if use_vwap else None + return calc_exit_levels( + entry=current_price, atr=current_atr, current_price=current_price, + day_high=quote['high'], day_low=quote['low'], + prev_high=max(highs), prev_low=min(lows), + vwap=eff_vwap, side=side, min_rr=eff_min_rr, + vol_sl_multi=vol_sl_multi, vol_tp1_multi=vol_tp1_multi, vol_tp2_multi=vol_tp2_multi, + ), strategy + + +def format_qq_output(levels, change, current_price, side, symbol, score, adr, mode): + if mode == 'strict': + return ( + f"\n📈 **{symbol}** (score {score}, ADR {adr}%)\n" + f"现价 ${current_price:.2f} ({change:+.2f}%) | {side.upper()}\n" + f"SL ${levels.sl:.2f} ({levels.sl_method})\n" + f"TP1 ${levels.tp1:.2f} ({levels.tp_method})\n" + f"TP2 ${levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ✅" + ) + elif mode == 'relaxed': + return ( + f"\n📈 **{symbol}** (score {score}, ADR {adr}%)\n" + f"现价 ${current_price:.2f} ({change:+.2f}%) | {side.upper()} [R:R 1.0 宽松]\n" + f"SL ${levels.sl:.2f}\n" + f"TP1 ${levels.tp1:.2f}\n" + f"TP2 ${levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ⚠️" + ) + else: + return ( + f"\n📈 **{symbol}** (score {score}, ADR {adr}%)\n" + f"现价 ${current_price:.2f} ({change:+.2f}%) | {side.upper()} [ATR 调整]\n" + f"SL ${levels.sl:.2f}\n" + f"TP1 ${levels.tp1:.2f}\n" + f"TP2 ${levels.tp2:.2f}\n" + f"R:R 1:{levels.rr_ratio:.2f} ⚠️" + ) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--top', type=int, default=5) + ap.add_argument('--period', default='5m') + ap.add_argument('--strategy', default='rsi2_revert', + choices=['rsi2_revert', 'vwap_revert', 'early_bird', 'turtle_breakout'], + help='策略 (default rsi2_revert)') + args = ap.parse_args() + + if not os.path.exists(CANDIDATE_FILE): + print(f"[skip] 候选池不存在: {CANDIDATE_FILE}") + return + + with open(CANDIDATE_FILE) as f: + candidate_data = json.load(f) + + top = candidate_data.get('results', [])[:args.top] + date = candidate_data.get('date', '?')[:10] + + print(f"📊 美股日内做T点位扫描 (候选池 {date}, top {args.top})") + print(f"📋 共扫描 {len(top)} 支\n") + + output_lines = [] + + for entry in top: + symbol = entry['ticker'] + score = entry['score'] + avg_adr = entry['avg_adr'] + + print(f"--- {symbol} (score {score}, ADR {avg_adr}%) ---") + + quote = fetch_quote(symbol) + if not quote: + print(f" ❌ quote 拉取失败") + continue + + klines = fetch_klines_us(symbol, args.period, 30) + if not klines: + print(f" ❌ K线 拉取失败") + continue + + current_price = quote['last_done'] + change = (current_price - quote['prev_close']) / quote['prev_close'] * 100 + print(f" 现价: ${current_price:.2f} ({change:+.2f}%)") + + side = 'long' if change > 0 else 'short' + + levels, _ = calc_levels(symbol, klines, quote, side, min_rr=1.5, strategy=args.strategy) + if levels: + print(f" ✅ R:R 1.5 [{args.strategy}] → SL=${levels.sl:.2f} TP1=${levels.tp1:.2f} TP2=${levels.tp2:.2f} R:R=1:{levels.rr_ratio:.2f}") + output_lines.append(format_qq_output(levels, change, current_price, side, symbol, score, avg_adr, 'strict')) + else: + levels_relaxed, _ = calc_levels(symbol, klines, quote, side, min_rr=1.0, strategy=args.strategy) + if levels_relaxed: + print(f" ⚠️ R:R 1.5 否决, 1.0 通过 → R:R=1:{levels_relaxed.rr_ratio:.2f}") + output_lines.append(format_qq_output(levels_relaxed, change, current_price, side, symbol, score, avg_adr, 'relaxed')) + else: + # ATR 调整 + closes = [k['close'] for k in klines] + highs = [k['high'] for k in klines] + lows = [k['low'] for k in klines] + volumes = [k.get('volume', 0) for k in klines] + atr_v = calc_atr(highs, lows, closes, 14)[-1] + vwaps = calc_vwap(closes, volumes) + levels_alt = calc_exit_levels( + entry=current_price, atr=atr_v, current_price=current_price, + day_high=quote['high'], day_low=quote['low'], + prev_high=max(highs), prev_low=min(lows), + vwap=vwaps[-1], side=side, min_rr=1.5, + vol_sl_multi=1.5, vol_tp1_multi=3.0, + ) + if levels_alt: + print(f" ⚠️ ATR 调整 → R:R=1:{levels_alt.rr_ratio:.2f}") + output_lines.append(format_qq_output(levels_alt, change, current_price, side, symbol, score, avg_adr, 'atr_adj')) + else: + print(f" ❌ 全部场景否决") + + print() + + if output_lines: + header = f"📊 美股日内做T点位 ({date})\n⚠️ 仅参考, 不交易\n" + print("\n=== QQ 推送内容 ===") + print(header + "\n---\n".join(output_lines)) + else: + print("\n💤 全部场景否决, 无输出") + + +if __name__ == '__main__': + main()