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)
This commit is contained in:
2026-07-24 11:42:13 +08:00
parent 1877a85cc5
commit 913d1945d4
9 changed files with 640 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
"""
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")