Files

394 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
data_comparator.py v1.0 — 同类数据横向对比与回测(v4.7 新增)
============================================================
对同类型的金融数据做:
1. 横向对比 — 多组同类序列(如多只股票的两融、多只 ETF 的资金流)逐字段对比,
输出最新值 / 均值 / 变化 / 排名。
2. 数据回测 — 对每组序列校验数据质量:完整性、时间连续性、数值异常跳变、
边界合理性,返回 0-100 质量评分与问题清单。
设计要点:
- 纯标准库,零外部依赖
- 泛化设计:接受任意 List[Dict] 序列,按数值字段自动识别
- 严格时间过滤由上游数据函数保证,本模块聚焦对比与质量校验
"""
import logging
from datetime import datetime
from typing import Dict, List, Any, Optional
log = logging.getLogger(__name__)
# 默认数值字段识别:排除常见非数值/标识字段
_NON_NUMERIC_KEYS = {
"code", "name", "date", "market", "type", "title", "url", "person",
"position", "reason", "buyer_branch", "seller_branch", "warning",
"error", "update_time", "source", "curve_type", "id",
}
def _safe_float(val, default=0.0):
if val is None:
return default
if isinstance(val, str) and val.strip() in ("-", "--", "", "—"):
return default
try:
return float(val)
except (ValueError, TypeError):
return default
def _numeric_fields(records: List[Dict]) -> List[str]:
"""识别记录中的数值字段(排除标识类字段)。"""
if not records:
return []
fields = []
for key in records[0].keys():
if key.lower() in _NON_NUMERIC_KEYS:
continue
if any(key.lower().startswith(p) for p in ("_", "spread_")):
continue
# 取最多 30 条样本判断是否数值型
sample = [r.get(key) for r in records[:30]]
numeric = 0
for v in sample:
if v is None or (isinstance(v, str) and v.strip() in ("-", "", "--")):
continue
try:
float(v)
numeric += 1
except (ValueError, TypeError):
break
if numeric >= max(1, len(sample) // 2):
fields.append(key)
return fields
def _parse_date(d: str) -> Optional[str]:
"""规范化日期为 yyyy-MM-dd,失败返回 None。"""
if not d:
return None
s = str(d)[:10].strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
try:
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return None
# ==================== 数据回测 ====================
def backtest_series(label: str, records: List[Dict],
date_key: str = "date",
value_fields: Optional[List[str]] = None,
jump_threshold: float = 5.0) -> Dict[str, Any]:
"""对单个数据序列做质量回测。
校验维度:
1. 完整性 — 记录数量、日期覆盖率
2. 时间连续性 — 日期去重数、最大间隔(交易日/自然日)
3. 数值异常 — 环比跳变超过阈值的记录
4. 边界合理性 — 非正/缺失值占比
Args:
label: 序列名称(如 "600519 贵州茅台")
records: 记录列表,每项含 date 与数值字段
date_key: 日期字段名
value_fields: 指定数值字段;为空则自动识别
jump_threshold: 环比跳变阈值(百分比),默认 500%(5.0 倍)
Returns:
{
"label": str, "record_count": int,
"score": int (0-100), "passed": bool,
"issues": [ {level, field, message}, ... ],
"by_dimension": {"completeness": {}, "continuity": {}, "numeric": {}, "boundary": {}},
"fields_checked": [...],
}
"""
issues: List[Dict[str, str]] = []
by_dim = {}
n = len(records)
if n == 0:
return {
"label": label, "record_count": 0, "score": 0, "passed": False,
"issues": [{"level": "error", "field": date_key, "message": "无任何记录"}],
"by_dimension": {}, "fields_checked": [],
}
fields = value_fields or _numeric_fields(records)
score = 100
dim = {}
# 1) 完整性
dates = [_parse_date(r.get(date_key, "")) for r in records]
dates = [d for d in dates if d]
unique_dates = len(set(dates))
completeness_issues = []
if unique_dates == 0:
completeness_issues.append({"level": "error", "field": date_key, "message": "缺少有效日期字段"})
score -= 30
elif unique_dates < max(2, n):
dup = n - unique_dates
if dup > 0:
completeness_issues.append({"level": "warn", "field": date_key, "message": f"存在 {dup} 条重复日期记录"})
score -= max(0, min(dup * 2, 15))
dim["completeness"] = {
"record_count": n, "unique_dates": unique_dates, "issue_count": len(completeness_issues),
}
issues.extend(completeness_issues)
# 2) 时间连续性(自然日间隔,跳过周末)
continuity_issues = []
if unique_dates >= 2:
sorted_dates = sorted(set(dates))
try:
gaps = []
for i in range(1, len(sorted_dates)):
d1 = datetime.strptime(sorted_dates[i - 1], "%Y-%m-%d")
d2 = datetime.strptime(sorted_dates[i], "%Y-%m-%d")
gaps.append((d2 - d1).days)
# 排除周末/节假日(1天正常;>4天视为可能缺失)
big_gaps = [g for g in gaps if g > 4]
max_gap = max(gaps)
if big_gaps:
continuity_issues.append({
"level": "warn", "field": date_key,
"message": f"存在 {len(big_gaps)} 处超长间隔(最大 {max_gap} 天,可能缺数据)",
})
score -= min(len(big_gaps) * 5, 20)
dim["continuity"] = {"span_days": (datetime.strptime(sorted_dates[-1], "%Y-%m-%d")
- datetime.strptime(sorted_dates[0], "%Y-%m-%d")).days,
"max_gap_days": max_gap, "big_gaps": len(big_gaps)}
except ValueError:
pass
issues.extend(continuity_issues)
# 3) 数值异常跳变
numeric_issues = []
for f in fields:
vals = [_safe_float(r.get(f)) for r in records]
# 只对正数值做环比跳变检测
for i in range(1, len(vals)):
prev, cur = vals[i - 1], vals[i]
if prev <= 0 or cur <= 0:
continue
ratio = abs(cur - prev) / abs(prev) * 100
if ratio > jump_threshold * 100:
numeric_issues.append({
"level": "warn", "field": f,
"message": f"第 {i + 1} 条相对上条跳变 {ratio:.1f}%(超 {jump_threshold * 100:.0f}%)",
})
score -= min(int(ratio // 100) * 3, 15)
break # 每字段最多报 1 条
dim["numeric"] = {"fields": fields, "issue_count": len(numeric_issues)}
issues.extend(numeric_issues)
# 4) 边界合理性
boundary_issues = []
for f in fields:
vals = [_safe_float(r.get(f)) for r in records]
non_positive = sum(1 for v in vals if v <= 0)
if non_positive > n * 0.5:
boundary_issues.append({
"level": "warn", "field": f,
"message": f"超过 50% 的记录该字段为 0 或负值({non_positive}/{n}),疑似异常",
})
score -= 10
dim["boundary"] = {"issue_count": len(boundary_issues)}
issues.extend(boundary_issues)
# 汇总
by_dim = dim
score = max(0, min(100, score))
return {
"label": label,
"record_count": n,
"score": score,
"passed": score >= 60,
"issues": issues,
"by_dimension": by_dim,
"fields_checked": fields,
}
# ==================== 横向对比 ====================
def compare_series(series_dict: Dict[str, List[Dict]],
date_key: str = "date",
value_fields: Optional[List[str]] = None,
limit: int = 50) -> Dict[str, Any]:
"""横向对比多组同类数据序列。
Args:
series_dict: {"标签": [记录列表], ...},如 {"贵州茅台": [...两融...], "五粮液": [...]}
date_key: 日期字段名
value_fields: 指定数值字段;为空则取各组共同数值字段的并集
limit: 每组取最近 N 条用于统计
Returns:
{
"series_count": int, "common_fields": [...],
"latest": {label: {field: 最新值}}, # 各序列最新值
"change": {label: {field: 环比变化}}, # 最新 vs 上一条
"ranking": {field: [ {label, value, rank}, ... ]}, # 按最新值排名
"per_series_stats": {label: {field: {latest, mean, min, max, change}}},
}
"""
if not series_dict:
return {"series_count": 0, "common_fields": [], "latest": {}, "change": {}, "ranking": {}, "per_series_stats": {}}
# 收集各组数值字段(并集)
all_fields: List[str] = []
for recs in series_dict.values():
for f in (_numeric_fields(recs) if value_fields is None else value_fields):
if f not in all_fields:
all_fields.append(f)
if value_fields:
all_fields = [f for f in all_fields if f in value_fields] or all_fields
latest: Dict[str, Dict[str, float]] = {}
change: Dict[str, Dict[str, float]] = {}
stats: Dict[str, Dict[str, Dict[str, float]]] = {}
for label, recs in series_dict.items():
recs = recs[-limit:] if limit else recs
latest[label] = {}
change[label] = {}
stats[label] = {}
for f in all_fields:
vals = [_safe_float(r.get(f)) for r in recs]
if not vals:
continue
latest_val = vals[-1]
prev_val = vals[-2] if len(vals) > 1 else 0.0
latest[label][f] = round(latest_val, 4)
change[label][f] = round(latest_val - prev_val, 4) if len(vals) > 1 else None
stats[label][f] = {
"latest": round(latest_val, 4),
"mean": round(sum(vals) / len(vals), 4),
"min": round(min(vals), 4),
"max": round(max(vals), 4),
"change": change[label][f],
}
# 排名(按最新值降序)
ranking: Dict[str, List[Dict[str, Any]]] = {}
for f in all_fields:
rows = []
for label, recs in series_dict.items():
vals = [_safe_float(r.get(f)) for r in (recs[-limit:] if limit else recs)]
if not vals:
continue
rows.append({"label": label, "value": round(vals[-1], 4)})
rows.sort(key=lambda x: x["value"], reverse=True)
for i, row in enumerate(rows, 1):
row["rank"] = i
ranking[f] = rows
return {
"series_count": len(series_dict),
"common_fields": all_fields,
"latest": latest,
"change": change,
"ranking": ranking,
"per_series_stats": stats,
}
def compare_and_backtest(series_dict: Dict[str, List[Dict]],
date_key: str = "date",
value_fields: Optional[List[str]] = None,
jump_threshold: float = 5.0,
limit: int = 50) -> Dict[str, Any]:
"""一键:横向对比 + 数据回测。
Args:
series_dict: {"标签": [记录列表], ...}
date_key: 日期字段名
value_fields: 指定数值字段
jump_threshold: 回测跳变阈值
limit: 每组参与统计的最近条数
Returns:
{
"comparison": {对比结果},
"backtests": [ {回测结果}, ... ],
"summary": {group_count, avg_score, best, worst},
}
"""
comparison = compare_series(series_dict, date_key, value_fields, limit)
backtests = [
backtest_series(label, recs, date_key, value_fields, jump_threshold)
for label, recs in series_dict.items()
]
scores = [b["score"] for b in backtests]
summary = {
"group_count": len(backtests),
"avg_score": round(sum(scores) / len(scores), 1) if scores else 0,
"best": max(backtests, key=lambda b: b["score"]) if backtests else None,
"worst": min(backtests, key=lambda b: b["score"]) if backtests else None,
}
# 精简 best/worst,避免嵌套过大
if summary.get("best"):
summary["best"] = {"label": summary["best"]["label"], "score": summary["best"]["score"]}
if summary.get("worst"):
summary["worst"] = {"label": summary["worst"]["label"], "score": summary["worst"]["score"]}
return {"comparison": comparison, "backtests": backtests, "summary": summary}
# ==================== 便捷函数 ====================
def format_comparison_report(result: Dict[str, Any]) -> str:
"""将对比回测结果格式化为可读文本。"""
comp = result.get("comparison", {})
lines = [f"📊 同类数据横向对比与回测({comp.get('series_count', 0)} 组)", "=" * 50]
# 排名
for field, rows in comp.get("ranking", {}).items():
lines.append(f"\n【{field}】最新值排名:")
for row in rows[:5]:
lines.append(f" {row['rank']}. {row['label']}: {row['value']}")
# 回测评分
lines.append("\n【数据质量回测】")
for b in result.get("backtests", []):
status = "✅" if b["passed"] else "⚠️"
lines.append(f" {status} {b['label']}: {b['score']} 分({b['record_count']} 条记录)")
for issue in b["issues"][:3]:
lines.append(f" - [{issue['level']}] {issue['message']}")
summary = result.get("summary", {})
lines.append(f"\n📈 汇总: 平均 {summary.get('avg_score', 0)} 分 | "
f"最佳 {summary.get('best', {}).get('label', '-')} | "
f"待关注 {summary.get('worst', {}).get('label', '-')}")
return "\n".join(lines)
# ==================== CLI 测试入口 ====================
if __name__ == "__main__":
# 示例:两只股票的两融模拟数据
demo_a = [
{"date": "2026-07-28", "margin_balance": 100.0, "margin_buy": 5.0},
{"date": "2026-07-29", "margin_balance": 102.0, "margin_buy": 6.0},
{"date": "2026-07-30", "margin_balance": 105.0, "margin_buy": 4.0},
{"date": "2026-07-31", "margin_balance": 103.0, "margin_buy": 7.0},
]
demo_b = [
{"date": "2026-07-28", "margin_balance": 50.0, "margin_buy": 2.0},
{"date": "2026-07-29", "margin_balance": 53.0, "margin_buy": 3.0},
{"date": "2026-07-30", "margin_balance": 49.0, "margin_buy": 2.5},
{"date": "2026-07-31", "margin_balance": 52.0, "margin_buy": 4.0},
]
result = compare_and_backtest({"股票A": demo_a, "股票B": demo_b})
print(format_comparison_report(result))