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:
Executable
+219
@@ -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")
|
||||
Reference in New Issue
Block a user