Files
Hermes-Skills/longbridge-cli/scripts/longport_http.py
T
mike 913d1945d4 feat: 迁移 trading 相关脚本到 skill 仓库 (第2批: 🟡 中优先级)
【迁移内容】
- dividend-investing/scripts/dca_monitor.py + dca_monitor_us.sh
- dividend-scanner/scripts/scan_cn.{py,sh} + scan_hk.sh + scan_us.sh
- okx-auto-position/scripts/signal_queue_retry.sh
- lottery-hk/scripts/verify_lottery.sh
- longbridge-cli/scripts/longport_http.py (公共模块)

【配套修改】
- 5 个 cron 任务 script 路径更新 (jobs.json):
  - 8929ca09 (DCA港股上午) → dividend-investing/scripts/dca_monitor.py
  - 9469c128 (DCA港股下午) → dividend-investing/scripts/dca_monitor.py
  - 3a4bef2c (DCA美股凌晨) → dividend-investing/scripts/dca_monitor_us.sh
  - a82a3ab0 (signal-queue-retry) → okx-auto-position/scripts/
  - b4868849 (lottery-verify-result) → lottery-hk/scripts/

【删除】本地 ~/.hermes/scripts/{dca_monitor,scan_*,signal_queue_retry,verify_lottery,longport_http}
【未迁移】scan_cn/hk/us 仍需挂 cron, 暂不删 (走 scanner skill)
2026-07-24 11:42:13 +08:00

219 lines
7.5 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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")