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 这类三位小数价格会被提前截断。若数据源价格本身已是整数(如以分为单位存储),则跳过放大步骤,直接使用原值。
符号化序列的结构与校验
下图展示单笔记录从原始三字段到带符号金额序列的字段演化,以及三条必须通过的校验线:金额非负性、符号取值域、以及净额与买卖分项之和的一致性。
用一段可运行的校验代码收口
构造完成后不要直接进入聚合,先跑一遍断言。下面的校验函数覆盖上图的三个检查点,并额外输出未知方向占比,便于判断数据质量是否足以支撑后续特征。
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 就是后续窗口聚合的直接输入:按时间窗口对它求和得到净买入金额,取绝对值求和得到总成交额,按符号分组求和得到买卖分项。符号约定一旦在此固定,下游所有特征的方向含义就都稳定了。