""" longport_http.py - 长桥 HTTP 公共模块 (替代 longport SDK WSS) 用户原话 2026-07-21: WSS 不稳定, 改用 HTTP 走 longport CLI 走 mihomo. 所有长桥脚本都应统一改用这个 module (避免每个脚本自己写 subprocess + 正则). 用法: from longport_http import get_quote, get_quotes, submit_order, get_positions 设计: - 所有函数返回 None / [] / {} 表示失败(不抛异常, 调用方自己检查) - subprocess 走 proxychains4 走 mihomo (国内 VPS 走海外 WSS 必须) - 单次调用超时 10 秒 (防止 cron 卡住) """ import subprocess import re import json from typing import List, Dict, Optional, Union # 路径 LONGBRIDGE_BIN = "/home/openclaw/.local/bin/longbridge" PROXYCHAINS = "proxychains4" PROXYCHAINS_CONF = "/home/openclaw/.proxychains/proxychains.conf" PROFILE = "lb_real" TIMEOUT = 10 def _run(*args) -> str: """底层调用: proxychains4 + longbridge CLI. 返回 stdout (失败返回空).""" cmd = [PROXYCHAINS, "-f", PROXYCHAINS_CONF, LONGBRIDGE_BIN, "--profile", PROFILE, *args] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT) # proxychains 诊断行混在 stdout (或 stderr) 里,统一过滤 combined = r.stdout + r.stderr lines = [l for l in combined.splitlines() if not l.startswith("[proxychains]")] if r.returncode != 0 and not lines: return "" return "\n".join(lines) except subprocess.TimeoutExpired: return "" except Exception: return "" def get_quote(symbol: str) -> Optional[Dict]: """ 拿 1 只票的实时报价 (替代 openapi.QuoteContext().quote([symbol])) 策略: 1. 先试 quote (实时, 但表格可能截断 A 股) 2. 失败则用 candlesticks day --count 1 (取收盘价) 返回: {"symbol": "NVDA.US", "price": 123.45, "change_pct": 1.2} 或 None """ out = _run("quote", symbol) if out: # 表格: │ NVDA.US │ 856.61 │ +1.20% │ ... # 表格列宽限制会截断长 symbol: │ 600519… │ 1308.0… │ symbol_trunc = symbol[:7] + "…" for cand in [symbol, symbol_trunc]: m = re.search( r"│\s*" + re.escape(cand) + r"\s*│\s*([\d.]+)\s*│\s*([+\-\d.%]+)\s*│", out ) if m: price_str = m.group(1) # 如果是 1308.0… 这种截断, candlesticks 取完整价 if "…" in price_str or len(price_str) < 4: break price = float(price_str) change_pct = float(m.group(2).rstrip("%")) return {"symbol": symbol, "price": price, "change_pct": change_pct} # fallback: candlesticks 拿日线收盘价 cs_out = _run("candlesticks", symbol, "day", "--count", "1") if cs_out: # 表格: │ 2026-07-21 00:00 │ 1338.980 │ 1344.700 │ 1296.870 │ 1308.000 │ 77,148 │ m = re.search(r"│\s*[\d\-]+\s*[\d:\s]*│\s*([\d.]+)\s*│\s*([\d.]+)\s*│\s*([\d.]+)\s*│\s*([\d.]+)\s*│", cs_out) if m: close = float(m.group(4)) return {"symbol": symbol, "price": close, "change_pct": 0, "source": "candlestick_close"} return None def get_quotes(symbols: List[str]) -> Dict[str, Dict]: """ 批量拿报价 (替代 openapi.QuoteContext().quote(batch)) 返回: {"NVDA.US": {"price": 123, "change_pct": 1.2}, ...} 失败的 symbol 不会出现在结果里 """ result = {} for sym in symbols: q = get_quote(sym) if q: result[sym] = q return result def get_positions() -> List[Dict]: """ 查持仓 (替代 openapi.TradeContext().position_list) 返回: [{"symbol": "NVDA.US", "quantity": 10, "cost_price": 100, ...}, ...] """ out = _run("positions") if not out: return [] # 解析长桥表格 (只解析包含股票代码的行) results = [] # 表格行格式: │ NVDA.US │ 10 │ 100.00 │ 856.00 │ ... │ pattern = re.compile( r"│\s*([A-Z\d]{1,6}\.(US|HK|SH|SZ))\s*│\s*(\d+)\s*│\s*([\d.]+)\s*│" ) for m in pattern.finditer(out): results.append({ "symbol": m.group(1), "market": m.group(2), "quantity": int(m.group(3)), "cost_price": float(m.group(4)) }) return results def submit_order( symbol: str, side: str, # "buy" / "sell" quantity: float, order_type: str = "MO", # "MO" = 市价, "LO" = 限价 price: Optional[float] = None, # LO 必填 time_in_force: str = "Day" ) -> Optional[Dict]: """ 下单 (替代 openapi.TradeContext().submit_order) 返回: {"order_id": "1234567890", "side": "buy", "quantity": 0.31, "price": 862.35} 或 None (失败) """ args = ["submit", symbol, side, "--qty", str(quantity), "--order-type", order_type, "--tif", time_in_force, "-y"] if order_type == "LO" and price is not None: args.extend(["--price", str(price)]) out = _run(*args) if not out: return None # 长桥返回: 订单号 1234567890 m = re.search(r"订单号[::\s]*(\d+)", out) if not m: return None return { "order_id": m.group(1), "symbol": symbol, "side": side, "quantity": quantity, "price": price } def get_candlesticks(symbol: str, period: str = "day", count: int = 30) -> Optional[List[Dict]]: """ 拿 K 线数据 (替代 openapi.QuoteContext().candlesticks) period: 'day' | '5m' | '15m' | '1h' | '1m' 等 返回: [{"timestamp": "2026-07-21", "open": 100, "high": 105, "low": 99, "close": 103, "volume": 12345}, ...] 或 None (失败) """ out = _run("candlesticks", symbol, period, "--count", str(count)) if not out: return None # 表格格式: │ 时间 │ 开盘 │ 最高 │ 最低 │ 收盘 │ 成交量 │ # A 股时间: '2026-07-21 09:30' / '2026-07-21 00:00' (日线) # 数字带千分位: '1,234,567' results = [] 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*│" ) def parse_num(s): return float(s.replace(",", "")) for m in pattern.finditer(out): ts_str = m.group(1).replace(" ", "T") # 日线时间格式: '2026-07-21T00:00' if ts_str.endswith("T00:00") and "T" + m.group(1).split()[1] == ts_str: ts_str = m.group(1).replace(" ", "T") results.append({ "timestamp": ts_str, "open": parse_num(m.group(2)), "high": parse_num(m.group(3)), "low": parse_num(m.group(4)), "close": parse_num(m.group(5)), "volume": parse_num(m.group(6)), }) return results if results else None # 测试 if __name__ == "__main__": print("=== 测试 longport_http 模块 ===\n") # 1. 单只报价 print("1. get_quote('NVDA.US'):") q = get_quote("NVDA.US") print(f" {q}\n") # 2. 批量报价 print("2. get_quotes(['NLY.US', 'HTGC.US', 'ARCC.US']):") qs = get_quotes(["NLY.US", "HTGC.US", "ARCC.US"]) for sym, data in qs.items(): print(f" {sym}: ${data['price']} ({data['change_pct']:+.2f}%)\n") # 3. 持仓 print("3. get_positions():") pos = get_positions() for p in pos: print(f" {p['symbol']}: {p['quantity']}股 @ ${p['cost_price']}\n") print(f" (共 {len(pos)} 个持仓)\n")