Files
chanlun.rs/chanlun-py/tests/test_position_update.py
2026-06-27 18:17:57 +08:00

302 lines
12 KiB
Python

"""Position.update() 集成测试 — 验证 Rust 核心状态机与 Python 行为一致。
测试覆盖:
- 基础开多/开空/平多/平空
- 间隔限制
- 止损/超时
- pairs 盈亏计算
- 时间倒退容错
- 空信号字典容错
- dump/load 含状态
"""
import pytest
from datetime import datetime, timezone
from chanlun._chanlun import Position, Event, Factor, Signal, Operate
# ---- 辅助函数 ----
def 开多事件(k3="中枢", v2="三买"):
s = Signal(k1="14400", k2="D1MO3", k3=k3, v2=v2)
return Event(Operate.LO, [Factor(signals_all=[s])])
def 平多事件(k3="中枢", v2="三卖"):
s = Signal(k1="14400", k2="D1MO3", k3=k3, v2=v2)
return Event(Operate.LE, [Factor(signals_all=[s])])
def 开空事件(k3="中枢", v2="三卖"):
s = Signal(k1="14400", k2="D1MO3", k3=k3, v2=v2)
return Event(Operate.SO, [Factor(signals_all=[s])])
def 平空事件(k3="中枢", v2="三买"):
s = Signal(k1="14400", k2="D1MO3", k3=k3, v2=v2)
return Event(Operate.SE, [Factor(signals_all=[s])])
def 信号字典(symbol="btc", dt=None, close=50000.0, bid=1, **kwargs):
"""构造信号字典(含 OHLCV + 信号键)。"""
if dt is None:
dt = datetime.now(timezone.utc)
d = {"symbol": symbol, "dt": dt, "close": close, "id": bid}
d.update(kwargs)
return d
# ---- 构造 ----
def test_构造状态初始化为默认值():
p = Position(symbol="btc", opens=[开多事件()], name="测试")
assert p.pos == 0
assert p.pos_changed is False
assert p.operates == []
assert p.holds == []
# ---- update: 开仓 ----
def test_update_开多():
p = Position(symbol="btc", opens=[开多事件()], name="测试")
p.update(信号字典(**{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1
assert p.pos_changed is True
assert len(p.operates) == 1
assert p.operates[0]["op"] == Operate.LO
assert len(p.holds) == 1
assert p.holds[0]["pos"] == 1
def test_update_开空():
p = Position(symbol="btc", opens=[开空事件()], name="测试")
p.update(信号字典(**{"14400_D1MO3_中枢": "任意_三卖_任意_0"}))
assert p.pos == -1
assert p.operates[0]["op"] == Operate.SO
def test_update_开多_已持仓_不重复开仓():
p = Position(symbol="btc", opens=[开多事件()], name="测试")
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1
assert len(p.operates) == 1
# 第二次相同信号,已多头,不再开仓
p.update(信号字典(dt=datetime(2020, 1, 1, 1, tzinfo=timezone.utc), bid=2, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1
assert len(p.operates) == 1 # 无新操作
# ---- update: 平仓 ----
def test_update_开多后平多():
p = Position(symbol="btc", opens=[开多事件()], exits=[平多事件()], name="测试")
# Step 1: LO
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1
# Step 2: LE (next day to allow exit when T0=False)
p.update(信号字典(dt=datetime(2020, 1, 2, tzinfo=timezone.utc), bid=2, **{"14400_D1MO3_中枢": "任意_三卖_任意_0"}))
assert p.pos == 0, f"Expected pos=0, got {p.pos}"
assert p.operates[-1]["op"] == Operate.LE
def test_update_开空后平空():
p = Position(symbol="btc", opens=[开空事件()], exits=[平空事件()], name="测试")
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, **{"14400_D1MO3_中枢": "任意_三卖_任意_0"}))
assert p.pos == -1
p.update(信号字典(dt=datetime(2020, 1, 2, tzinfo=timezone.utc), bid=2, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 0
assert p.operates[-1]["op"] == Operate.SE
# ---- update: 止损 ----
def test_update_多头止损():
p = Position(symbol="btc", opens=[开多事件()], name="测试", stop_loss=500)
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, close=50000.0, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1
# Price drops to 47000: (47000/50000 - 1) = -0.06 = -600 BP < -500 BP stop_loss
p.update(信号字典(dt=datetime(2020, 1, 2, tzinfo=timezone.utc), bid=2, close=47000.0, **{"14400_D1MO3_中枢": "任意_无_任意_0"}))
assert p.pos == 0, "Should be stopped out"
assert "止损" in p.operates[-1]["op_desc"]
def test_update_空头止损():
p = Position(symbol="btc", opens=[开空事件()], name="测试", stop_loss=500)
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, close=50000.0, **{"14400_D1MO3_中枢": "任意_三卖_任意_0"}))
assert p.pos == -1
# Price rises to 53000: (1 - 53000/50000) = -0.06 = -600 BP < -500 BP stop_loss
p.update(信号字典(dt=datetime(2020, 1, 2, tzinfo=timezone.utc), bid=2, close=53000.0, **{"14400_D1MO3_中枢": "任意_无_任意_0"}))
assert p.pos == 0, "Should be stopped out"
assert "止损" in p.operates[-1]["op_desc"]
# ---- update: 超时 ----
def test_update_多头超时():
p = Position(symbol="btc", opens=[开多事件()], name="测试", timeout=5)
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, close=50000.0, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1
# bid diff=9 > timeout=5
p.update(信号字典(dt=datetime(2020, 1, 2, tzinfo=timezone.utc), bid=10, close=50000.0, **{"14400_D1MO3_中枢": "任意_无_任意_0"}))
assert p.pos == 0, "Should be timed out"
assert "超时" in p.operates[-1]["op_desc"]
# ---- update: 间隔限制 ----
def test_update_间隔限制():
p = Position(symbol="btc", opens=[开多事件()], name="测试", interval=3600)
# Create fresh position, open, test interval protection
p2 = Position(symbol="btc", opens=[开多事件()], name="测试", interval=3600)
p2.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, close=50000, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert len(p2.operates) == 1
# Within interval, same day (T0=False) — no new open
p2.update(信号字典(dt=datetime(2020, 1, 1, 1, tzinfo=timezone.utc), bid=2, close=50000, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert len(p2.operates) == 1 # No new operate (already long, interval not elapsed)
# ---- update: 边界条件 ----
def test_update_时间倒退_跳过():
p = Position(symbol="btc", opens=[开多事件()], name="测试")
dt1 = datetime(2020, 1, 2, tzinfo=timezone.utc)
dt2 = datetime(2020, 1, 1, tzinfo=timezone.utc) # earlier
p.update(信号字典(dt=dt1, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
operates_before = len(p.operates)
p.update(信号字典(dt=dt2, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert len(p.operates) == operates_before # skipped
def test_update_空事件列表():
p = Position(symbol="btc", opens=[], name="空")
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 0
assert len(p.holds) == 1
def test_update_无匹配事件_仅追加holds():
p = Position(symbol="btc", opens=[开多事件()], name="测试")
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), **{"14400_D1MO3_中枢": "任意_无_任意_0"}))
assert p.pos == 0
assert p.operates == []
assert len(p.holds) == 1
def test_update_缺键错误():
"""信号字典缺少事件所需 key 时抛 ValueError。"""
p = Position(symbol="btc", opens=[开多事件()], name="测试")
with pytest.raises(ValueError, match="不在信号列表中"):
# 空信号字典缺少 "14400_D1MO3_中枢" 键
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc)))
def test_update_T0模式_同一天可操作():
p = Position(symbol="btc", opens=[开多事件()], exits=[平多事件()], name="测试", T0=True)
dt = datetime(2020, 1, 1, 0, 0, tzinfo=timezone.utc)
p.update(信号字典(dt=dt, bid=1, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1
# Same day, T0=True → 允许平仓
p.update(信号字典(dt=dt.replace(hour=1), bid=2, **{"14400_D1MO3_中枢": "任意_三卖_任意_0"}))
assert p.pos == 0
# ---- pairs ----
def test_pairs_空():
p = Position(symbol="btc", opens=[开多事件()], name="测试")
assert p.pairs == []
def test_pairs_单笔开平_多头盈利():
p = Position(symbol="btc", opens=[开多事件()], exits=[平多事件()], name="测试")
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, close=50000.0, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
p.update(信号字典(dt=datetime(2020, 1, 2, tzinfo=timezone.utc), bid=2, close=51000.0, **{"14400_D1MO3_中枢": "任意_三卖_任意_0"}))
pairs = p.pairs
assert len(pairs) == 1
assert pairs[0]["交易方向"] == "多头"
assert pairs[0]["开仓价格"] == 50000.0
assert pairs[0]["平仓价格"] == 51000.0
# (51000/50000 - 1) * 10000 = 200 BP
assert pairs[0]["盈亏比例"] == pytest.approx(200.0, abs=0.1)
def test_pairs_单笔开平_空头盈利():
p = Position(symbol="btc", opens=[开空事件()], exits=[平空事件()], name="测试")
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, close=50000.0, **{"14400_D1MO3_中枢": "任意_三卖_任意_0"}))
p.update(信号字典(dt=datetime(2020, 1, 2, tzinfo=timezone.utc), bid=2, close=48000.0, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
pairs = p.pairs
assert len(pairs) == 1
assert pairs[0]["交易方向"] == "空头"
assert pairs[0]["开仓价格"] == 50000.0
assert pairs[0]["平仓价格"] == 48000.0
# (1 - 48000/50000) * 10000 = 400 BP
assert pairs[0]["盈亏比例"] == pytest.approx(400.0, abs=0.1)
def test_pairs_持仓天数():
p = Position(symbol="btc", opens=[开多事件()], exits=[平多事件()], name="测试")
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, close=50000.0, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
# 3 days later
p.update(信号字典(dt=datetime(2020, 1, 4, tzinfo=timezone.utc), bid=2, close=51000.0, **{"14400_D1MO3_中枢": "任意_三卖_任意_0"}))
assert p.pairs[0]["持仓天数"] == pytest.approx(3.0, abs=0.1)
# ---- dump/load ----
def test_dump_with_data():
p = Position(symbol="btc", opens=[开多事件()], name="测试")
p.update(信号字典(dt=datetime(2020, 1, 1, tzinfo=timezone.utc), bid=1, close=50000.0, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
raw = p.dump(with_data=True)
assert "pairs" in raw
assert "holds" in raw
assert raw["symbol"] == "btc"
assert len(raw["holds"]) == 1
def test_dump_without_data():
p = Position(symbol="btc", opens=[开多事件()], name="测试")
raw = p.dump(with_data=False)
assert "symbol" in raw
assert "pairs" not in raw
def test_load_roundtrip():
from chanlun.chan_external import Position as PyPos
p = PyPos(symbol="btc", opens=[开多事件()], name="测试", timeout=500)
p2 = PyPos.load(p.dump())
assert p2.symbol == p.symbol
assert p2.name == p.name
assert p2.timeout == 500
assert p2.pos == 0 # 新构造,状态初始
# ---- 信号字典 dt 类型兼容 ----
def test_update_dt_支持int时间戳():
"""验证 update() 支持 int Unix 时间戳(除 datetime 外)。"""
p = Position(symbol="btc", opens=[开多事件()], name="测试")
ts = int(datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp())
p.update(信号字典(dt=ts, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1
def test_update_dt_支持float时间戳():
"""验证 update() 支持 float Unix 时间戳。"""
p = Position(symbol="btc", opens=[开多事件()], name="测试")
ts = datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp()
p.update(信号字典(dt=ts, **{"14400_D1MO3_中枢": "任意_三买_任意_0"}))
assert p.pos == 1