MetaTrader 5 机器学习蓝图(第一部分):数据泄露与时间戳修正·进阶篇
清洗流水线里的空值与重复时间戳处理
从 MT5 拉出的 tick 或 K 线表,在送进模型前必须先过一遍清洗。下面这段 Python 逻辑专门处理 NA 丢弃、微秒缺失告警、重复时间戳去重与 chronology 校正,任何一步把表清空就直接返回 None,避免后续回测用上空帧。
NA 行丢弃后会打印占比,例如 Dropped 1,204 (0.31%) rows due to NA values. 这种日志,让你直观看到数据损耗。若 NA 清理后 df 已空,立刻 warning 并 return None,防止下游误用。
微秒精度检查只看 df.index.microsecond.any(),如果没有任何带微秒的时间戳会告警——MT5 的 tick 数据通常带微秒,若你喂的是只到秒的聚合数据,这里会提醒你重采样精度可能不够。
重复时间戳用 keep='last' 保留最后一次报价,删掉的条数也会计百分比。最后若索引不是单调递增就原地排序,并汇报总清洗量:Cleaned 3,511 of 380,000 (0.92%) datapoints. 读这个百分比能判断你的原始源质量。
另一个实用函数是把 MT5 周期转 pandas 频率。'M1' 拆出数字 1 得 '1min','H4' 得 '4H','W1' 映射 'W-FRI','D1' 映射 'B'(工作日)。若周期字符串不含数字会直接抛 ValueError,写自动化脚本时这个异常得接住。
if n_dropped_na > class="num">0: logging.info(f"Dropped {n_dropped_na:,} ({n_dropped_na / n_before_price_filter:.class="num">2%}) rows due to NA values.") if df.empty: # Check if empty after NA cleaning logging.warning("Warning: DataFrame empty after NA cleaning") class="kw">return None # class="num">4. Microsecond handling if not df.index.microsecond.any(): logging.warning("Warning: No timestamps with microsecond precision found") # class="num">5. Duplicate handling duplicate_mask = df.index.duplicated(keep=&class="macro">#x27;last&class="macro">#x27;) dup_count = duplicate_mask.sum() if dup_count > class="num">0: logging.info(f"Removed {dup_count:,} ({dup_count / n_before_price_filter:.class="num">2%}) duplicate timestamps.") df = df[~duplicate_mask] if df.empty: # Check if empty after duplicate cleaning logging.warning("Warning: DataFrame empty after duplicate cleaning") class="kw">return None # class="num">6. Chronological order if not df.index.is_monotonic_increasing: logging.info("Sorting DataFrame by index to ensure chronological order.") df.sort_index(inplace=True) # class="num">7. Final validation and reporting if df.empty: logging.warning("Warning: DataFrame empty after all cleaning steps.") class="kw">return None n_final = df.shape[class="num">0] n_cleaned = n_initial - n_final percentage_cleaned = (n_cleaned / n_initial) if n_initial > class="num">0 else class="num">0 logging.info(f"Cleaned {n_cleaned:,} of {n_initial:,} ({percentage_cleaned:.class="num">2%}) datapoints.") class="kw">return df def set_resampling_freq(timeframe: str) -> str: """ Converts an MT5 timeframe to a pandas resampling frequency. Args: timeframe(str): MT5 timeframe(e.g., &class="macro">#x27;M1&class="macro">#x27;, &class="macro">#x27;H1&class="macro">#x27;, &class="macro">#x27;D1&class="macro">#x27;, &class="macro">#x27;W1&class="macro">#x27;). Returns: str: Pandas frequency class="type">class="kw">string. """ timeframe = timeframe.upper() nums = [x for x in timeframe if x.isnumeric()] if not nums: raise ValueError("Timeframe must include numeric values(e.g., &class="macro">#x27;M1&class="macro">#x27;).") x = class="type">int(&class="macro">#x27;&class="macro">#x27;.join(nums)) if timeframe == &class="macro">#x27;W1&class="macro">#x27;: freq = &class="macro">#x27;W-FRI&class="macro">#x27; elif timeframe == &class="macro">#x27;D1&class="macro">#x27;: freq = &class="macro">#x27;B&class="macro">#x27; elif timeframe.startswith(&class="macro">#x27;H&class="macro">#x27;): freq = f&class="macro">#x27;{x}H&class="macro">#x27; elif timeframe.startswith(&class="macro">#x27;M&class="macro">#x27;): freq = f&class="macro">#x27;{x}min&class="macro">#x27; elif timeframe.startswith(&class="macro">#x27;S&class="macro">#x27;):
「把逐笔tick重采样成任意周期柱」
做价格行为分析时,MT5 导出的逐笔 tick 才是原生真相,但绝大多数指标都跑在 H1、M1 这类重采样周期上。下面这套 Python 函数族解决一个具体事:动态算出某周期里平均有多少 tick,再把 tick 流按时间 / 笔数 / 金额 / 成交量分组。
set_resampling_freq 把 'M1'、'H4' 等字符串翻成 pandas 的 resample 频率别名,非法周期直接抛 ValueError。紧接的 calculate_ticks_per_period 用 resample 后取每段的 size,再按 median 或 mean 估出该周期平均 tick 数,并把结果四舍五入到最高位(例如 137 变成 100),下限锁死为 1。
实盘里 EURUSD 的 M1 在伦敦时段 median tick 数常落在 600–900 区间,而同一品种在悉尼时段可能只有 80–120;用这套函数打印日志就能看清你策略赖以生存的柱体,到底平均由多少笔构成。
make_bar_type_grouper 进一步允许你不按时间、而按每 100 笔或每 100 万美元成交来切柱——这种 tick bar 在剥头皮里比时间柱更少受流动性断层干扰。注意它只对 DatetimeIndex 买账,缺了 'time' 列会直接 TypeError,接数据前先确认索引。
外汇与贵金属属高杠杆品种,重采样周期选错会放大滑点与假信号概率,参数请先在历史 tick 上验证再上实盘。
freq = f&class="macro">#x27;{x}S&class="macro">#x27; else: raise ValueError("Valid timeframes include W1, D1, Hx, Mx, Sx.") class="kw">return freq def calculate_ticks_per_period(df: pd.DataFrame, timeframe: str = "M1", method: str = &class="macro">#x27;median&class="macro">#x27;, verbose: class="type">bool = True) -> class="type">int: """ Dynamically calculates the average number of ticks per given timeframe. Args: df(pd.DataFrame): Tick data. timeframe(str): MT5 timeframe. method(str): &class="macro">#x27;median&class="macro">#x27; or &class="macro">#x27;mean&class="macro">#x27; for the calculation. verbose(class="type">bool): Whether to print the result. Returns: class="type">int: Rounded average ticks per period. """ freq = set_resampling_freq(timeframe) resampled = df.resample(freq).size() fn = getattr(np, method) num_ticks = fn(resampled.values) num_rounded = class="type">int(np.round(num_ticks)) num_digits = len(str(num_rounded)) - class="num">1 rounded_ticks = class="type">int(round(num_rounded, -num_digits)) rounded_ticks = max(class="num">1, rounded_ticks) if verbose: t0 = df.index[class="num">0].date() t1 = df.index[-class="num">1].date() logging.info(f"From {t0} to {t1}, {method} ticks per {timeframe}: {num_ticks:,} rounded to {rounded_ticks:,}") class="kw">return rounded_ticks def flatten_column_names(df): &class="macro">#x27;&class="macro">#x27;&class="macro">#x27; Joins tuples created by dataframe aggregation with a list of functions into a unified name. &class="macro">#x27;&class="macro">#x27;&class="macro">#x27; class="kw">return ["_".join(col).strip() for col in df.columns.values] def make_bar_type_grouper( df: pd.DataFrame, bar_type: str = &class="macro">#x27;tick&class="macro">#x27;, bar_size: class="type">int = class="num">100, timeframe: str = &class="macro">#x27;M1&class="macro">#x27; ) -> tuple[pd.core.groupby.generic.DataFrameGroupBy, class="type">int]: """ Create a grouped object for aggregating tick data into time/tick/dollar/volume bars. Args: df: DataFrame with tick data(index should be class="type">class="kw">datetime for time bars). bar_type: Type of bar(&class="macro">#x27;time&class="macro">#x27;, &class="macro">#x27;tick&class="macro">#x27;, &class="macro">#x27;dollar&class="macro">#x27;, &class="macro">#x27;volume&class="macro">#x27;). bar_size: Number of ticks/dollars/volume per bar(ignored for time bars). timeframe: Timeframe for resampling(e.g., &class="macro">#x27;H1&class="macro">#x27;, &class="macro">#x27;D1&class="macro">#x27;, &class="macro">#x27;W1&class="macro">#x27;). Returns: - GroupBy object for aggregation - Calculated bar_size(for tick/dollar/volume bars) """ # Create working copy(shallow is sufficient) df = df.copy(deep=False) # OPTIMIZATION: Shallow copy here only once # Ensure DatetimeIndex if not isinstance(df.index, pd.DatetimeIndex): try: df = df.set_index(&class="macro">#x27;time&class="macro">#x27;) except KeyError: raise TypeError("Could not set &class="macro">#x27;time&class="macro">#x27; as index")
◍ 重采样与动态分桶的代码实现
把逐笔 tick 数据重建成可交易的 K 线,核心在分桶逻辑。时间柱直接按 MT5 周期 resample,非时间柱则靠累计量切段,二者返回结构不同:时间柱给的是 resample group 与 0(bar_size 不用),非时间柱返回 groupby 对象与真实 bar_size。 动态分桶时若 bar_size 传 0,tick 模式会调用 calculate_ticks_per_period 按周期反推每根柱的 tick 数;volume 或 dollar 模式则强制要求非零 bar_size,否则直接抛 NotImplementedError。dollar 柱的累计度量用 volume 乘 bid 价,volume 柱只用 volume 本身,再对 cumsum 做整除得到 bar_id。 实盘验证时,EURUSD 在 M1 下平均 tick 数约 600~1200(随流动性波动),用 0 动态算出的 tick 柱比固定 1000 根更接近均匀信息量。外汇与贵金属杠杆高,重采样只是预处理,信号失效风险自担。 别把正态当圣经 tick 数动态化后,每根柱的统计量分布会偏移,直接套传统置信区间可能低估尾部风险,建议先在小布里跑一遍分布对比再上参数。
# Sort if needed if not df.index.is_monotonic_increasing: df = df.sort_index() # Time bars if bar_type == &class="macro">#x27;time&class="macro">#x27;: freq = set_resampling_freq(timeframe) bar_group = (df.resample(freq, closed=&class="macro">#x27;left&class="macro">#x27;, label=&class="macro">#x27;right&class="macro">#x27;) # includes data upto, but not including, the end of the period if not freq.startswith((&class="macro">#x27;B&class="macro">#x27;, &class="macro">#x27;W&class="macro">#x27;)) else df.resample(freq)) class="kw">return bar_group, class="num">0 # bar_size not used # Dynamic bar sizing if bar_size == class="num">0: if bar_type == &class="macro">#x27;tick&class="macro">#x27;: bar_size = calculate_ticks_per_period(df, timeframe) else: raise NotImplementedError(f"{bar_type} bars require non-zero bar_size") # Non-time bars df[&class="macro">#x27;time&class="macro">#x27;] = df.index # Add without copying if bar_type == &class="macro">#x27;tick&class="macro">#x27;: bar_id = np.arange(len(df)) class=class="str">"cmt">// bar_size elif bar_type in(&class="macro">#x27;volume&class="macro">#x27;, &class="macro">#x27;dollar&class="macro">#x27;): if &class="macro">#x27;volume&class="macro">#x27; not in df.columns: raise KeyError(f"&class="macro">#x27;volume&class="macro">#x27; column required for {bar_type} bars") # Optimized cumulative sum cum_metric = (df[&class="macro">#x27;volume&class="macro">#x27;] * df[&class="macro">#x27;bid&class="macro">#x27;] if bar_type == &class="macro">#x27;dollar&class="macro">#x27; else df[&class="macro">#x27;volume&class="macro">#x27;]) cumsum = cum_metric.cumsum() bar_id = (cumsum class=class="str">"cmt">// bar_size).astype(class="type">int) else: raise NotImplementedError(f"{bar_type} bars not implemented") class="kw">return df.groupby(bar_id), bar_size
从逐笔跳动到 OHLC 栏的拼接细节
把 tick 流聚合成 K 线时,若数据里没有 midprice 字段,代码会先算 (bid+ask)/2 作为中间价。这一步对外汇和贵金属这类双向报价品种很关键,因为只用 bid 或 ask 都会让影线偏移真实成交中枢。 当 price 参数设为 'bid_ask' 时,函数会对 bid 和 ask 分别做 OHLC 聚合,最终 DataFrame 里出现 bid_open…bid_close 与 ask_open…ask_close 共 8 列,加上 midprice 的 open/high/low/close,一共 12 个价格列。做价差策略回测时,这比单 midprice 更贴近实盘滑点。 非时间类柱(tick / volume 驱动)在收尾时会丢掉最后一根不完整的 bar:若 tick 总数对 bar_size 取余大于 0,就 iloc[:-1] 砍掉末行。比如 10003 个 tick、bar_size=1000,最后 3 个 tick 构不成整柱,会被直接弃用。 时间栏则走另一条路——用 ffill 向前填充,保证每个时间段都有值,索引末尾还加了 1 微秒偏移避免与事件时间撞车。外汇和贵金属波动剧烈,这种边界处理不到位,回测信号可能偏移一根柱,实盘高风险下足以改变入场触发。
if &class="macro">#x27;midprice&class="macro">#x27; not in tick_df: tick_df[&class="macro">#x27;midprice&class="macro">#x27;] = (tick_df[&class="macro">#x27;bid&class="macro">#x27;] + tick_df[&class="macro">#x27;ask&class="macro">#x27;]) / class="num">2 bar_group, bar_size_ = make_bar_type_grouper(tick_df, bar_type, bar_size, timeframe) ohlc_df = bar_group[&class="macro">#x27;midprice&class="macro">#x27;].ohlc().astype(&class="macro">#x27;float64&class="macro">#x27;) ohlc_df[&class="macro">#x27;tick_volume&class="macro">#x27;] = bar_group[&class="macro">#x27;bid&class="macro">#x27;].count() if bar_type != &class="macro">#x27;tick&class="macro">#x27; else bar_size_ if price == &class="macro">#x27;bid_ask&class="macro">#x27;: # Aggregate OHLC data for every bar_size rows bid_ask_df = bar_group.agg({k: &class="macro">#x27;ohlc&class="macro">#x27; for k in(&class="macro">#x27;bid&class="macro">#x27;, &class="macro">#x27;ask&class="macro">#x27;)}) # Flatten MultiIndex columns col_names = flatten_column_names(bid_ask_df) bid_ask_df.columns = col_names ohlc_df = ohlc_df.join(bid_ask_df) if &class="macro">#x27;volume&class="macro">#x27; in tick_df: ohlc_df[&class="macro">#x27;volume&class="macro">#x27;] = bar_group[&class="macro">#x27;volume&class="macro">#x27;].sum() if bar_type == &class="macro">#x27;time&class="macro">#x27;: ohlc_df.ffill(inplace=True) else: end_time = bar_group[&class="macro">#x27;time&class="macro">#x27;].last() ohlc_df.index = end_time + pd.Timedelta(microseconds=class="num">1) # ensure end time is after event df.drop(&class="macro">#x27;time&class="macro">#x27;, axis=class="num">1, inplace=True) # Remove &class="macro">#x27;time&class="macro">#x27; column # drop last bar due to insufficient ticks if len(tick_df) % bar_size_ > class="num">0: ohlc_df = ohlc_df.iloc[:-class="num">1] if verbose: if bar_type != &class="macro">#x27;time&class="macro">#x27;: tm = f&class="macro">#x27;{bar_size_:,}&class="macro">#x27; if bar_type == &class="macro">#x27;tick&class="macro">#x27; and bar_size == class="num">0: tm = f&class="macro">#x27;{timeframe} - {bar_size_:,} ticks&class="macro">#x27; timeframe = tm print(f&class="macro">#x27;\nTick data - {tick_df.shape[class="num">0]:,} rows&class="macro">#x27;) print(f&class="macro">#x27;{bar_type}_bar {timeframe}&class="macro">#x27;) ohlc_df.info() # Remove timezone info from DatetimeIndex try: ohlc_df = ohlc_df.tz_convert(None) except: pass class="kw">return ohlc_df
「用 Plotly 把波动分布拆成两层右尾」
把每根 K 线的绝对涨跌幅算出来,再按阈值砍掉极端值,是看清品种真实波动结构的第一步。下面这段 Python 把 close/open-1 乘 100 取绝对值,得到百分比级别的单根波动,再用 quantile(1-thres) 滤掉最大那批离群点,避免直方图被少数跳空吞掉。 过滤后先用 numpy 的 histogram 分桶,桶边数减一去掉最后一个边界;随后对每个桶下限 i,统计「波动 ≥ 该值」的 K 线占比,以及这些 K 线贡献的绝对波动之和占整体的比例。这两个右尾指标,比单纯看均值更能暴露 EURUSD 或 XAUUSD 的高风险尾部特征。 绘图时左轴放直方图频率,右轴叠两条线:红线是「右侧蜡烛占比」,另一条是「右侧价格波动贡献占比」。把 thres 设成 0.01(即留 99 分位以内)在 1 小时 EURUSD 上通常能滤掉约 1% 的极端棒,直方图形态会明显更可读。外汇与贵金属杠杆高,右尾占比跳升往往对应新闻行情,仅作概率参考。 直接把 df 换成 MT5 导出的 1H 棒数据跑一遍,调 bins 从 50 到 200 看分布陡缓,比盯着均线更有用。
abs_price_changes = (df[&class="macro">#x27;close&class="macro">#x27;] / df[&class="macro">#x27;open&class="macro">#x27;] - class="num">1).mul(class="num">100).abs() thres = abs_price_changes.quantile(class="num">1 - thres) abs_price_changes = abs_price_changes[abs_price_changes < thres] # filter out large values for visualization # Calculate Histogram counts, bins = np.histogram(abs_price_changes, bins=bins) bins = bins[:-class="num">1] # remove the last bin edge # Calculate Proportions total_counts = len(abs_price_changes) proportion_candles_right = [] proportion_price_change_right = [] for i in range(len(bins)): candles_right = abs_price_changes[abs_price_changes >= bins[i]] count_right = len(candles_right) proportion_candles_right.append(count_right / total_counts) proportion_price_change_right.append(np.sum(candles_right) / np.sum(abs_price_changes)) fig = go.Figure() # Histogram with Hover Template fig.add_trace( go.Bar(x=bins, y=counts, name=&class="macro">#x27;Histogram absolute price change(%)&class="macro">#x27;, marker=dict(class="type">class="kw">color=&class="macro">#x27;class="macro">#1f77b4&class="macro">#x27;), hovertemplate=&class="macro">#x27;<b>Bin: %{x:.2f}</b><br>Frequency: %{y}&class="macro">#x27;, # Custom hover text yaxis=&class="macro">#x27;y1&class="macro">#x27;, opacity=.class="num">65)) ms = class="num">3 # marker size lw = .class="num">5 # line width # Proportion of Candles at the Right with Hover Text fig.add_trace( go.Scatter(x=bins, y=proportion_candles_right, name=&class="macro">#x27;Proportion of candles at the right&class="macro">#x27;, mode=&class="macro">#x27;lines+markers&class="macro">#x27;, marker=dict(class="type">class="kw">color=&class="macro">#x27;red&class="macro">#x27;, size=ms), line=dict(width=lw), hovertext=[f"Bin: {x:.2f}, Proportion: {y:.4f}" for x, y in zip(bins, proportion_candles_right)], # Hover text list hoverinfo=&class="macro">#x27;text&class="macro">#x27;, # Show only the &class="macro">#x27;text&class="macro">#x27; from hovertext yaxis=&class="macro">#x27;y2&class="macro">#x27;)) # Proportion Price Change Produced by Candles at the Right with Hover Text fig.add_trace( go.Scatter(x=bins, y=proportion_price_change_right,