423 lines
15 KiB
Python
423 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""collect_evaluation_data.py — 六维评估原始数据采集
|
||
|
||
纯数据收集脚本(no_agent),不做任何评估/判断/RR计算。
|
||
输出:data/evaluation_input.json — 供 21:00 LLM cron 使用。
|
||
|
||
采集内容:
|
||
D1 宏观环境 — 五大指数(上证/深证/恒生/恒科/A50)
|
||
D2 行业表现 — 持仓+自选按行业分组
|
||
D3 技术面(当前) — 今开/今高/今低/昨收/现价/成交量
|
||
D4 基本面 — PE/PB/总市值/52周高/52周低
|
||
D5 消息面 — (此脚本不采集,LLM cron web_search)
|
||
D6 资金面 — 成交额/换手率/量比
|
||
|
||
日期:2026-06-18 v1 — 初始版本
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
# ── 消息通道统一路由(broadcast/xmpp by delivery) ──
|
||
try:
|
||
from messenger import install_stdio_hook as _msh
|
||
_msh()
|
||
except Exception:
|
||
pass
|
||
|
||
DATA_DIR = Path(__file__).parent.parent / "data"
|
||
PROFILES_PATH = DATA_DIR / "stock_profiles.json"
|
||
OUTPUT_PATH = DATA_DIR / "evaluation_input.json"
|
||
|
||
|
||
def load_json(path, default=None):
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except (FileNotFoundError, json.JSONDecodeError):
|
||
return {} if default is None else default
|
||
|
||
|
||
def save_json(path, data):
|
||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
|
||
|
||
def fetch_tencent_data(symbols):
|
||
"""批量拉行情。读 DB live_prices + stock_daily(2026-08-26 分层铁律:消费层不直连腾讯API)"""
|
||
if not symbols:
|
||
return {}
|
||
import sqlite3
|
||
result = {}
|
||
try:
|
||
conn = sqlite3.connect('/home/hmo/MoFin/data/mofin.db', timeout=5)
|
||
syms = list(symbols)
|
||
ph = ",".join("?" * len(syms))
|
||
# 实时价:一次查
|
||
price_rows = conn.execute(
|
||
f"SELECT code, price, change_pct FROM live_prices WHERE code IN ({ph})", syms
|
||
).fetchall()
|
||
prices = {r[0]: (r[1], r[2]) for r in price_rows}
|
||
# 名称
|
||
name_rows = conn.execute(
|
||
f"SELECT code, name FROM stocks WHERE code IN ({ph})", syms
|
||
).fetchall()
|
||
names = {r[0]: r[1] or "" for r in name_rows}
|
||
# 最近日K(昨收/今开/高低/量)
|
||
sd = {}
|
||
for c in syms:
|
||
row = conn.execute(
|
||
"SELECT close, open, high, low, volume FROM stock_daily "
|
||
"WHERE code=? ORDER BY date DESC LIMIT 1", (c,)
|
||
).fetchone()
|
||
if row:
|
||
sd[c] = row
|
||
conn.close()
|
||
|
||
for code in syms:
|
||
if code not in prices:
|
||
continue
|
||
price = prices[code][0]
|
||
change_pct = prices[code][1] or 0
|
||
if not price or price <= 0:
|
||
continue
|
||
row = sd.get(code)
|
||
row_c = row[0] if row else 0
|
||
row_o = row[1] if row else 0
|
||
row_h = row[2] if row else 0
|
||
row_l = row[3] if row else 0
|
||
row_v = row[4] if row else 0
|
||
result[code] = {
|
||
"name": names.get(code, ""),
|
||
"price": price,
|
||
"prev_close": row_c,
|
||
"open": row_o,
|
||
"change_pct": change_pct,
|
||
"high": row_h,
|
||
"low": row_l,
|
||
"volume": row_v,
|
||
}
|
||
except Exception as e:
|
||
print(f"行情拉取失败: {e}", file=sys.stderr)
|
||
return {}
|
||
return result
|
||
|
||
|
||
def fetch_indices():
|
||
"""拉指数:读 DB stock_daily 最近收盘(2026-08-26 分层铁律:消费层不直连腾讯API)"""
|
||
import sqlite3
|
||
index_codes = {
|
||
"sh000001": "上证指数",
|
||
"sz399001": "深证成指",
|
||
"sz399006": "创业板指",
|
||
"hkHSI": "恒生指数",
|
||
"hkHSTECH": "恒生科技",
|
||
}
|
||
result = {}
|
||
try:
|
||
conn = sqlite3.connect('/home/hmo/MoFin/data/mofin.db', timeout=5)
|
||
for c, n in index_codes.items():
|
||
rows = conn.execute(
|
||
"SELECT date, close, open, high, low FROM stock_daily "
|
||
"WHERE code=? ORDER BY date DESC LIMIT 2", (c,)
|
||
).fetchall()
|
||
if not rows:
|
||
continue # 读不到则跳过(中性)
|
||
latest = rows[0]
|
||
prev = rows[1] if len(rows) > 1 else None
|
||
prev_close = prev[1] if prev else latest[1]
|
||
price = latest[1] or 0
|
||
change_pct = (price - prev_close) / prev_close * 100 if prev_close else 0
|
||
result[n] = {
|
||
"price": safe_float(price),
|
||
"prev_close": safe_float(prev_close if prev else None),
|
||
"change_pct": safe_float(change_pct),
|
||
"high": safe_float(latest[3] or price),
|
||
"low": safe_float(latest[4] or price),
|
||
"timestamp": latest[0],
|
||
}
|
||
conn.close()
|
||
except Exception as e:
|
||
print(f"指数拉取失败: {e}", file=sys.stderr)
|
||
return {}
|
||
return result
|
||
|
||
|
||
def safe_float(v):
|
||
try:
|
||
return float(v) if v else None
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def parse_stock_data(code, fields, is_hk=False):
|
||
"""从腾讯 API 字段解析为结构化数据"""
|
||
# fields可能是dict(已解析)或list(原始split)
|
||
if isinstance(fields, dict):
|
||
data = {
|
||
"code": code,
|
||
"name": fields.get("name", code),
|
||
"price": safe_float(fields.get("price", 0)),
|
||
"prev_close": safe_float(fields.get("prev_close", 0)),
|
||
"open": safe_float(fields.get("open", 0)) if not is_hk else None,
|
||
"change_pct": safe_float(fields.get("change_pct", 0)),
|
||
"high": safe_float(fields.get("high", 0)),
|
||
"low": safe_float(fields.get("low", 0)),
|
||
"volume": safe_float(fields.get("volume", 0)),
|
||
}
|
||
else:
|
||
data = {
|
||
"code": code,
|
||
"name": fields[1] if len(fields) > 1 else code,
|
||
"price": safe_float(fields[3]),
|
||
"prev_close": safe_float(fields[4]),
|
||
"open": safe_float(fields[5]) if not is_hk else None,
|
||
"change_pct": safe_float(fields[32]),
|
||
"high": safe_float(fields[33]),
|
||
"low": safe_float(fields[34]),
|
||
"volume": safe_float(fields[6]),
|
||
}
|
||
# A股特有字段 (index 35+)
|
||
if not is_hk:
|
||
if isinstance(fields, dict):
|
||
data["turnover_rate"] = safe_float(fields.get("turnover_rate", 0))
|
||
data["amplitude"] = safe_float(fields.get("amplitude", 0))
|
||
data["pe"] = safe_float(fields.get("pe", 0))
|
||
data["market_cap"] = safe_float(fields.get("market_cap", 0))
|
||
else:
|
||
data["turnover_rate"] = safe_float(fields[38]) if len(fields) > 38 else 0
|
||
data["amplitude"] = safe_float(fields[39]) if len(fields) > 39 else 0
|
||
data["pe"] = safe_float(fields[39]) if len(fields) > 39 else 0
|
||
data["market_cap"] = safe_float(fields[45]) if len(fields) > 45 else 0
|
||
# 港股特有
|
||
if is_hk:
|
||
data["market"] = "HK"
|
||
if isinstance(fields, dict):
|
||
data["high_52w"] = safe_float(fields.get("high_52w", 0))
|
||
data["low_52w"] = safe_float(fields.get("low_52w", 0))
|
||
data["amplitude"] = safe_float(fields.get("amplitude", 0))
|
||
else:
|
||
data["high_52w"] = safe_float(fields[48]) if len(fields) > 48 else 0
|
||
data["low_52w"] = safe_float(fields[49]) if len(fields) > 49 else 0
|
||
data["amplitude"] = safe_float(fields[43]) if len(fields) > 43 else 0
|
||
# 港股特有字段
|
||
if is_hk:
|
||
if isinstance(fields, dict):
|
||
data["pe"] = safe_float(fields.get("pe", 0))
|
||
data["total_market_cap"] = safe_float(fields.get("total_market_cap", 0))
|
||
data["high_52w"] = safe_float(fields.get("high_52w", 0))
|
||
data["low_52w"] = safe_float(fields.get("low_52w", 0))
|
||
else:
|
||
if len(fields) > 70:
|
||
data["pe"] = safe_float(fields[71])
|
||
data["total_market_cap"] = safe_float(fields[69])
|
||
data["high_52w"] = safe_float(fields[48])
|
||
data["low_52w"] = safe_float(fields[49])
|
||
return data
|
||
|
||
|
||
def get_sector_mapping(profiles, decisions):
|
||
"""
|
||
从 stock_profiles.json 和 decisions.json 建立
|
||
{code: {name, sector, business, market, type}} 映射
|
||
"""
|
||
mapping = {}
|
||
# 先读 stock_profiles
|
||
profile_list = profiles.get("profiles", []) if isinstance(profiles, dict) else profiles
|
||
if isinstance(profile_list, list):
|
||
for p in profile_list:
|
||
code = p.get("code", "")
|
||
if code:
|
||
mapping[code] = {
|
||
"name": p.get("name", ""),
|
||
"sector": p.get("sector", ""),
|
||
"business": p.get("business", ""),
|
||
"market": p.get("market", ""),
|
||
"type": p.get("type", ""),
|
||
}
|
||
# 再补全 decisions.json 中的信息
|
||
for d in decisions.get("decisions", []):
|
||
code = d.get("code", "")
|
||
if code and code not in mapping:
|
||
trig = d.get("trigger", {})
|
||
mapping[code] = {
|
||
"name": d.get("name", code),
|
||
"sector": trig.get("sector_name", d.get("sector_name", "")),
|
||
"business": "",
|
||
"market": "港股" if len(code) == 5 else "A股",
|
||
"type": d.get("type", "持仓策略"),
|
||
}
|
||
return mapping
|
||
|
||
|
||
def get_portfolio_info(portfolio):
|
||
"""建立 {code: {cost, shares, position_pct}} 映射"""
|
||
result = {}
|
||
for h in portfolio.get("holdings", []):
|
||
code = h.get("code", "")
|
||
result[code] = {
|
||
"cost": h.get("cost", 0),
|
||
"shares": h.get("shares", 0),
|
||
"position_pct": h.get("position_pct", 0),
|
||
}
|
||
return result
|
||
|
||
|
||
def get_decisions_info(decisions):
|
||
"""提取 decisions.json 中的策略参数"""
|
||
return decisions.get("decisions", [])
|
||
|
||
|
||
def run():
|
||
# 加载数据
|
||
from mo_data import read_decisions, read_portfolio
|
||
decisions = read_decisions()
|
||
portfolio = read_portfolio()
|
||
profiles = load_json(PROFILES_PATH, {"profiles": []})
|
||
|
||
# 获取行业映射
|
||
sector_mapping = get_sector_mapping(profiles, decisions)
|
||
|
||
# 获取持仓信息
|
||
portfolio_info = get_portfolio_info(portfolio)
|
||
|
||
# 收集所有代码
|
||
all_codes = set()
|
||
for d in decisions.get("decisions", []):
|
||
code = d.get("code", "")
|
||
if code:
|
||
all_codes.add(code)
|
||
for h in portfolio.get("holdings", []):
|
||
code = h.get("code", "")
|
||
if code:
|
||
all_codes.add(code)
|
||
|
||
# 区分 A/H 股
|
||
a_codes = [c for c in all_codes if len(c) != 5]
|
||
hk_codes = [c for c in all_codes if len(c) == 5]
|
||
|
||
# 拉行情
|
||
a_prices = fetch_tencent_data(a_codes) if a_codes else {}
|
||
hk_prices = fetch_tencent_data(hk_codes) if hk_codes else {}
|
||
|
||
# 拉指数
|
||
index_data = fetch_indices()
|
||
|
||
# 解析个股数据
|
||
stock_data = {}
|
||
for code in a_codes:
|
||
if code in a_prices:
|
||
stock_data[code] = parse_stock_data(code, a_prices[code], is_hk=False)
|
||
for code in hk_codes:
|
||
if code in hk_prices:
|
||
stock_data[code] = parse_stock_data(code, hk_prices[code], is_hk=True)
|
||
|
||
# 组装输出
|
||
stocks = []
|
||
all_codes_sorted = sorted(all_codes)
|
||
|
||
for code in all_codes_sorted:
|
||
raw = stock_data.get(code, {})
|
||
sector_info = sector_mapping.get(code, {})
|
||
port = portfolio_info.get(code, {})
|
||
strategy = None
|
||
for d in decisions.get("decisions", []):
|
||
if d.get("code") == code:
|
||
trig = d.get("trigger", {})
|
||
strategy = {
|
||
"action": trig.get("action", d.get("action", "")),
|
||
"entry_zone": trig.get("entry_zone", ""),
|
||
"stop_loss": trig.get("stop_loss", d.get("stop_loss", "")),
|
||
"take_profit": trig.get("take_profit", d.get("take_profit", "")),
|
||
"type": d.get("type", "持仓策略"),
|
||
"tech_snapshot": trig.get("tech_snapshot", d.get("tech_snapshot", "")),
|
||
}
|
||
break
|
||
|
||
stock_entry = {
|
||
"code": code,
|
||
"name": raw.get("name", sector_info.get("name", code)),
|
||
"market": "港股" if len(code) == 5 else "A股",
|
||
"type": sector_info.get("type", "持仓策略"),
|
||
"sector": sector_info.get("sector", ""),
|
||
"business": sector_info.get("business", ""),
|
||
# 当天行情
|
||
"price": raw.get("price"),
|
||
"prev_close": raw.get("prev_close"),
|
||
"open": raw.get("open"),
|
||
"high": raw.get("high"),
|
||
"low": raw.get("low"),
|
||
"change_pct": raw.get("change_pct"),
|
||
"volume": raw.get("volume"),
|
||
# 基本面
|
||
"pe": raw.get("pe"),
|
||
"total_market_cap": raw.get("total_market_cap"),
|
||
"high_52w": raw.get("high_52w"),
|
||
"low_52w": raw.get("low_52w"),
|
||
"turnover_rate": raw.get("turnover_rate"),
|
||
"amplitude": raw.get("amplitude"),
|
||
# 持仓
|
||
"cost": port.get("cost", 0),
|
||
"shares": port.get("shares", 0),
|
||
"position_pct": port.get("position_pct", 0),
|
||
# 现策略
|
||
"strategy": strategy,
|
||
}
|
||
# 浮亏%
|
||
cost = port.get("cost", 0)
|
||
price = raw.get("price", 0)
|
||
if cost > 0 and price > 0:
|
||
stock_entry["pnl_pct"] = round((price - cost) / cost * 100, 2)
|
||
else:
|
||
stock_entry["pnl_pct"] = None
|
||
|
||
stocks.append(stock_entry)
|
||
|
||
# 按行业分组统计
|
||
sector_groups = {}
|
||
for s in stocks:
|
||
sector = s.get("sector", "未分类")
|
||
if sector not in sector_groups:
|
||
sector_groups[sector] = []
|
||
sector_groups[sector].append({
|
||
"code": s["code"],
|
||
"name": s["name"],
|
||
"change_pct": s["change_pct"],
|
||
"pnl_pct": s["pnl_pct"],
|
||
"type": s["type"],
|
||
})
|
||
|
||
# 汇总
|
||
total = len(stocks)
|
||
up_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] > 0)
|
||
down_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] < 0)
|
||
deep_loss = sum(1 for s in stocks if s["pnl_pct"] is not None and s["pnl_pct"] < -20)
|
||
|
||
output = {
|
||
"collected_at": datetime.now().isoformat(),
|
||
"total_stocks": total,
|
||
"summary": {
|
||
"up_count": up_count,
|
||
"down_count": down_count,
|
||
"deep_loss_count": deep_loss,
|
||
"holdings_count": len(portfolio_info),
|
||
"watchlist_count": total - len(portfolio_info),
|
||
},
|
||
"index_data": index_data,
|
||
"sector_groups": sector_groups,
|
||
"stocks": stocks,
|
||
}
|
||
|
||
save_json(OUTPUT_PATH, output)
|
||
print(f"数据收集完成: {total}只股票, {len(index_data)}个指数, {len(sector_groups)}个行业分组")
|
||
print(f" 上涨{up_count} 下跌{down_count} 深套{deep_loss}")
|
||
print(f" 输出: {OUTPUT_PATH}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run()
|