2026-07-27 17:12:09 +08:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""
|
|
|
|
|
行业划分接口 (设计 §5 的可插拔适配器)
|
|
|
|
|
======================================
|
|
|
|
|
IndustryClassifier.get(ts_code) -> 行业名 | None
|
|
|
|
|
|
|
|
|
|
数据源经参数 PMS_SECTOR_SOURCE 切换:
|
|
|
|
|
custom_table 自定义映射表 pms_industry_map (默认建议; 用户后续灌何种划分都只是灌数)
|
|
|
|
|
gp_stock_category 决策系统生态已有的行业表 (映射表就绪前的临时数据源)
|
|
|
|
|
""(空) 未配置 → 行业约束自动停用, 页面显著提示 (不静默放行也不误拦)
|
2026-07-30 16:21:16 +08:00
|
|
|
|
|
|
|
|
映射表现在有稳定的灌数来源: 上游选股计划 `/plan` 的 `evidence.theme`, 由
|
|
|
|
|
`plan_feed` 每次刷新时 upsert 进 pms_industry_map (见 UPSTREAM_PLAN_API.md §2)。
|
|
|
|
|
所以本模块**不需要**新增 plan_api 数据源 —— 即时查当日计划有个洞: 今天没上榜的持仓票
|
|
|
|
|
查不到 theme, 它的行业硬拦截就悄悄失效了。落库以后覆盖面随每天刷新累积, 且页面可见可手改。
|
2026-07-27 17:12:09 +08:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
from app.repo import downstream_repo, pms_repo
|
|
|
|
|
from app.services import param_store
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("pms.industry")
|
|
|
|
|
|
|
|
|
|
_cache = {"at": 0.0, "map": {}, "source": None}
|
2026-07-31 10:13:56 +08:00
|
|
|
_probe = {"at": 0.0, "data": None}
|
2026-07-27 17:12:09 +08:00
|
|
|
_lock = threading.Lock()
|
|
|
|
|
CACHE_TTL = 300.0
|
2026-07-31 10:13:56 +08:00
|
|
|
PROBE_TTL = 300.0
|
|
|
|
|
# 实探样本: 优先拿持仓票 (它们才是真要判行业的), 不够就补几只一定在任何行业表里的大盘股。
|
|
|
|
|
PROBE_FALLBACK = ("600000.SH", "000001.SZ", "600519.SH")
|
|
|
|
|
PROBE_SAMPLE_MAX = 5
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def source() -> str:
|
|
|
|
|
return (param_store.get("PMS_SECTOR_SOURCE", "") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 10:13:56 +08:00
|
|
|
def _probe_category() -> dict:
|
|
|
|
|
"""对 gp_stock_category 做一次小样本实探 (缓存 PROBE_TTL 秒)。
|
|
|
|
|
|
|
|
|
|
**为什么非探不可**: 老的 ready() 只看参数值 —— 参数一填就报 True, 页面显示"行业约束
|
|
|
|
|
已生效", 而那张表完全可能列名对不上/代理拒绝/根本没有这些票, 加上 repo 层把异常吞成
|
|
|
|
|
None, 结果就是行业硬拦截**静默失效**, 页面上还一片绿。2026-07-31 切到这个源时
|
|
|
|
|
`/api/industry` 回的正是 `ready=true, count=0` —— 那个 count 只统计 custom_table,
|
|
|
|
|
对本数据源一个字的证据都没有。这跟本仓库到处在讲的"拿不到 ≠ 通过"是同一件事。
|
|
|
|
|
"""
|
|
|
|
|
now = time.time()
|
|
|
|
|
if _probe["data"] is not None and now - _probe["at"] < PROBE_TTL:
|
|
|
|
|
return _probe["data"]
|
|
|
|
|
codes = []
|
|
|
|
|
try:
|
|
|
|
|
from app.repo import pms_repo
|
|
|
|
|
codes = [p["ts_code"] for p in pms_repo.list_positions(only_open=True)][:PROBE_SAMPLE_MAX]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("行业实探: 取持仓样本失败, 改用兜底样本: %s", e)
|
|
|
|
|
for c in PROBE_FALLBACK:
|
|
|
|
|
if len(codes) >= PROBE_SAMPLE_MAX:
|
|
|
|
|
break
|
|
|
|
|
if c not in codes:
|
|
|
|
|
codes.append(c)
|
|
|
|
|
hit, errors, samples, cols = 0, [], {}, None
|
|
|
|
|
for c in codes:
|
|
|
|
|
try:
|
|
|
|
|
r = downstream_repo.probe_category(c)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
errors.append(f"{c}: {type(e).__name__}: {e}")
|
|
|
|
|
continue
|
|
|
|
|
if r.get("error"):
|
|
|
|
|
errors.append(f"{c}: {r['error']}")
|
|
|
|
|
continue
|
|
|
|
|
cols = cols or r.get("columns")
|
|
|
|
|
if r.get("value"):
|
|
|
|
|
hit += 1
|
|
|
|
|
samples[c] = r["value"]
|
|
|
|
|
data = {"tried": len(codes), "hit": hit, "samples": samples, "columns": cols,
|
|
|
|
|
"errors": errors[:3], "sample_codes": codes}
|
|
|
|
|
with _lock:
|
|
|
|
|
_probe.update({"at": now, "data": data})
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
def ready() -> bool:
|
2026-07-31 10:13:56 +08:00
|
|
|
"""行业约束是否**真的**生效 —— 配置了数据源, 且那个源确实给得出行业。
|
|
|
|
|
|
|
|
|
|
只看参数值不够: 空的映射表、连不上的行业表, 都会让 get() 一律返回 None, 而 None 在
|
|
|
|
|
sizer.check_caps 里等于"跳过行业约束"。所以这里必须要有证据。
|
|
|
|
|
"""
|
|
|
|
|
src = source()
|
|
|
|
|
if src == "custom_table":
|
|
|
|
|
try:
|
|
|
|
|
return len(_load_custom()) > 0
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
if src == "gp_stock_category":
|
|
|
|
|
try:
|
|
|
|
|
return _probe_category()["hit"] > 0
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("行业实探失败, 按数据源不可用处理: %s", e)
|
|
|
|
|
return False
|
|
|
|
|
return False
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def status() -> dict:
|
|
|
|
|
src = source()
|
|
|
|
|
st = {"source": src, "ready": ready(), "count": 0,
|
|
|
|
|
"hint": "行业划分数据源未配置 —— 行业集中度硬拦截已自动停用, "
|
|
|
|
|
"行业类命令置灰 (设计 §5/§13)"}
|
|
|
|
|
if src == "custom_table":
|
|
|
|
|
try:
|
|
|
|
|
st["count"] = len(_load_custom())
|
2026-07-31 10:13:56 +08:00
|
|
|
st["hint"] = (f"自定义映射表 pms_industry_map 已加载 {st['count']} 条"
|
|
|
|
|
if st["count"] else
|
|
|
|
|
"映射表 pms_industry_map 是空的 —— 行业约束按未配置处理, 请先灌数")
|
2026-07-27 17:12:09 +08:00
|
|
|
except Exception as e:
|
|
|
|
|
st["ready"] = False
|
|
|
|
|
st["hint"] = f"映射表读取失败: {type(e).__name__}: {e}"
|
|
|
|
|
elif src == "gp_stock_category":
|
2026-07-31 10:13:56 +08:00
|
|
|
try:
|
|
|
|
|
pr = _probe_category()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
st.update({"ready": False, "hint": f"行业实探失败: {type(e).__name__}: {e}"})
|
|
|
|
|
return st
|
|
|
|
|
st["probe"] = pr
|
|
|
|
|
st["count"] = pr["hit"]
|
|
|
|
|
if pr["hit"]:
|
|
|
|
|
st["hint"] = (f"临时数据源 gp_stock_category 实探 {pr['tried']} 只命中 "
|
|
|
|
|
f"{pr['hit']} 只 (列: {pr.get('columns')}), 逐票查询, "
|
|
|
|
|
f"建议尽快切 custom_table")
|
|
|
|
|
else:
|
|
|
|
|
st["hint"] = ("gp_stock_category **一只都没查到** —— 行业约束等同于未配置且会"
|
|
|
|
|
"静默失效。样本 " + ", ".join(pr["sample_codes"][:3])
|
|
|
|
|
+ ("; 错误: " + " | ".join(pr["errors"]) if pr["errors"]
|
|
|
|
|
else "; 无报错, 是表里没有这些票或没有行业列"))
|
2026-07-27 17:12:09 +08:00
|
|
|
return st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_custom() -> dict:
|
|
|
|
|
now = time.time()
|
|
|
|
|
if _cache["source"] == "custom_table" and now - _cache["at"] < CACHE_TTL:
|
|
|
|
|
return _cache["map"]
|
|
|
|
|
with _lock:
|
|
|
|
|
rows = pms_repo.list_industry()
|
|
|
|
|
m = {r["ts_code"]: r["industry"] for r in rows}
|
|
|
|
|
_cache.update({"at": now, "map": m, "source": "custom_table"})
|
|
|
|
|
return m
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get(ts_code: str):
|
|
|
|
|
"""返回行业名; 未配置数据源或查不到 → None (调用方据此跳过行业约束)。"""
|
|
|
|
|
src = source()
|
|
|
|
|
if not ts_code or src not in ("custom_table", "gp_stock_category"):
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
if src == "custom_table":
|
|
|
|
|
return _load_custom().get(ts_code)
|
|
|
|
|
return downstream_repo.fetch_sector_from_category(ts_code)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("行业查询失败 [%s]: %s", ts_code, e)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_many(codes) -> dict:
|
|
|
|
|
src = source()
|
|
|
|
|
if src != "custom_table":
|
|
|
|
|
return {c: get(c) for c in (codes or [])}
|
|
|
|
|
m = _load_custom()
|
|
|
|
|
return {c: m.get(c) for c in (codes or [])}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def invalidate():
|
|
|
|
|
_cache["at"] = 0.0
|
2026-07-31 10:13:56 +08:00
|
|
|
_probe.update({"at": 0.0, "data": None})
|