添加批量解析
This commit is contained in:
+511
@@ -0,0 +1,511 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AI Agent MT5 报告分析 API
|
||||
=========================
|
||||
专为 AI Agent 设计的结构化接口。解析 MT5 报告,输出 JSON 可序列化结果,
|
||||
提供设参优化建议。
|
||||
|
||||
用法:
|
||||
from mt5_agent import parse_report, analyze_report, compare_reports, get_param_suggestions
|
||||
|
||||
# 解析单份报告
|
||||
report = parse_report("path/to/report.htm")
|
||||
print(report.summary) # 汇总指标
|
||||
print(report.metrics) # 扩展指标(PF、胜率、回撤等)
|
||||
print(report.trades) # 逐笔交易列表
|
||||
|
||||
# 单份报告分析
|
||||
analysis = analyze_report(report)
|
||||
print(analysis.suggestions) # 优化建议列表
|
||||
|
||||
# 对比两份报告
|
||||
comparison = compare_reports(report_a, report_b)
|
||||
print(comparison.diffs) # 关键指标差异
|
||||
|
||||
# 批量分析
|
||||
from mt5_agent import batch_analyze
|
||||
result = batch_analyze(["dir1/", "dir2/"], "*.htm")
|
||||
print(result.rankings) # 按 PF 排名
|
||||
print(result.anomalies) # 异常报告
|
||||
|
||||
# 设参优化建议
|
||||
suggestions = get_param_suggestions(report, analysis)
|
||||
print(suggestions) # 可写入 .set 的参数修改建议
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# 导入内部模块
|
||||
import mt5_report_parser as mp
|
||||
from mt5_report_parser import parse_report as _parse_report, MT5Report
|
||||
import run_analysis as ra
|
||||
import walk_forward as wf
|
||||
from param_scan import detect_plateaus, build_pivot, load_scan_results, overfit_score
|
||||
import mae_mfe as mm
|
||||
|
||||
|
||||
# ============================================================================= #
|
||||
# 数据模型(JSON 可序列化)
|
||||
# ============================================================================= #
|
||||
|
||||
class ReportData:
|
||||
"""解析后的 MT5 报告数据(AI 友好结构)。"""
|
||||
def __init__(self, rep: MT5Report):
|
||||
self.source_file = rep.source_file
|
||||
self.meta = _to_dict(rep.meta)
|
||||
self.summary = _to_dict(rep.summary_norm) # 数值化后的汇总
|
||||
self._trades_df = rep.trades # 保持 DataFrame 原类型
|
||||
self.trades = _trades_to_dict(rep.trades) if rep.trades is not None else []
|
||||
self.metrics = _compute_metrics(rep.trades) if rep.trades is not None else {}
|
||||
# What-If 需要 open_time 列,部分报告可能缺失
|
||||
if rep.trades is not None and "open_time" in rep.trades.columns:
|
||||
self.what_if = _what_if_to_dict(ra.whatif_scenarios(rep))
|
||||
else:
|
||||
self.what_if = []
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
"""序列化为 JSON 字符串。"""
|
||||
return json.dumps({
|
||||
"source_file": self.source_file,
|
||||
"meta": self.meta,
|
||||
"summary": self.summary,
|
||||
"metrics": self.metrics,
|
||||
"trades_count": len(self.trades),
|
||||
"what_if_scenarios": self.what_if,
|
||||
}, ensure_ascii=False, indent=indent)
|
||||
|
||||
|
||||
class AnalysisResult:
|
||||
"""单份报告分析结果。"""
|
||||
def __init__(self, report: ReportData):
|
||||
self.report = report
|
||||
self.walk_forward = _wf_analysis(report)
|
||||
self.anomalies = _detect_anomalies(report)
|
||||
self.suggestions = _generate_suggestions(report, self.walk_forward, self.anomalies)
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
return json.dumps({
|
||||
"source_file": self.report.source_file,
|
||||
"summary": self.report.summary,
|
||||
"metrics": self.report.metrics,
|
||||
"walk_forward": self.walk_forward,
|
||||
"anomalies": self.anomalies,
|
||||
"suggestions": self.suggestions,
|
||||
}, ensure_ascii=False, indent=indent)
|
||||
|
||||
|
||||
class ComparisonResult:
|
||||
"""两份报告对比结果。"""
|
||||
def __init__(self, report_a: ReportData, report_b: ReportData):
|
||||
self.report_a = report_a
|
||||
self.report_b = report_b
|
||||
self.diffs = _compute_diffs(report_a, report_b)
|
||||
self.suggestions = _compare_suggestions(report_a, report_b)
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
return json.dumps({
|
||||
"report_a": {"file": self.report_a.source_file, "summary": self.report_a.summary},
|
||||
"report_b": {"file": self.report_b.source_file, "summary": self.report_b.summary},
|
||||
"diffs": self.diffs,
|
||||
"suggestions": self.suggestions,
|
||||
}, ensure_ascii=False, indent=indent)
|
||||
|
||||
|
||||
class BatchResult:
|
||||
"""批量分析结果。"""
|
||||
def __init__(self, reports: List[ReportData]):
|
||||
self.reports = reports
|
||||
self.rankings = _batch_rankings(reports)
|
||||
self.anomalies = [r for r in reports if r.metrics.get("n_trades", 0) < 30]
|
||||
self.best = self.rankings[0] if self.rankings else None
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
return json.dumps({
|
||||
"count": len(self.reports),
|
||||
"rankings": self.rankings,
|
||||
"anomalies_count": len(self.anomalies),
|
||||
"best": self.best,
|
||||
}, ensure_ascii=False, indent=indent)
|
||||
|
||||
|
||||
# ============================================================================= #
|
||||
# 核心函数
|
||||
# ============================================================================= #
|
||||
|
||||
def parse_report(path: str) -> ReportData:
|
||||
"""解析 MT5 报告(支持 .xlsx / .htm / .html)。"""
|
||||
rep = _parse_report(path)
|
||||
return ReportData(rep)
|
||||
|
||||
|
||||
def analyze_report(report: ReportData) -> AnalysisResult:
|
||||
"""对单份报告做深度分析。"""
|
||||
return AnalysisResult(report)
|
||||
|
||||
|
||||
def compare_reports(report_a: ReportData, report_b: ReportData) -> ComparisonResult:
|
||||
"""对比两份报告。"""
|
||||
return ComparisonResult(report_a, report_b)
|
||||
|
||||
|
||||
def batch_analyze(paths: List[str], pattern: str = "*.htm") -> BatchResult:
|
||||
"""批量分析多个报告。"""
|
||||
from batch_report import scan_reports, parse_batch, build_summary_df
|
||||
files = scan_reports(paths, pattern)
|
||||
raw_reports = parse_batch(files)
|
||||
report_datas = []
|
||||
for name, rep in raw_reports:
|
||||
try:
|
||||
rd = ReportData(rep)
|
||||
rd._filename = name
|
||||
report_datas.append(rd)
|
||||
except Exception as e:
|
||||
print("警告: 跳过 %s - %s" % (name, e))
|
||||
return BatchResult(report_datas)
|
||||
|
||||
|
||||
def get_param_suggestions(analysis: AnalysisResult) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成 .set 文件参数修改建议。
|
||||
返回格式:[{"param_name": "InpStopLossPoints", "suggested_value": "150",
|
||||
"reason": "当前 200,亏损截断场景显示 150 更优"}]
|
||||
"""
|
||||
suggestions = []
|
||||
report = analysis.report
|
||||
metrics = report.metrics
|
||||
summary = report.summary
|
||||
what_if = report.what_if
|
||||
|
||||
# 建议 1:止损优化
|
||||
if metrics.get("avg_loss", 0) != 0:
|
||||
# 找亏损截断场景
|
||||
cut_scenarios = [s for s in what_if if "亏损截断" in s.get("name", "")]
|
||||
if cut_scenarios:
|
||||
base_pf = summary.get("profit_factor", 0)
|
||||
for s in cut_scenarios:
|
||||
if s.get("pf", 0) > base_pf and s.get("dd", 0) < metrics.get("max_dd", 0):
|
||||
cap = s.get("desc", "")
|
||||
suggestions.append({
|
||||
"param_name": "InpStopLossPoints",
|
||||
"suggested_value": None, # 需要从场景推算
|
||||
"reason": f"亏损截断场景 PF={s['pf']:.2f} > 基线 PF={base_pf:.2f},回撤从 {metrics.get('max_dd',0):.0f} 降至 {s['dd']:.0f}",
|
||||
"priority": "high",
|
||||
})
|
||||
|
||||
# 建议 2:止盈优化
|
||||
tp_scenarios = [s for s in what_if if "盈利单放大" in s.get("name", "")]
|
||||
if tp_scenarios:
|
||||
base_pf = summary.get("profit_factor", 0)
|
||||
for s in tp_scenarios:
|
||||
if s.get("pf", 0) > base_pf:
|
||||
suggestions.append({
|
||||
"param_name": "InpTakeProfitPoints",
|
||||
"suggested_value": None,
|
||||
"reason": f"盈利单放大场景 PF={s['pf']:.2f} > 基线 PF={base_pf:.2f},当前止盈可能太紧",
|
||||
"priority": "medium",
|
||||
})
|
||||
|
||||
# 建议 3:仓位优化
|
||||
halve_scenarios = [s for s in what_if if "仓位减半" in s.get("name", "")]
|
||||
if halve_scenarios:
|
||||
base_dd = metrics.get("max_dd", 0)
|
||||
for s in halve_scenarios:
|
||||
if abs(s.get("dd", 0)) < abs(base_dd * 0.5):
|
||||
suggestions.append({
|
||||
"param_name": "InpFixedLots",
|
||||
"suggested_value": None,
|
||||
"reason": f"仓位减半后回撤从 {base_dd:.0f} 降至 {s['dd']:.0f},可考虑降低仓位",
|
||||
"priority": "medium",
|
||||
})
|
||||
|
||||
# 建议 4:方向性建议
|
||||
if metrics.get("by_direction"):
|
||||
for direction, stats in metrics["by_direction"].items():
|
||||
if stats.get("win_rate", 0) < 35 and stats.get("profit_factor", 0) < 0.8:
|
||||
suggestions.append({
|
||||
"param_name": None,
|
||||
"suggested_value": None,
|
||||
"reason": f"方向 {direction} 胜率仅 {stats['win_rate']:.1f}%,PF={stats['profit_factor']:.2f},考虑单独过滤该方向信号",
|
||||
"priority": "high",
|
||||
})
|
||||
|
||||
# 建议 5:时段过滤
|
||||
if metrics.get("by_hour"):
|
||||
bad_hours = [h for h, stats in metrics["by_hour"].items()
|
||||
if stats.get("sum", 0) < 0 and stats.get("count", 0) > 5]
|
||||
if bad_hours:
|
||||
suggestions.append({
|
||||
"param_name": "InpFreezeBarCount",
|
||||
"suggested_value": None,
|
||||
"reason": f"时段 {bad_hours[:3]} 净盈利持续为负,建议添加时段过滤",
|
||||
"priority": "low",
|
||||
})
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
# ============================================================================= #
|
||||
# 内部工具函数
|
||||
# ============================================================================= #
|
||||
|
||||
def _to_dict(obj: Any) -> Any:
|
||||
"""递归将 pandas/numpy 对象转为原生 Python 类型。"""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, dict):
|
||||
return {k: _to_dict(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_to_dict(v) for v in obj]
|
||||
if isinstance(obj, (np.integer,)):
|
||||
return int(obj)
|
||||
if isinstance(obj, (np.floating,)):
|
||||
if np.isnan(obj) or np.isinf(obj):
|
||||
return None
|
||||
return float(obj)
|
||||
if isinstance(obj, pd.Timestamp):
|
||||
return str(obj)
|
||||
if isinstance(obj, pd.DataFrame):
|
||||
return _to_dict(obj.to_dict(orient="index"))
|
||||
if isinstance(obj, pd.Series):
|
||||
return _to_dict(obj.to_dict())
|
||||
if isinstance(obj, pd.Index):
|
||||
return _to_dict(list(obj))
|
||||
if isinstance(obj, (float, int)) and (np.isnan(obj) or np.isinf(obj)):
|
||||
return None
|
||||
if isinstance(obj, (int, float, str, bool)):
|
||||
return obj
|
||||
return str(obj)
|
||||
|
||||
|
||||
def _trades_to_dict(trades: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
"""将逐笔交易 DataFrame 转为字典列表。"""
|
||||
if trades is None or trades.empty:
|
||||
return []
|
||||
result = []
|
||||
for _, row in trades.iterrows():
|
||||
item = {}
|
||||
for col in ["open_time", "close_time", "direction", "volume",
|
||||
"open_price", "close_price", "profit", "swap",
|
||||
"commission", "net_profit", "duration_min"]:
|
||||
val = row.get(col)
|
||||
if val is None or (isinstance(val, float) and np.isnan(val)):
|
||||
item[col] = None
|
||||
elif isinstance(val, pd.Timestamp):
|
||||
item[col] = str(val)
|
||||
elif isinstance(val, (np.integer,)):
|
||||
item[col] = int(val)
|
||||
elif isinstance(val, (np.floating,)):
|
||||
item[col] = float(val)
|
||||
else:
|
||||
item[col] = val
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _compute_metrics(trades: pd.DataFrame) -> Dict[str, Any]:
|
||||
"""计算扩展指标(JSON 可序列化)。"""
|
||||
m = ra.extended_metrics(trades) if trades is not None and not trades.empty else {}
|
||||
result = _to_dict(m)
|
||||
# 移除内部字段
|
||||
for key in ["_equity", "_dd", "_net"]:
|
||||
result.pop(key, None)
|
||||
return result
|
||||
|
||||
|
||||
def _what_if_to_dict(scenarios: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""将 What-If 场景转为字典列表。"""
|
||||
return [_to_dict(s) for s in scenarios]
|
||||
|
||||
|
||||
def _wf_analysis(report: ReportData) -> Dict[str, Any]:
|
||||
"""Walk-Forward 分析。"""
|
||||
trades_df = getattr(report, "_trades_df", None)
|
||||
if trades_df is None or trades_df.empty:
|
||||
return {}
|
||||
# 确保 open_time 全为 Timestamp 类型
|
||||
trades_df = trades_df.copy()
|
||||
trades_df["open_time"] = pd.to_datetime(trades_df["open_time"], errors="coerce")
|
||||
trades_df = trades_df.dropna(subset=["open_time"])
|
||||
trades_df["net_profit"] = pd.to_numeric(trades_df["net_profit"], errors="coerce")
|
||||
trades_df = trades_df.dropna(subset=["net_profit"])
|
||||
if trades_df.empty:
|
||||
return {}
|
||||
windows = wf.walk_forward(trades_df)
|
||||
summary = wf.wf_summary(windows)
|
||||
return _to_dict(summary)
|
||||
|
||||
|
||||
def _detect_anomalies(report: ReportData) -> List[Dict[str, Any]]:
|
||||
"""检测异常指标。"""
|
||||
anomalies = []
|
||||
metrics = report.metrics
|
||||
summary = report.summary
|
||||
|
||||
if metrics.get("n_trades", 0) < 30:
|
||||
anomalies.append({
|
||||
"type": "样本不足",
|
||||
"detail": f"仅 {metrics['n_trades']} 笔交易",
|
||||
})
|
||||
|
||||
if summary.get("profit_factor", 0) > 10:
|
||||
anomalies.append({
|
||||
"type": "PF异常高",
|
||||
"detail": f"PF={summary['profit_factor']:.2f},可能过拟合",
|
||||
})
|
||||
|
||||
if metrics.get("max_dd", 0) < -1000:
|
||||
anomalies.append({
|
||||
"type": "回撤过大",
|
||||
"detail": f"最大回撤 {metrics['max_dd']:.0f}",
|
||||
})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def _generate_suggestions(
|
||||
report: ReportData,
|
||||
wf_result: Dict[str, Any],
|
||||
anomalies: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成优化建议。"""
|
||||
suggestions = []
|
||||
metrics = report.metrics
|
||||
summary = report.summary
|
||||
|
||||
# Walk-Forward 建议
|
||||
if wf_result:
|
||||
wfe = wf_result.get("wfe", 0)
|
||||
if wfe is not None and wfe < 0.3:
|
||||
suggestions.append({
|
||||
"type": "Walk-Forward",
|
||||
"detail": f"WFE={wfe:.2f},OOS 表现弱,参数可能过拟合 IS 段",
|
||||
"priority": "high",
|
||||
})
|
||||
|
||||
# 什么-If 建议
|
||||
for s in report.what_if:
|
||||
name = s.get("name", "")
|
||||
if "信号反向" in name and s.get("net", 0) > 0 and s.get("pf", 0) > 1:
|
||||
suggestions.append({
|
||||
"type": "信号方向",
|
||||
"detail": f"反向场景净盈利={s['net']:+.2f}, PF={s['pf']:.2f},可能方向逻辑写反",
|
||||
"priority": "critical",
|
||||
})
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def _compute_diffs(report_a: ReportData, report_b: ReportData) -> Dict[str, Any]:
|
||||
"""计算两份报告的关键指标差异。"""
|
||||
diffs = {}
|
||||
for key in ["profit_factor", "win_rate", "net_profit", "max_dd", "sharpe", "sortino"]:
|
||||
va = report_a.summary.get(key)
|
||||
vb = report_b.summary.get(key)
|
||||
if va is not None and vb is not None:
|
||||
diff = vb - va if (not np.isinf(va) or not np.isinf(vb)) else None
|
||||
pct = ((vb - va) / va * 100) if va != 0 and not np.isinf(va) else None
|
||||
diffs[key] = {
|
||||
"a": va,
|
||||
"b": vb,
|
||||
"diff": diff,
|
||||
"pct_change": pct,
|
||||
}
|
||||
return diffs
|
||||
|
||||
|
||||
def _compare_suggestions(report_a: ReportData, report_b: ReportData) -> List[Dict[str, Any]]:
|
||||
"""对比分析建议。"""
|
||||
suggestions = []
|
||||
pf_a = report_a.summary.get("profit_factor", 0)
|
||||
pf_b = report_b.summary.get("profit_factor", 0)
|
||||
|
||||
if pf_b > pf_a and pf_a > 0:
|
||||
suggestions.append({
|
||||
"type": "参数优化",
|
||||
"detail": f"报告B PF={pf_b:.2f} 优于报告A PF={pf_a:.2f},差 {pf_b-pf_a:.2f}",
|
||||
"priority": "medium",
|
||||
})
|
||||
elif pf_b < pf_a and pf_b > 0:
|
||||
suggestions.append({
|
||||
"type": "参数回退",
|
||||
"detail": f"报告B PF={pf_b:.2f} 差于报告A PF={pf_a:.2f},参数可能过拟合",
|
||||
"priority": "medium",
|
||||
})
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def _batch_rankings(reports: List[ReportData]) -> List[Dict[str, Any]]:
|
||||
"""按 PF 排名。"""
|
||||
valid = [r for r in reports if r.summary.get("profit_factor") is not None
|
||||
and r.metrics.get("n_trades", 0) > 0]
|
||||
sorted_reports = sorted(valid, key=lambda r: r.summary.get("profit_factor", 0), reverse=True)
|
||||
rankings = []
|
||||
for i, r in enumerate(sorted_reports[:20]): # 只取前 20
|
||||
rankings.append({
|
||||
"rank": i + 1,
|
||||
"file": r.source_file,
|
||||
"filename": getattr(r, "_filename", os.path.basename(r.source_file)),
|
||||
"profit_factor": r.summary.get("profit_factor"),
|
||||
"net_profit": r.summary.get("total_net_profit"),
|
||||
"win_rate": r.metrics.get("win_rate"),
|
||||
"n_trades": r.metrics.get("n_trades"),
|
||||
"max_dd": r.metrics.get("max_dd"),
|
||||
})
|
||||
return rankings
|
||||
|
||||
|
||||
# ============================================================================= #
|
||||
# CLI
|
||||
# ============================================================================= #
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser(description="AI Agent MT5 报告分析 API")
|
||||
ap.add_argument("path", help="报告文件路径或目录")
|
||||
ap.add_argument("--compare", help="对比的另一份报告路径")
|
||||
ap.add_argument("--batch", action="store_true", help="批量分析目录")
|
||||
ap.add_argument("--suggestions", action="store_true", help="输出设参建议")
|
||||
ap.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.batch:
|
||||
result = batch_analyze([args.path])
|
||||
print(result.to_json())
|
||||
elif args.compare:
|
||||
a = parse_report(args.path)
|
||||
b = parse_report(args.compare)
|
||||
comparison = compare_reports(a, b)
|
||||
if args.format == "json":
|
||||
print(comparison.to_json())
|
||||
else:
|
||||
print(f"\n报告A: {args.path}")
|
||||
print(f" PF={a.summary.get('profit_factor')} 净盈利={a.summary.get('total_net_profit')}")
|
||||
print(f"报告B: {args.compare}")
|
||||
print(f" PF={b.summary.get('profit_factor')} 净盈利={b.summary.get('total_net_profit')}")
|
||||
print(f"\n关键差异:")
|
||||
for key, diff in comparison.diffs.items():
|
||||
print(f" {key}: A={diff['a']} B={diff['b']} 差={diff['diff']:.2f}")
|
||||
else:
|
||||
report = parse_report(args.path)
|
||||
analysis = analyze_report(report)
|
||||
if args.suggestions:
|
||||
suggestions = get_param_suggestions(analysis)
|
||||
print(json.dumps(suggestions, ensure_ascii=False, indent=2))
|
||||
elif args.format == "json":
|
||||
print(analysis.to_json())
|
||||
else:
|
||||
print(f"\n报告: {args.path}")
|
||||
print(f" PF={report.summary.get('profit_factor')} 净盈利={report.summary.get('total_net_profit')}")
|
||||
print(f" 胜率={report.metrics.get('win_rate')} 笔数={report.metrics.get('n_trades')}")
|
||||
print(f" 回撤={report.metrics.get('max_dd')}")
|
||||
print(f"\n建议 ({len(analysis.suggestions)} 条):")
|
||||
for s in analysis.suggestions:
|
||||
print(f" [{s.get('priority','?')}] {s['type']}: {s['detail']}")
|
||||
Reference in New Issue
Block a user