添加批量解析
This commit is contained in:
+372
-7
@@ -14,19 +14,108 @@ MT5 策略测试报告解析器(中文版 xlsx)
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import os as _os_std
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import numpy as np
|
||||
import openpyxl
|
||||
import pandas as pd
|
||||
|
||||
# 报告中区段标题(A 列出现的精确字符串)
|
||||
# 报告中区段标题
|
||||
SEC_SETTINGS = "设置"
|
||||
SEC_RESULTS = "结果"
|
||||
SEC_ORDERS = "订单"
|
||||
SEC_DEALS = "成交"
|
||||
|
||||
# 支持的报告格式
|
||||
_Supported_Extensions = {".xlsx", ".htm", ".html"}
|
||||
|
||||
|
||||
class _MT5HTMLParser(HTMLParser):
|
||||
"""MT5 HTML 报告解析器。"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.in_td = False
|
||||
self.in_th = False
|
||||
self.in_b = False
|
||||
self.current_row: List[Optional[str]] = []
|
||||
self.current_cell: str = ""
|
||||
self.b_content: str = "" # Content inside <b> tags
|
||||
self.rows: List[List[Optional[str]]] = []
|
||||
self.section_rows: Dict[str, int] = {} # Track section headers
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "tr":
|
||||
if self.current_row:
|
||||
self.rows.append(self.current_row)
|
||||
self.current_row = []
|
||||
elif tag == "td":
|
||||
self.current_cell = ""
|
||||
self.b_content = ""
|
||||
self.in_td = True
|
||||
self.in_b = False
|
||||
elif tag == "th":
|
||||
self.current_cell = ""
|
||||
self.b_content = ""
|
||||
self.in_th = True
|
||||
self.in_b = False
|
||||
elif tag == "b":
|
||||
self.in_b = True
|
||||
self.b_content = ""
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "tr":
|
||||
if self.current_row:
|
||||
self.rows.append(self.current_row)
|
||||
self.current_row = []
|
||||
self.current_cell = ""
|
||||
self.in_td = False
|
||||
self.in_th = False
|
||||
elif tag == "td":
|
||||
# Build full cell: non-b text + <b> content
|
||||
full_cell = self.current_cell + self.b_content
|
||||
self.current_row.append(full_cell.strip() if full_cell.strip() else "")
|
||||
self.current_cell = ""
|
||||
self.b_content = ""
|
||||
self.in_td = False
|
||||
elif tag == "th":
|
||||
full_cell = self.current_cell + self.b_content
|
||||
# Check if this is a section header
|
||||
b_val = self.b_content.strip()
|
||||
if b_val in (SEC_SETTINGS, SEC_RESULTS, SEC_ORDERS, SEC_DEALS):
|
||||
row_idx = len(self.rows)
|
||||
self.section_rows[b_val] = row_idx
|
||||
self.current_row.append(full_cell.strip() if full_cell.strip() else "")
|
||||
self.current_cell = ""
|
||||
self.b_content = ""
|
||||
self.in_th = False
|
||||
elif tag == "b":
|
||||
self.in_b = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.in_td or self.in_th:
|
||||
if not self.in_b:
|
||||
self.current_cell += data
|
||||
else:
|
||||
self.b_content += data
|
||||
|
||||
def finish(self):
|
||||
if self.current_row:
|
||||
self.rows.append(self.current_row)
|
||||
# Also search all cells for section markers (they may be in <td> not just <th>)
|
||||
for i, row in enumerate(self.rows):
|
||||
if row:
|
||||
for j, cell in enumerate(row):
|
||||
if cell:
|
||||
b_val = _get_b_text(cell)
|
||||
if b_val and b_val.strip() in (SEC_SETTINGS, SEC_RESULTS, SEC_ORDERS, SEC_DEALS):
|
||||
sec = b_val.strip()
|
||||
if sec not in self.section_rows:
|
||||
self.section_rows[sec] = i
|
||||
return self.rows, self.section_rows
|
||||
|
||||
|
||||
@dataclass
|
||||
class MT5Report:
|
||||
@@ -121,10 +210,253 @@ def _pct_to_float(x: Any) -> Optional[float]:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 主入口
|
||||
# HTML 报告解析
|
||||
# --------------------------------------------------------------------------- #
|
||||
def parse_report(path: str) -> MT5Report:
|
||||
"""解析一份 MT5 xlsx 报告。"""
|
||||
def _parse_html_report(text: str) -> MT5Report:
|
||||
"""解析 MT5 HTML 报告。"""
|
||||
parser = _MT5HTMLParser()
|
||||
parser.feed(text)
|
||||
rows, section_rows = parser.finish()
|
||||
|
||||
r_settings = section_rows.get(SEC_SETTINGS, 0)
|
||||
r_results = section_rows.get(SEC_RESULTS, r_settings)
|
||||
r_orders = section_rows.get(SEC_ORDERS, r_results)
|
||||
r_deals = section_rows.get(SEC_DEALS, r_orders)
|
||||
|
||||
rep = MT5Report()
|
||||
rep.meta, rep.summary = _parse_html_meta_and_summary(rows, r_settings, r_results, r_orders)
|
||||
rep.orders, rep.deals = _parse_html_tables(rows, r_orders, r_deals, len(rows))
|
||||
|
||||
rep.trades = _reconstruct_trades(rep.deals)
|
||||
_normalize_summary_numbers(rep)
|
||||
|
||||
return rep
|
||||
|
||||
|
||||
def _read_html_file(path: str) -> str:
|
||||
"""读取 HTML 文件,自动检测编码。"""
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
|
||||
# 检测 BOM
|
||||
if raw[:2] == b"\xff\xfe":
|
||||
return raw.decode("utf-16-le")
|
||||
elif raw[:2] == b"\xfe\xff":
|
||||
return raw.decode("utf-16-be")
|
||||
elif raw[:3] == b"\xef\xbb\xbf":
|
||||
return raw[3:].decode("utf-8")
|
||||
elif raw[:4] == b"\xff\xfe\x00\x00":
|
||||
return raw[4:].decode("utf-32-le")
|
||||
|
||||
# 尝试 UTF-8
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
# 回退到 latin-1
|
||||
return raw.decode("latin-1")
|
||||
|
||||
|
||||
def _get_b_text(cell_text: str) -> Optional[str]:
|
||||
"""从 HTML 单元格文本中提取 <b>...</b> 内的文本。"""
|
||||
if cell_text is None or not cell_text.strip():
|
||||
return None
|
||||
# 直接提取 <b>...</b> 内容
|
||||
m = re.search(r'<b>(.*?)</b>', cell_text, re.DOTALL)
|
||||
if m:
|
||||
text = m.group(1).strip()
|
||||
# 去除内嵌的 <br> 标签
|
||||
text = re.sub(r'<br\s*/?>', '', text, flags=re.IGNORECASE)
|
||||
return text if text else None
|
||||
# 如果没有 <b> 标签,返回去除 HTML 标签的纯文本
|
||||
clean = re.sub(r'<[^>]+>', '', cell_text).strip()
|
||||
return clean if clean else None
|
||||
|
||||
|
||||
def _parse_html_meta_and_summary(
|
||||
rows: List[List[Optional[str]]],
|
||||
r_settings: int, r_results: int, r_orders: int,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""解析 HTML 报告中的设置和结果部分。"""
|
||||
meta: Dict[str, Any] = {}
|
||||
summary: Dict[str, Any] = {}
|
||||
|
||||
# 解析设置段
|
||||
for i in range(r_settings, r_results):
|
||||
if i >= len(rows):
|
||||
break
|
||||
row = rows[i]
|
||||
if not row or len(row) < 2:
|
||||
continue
|
||||
label = _get_b_text(row[0] or "")
|
||||
if label and label.endswith(":"):
|
||||
label = label[:-1].strip()
|
||||
# 值在第二个非空单元格
|
||||
for j in range(1, len(row)):
|
||||
val = _get_b_text(row[j] or "")
|
||||
if val is not None:
|
||||
if label == "输入":
|
||||
# 输入参数
|
||||
continue
|
||||
meta[label] = val
|
||||
break
|
||||
|
||||
# 解析输入参数(在设置段中查找 Key=Value 格式)
|
||||
inputs: Dict[str, str] = {}
|
||||
for i in range(r_settings, r_results):
|
||||
if i >= len(rows):
|
||||
break
|
||||
for j in range(len(rows[i])):
|
||||
cell = rows[i][j]
|
||||
if cell:
|
||||
b_val = _get_b_text(cell)
|
||||
if b_val and "=" in b_val and not b_val.strip().startswith("==="):
|
||||
k, _, val = b_val.partition("=")
|
||||
inputs[k.strip()] = val.strip()
|
||||
meta["inputs"] = inputs
|
||||
|
||||
# 解析结果段
|
||||
for i in range(r_results, r_orders):
|
||||
if i >= len(rows):
|
||||
break
|
||||
row = rows[i]
|
||||
if not row or len(row) < 2:
|
||||
continue
|
||||
label = _get_b_text(row[0] or "")
|
||||
if label and label.endswith(":"):
|
||||
label = label[:-1].strip()
|
||||
for j in range(1, len(row)):
|
||||
val = _get_b_text(row[j] or "")
|
||||
if val is not None:
|
||||
summary[label] = val
|
||||
break
|
||||
|
||||
return meta, summary
|
||||
|
||||
|
||||
def _parse_html_tables(
|
||||
rows: List[List[Optional[str]]],
|
||||
r_orders: int, r_deals: int, max_row: int,
|
||||
) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
|
||||
"""解析 HTML 报告中的订单表和成交表。"""
|
||||
orders_df: Optional[pd.DataFrame] = None
|
||||
deals_df: Optional[pd.DataFrame] = None
|
||||
|
||||
# HTML 报告列名到标准英文列名的映射
|
||||
DEALS_COL_MAP = {
|
||||
"时间": "time",
|
||||
"成交": "deal_id",
|
||||
"交易品种": "symbol",
|
||||
"类型": "type",
|
||||
"趋势": "entry",
|
||||
"交易量": "volume",
|
||||
"价位": "price",
|
||||
"订单": "order",
|
||||
"手续费": "commission",
|
||||
"库存费": "swap",
|
||||
"盈利": "profit",
|
||||
"结余": "balance",
|
||||
"注释": "comment",
|
||||
}
|
||||
ORDERS_COL_MAP = {
|
||||
"时间": "time",
|
||||
"交易": "ticket",
|
||||
"交易品种": "symbol",
|
||||
"类型": "type",
|
||||
"状态": "state",
|
||||
"交易量": "volume",
|
||||
"价位": "price",
|
||||
"止损": "sl",
|
||||
"止盈": "tp",
|
||||
"注释": "comment",
|
||||
"订单": "order",
|
||||
}
|
||||
|
||||
def _parse_table_section(start_row: int, end_row: int, col_map: Dict[str, str]) -> Optional[pd.DataFrame]:
|
||||
"""Parse a table section from rows."""
|
||||
if start_row >= max_row:
|
||||
return None
|
||||
|
||||
# Find header row
|
||||
header_row = None
|
||||
for i in range(start_row + 1, min(start_row + 5, end_row)):
|
||||
if i >= len(rows):
|
||||
break
|
||||
row = rows[i]
|
||||
if row:
|
||||
b_count = sum(1 for cell in row if cell and _get_b_text(cell))
|
||||
if b_count >= 3:
|
||||
header_row = i
|
||||
break
|
||||
|
||||
if header_row is None:
|
||||
return None
|
||||
|
||||
# Extract headers
|
||||
headers = [_get_b_text(cell) for cell in rows[header_row] if cell and _get_b_text(cell)]
|
||||
if not headers:
|
||||
return None
|
||||
|
||||
# Rename columns using col_map
|
||||
renamed_headers = [col_map.get(h, h) for h in headers]
|
||||
|
||||
# Extract data rows
|
||||
data_rows = []
|
||||
for i in range(header_row + 1, end_row):
|
||||
if i >= len(rows):
|
||||
break
|
||||
row = rows[i]
|
||||
if row:
|
||||
values = [_get_b_text(cell) or "" for cell in row]
|
||||
if any(v for v in values):
|
||||
while len(values) < len(renamed_headers):
|
||||
values.append("")
|
||||
data_rows.append(values[:len(renamed_headers)])
|
||||
|
||||
if not data_rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(data_rows, columns=renamed_headers)
|
||||
return df
|
||||
|
||||
if r_orders < max_row:
|
||||
orders_df = _parse_table_section(r_orders, r_deals, ORDERS_COL_MAP)
|
||||
|
||||
if r_deals < max_row:
|
||||
deals_df = _parse_table_section(r_deals, max_row, DEALS_COL_MAP)
|
||||
|
||||
return orders_df, deals_df
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 解析缓存(文件级 LRU 缓存)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_parse_cache: Dict[str, MT5Report] = {}
|
||||
|
||||
|
||||
def parse_report(path: str, use_cache: bool = True) -> MT5Report:
|
||||
"""解析一份 MT5 报告(支持 xlsx / htm / html 格式)。"""
|
||||
real_path = _os_std.path.realpath(str(path))
|
||||
if use_cache and real_path in _parse_cache:
|
||||
return _parse_cache[real_path]
|
||||
|
||||
ext = _os_std.path.splitext(real_path)[1].lower()
|
||||
if ext == ".xlsx":
|
||||
rep = _parse_xlsx_report(real_path)
|
||||
elif ext in (".htm", ".html"):
|
||||
text = _read_html_file(real_path)
|
||||
rep = _parse_html_report(text)
|
||||
rep.source_file = real_path
|
||||
else:
|
||||
raise ValueError(f"不支持的报告格式: {ext}(仅支持 .xlsx / .htm / .html)")
|
||||
|
||||
_parse_cache[real_path] = rep
|
||||
return rep
|
||||
|
||||
|
||||
def _parse_xlsx_report(path: str) -> MT5Report:
|
||||
"""解析 xlsx 格式报告(原 parse_report 逻辑)。"""
|
||||
wb = openpyxl.load_workbook(path, data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
rep = MT5Report(source_file=path)
|
||||
@@ -136,16 +468,15 @@ def parse_report(path: str) -> MT5Report:
|
||||
r_deals = sec.get(SEC_DEALS, r_orders)
|
||||
|
||||
# ---- 元信息 / 设置 ----
|
||||
# 整个设置块(设置标题行之后到结果段之前)逐行解析 “标签: 值” 对
|
||||
meta: Dict[str, Any] = {}
|
||||
for r in range(r_settings + 1, r_results):
|
||||
kv = _kv_in_row(ws, r)
|
||||
for k, v in kv.items():
|
||||
if k == "输入": # “输入:” 是子段标题,其值为分节字符串,跳过
|
||||
if k == "输入": # "输入:" 是子段标题,其值为分节字符串,跳过
|
||||
continue
|
||||
meta[k] = v
|
||||
|
||||
# 输入参数:在 “输入:” 段落里,列 D 形如 Key=Value
|
||||
# 输入参数:在 "输入:" 段落里,列 D 形如 Key=Value
|
||||
inputs: Dict[str, str] = {}
|
||||
for r in range(r_settings + 1, r_results):
|
||||
for c in (4,): # 经验上输入参数在 D 列
|
||||
@@ -173,9 +504,15 @@ def parse_report(path: str) -> MT5Report:
|
||||
rep.summary = summary
|
||||
rep.trades = _reconstruct_trades(rep.deals)
|
||||
_normalize_summary_numbers(rep)
|
||||
|
||||
return rep
|
||||
|
||||
|
||||
def clear_parse_cache() -> None:
|
||||
"""清除解析缓存(调试 / 释放内存用)。"""
|
||||
_parse_cache.clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 逐笔交易重建
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -256,6 +593,34 @@ def _reconstruct_trades(deals: pd.DataFrame) -> pd.DataFrame:
|
||||
return pd.DataFrame(trades)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 单段通用指标(供 walk_forward / run_analysis 复用)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def compute_segment_metrics(net: pd.Series) -> Dict[str, float]:
|
||||
"""
|
||||
由净盈亏序列计算核心指标。
|
||||
无状态、纯函数,可直接用于单段或单份报告。
|
||||
"""
|
||||
n = len(net)
|
||||
if n == 0:
|
||||
return {"n": 0, "net": 0.0, "pf": 0.0, "win": 0.0, "dd": 0.0, "exp": 0.0}
|
||||
wins = net[net > 0]
|
||||
losses = net[net <= 0]
|
||||
gp = wins.sum()
|
||||
gl = -losses.sum()
|
||||
pf = gp / gl if gl > 0 else np.inf
|
||||
equity = net.cumsum()
|
||||
dd = float((equity - equity.cummax()).min())
|
||||
return {
|
||||
"n": int(n),
|
||||
"net": float(net.sum()),
|
||||
"pf": float(pf),
|
||||
"win": float(len(wins) / n * 100),
|
||||
"dd": dd,
|
||||
"exp": float(net.mean()),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 汇总指标数值化(便于程序对比)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user