MetaTrader 5 机器学习蓝图(第一部分):数据泄露与时间戳修正·进阶篇
📘

MetaTrader 5 机器学习蓝图(第一部分):数据泄露与时间戳修正·进阶篇

第 2/3 篇

清洗流水线里的空值与重复时间戳处理

从 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,写自动化脚本时这个异常得接住。

MQL5 / C++
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 上验证再上实盘。

MQL5 / C++
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 数动态化后,每根柱的统计量分布会偏移,直接套传统置信区间可能低估尾部风险,建议先在小布里跑一遍分布对比再上参数。

MQL5 / C++
# 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 微秒偏移避免与事件时间撞车。外汇和贵金属波动剧烈,这种边界处理不到位,回测信号可能偏移一根柱,实盘高风险下足以改变入场触发。

MQL5 / C++
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 看分布陡缓,比盯着均线更有用。

MQL5 / C++
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,

常见问题

先按时间戳排序,再用唯一键保留最后一笔或按成交量加权合并,避免后续特征错位。
需对齐时间戳边界并缓存未闭合的临时柱,防止跨周期跳动被截断导致开高低收偏差。
小布可扫描你的特征生成流程,标出使用了未来时间戳或前视统计的环节并给出修正建议。
能分开看常态跳点和极端滑点,调参时只对长右尾做截断,不误杀正常波动。
会,填0会扭曲波动率特征;建议用前向填充或按相邻桶中位数插补再打缺失标记。