341 lines
17 KiB
Python
341 lines
17 KiB
Python
# tests/factor/test_fundamental_p1_adapter.py
|
||
"""P1 批财务因子适配层: 35 因子原料列的数值/守卫/PIT 契约.
|
||
|
||
口径锚(docs/fundamental_factor_survey_20260907.md §1.3 + P1 任务书):
|
||
- EBIT = TOTAL_PROFIT + FE_INTEREST_EXPENSE(利息未披露按 0)
|
||
- DA = 四件折旧摊销之和(缺列/缺值按 0)
|
||
- τ = INCOME_TAX/TOTAL_PROFIT(TTM)截断 [0,0.5],缺失/TP≤0 → 0.25
|
||
- IBD 含 LEASE_LIAB(钉死版);VSIG 16 季 sample std(ddof=1)
|
||
- F07 锚 = max(实际披露日, 预告公告日)
|
||
|
||
报告期索引(24 期合成史): 2023Q1=i20 / 2023H1=i21 / 2023Q3=i22 / 2023Q4=i23;
|
||
2022Q4=i19 / 2022H1=i17 / 2021Q4=i15 / 2019Q4=i15 前推。
|
||
"""
|
||
import statistics
|
||
import sys, os
|
||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")))
|
||
|
||
import pytest
|
||
|
||
from sanguo_factor.fundamental_adapter import build_fundamental_features
|
||
|
||
A, B, C = "600000.SSE", "000001.SZSE", "300001.SZSE"
|
||
BANK = "601398.SSE"
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def feat(synthetic_static):
|
||
return build_fundamental_features(
|
||
[A, B, C], "2023-01-01", "2023-12-31", data_dir=synthetic_static)
|
||
|
||
|
||
def _dt(day: str):
|
||
import datetime as _d
|
||
return _d.datetime.strptime(day, "%Y-%m-%d")
|
||
|
||
|
||
def val(df, vt: str, day: str, col: str):
|
||
row = df.filter((df["vt_symbol"] == vt) & (df["datetime"] == _dt(day)))
|
||
assert row.height == 1, f"grid 缺行 {vt} {day}"
|
||
v = row[col][0]
|
||
return None if v is None else float(v)
|
||
|
||
|
||
# ---------- A 盈利能力 P1(7) ----------
|
||
|
||
def test_a_family_values_at_h1(feat):
|
||
# 2023H1(i=21, 披露 2023-08-29): NP_TTM=60 REV_TTM=600 TA=1450 EQ=680
|
||
# INT_TTM=0.2×60=12 TP_TTM=66 → EBIT=78; DA_TTM=60; τ=0.25; MON=240 IBD=109
|
||
assert val(feat, A, "2023-08-29", "ebit_over_assets") == pytest.approx(78 / 1450)
|
||
assert val(feat, A, "2023-08-29", "roa_pretax") == pytest.approx((60 + 12 * 0.75) / 1450)
|
||
assert val(feat, A, "2023-08-29", "ebitda_margin") == pytest.approx(138 / 600)
|
||
assert val(feat, A, "2023-08-29", "roic") == pytest.approx(78 * 0.75 / (680 + 109 - 240))
|
||
assert val(feat, A, "2023-08-29", "roe_avg") == pytest.approx(60 / 640) # mean(680,600)
|
||
assert val(feat, A, "2023-08-29", "rd_intensity") == pytest.approx(0.05)
|
||
assert val(feat, A, "2023-08-29", "sale_expense_ratio") == pytest.approx(0.08)
|
||
assert val(feat, A, "2023-08-29", "interest_cover") == pytest.approx(78 / 12)
|
||
assert val(feat, A, "2023-08-29", "ebit_ttm") == pytest.approx(78.0)
|
||
assert val(feat, A, "2023-08-29", "ebitda_ttm") == pytest.approx(138.0)
|
||
# scale 不变性: C(scale=2) EBIT 翻倍、比率不变
|
||
assert val(feat, C, "2023-08-29", "ebit_ttm") == pytest.approx(156.0)
|
||
assert val(feat, C, "2023-08-29", "roic") == pytest.approx(78 * 0.75 / (680 + 109 - 240))
|
||
|
||
|
||
def test_a_family_pit_boundary(feat):
|
||
# 2023-08-28 仍见 2023Q1(i=20): TTM=q(17..20)=60 → EBIT=78, TA=1400
|
||
assert val(feat, A, "2023-08-28", "ebit_over_assets") == pytest.approx(78 / 1400)
|
||
assert val(feat, A, "2023-08-29", "ebit_over_assets") == pytest.approx(78 / 1450)
|
||
|
||
|
||
def test_a_family_guards(feat):
|
||
# B 缺 2022Q1(i=16) → 2023Q1(i=20) 平均净资产基期不连续 → roe_avg NaN
|
||
assert val(feat, B, "2023-04-28", "roe_avg") is None
|
||
# 滑出坏点后恢复: 2023H1 基期 = 2022H1(i=17) 行存在且恰隔 4 季
|
||
assert val(feat, B, "2023-08-29", "roe_avg") == pytest.approx(60 / 640)
|
||
|
||
|
||
# ---------- B 盈利质量 P1(8) ----------
|
||
|
||
def test_b_family_values_at_h1(feat):
|
||
assert val(feat, A, "2023-08-29", "cash_ibd_product") == pytest.approx((240 / 1450) * (109 / 1450))
|
||
assert val(feat, A, "2023-08-29", "da_intensity") == pytest.approx(0.10)
|
||
assert val(feat, A, "2023-08-29", "gm_nm_scissors") == pytest.approx(0.4 - 0.1, abs=1e-9)
|
||
# 存货异常 = 存货期末同比 − REV_TTM 同比(INV: 245/225; REV_TTM: 600/560)
|
||
assert val(feat, A, "2023-08-29", "inventory_anomaly") == pytest.approx(
|
||
245 / 225 - 600 / 560)
|
||
# 持续盈利: A 全史单季 NP>0 → 截断 8
|
||
assert val(feat, A, "2023-08-29", "profit_streak") == pytest.approx(8.0)
|
||
|
||
|
||
def test_b_family_pit_boundary(feat):
|
||
# 2023-08-28 仍见 2023Q1(i=20): INV 240/220 − REV_TTM 600/540
|
||
assert val(feat, A, "2023-08-28", "inventory_anomaly") == pytest.approx(
|
||
240 / 220 - 600 / 540)
|
||
|
||
|
||
def test_b_family_profit_streak_recount_after_gap(feat):
|
||
# B: i=16 行缺失且 i=17 单季 NaN(两个断流点)→ 2023H1 连续计数 = 4 季
|
||
assert val(feat, B, "2023-08-29", "profit_streak") == pytest.approx(4.0)
|
||
# 2023Q3(i=22) 再 +1 → 5
|
||
assert val(feat, B, "2023-10-27", "profit_streak") == pytest.approx(5.0)
|
||
|
||
|
||
def test_vsig_three_variants(feat):
|
||
# 独立重算 16 季窗(i=6..21): series = 单季NP/TA, sample std(ddof=1)
|
||
np_q = [7, 10, 11, 12, 8, 11, 12, 13, 9, 13, 14, 15, 10, 12, 15, 16,
|
||
11, 14, 15, 18, 13, 14, 17, 18]
|
||
ta = lambda i: 1000 + 50 * max(i - 12, 0)
|
||
series = [np_q[i] / ta(i) for i in range(6, 22)]
|
||
expect = statistics.stdev(series)
|
||
assert val(feat, A, "2023-08-29", "vsig") == pytest.approx(expect)
|
||
# CFO = 1.2×NP / 应计 = −0.2×NP → 线性变换下 std 同比例(口径自洽性)
|
||
assert val(feat, A, "2023-08-29", "vsig_cfo") == pytest.approx(1.2 * expect)
|
||
assert val(feat, A, "2023-08-29", "vsig_acc") == pytest.approx(0.2 * expect)
|
||
|
||
|
||
def test_vsig_insufficient_history_nan(synthetic_static, np_q_series):
|
||
# 2020Q3(i=10) 报告期: 16 季窗需 i=-5..10 → 不足 → NaN
|
||
df = build_fundamental_features([A], "2020-10-20", "2020-11-05", data_dir=synthetic_static)
|
||
assert val(df, A, "2020-10-27", "vsig") is None
|
||
# 首个可算窗 = 2019Q4(i=15, 窗 i=0..15),2022-04-25 可见
|
||
# (TA 自 i=13 起线性抬升,series = q/TA 非恒定分母)
|
||
df2 = build_fundamental_features([A], "2022-04-20", "2022-05-05", data_dir=synthetic_static)
|
||
assert val(df2, A, "2022-04-24", "vsig") is None
|
||
assert val(df2, A, "2022-04-25", "vsig") == pytest.approx(
|
||
statistics.stdev(
|
||
[float(np_q_series[i]) / (1000 + 50 * max(i - 12, 0)) for i in range(0, 16)]))
|
||
|
||
|
||
def test_vsig_null_in_window_nan(feat):
|
||
# B 窗 i=6..21 含 i=17 单季 NaN → vsig NaN(窗内缺失不填 0)
|
||
assert val(feat, B, "2023-08-29", "vsig") is None
|
||
assert val(feat, B, "2023-08-29", "vsig_acc") is None
|
||
assert val(feat, B, "2023-08-29", "vsig_cfo") is None
|
||
|
||
|
||
# ---------- C 成长 P1(8) ----------
|
||
|
||
def test_c_family_acceleration(feat):
|
||
# np_accel(2023H1) = yoy(i21) − yoy(i17) = 0 − (14/12−1)
|
||
assert val(feat, A, "2023-08-29", "np_accel") == pytest.approx(-(14 / 12 - 1))
|
||
assert val(feat, A, "2023-08-29", "rev_accel") == pytest.approx(-(140 / 120 - 1))
|
||
# nm_delta = 0.1 − 0.1(合成史 NM 恒 0.1;管线+守卫仍被锁定)
|
||
assert val(feat, A, "2023-08-29", "nm_delta") == pytest.approx(0.0, abs=1e-9)
|
||
|
||
|
||
def test_c_family_pit_boundary(synthetic_static):
|
||
# 2023-04-24 仍见 2022Q3(i=18): accel = (15/15−1)−(15/14−1)
|
||
df = build_fundamental_features([A], "2023-04-20", "2023-04-30", data_dir=synthetic_static)
|
||
assert val(df, A, "2023-04-24", "np_accel") == pytest.approx(0.0 - (15 / 14 - 1))
|
||
# 2023-04-25 起 2022Q4(i=19): accel = (18/16−1)−(16/15−1)
|
||
assert val(df, A, "2023-04-25", "np_accel") == pytest.approx((18 / 16 - 1) - (16 / 15 - 1))
|
||
|
||
|
||
def test_c_family_growth_guards(feat):
|
||
# B 基期(i=17) 单季 NaN → yoy NaN → 加速度二次差分 NaN 传播
|
||
assert val(feat, B, "2023-08-29", "np_accel") is None
|
||
# B 2023H1 的 NM_TTM 基期(i=17) TTM 含 NaN → nm_delta NaN
|
||
assert val(feat, B, "2023-08-29", "nm_delta") is None
|
||
# invest_growth 仅年报行: 2023H1(非年报) → NaN
|
||
assert val(feat, A, "2023-08-29", "invest_growth") is None
|
||
|
||
|
||
def test_c_family_stock_growth(feat):
|
||
# nwc = (存货+应收): 2023H1 435/2022H1 375 − 1
|
||
assert val(feat, A, "2023-08-29", "nwc_growth") == pytest.approx(435 / 375 - 1)
|
||
# equity_growth = 680/600 − 1;goodwill 恒 50 → 0
|
||
assert val(feat, A, "2023-08-29", "equity_growth") == pytest.approx(680 / 600 - 1)
|
||
assert val(feat, A, "2023-08-29", "goodwill_growth") == pytest.approx(0.0, abs=1e-9)
|
||
|
||
|
||
def test_c_family_pit_equity_growth(feat):
|
||
# 2023-08-28 见 2023Q1(i=20): 660/580−1;08-29 起换 2023H1: 680/600−1
|
||
assert val(feat, A, "2023-08-28", "equity_growth") == pytest.approx(660 / 580 - 1)
|
||
assert val(feat, A, "2023-08-29", "equity_growth") == pytest.approx(680 / 600 - 1)
|
||
|
||
|
||
def test_cagr5_and_annual_factors(synthetic_static):
|
||
"""5 年 CAGR 首个可算点 = 2023 年报(基期 2018 年报),2024-04-25 可见."""
|
||
df = build_fundamental_features([A], "2024-04-20", "2024-04-30", data_dir=synthetic_static)
|
||
assert val(df, A, "2024-04-24", "rev_cagr5") is None
|
||
assert val(df, A, "2024-04-24", "np_cagr5") is None
|
||
# 2023 年报 REV=620 NP=62;2018 年报 REV=400 NP=40
|
||
assert val(df, A, "2024-04-25", "rev_cagr5") == pytest.approx((620 / 400) ** 0.2 - 1)
|
||
assert val(df, A, "2024-04-25", "np_cagr5") == pytest.approx((62 / 40) ** 0.2 - 1)
|
||
# 投资增速(年度口径) = 2023 年 capex / 2022 年 capex − 1(capex=0.15×年报REV)
|
||
assert val(df, A, "2024-04-25", "invest_growth") == pytest.approx(620 / 580 - 1)
|
||
# 2022 年报(i=19) 投资增速 2023-04-25 已可见 = 580/530−1(2021 年报 530)
|
||
df2 = build_fundamental_features([A], "2023-04-20", "2023-04-30", data_dir=synthetic_static)
|
||
assert val(df2, A, "2023-04-25", "invest_growth") == pytest.approx(580 / 530 - 1)
|
||
|
||
|
||
# ---------- D 估值 P1(6 的原料列) ----------
|
||
|
||
def test_d_family_raw_materials(feat):
|
||
assert val(feat, A, "2023-08-29", "gp_ttm") == pytest.approx(240.0)
|
||
assert val(feat, A, "2023-08-29", "fcf_ttm") == pytest.approx(72 - 90)
|
||
assert val(feat, A, "2023-08-29", "debt_issue_ttm") == pytest.approx(60.0)
|
||
# EV 外生部分 = IBD − MON = 109 − 240 = −131(表达式层再 + close×share_capital)
|
||
assert val(feat, A, "2023-08-29", "ev_ex_mv") == pytest.approx(-131.0)
|
||
|
||
|
||
def test_d_family_pit_boundary(feat):
|
||
# 2023-08-28 见 2023Q1(i=20): IBD=108 MON=230 → −122;08-29 起 −131
|
||
assert val(feat, A, "2023-08-28", "ev_ex_mv") == pytest.approx(-122.0)
|
||
assert val(feat, A, "2023-08-29", "ev_ex_mv") == pytest.approx(-131.0)
|
||
|
||
|
||
# ---------- F 预期事件 P1(3 + 严窗变体) ----------
|
||
|
||
def test_sue_eps_foster(synthetic_static):
|
||
"""SUE(EPS): EPS 累计 = 年内 NP 累计/当期股本(2023 起 110 股)."""
|
||
np_q = [7, 10, 11, 12, 8, 11, 12, 13, 9, 13, 14, 15, 10, 12, 15, 16,
|
||
11, 14, 15, 18, 13, 14, 17, 18]
|
||
sc = lambda i: 100 if i < 20 else 110
|
||
cum = lambda i: float(sum(np_q[(i // 4) * 4:i + 1]))
|
||
eps_c = [cum(i) / sc(i) for i in range(24)]
|
||
q_eps = [eps_c[0]] + [eps_c[i] - eps_c[i - 1] if (i % 4) else eps_c[i]
|
||
for i in range(1, 24)] # Q1 直接取累计
|
||
diff4 = [q_eps[i] - q_eps[i - 4] for i in range(4, 24)]
|
||
df = build_fundamental_features([A], "2023-10-25", "2023-10-28", data_dir=synthetic_static)
|
||
# 2023Q3(i=22, 披露 10-27): 窗 diff4[11..18]
|
||
assert val(df, A, "2023-10-27", "sue_eps") == pytest.approx(
|
||
diff4[18] / statistics.stdev(diff4[11:19]))
|
||
assert val(df, A, "2023-10-26", "sue_eps") is None or \
|
||
val(df, A, "2023-10-26", "sue_eps") == pytest.approx(
|
||
diff4[17] / statistics.stdev(diff4[10:18]))
|
||
|
||
|
||
def test_sue_np_strict_window_excludes_current(feat, np_q_series):
|
||
"""严窗变体: σ 只用 t−1 及更早差分(shift(1) 后滚 8 期)."""
|
||
q = [float(x) for x in np_q_series]
|
||
diff4 = [q[i] - q[i - 4] for i in range(4, 24)]
|
||
# 2023Q3(i=22, r=18): σ 窗 = diff4[10..17](不含当期)
|
||
expect = diff4[18] / statistics.stdev(diff4[10:18])
|
||
assert val(feat, A, "2023-10-27", "sue_np_strict") == pytest.approx(expect)
|
||
# 与标准 SUE 数值不同(σ 窗不同)且均非空
|
||
std = val(feat, A, "2023-10-27", "sue_np")
|
||
assert std is not None and std != pytest.approx(expect)
|
||
|
||
|
||
def test_disclosure_speed(feat):
|
||
# A 2023H1: 披露 08-29 − 报告期 06-30 = 60 天 → −60(早披露=高分)
|
||
assert val(feat, A, "2023-08-28", "disclosure_speed") == pytest.approx(-28.0)
|
||
assert val(feat, A, "2023-08-29", "disclosure_speed") == pytest.approx(-60.0)
|
||
# 年报 2022Q4: 2023-04-25 − 2022-12-31 = 115 天
|
||
assert val(feat, A, "2023-04-25", "disclosure_speed") == pytest.approx(-115.0)
|
||
|
||
|
||
def test_forecast_np_annualized(feat):
|
||
# A: 2022 年报预告(公告 2023-05-10,中值 55)年化系数 = 年报×1 → 55
|
||
assert val(feat, A, "2023-05-10", "forecast_np_annualized") == pytest.approx(55.0)
|
||
# 2023H1 预告(公告 07-15)覆盖: 中值 30 × 2 = 60
|
||
assert val(feat, A, "2023-07-14", "forecast_np_annualized") == pytest.approx(55.0)
|
||
assert val(feat, A, "2023-07-15", "forecast_np_annualized") == pytest.approx(60.0)
|
||
assert val(feat, B, "2023-07-20", "forecast_np_annualized") == pytest.approx(80.0)
|
||
# C 只有营业收入行 → 年化预告净利不产出(收入中值不作净利)
|
||
assert val(feat, C, "2023-07-10", "forecast_np_annualized") is None
|
||
# Q3 预告(公告 10-15): 中值 65 × 4/3
|
||
assert val(feat, A, "2023-10-15", "forecast_np_annualized") == pytest.approx(65 * 4 / 3)
|
||
|
||
|
||
def test_forecast_beat_pit_anchor(feat):
|
||
# 2022Q4 配对先可见: 实际 NP 58 vs 中值 55(锚 05-10)→ 3/55
|
||
assert val(feat, A, "2023-08-28", "forecast_beat") == pytest.approx(3 / 55)
|
||
# 2023H1 配对: 实际 NP 27 vs 中值 30 → beat = −0.1,锚 = max(08-29, 07-15) = 08-29
|
||
assert val(feat, A, "2023-08-29", "forecast_beat") == pytest.approx(-0.1)
|
||
assert val(feat, B, "2023-08-29", "forecast_beat") == pytest.approx((27 - 40) / 40)
|
||
# 2023Q3 配对: 实际 44 vs 中值 65,锚 = max(10-27, 10-15) = 10-27
|
||
assert val(feat, A, "2023-10-26", "forecast_beat") == pytest.approx(-0.1)
|
||
assert val(feat, A, "2023-10-27", "forecast_beat") == pytest.approx((44 - 65) / 65)
|
||
|
||
|
||
def test_forecast_beat_late_forecast_anchor(synthetic_static):
|
||
"""迟到预告: 2022 年报披露 04-25,预告公告 05-10 晚于披露 → 锚 = 05-10
|
||
(不早于两者较晚者;若锚错取披露日则 04-25 即可见 → 测试即红)."""
|
||
df = build_fundamental_features([A], "2023-04-20", "2023-05-15", data_dir=synthetic_static)
|
||
assert val(df, A, "2023-05-09", "forecast_beat") is None
|
||
assert val(df, A, "2023-05-10", "forecast_beat") == pytest.approx((58 - 55) / 55)
|
||
|
||
|
||
# ---------- 北交所预告映射(随批互评 1) ----------
|
||
|
||
def test_bj_forecast_exchange_mapping(synthetic_static):
|
||
"""92/43 前缀 → .BJSE(不再落入 SZSE);北交股无三表 → 报表特征全 null."""
|
||
df = build_fundamental_features(
|
||
["920001.BJSE", "430047.BJSE"], "2023-07-14", "2023-07-21",
|
||
data_dir=synthetic_static)
|
||
assert val(df, "920001.BJSE", "2023-07-17", "forecast_type_score") is None
|
||
assert val(df, "920001.BJSE", "2023-07-18", "forecast_type_score") == 3.0
|
||
assert val(df, "920001.BJSE", "2023-07-18", "forecast_change_pct") == pytest.approx(20.0)
|
||
assert val(df, "920001.BJSE", "2023-07-18", "forecast_np_annualized") == pytest.approx(100.0)
|
||
assert val(df, "430047.BJSE", "2023-07-19", "forecast_type_score") == 3.0
|
||
assert val(df, "430047.BJSE", "2023-07-19", "forecast_np_annualized") == pytest.approx(40.0)
|
||
# 三表侧: BJSE 无文件映射 → 报表特征 null,不炸
|
||
assert val(df, "920001.BJSE", "2023-07-18", "equity") is None
|
||
|
||
|
||
# ---------- 金融股红线(§7 红线 5) ----------
|
||
|
||
def test_financial_stock_family_gating(synthetic_static):
|
||
"""银行模板(OPERATE_COST 缺失): 盈利质量/成长/费用类 NaN,盈利能力/估值保留."""
|
||
df = build_fundamental_features([BANK], "2023-08-25", "2023-09-02", data_dir=synthetic_static)
|
||
assert val(df, BANK, "2023-08-29", "roe_ttm") == pytest.approx(60 / 680)
|
||
assert val(df, BANK, "2023-08-29", "ebit_over_assets") == pytest.approx(78 / 1450)
|
||
assert val(df, BANK, "2023-08-29", "interest_cover") == pytest.approx(6.5)
|
||
for col in ("rd_intensity", "sale_expense_ratio", "inventory_anomaly",
|
||
"cash_ibd_product", "vsig", "vsig_acc", "vsig_cfo", "da_intensity",
|
||
"gm_nm_scissors", "profit_streak", "np_accel", "rev_accel",
|
||
"nm_delta", "rev_cagr5", "np_cagr5", "nwc_growth",
|
||
"invest_growth", "equity_growth"):
|
||
assert val(df, BANK, "2023-08-29", col) is None, f"{col} 金融股应置 NaN"
|
||
|
||
|
||
# ---------- 列子集(引用瘦身)与分块 ----------
|
||
|
||
def test_columns_subset(synthetic_static):
|
||
df = build_fundamental_features(
|
||
[A, B], "2023-08-25", "2023-09-02", data_dir=synthetic_static,
|
||
columns=["vsig", "share_capital"])
|
||
assert df.columns == ["vt_symbol", "datetime", "vsig", "share_capital"]
|
||
assert val(df, A, "2023-08-29", "share_capital") == pytest.approx(110.0)
|
||
# 子集列与全量产出逐值一致
|
||
full = build_fundamental_features(
|
||
[A, B], "2023-08-25", "2023-09-02", data_dir=synthetic_static)
|
||
key = ["vt_symbol", "datetime"]
|
||
assert (df.sort(key).select(key + ["vsig"]).equals(
|
||
full.sort(key).select(key + ["vsig"])))
|
||
|
||
|
||
def test_chunked_equals_full_p1_columns(synthetic_static):
|
||
"""新列也过一遍分块等值(batch_codes=1 极端路径)."""
|
||
six = ["600000.SSE", "000001.SZSE", "300001.SZSE",
|
||
"600004.SSE", "000333.SZSE", "300124.SZSE"]
|
||
full = build_fundamental_features(
|
||
six, "2023-01-01", "2023-12-31", data_dir=synthetic_static, batch_codes=6)
|
||
by_one = build_fundamental_features(
|
||
six, "2023-01-01", "2023-12-31", data_dir=synthetic_static, batch_codes=1)
|
||
key = ["vt_symbol", "datetime"]
|
||
assert full.sort(key).equals(by_one.sort(key))
|