feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善

- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
This commit is contained in:
shy3130
2026-08-16 23:39:07 +08:00
parent eb869c7ad2
commit 697c27bb02
129 changed files with 20324 additions and 602 deletions
+138 -6
View File
@@ -5,12 +5,19 @@
"""
from __future__ import annotations
import copy
import json
import logging
import re
from pathlib import Path
logger = logging.getLogger(__name__)
# 进程内缓存: 行情轮询线程一轮会调用 8~12 次 getter, 每次读盘+parse 是纯重复;
# 文件仅在用户改设置时变化, 以 (mtime_ns, size) 签名判断是否重读。
_cache: dict | None = None
_cache_sig: tuple[int, int] | None = None
def _path() -> Path:
from app.config import settings
@@ -19,14 +26,32 @@ def _path() -> Path:
return p
def _invalidate_cache() -> None:
global _cache, _cache_sig
_cache = None
_cache_sig = None
def load() -> dict:
"""读取 preferences.json (带 mtime 签名缓存)。返回深拷贝, 调用方可自由修改。"""
global _cache, _cache_sig
p = _path()
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
logger.warning("preferences.json malformed: %s", e)
return {}
try:
sig = (p.stat().st_mtime_ns, p.stat().st_size)
except OSError:
return {}
if _cache is not None and sig == _cache_sig:
return copy.deepcopy(_cache)
try:
data = json.loads(p.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except Exception as e:
logger.warning("preferences.json malformed: %s", e)
return {}
_cache = data
_cache_sig = sig
return copy.deepcopy(_cache)
def save(updates: dict) -> dict:
@@ -36,6 +61,7 @@ def save(updates: dict) -> dict:
_path().write_text(
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
)
_invalidate_cache()
return current
@@ -88,6 +114,7 @@ def set_realtime_quote_interval(interval: float) -> float:
_path().write_text(
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
)
_invalidate_cache()
return interval
@@ -316,6 +343,74 @@ def get_regime_warmup_days() -> int:
return 40
# ── 市场主线(概念/行业涨停梯队)过滤 ──
# 宽基/风格标签(融资融券 ~7700 成分、深股通/沪股通 ~3300-3700、国企改革 ~2900)
# 会按"家数"霸占主线榜首, 但它们不是可操作的题材主线。默认按成分股数上限过滤。
# 标定(2026-08 THS 概念): 成员 >600 的 55 个概念几乎全是此类风格标签,
# 真实题材(华为概念 2006/人工智能 2166/固态电池等)均在 600 以下或可自行调整。
_MAINLINE_MAX_MEMBERS_MIN = 50
_MAINLINE_MAX_MEMBERS_MAX = 5000
_MAINLINE_MIN_MEMBERS_MIN = 1
_MAINLINE_MIN_MEMBERS_MAX = 200
def get_mainline_max_members() -> int:
"""主线维度成员数上限, 超过视为宽基/风格标签被过滤。默认 600。"""
v = load().get("mainline_max_members", 600)
try:
return max(_MAINLINE_MAX_MEMBERS_MIN, min(_MAINLINE_MAX_MEMBERS_MAX, int(v)))
except (TypeError, ValueError):
return 600
def get_mainline_min_members() -> int:
"""主线维度成员数下限, 过滤微型标签。默认 4。"""
v = load().get("mainline_min_members", 4)
try:
return max(_MAINLINE_MIN_MEMBERS_MIN, min(_MAINLINE_MIN_MEMBERS_MAX, int(v)))
except (TypeError, ValueError):
return 4
def get_mainline_blacklist() -> list[str]:
"""用户自定义屏蔽的维度成员名(不论成员数大小)。默认空。
保存时接受 list 或逗号/顿号/分号/空白分隔的字符串。
"""
v = load().get("mainline_blacklist", [])
if isinstance(v, str):
v = [part for part in re.split(r"[,,、;\s]+", v) if part] # noqa: RUF001
if not isinstance(v, list):
return []
return [str(x).strip() for x in v if str(x).strip()]
def get_mainline_filter_config() -> dict:
"""主线过滤配置汇总(供 API 返回与计算读取)。"""
return {
"min_members": get_mainline_min_members(),
"max_members": get_mainline_max_members(),
"blacklist": get_mainline_blacklist(),
}
def set_mainline_filter_config(cfg: dict) -> dict:
"""保存主线过滤配置(白名单字段, 部分更新)。修改后需重算主线生效。"""
updates: dict = {}
if "min_members" in cfg and cfg["min_members"] is not None:
updates["mainline_min_members"] = cfg["min_members"]
if "max_members" in cfg and cfg["max_members"] is not None:
updates["mainline_max_members"] = cfg["max_members"]
if "blacklist" in cfg and cfg["blacklist"] is not None:
raw = cfg["blacklist"]
if isinstance(raw, str):
raw = [part for part in re.split(r"[,,、;\s]+", raw) if part] # noqa: RUF001
updates["mainline_blacklist"] = [str(x).strip() for x in (raw or []) if str(x).strip()]
if updates:
save(updates)
return get_mainline_filter_config()
_PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index")
@@ -475,6 +570,43 @@ def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict:
return {"enabled": bool(enabled), "hour": h, "minute": m}
MINING_BUDGET_PROFILES = frozenset({"balanced", "strict"})
def get_mining_schedule() -> dict:
"""返回周度自动 mining 配置。历史配置缺字段时默认关闭。"""
data = load()
weekday = data.get("mining_schedule_weekday", 4)
if isinstance(weekday, bool) or not isinstance(weekday, int) or not 0 <= weekday <= 4:
weekday = 4
profile = data.get("mining_budget_profile", "balanced")
if not isinstance(profile, str) or profile not in MINING_BUDGET_PROFILES:
profile = "balanced"
enabled = data.get("mining_schedule_enabled", False)
if not isinstance(enabled, bool):
enabled = False
return {
"mining_schedule_enabled": enabled,
"mining_schedule_weekday": weekday,
"mining_budget_profile": profile,
}
def set_mining_schedule(enabled: bool, weekday: int, profile: str) -> dict:
"""校验并一次写入周度自动 mining 的整组配置。"""
if isinstance(weekday, bool) or not isinstance(weekday, int) or not 0 <= weekday <= 4:
raise ValueError("mining schedule weekday must be between 0 and 4")
if profile not in MINING_BUDGET_PROFILES:
raise ValueError("mining budget profile must be balanced or strict")
result = {
"mining_schedule_enabled": bool(enabled),
"mining_schedule_weekday": weekday,
"mining_budget_profile": profile,
}
save(result)
return result
def get_review_push_channels() -> list[str]:
"""复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。