Files
shy3130 697c27bb02 feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
2026-08-16 23:39:07 +08:00

377 lines
13 KiB
Python

"""量化研究候选方案的轻量本地存储。"""
from __future__ import annotations
import json
import os
import threading
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal
CandidateKind = Literal["factor", "strategy"]
CandidateStatus = Literal["pending", "validated", "rejected"]
MAX_CANDIDATES = 200
MAX_NAME_LENGTH = 80
MAX_PAYLOAD_BYTES = 32 * 1024
MAX_FILE_BYTES = 2 * 1024 * 1024
_MINING_SOURCE_CONFIG_FIELDS = frozenset(
{
"origin_run_id",
"candidate_signature",
"regime_state",
"algorithm_version",
"methodology_version",
}
)
_CONFIG_FIELDS: dict[str, frozenset[str]] = {
"factor": frozenset(
{
"factor_name",
"symbols",
"start",
"end",
"n_groups",
"rebalance",
"weight",
"fees_pct",
"slippage_bps",
"asset_type",
}
)
| _MINING_SOURCE_CONFIG_FIELDS,
"strategy": frozenset(
{
"strategy_id",
"symbols",
"start",
"end",
"params",
"overrides",
"matching",
"entry_fill",
"exit_fill",
"fees_pct",
"commission_pct",
"stamp_tax_pct",
"slippage_bps",
"max_positions",
"max_exposure_pct",
"initial_capital",
"position_sizing",
"mode",
"holding_days",
"asset_type",
"minute_fill",
"regime_filter",
"factor_names",
"directions",
"weights",
}
)
| _MINING_SOURCE_CONFIG_FIELDS,
}
_MINING_METRIC_FIELDS = frozenset(
{
"oos_sharpe",
"oos_return",
"oos_max_drawdown",
"oos_positive_fold_ratio",
"oos_n_trades",
"valid_folds",
"skipped_folds",
"confidence",
"coverage",
"turnover",
"long_short_sharpe",
}
)
_METRIC_FIELDS: dict[str, frozenset[str]] = {
"factor": frozenset(
{
"ic_mean",
"ic_std",
"ir",
"ic_win_rate",
"long_short_return",
"long_short_max_drawdown",
"n_symbols",
"n_dates",
"elapsed_ms",
}
)
| _MINING_METRIC_FIELDS,
"strategy": frozenset(
{
"total_return",
"annual_return",
"max_drawdown",
"sharpe",
"sortino",
"win_rate",
"n_trades",
"profit_factor",
"avg_return",
"median_return",
"elapsed_ms",
}
)
| _MINING_METRIC_FIELDS,
}
_lock = threading.RLock()
class CandidateStoreError(RuntimeError):
pass
class CandidateValidationError(CandidateStoreError):
pass
class CandidateStore:
def __init__(self, data_dir: Path) -> None:
self.path = Path(data_dir) / "user_data" / "research_candidates.json"
def list(self) -> list[dict[str, Any]]:
with _lock:
return self._load()
def create(
self,
*,
kind: CandidateKind,
name: str,
source_id: str,
config: dict[str, Any],
metrics: dict[str, Any],
data_as_of: str | None,
status: CandidateStatus = "pending",
) -> dict[str, Any]:
clean_name = self._validate_name(name)
clean_source_id = source_id.strip()
if not clean_source_id or len(clean_source_id) > 120:
raise CandidateValidationError("候选来源标识不能为空且不能超过 120 个字符")
clean_config = self._validate_config(kind, config)
clean_metrics = self._validate_metrics(kind, metrics)
with _lock:
items = self._load()
if len(items) >= MAX_CANDIDATES:
raise CandidateValidationError(f"候选方案最多保存 {MAX_CANDIDATES} 个")
now = datetime.now(UTC).isoformat()
item = {
"id": uuid.uuid4().hex,
"kind": kind,
"name": clean_name,
"source_id": clean_source_id,
"config": clean_config,
"metrics": clean_metrics,
"data_as_of": data_as_of,
"status": status,
"created_at": now,
"updated_at": now,
}
items.insert(0, item)
self._write(items)
return item
def create_or_get_by_provenance(
self,
*,
origin_run_id: str,
candidate_signature: str,
kind: CandidateKind,
name: str,
source_id: str,
config: dict[str, Any],
metrics: dict[str, Any],
data_as_of: str | None,
status: CandidateStatus = "pending",
) -> dict[str, Any]:
"""Atomically return or create one item for a mining run candidate."""
clean_name = self._validate_name(name)
clean_source_id = source_id.strip()
if not clean_source_id or len(clean_source_id) > 120:
raise CandidateValidationError("候选来源标识不能为空且不能超过 120 个字符")
clean_config = self._validate_config(kind, config)
clean_metrics = self._validate_metrics(kind, metrics)
if (
clean_config.get("origin_run_id") != origin_run_id
or clean_config.get("candidate_signature") != candidate_signature
):
raise CandidateValidationError("候选来源与配置中的挖掘溯源不一致")
with _lock:
items = self._load()
for item in items:
item_config = item.get("config") or {}
if (
item_config.get("origin_run_id") == origin_run_id
and item_config.get("candidate_signature") == candidate_signature
):
if (
item.get("kind") == kind
and item.get("source_id") == clean_source_id
and item_config == clean_config
and item.get("metrics") == clean_metrics
and item.get("data_as_of") == data_as_of
):
return item
raise CandidateValidationError(
"相同挖掘溯源的候选内容冲突, 已停止覆盖"
)
if len(items) >= MAX_CANDIDATES:
raise CandidateValidationError(f"候选方案最多保存 {MAX_CANDIDATES} 个")
now = datetime.now(UTC).isoformat()
item = {
"id": uuid.uuid4().hex,
"kind": kind,
"name": clean_name,
"source_id": clean_source_id,
"config": clean_config,
"metrics": clean_metrics,
"data_as_of": data_as_of,
"status": status,
"created_at": now,
"updated_at": now,
}
items.insert(0, item)
self._write(items)
return item
def update(
self,
candidate_id: str,
*,
name: str | None = None,
status: CandidateStatus | None = None,
) -> dict[str, Any]:
with _lock:
items = self._load()
for item in items:
if item["id"] != candidate_id:
continue
if name is not None:
item["name"] = self._validate_name(name)
if status is not None:
item["status"] = status
item["updated_at"] = datetime.now(UTC).isoformat()
self._write(items)
return item
raise KeyError(candidate_id)
def delete(self, candidate_id: str) -> None:
with _lock:
items = self._load()
remaining = [item for item in items if item["id"] != candidate_id]
if len(remaining) == len(items):
raise KeyError(candidate_id)
self._write(remaining)
def _load(self) -> list[dict[str, Any]]:
if not self.path.exists():
return []
try:
if self.path.stat().st_size > MAX_FILE_BYTES:
raise CandidateStoreError("候选方案文件过大, 已停止读取")
raw = json.loads(self.path.read_text(encoding="utf-8"))
except CandidateStoreError:
raise
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise CandidateStoreError("候选方案文件损坏或无法读取, 未执行覆盖写入") from exc
if not isinstance(raw, list):
raise CandidateStoreError("候选方案文件格式无效, 未执行覆盖写入")
return [item for value in raw if (item := self._normalize(value)) is not None]
def _write(self, items: list[dict[str, Any]]) -> None:
payload = json.dumps(items, ensure_ascii=False, indent=2, allow_nan=False)
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(".json.tmp")
try:
with temporary.open("w", encoding="utf-8") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.path)
except OSError as exc:
temporary.unlink(missing_ok=True)
raise CandidateStoreError("候选方案保存失败") from exc
@staticmethod
def _normalize(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
kind = value.get("kind")
if kind not in _CONFIG_FIELDS or not isinstance(value.get("id"), str):
return None
raw_config = value.get("config") if isinstance(value.get("config"), dict) else {}
config = {key: item for key, item in raw_config.items() if key in _CONFIG_FIELDS[kind]}
raw_metrics = value.get("metrics") if isinstance(value.get("metrics"), dict) else {}
metrics = {
key: item
for key, item in raw_metrics.items()
if key in _METRIC_FIELDS[kind] and not isinstance(item, (dict, list))
}
source_id = value.get("source_id") or config.get(f"{kind}_name") or config.get(f"{kind}_id")
if not isinstance(source_id, str) or not source_id:
return None
status = value.get("status")
if status not in {"pending", "validated", "rejected"}:
status = "pending"
return {
"id": value["id"],
"kind": kind,
"name": str(value.get("name") or source_id)[:MAX_NAME_LENGTH],
"source_id": source_id,
"config": config,
"metrics": metrics,
"data_as_of": value.get("data_as_of"),
"status": status,
"created_at": str(value.get("created_at") or ""),
"updated_at": str(value.get("updated_at") or value.get("created_at") or ""),
}
@staticmethod
def _validate_name(name: str) -> str:
clean = name.strip()
if not clean:
raise CandidateValidationError("候选名称不能为空")
if len(clean) > MAX_NAME_LENGTH:
raise CandidateValidationError(f"候选名称不能超过 {MAX_NAME_LENGTH} 个字符")
return clean
@staticmethod
def _validate_config(kind: CandidateKind, config: dict[str, Any]) -> dict[str, Any]:
unknown = set(config) - _CONFIG_FIELDS[kind]
if unknown:
raise CandidateValidationError(
f"候选配置包含不允许的字段: {', '.join(sorted(unknown))}"
)
CandidateStore._check_json_size(config)
return config
@staticmethod
def _validate_metrics(kind: CandidateKind, metrics: dict[str, Any]) -> dict[str, Any]:
unknown = set(metrics) - _METRIC_FIELDS[kind]
if unknown:
raise CandidateValidationError(
f"候选指标包含不允许的字段: {', '.join(sorted(unknown))}"
)
if any(isinstance(value, (dict, list)) for value in metrics.values()):
raise CandidateValidationError("候选指标只允许保存标量摘要")
CandidateStore._check_json_size(metrics)
return metrics
@staticmethod
def _check_json_size(value: dict[str, Any]) -> None:
try:
payload = json.dumps(value, ensure_ascii=False, allow_nan=False)
except (TypeError, ValueError) as exc:
raise CandidateValidationError("候选内容无法序列化") from exc
if len(payload.encode("utf-8")) > MAX_PAYLOAD_BYTES:
raise CandidateValidationError("候选内容超过 32KB 限制")