3. 单笔成交金额与方向符号化特征构造

任务定位:从“价格×数量”到“带方向的金额流”

逐笔成交原始表里通常只有价格、数量、方向三个字段。单笔成交金额本身是标量,但特征工程真正需要的是带符号的金额流:把买方发起的成交记为正、卖方发起的成交记为负,这样后续按窗口求和时,正负自然抵消,直接得到“净买入金额”这类有经济含义的量。本节的实现重点有三处:金额计算的精度处理、方向字段到符号的稳健映射、以及符号化后序列的可验证性。

方向字段的取值约定与符号映射

不同数据源的买卖方向编码不一致,常见有三类:字符串型(B/S、BUY/SELL)、数值型(1/-1 或 1/2)、以及交易所侧的主动方标记(bid/ask 表示买方主动/卖方主动)。映射前必须先确认约定,否则符号整体翻转会让所有聚合特征方向相反。下面给出一个显式映射表,把“未知方向”单独留成一个分支,避免静默错标。

原始取值示例含义符号 sign处理策略
B / BUY / bid / 1买方主动成交+1正常映射
S / SELL / ask / -1卖方主动成交-1正常映射
空值 / 0 / 其它方向缺失或未知0标记为待核查,不参与净额计算

金额计算:定点整数化避免浮点漂移

价格常以小数出现(如 12.35),数量为整数股。若直接用浮点相乘再累加,长序列求和会出现末位漂移,导致净额与人工核对差几分钱。稳妥做法是把价格换算成“最小价格单位的整数”(例如价格乘以 100 得到分),金额 = 价格整数 × 数量,得到以“分”为单位的整数金额,聚合完成后再除以 100 还原。这样整条链路都是整数运算,结果可精确复现。

import pandas as pd

# 假设已清洗的逐笔成交表 trades,字段:trade_id, price, volume, side
# price 为浮点价格,volume 为整数股数,side 为原始方向字符串

PRICE_SCALE = 100  # 价格放大倍数,按最小报价单位设定

def build_signed_amount(trades: pd.DataFrame) -> pd.DataFrame:
    df = trades.copy()

    # 1) 价格定点整数化:四舍五入到最小价格单位,避免 12.35*100=1234.9999
    df["price_int"] = (df["price"] * PRICE_SCALE).round().astype("int64")

    # 2) 单笔成交金额(单位:分),整数运算
    df["amount_cents"] = df["price_int"] * df["volume"].astype("int64")

    # 3) 方向到符号的显式映射,未知方向记 0
    side_map = {
        "B": 1, "BUY": 1, "bid": 1, "1": 1,
        "S": -1, "SELL": -1, "ask": -1, "-1": -1,
    }
    df["sign"] = df["side"].astype(str).str.strip().map(side_map).fillna(0).astype("int8")

    # 4) 带符号金额序列
    df["signed_amount_cents"] = df["amount_cents"] * df["sign"]

    # 5) 还原为元,保留两位小数用于展示
    df["amount"] = df["amount_cents"] / PRICE_SCALE
    df["signed_amount"] = df["signed_amount_cents"] / PRICE_SCALE
    return df

注意第 1 步的 round() 必须在乘完 PRICE_SCALE 之后调用,而不是先 round 价格再乘,否则 12.345 这类三位小数价格会被提前截断。若数据源价格本身已是整数(如以分为单位存储),则跳过放大步骤,直接使用原值。

符号化序列的结构与校验

下图展示单笔记录从原始三字段到带符号金额序列的字段演化,以及三条必须通过的校验线:金额非负性、符号取值域、以及净额与买卖分项之和的一致性。

原始逐笔字段 price 12.35 volume 800 side "B" (一行 = 一笔成交) 定点计算 price_int = 1235 amount_cents = 1235×800 = 988000 sign = +1(B→买) 带符号金额序列 amount = 9880.00 元 signed_amount = +9880.00 sign ∈ {+1, -1, 0} 供窗口聚合直接求和 三条校验线 ① 金额非负性 amount_cents ≥ 0 全部成立 负值说明价格或数量异常 ② 符号取值域 sign 只允许 +1 / -1 / 0 0 的笔数占比需单独统计 ③ 净额一致性 Σsigned = Σ买 − Σ卖 与分项求和结果必须相等

用一段可运行的校验代码收口

构造完成后不要直接进入聚合,先跑一遍断言。下面的校验函数覆盖上图的三个检查点,并额外输出未知方向占比,便于判断数据质量是否足以支撑后续特征。

def validate_signed_amount(df: pd.DataFrame) -> dict:
    report = {}

    # ① 金额非负性
    neg = (df["amount_cents"] < 0).sum()
    report["negative_amount_rows"] = int(neg)
    assert neg == 0, f"存在 {neg} 笔负金额,检查价格/数量字段"

    # ② 符号取值域
    bad_sign = (~df["sign"].isin([1, -1, 0])).sum()
    report["invalid_sign_rows"] = int(bad_sign)
    assert bad_sign == 0, "sign 出现非法取值"

    unknown_ratio = (df["sign"] == 0).mean()
    report["unknown_side_ratio"] = round(float(unknown_ratio), 4)

    # ③ 净额一致性:带符号求和 == 买方金额和 - 卖方金额和
    net = df["signed_amount_cents"].sum()
    buy = df.loc[df["sign"] == 1, "amount_cents"].sum()
    sell = df.loc[df["sign"] == -1, "amount_cents"].sum()
    report["net_cents"] = int(net)
    report["buy_minus_sell_cents"] = int(buy - sell)
    assert net == buy - sell, "净额与买卖分项不一致"

    return report

# 示例调用
# result = build_signed_amount(trades)
# print(validate_signed_amount(result))
若 unknown_side_ratio 超过 1%,不要急着丢弃这些行。先回查原始方向字段的取值分布,很可能是映射表漏了某个编码(例如 "N" 表示中性盘),补全映射比删数据更合适。

结果判读与常见偏差

  • 净额符号整体反了:几乎总是方向映射约定搞反。用一小段已知买卖的样本手工核对,确认 B 对应买方主动后,再检查是否把交易所的 bid/ask 理解成了报价方而非主动方。
  • 金额末位与人工核对差 0.01:检查是否在乘 PRICE_SCALE 之前就 round 了价格,或聚合时混用了浮点与整数两条路径。
  • 未知方向占比异常高:方向字段可能带空格或大小写不一致,映射前统一 strip() 并转大写可消除大部分问题。
  • 净额一致性断言失败:通常是 sign == 0 的行被计入了某一侧,或 amount_cents 在符号化后被原地修改。保持 amount_cents 为不可变基准列即可避免。

完成本节后,signed_amount_cents 就是后续窗口聚合的直接输入:按时间窗口对它求和得到净买入金额,取绝对值求和得到总成交额,按符号分组求和得到买卖分项。符号约定一旦在此固定,下游所有特征的方向含义就都稳定了。