diff --git a/sanguo_trader/position_ledger.py b/sanguo_trader/position_ledger.py new file mode 100644 index 0000000..c88cb6c --- /dev/null +++ b/sanguo_trader/position_ledger.py @@ -0,0 +1,40 @@ +"""单标的持仓对象(raw 计均价、T+1 冻结)。 + +mutable,被 Account / StrategyRunner 持有(spec §6.3 双层记账)。 +- 均价:移动加权平均,买入刷新;卖出不影响剩余持仓成本(A 股惯例) +- T+1:买入当日 frozen,次日开盘前由 Account.unfreeze_all() 解冻 +- 零股:卖出允许零股(退出持仓基本操作),买入由 matcher 保证 100 股整取 +""" +class PositionLedger: + def __init__(self, symbol: str): + self.symbol: str = symbol + self.volume: int = 0 + self.frozen: int = 0 # T+1 当日买入冻结 + self.avg_price: float = 0.0 + + @property + def available(self) -> int: + """可卖出量 = 总持仓 - T+1 冻结。""" + return self.volume - self.frozen + + def apply_buy(self, price: float, volume: int) -> None: + """买入:刷新移动加权均价,新买入量计入 frozen(T+1)。""" + total_cost = self.avg_price * self.volume + price * volume + self.volume += volume + self.avg_price = total_cost / self.volume if self.volume else 0.0 + self.frozen += volume # T+1 + + def apply_sell(self, price: float, volume: int) -> None: + """卖出:扣减持仓量。price 保留接口对称(不影响剩余持仓均价)。 + 清仓时 avg_price 归零(避免下一次买入残留历史成本)。""" + if volume > self.available: + raise ValueError( + f"卖出超过可卖量: want {volume}, available {self.available}" + ) + self.volume -= volume + if self.volume == 0: + self.avg_price = 0.0 + + def unfreeze(self) -> None: + """次日开盘前调用:frozen → available。""" + self.frozen = 0 diff --git a/tests/trader/test_position_ledger.py b/tests/trader/test_position_ledger.py new file mode 100644 index 0000000..af0446d --- /dev/null +++ b/tests/trader/test_position_ledger.py @@ -0,0 +1,91 @@ +"""PositionLedger 单标的持仓对象测试(raw 计均价、T+1 冻结)。""" +import pytest + +from sanguo_trader.position_ledger import PositionLedger + + +def test_new_position_empty(): + p = PositionLedger(symbol="600000") + assert p.volume == 0 + assert p.frozen == 0 + assert p.avg_price == 0.0 + assert p.available == 0 + + +def test_buy_sets_avg_price_and_freezes(): + p = PositionLedger(symbol="600000") + p.apply_buy(price=10.0, volume=100) + assert p.volume == 100 + assert p.frozen == 100 # T+1:买入当日冻结 + assert p.available == 0 # 当日不可卖 + assert p.avg_price == 10.0 + + +def test_avg_price_weighted_on_add(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + p.unfreeze() # 次日解冻 + p.apply_buy(12.0, 100) + assert p.avg_price == 11.0 # (10*100 + 12*100)/200 + assert p.volume == 200 + # 新买的 100 又被冻结(T+1) + assert p.frozen == 100 + assert p.available == 100 + + +def test_cannot_sell_frozen(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + assert p.frozen == 100 + assert p.available == 0 # 当日不可卖 + + +def test_unfreeze_makes_available(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + p.unfreeze() + assert p.frozen == 0 + assert p.available == 100 + + +def test_sell_reduces_volume(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 200) + p.unfreeze() + p.apply_sell(11.0, 100) + assert p.volume == 100 + assert p.available == 100 + + +def test_sell_odd_lot_allowed(): + """A 股卖出允许零股(退出持仓的基本操作)。""" + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 200) + p.unfreeze() + p.apply_sell(11.0, 50) # 50 股零股 + assert p.volume == 150 + + +def test_sell_over_available_raises(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + p.unfreeze() + with pytest.raises(ValueError, match="available"): + p.apply_sell(11.0, 200) + + +def test_sell_frozen_raises(): + """T+1:当日买入冻结,卖出冻结量必报错。""" + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + with pytest.raises(ValueError): + p.apply_sell(11.0, 50) + + +def test_clear_position_avg_price_resets(): + p = PositionLedger(symbol="600000") + p.apply_buy(10.0, 100) + p.unfreeze() + p.apply_sell(11.0, 100) + assert p.volume == 0 + assert p.avg_price == 0.0