tradingSystem/app/core/cushion.py

126 lines
4.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
安全垫与成本账 (纯函数/纯对象, 无外部依赖, 可单测)
==================================================
职责 (POSITION_MGMT_DESIGN.md §4/§6):
1. 摊薄成本口径: avg_cost = (累计买入额 累计卖出额 做T利润) / 当前持股数。
卖得比成本高、做T赚了钱, 摊薄成本都会下降 —— 安全垫由此增厚。
2. 安全垫状态机: NONE(<0) / THIN(0~solid) / SOLID(≥solid, 解锁盈利加仓)。
3. 保垫减仓触发: 垫子峰值 ≥ peak_min 且 现值回吐 ≥ 峰值 × giveback。
4. 卖出核销次序: T0 → ADD(新→旧) → DCA → FILL → BASE (保底仓纪律)。
"""
from __future__ import annotations
class PositionCost:
"""一只股票的成本账 (摊薄口径)。所有金额单位元, 数量单位股。"""
def __init__(self):
self.qty = 0
self.cum_buy_amt = 0.0
self.cum_sell_amt = 0.0
self.t_profit = 0.0
def buy(self, qty: int, price: float):
if qty <= 0 or price <= 0:
raise ValueError(f"非法买入: qty={qty}, price={price}")
self.qty += qty
self.cum_buy_amt += qty * price
def sell(self, qty: int, price: float):
if qty <= 0 or price <= 0:
raise ValueError(f"非法卖出: qty={qty}, price={price}")
if qty > self.qty:
raise ValueError(f"卖出超持仓: sell={qty} > hold={self.qty}")
self.qty -= qty
self.cum_sell_amt += qty * price
if self.qty == 0: # 清仓即结账, 防止空仓残留成本影响下一轮
self.reset_keep_nothing()
def add_t_profit(self, amount: float):
"""做T已实现利润 (可为负 = T亏损), 直接摊入成本。"""
self.t_profit += amount
def reset_keep_nothing(self):
self.qty = 0
self.cum_buy_amt = 0.0
self.cum_sell_amt = 0.0
self.t_profit = 0.0
@property
def avg_cost(self):
"""摊薄成本。空仓返回 None; 净成本为负 (卖出已收回全部本金) 时返回 0.0 (垫子视为无限厚)。"""
if self.qty <= 0:
return None
net = self.cum_buy_amt - self.cum_sell_amt - self.t_profit
return max(net / self.qty, 0.0)
def cushion(self, price: float):
"""安全垫幅度 = 现价/摊薄成本 1。空仓返回 None; 成本≤0 返回大数 (视为极厚)。"""
c = self.avg_cost
if c is None:
return None
if c <= 0:
return 9.99
return price / c - 1.0
def cushion_state(cushion_pct, solid: float = 0.03) -> str:
"""垫子状态: NONE(<0) / THIN(0~solid) / SOLID(≥solid)。None 视为 NONE。"""
if cushion_pct is None or cushion_pct < 0:
return "NONE"
return "SOLID" if cushion_pct >= solid else "THIN"
def trim_trigger(cushion_peak: float, cushion_now, peak_min: float = 0.06,
giveback: float = 0.5) -> bool:
"""保垫减仓触发: 峰值曾 ≥ peak_min 且 现值回吐 ≥ 峰值 × giveback。"""
if cushion_now is None or cushion_peak is None:
return False
if cushion_peak < peak_min:
return False
return (cushion_peak - cushion_now) >= cushion_peak * giveback - 1e-12
# 卖出核销优先级 (数值小者先卖): 保底仓纪律
_SELL_ORDER = {"T0": 0, "ADD": 1, "DCA": 2, "FILL": 3, "BASE": 4, "RECON": 5}
def sell_allocation(lots: list, sell_qty: int) -> list:
"""把卖出数量分配到批次: T0 → ADD(新→旧) → DCA → FILL → BASE。
lots: [{"lot_id", "lot_type", "qty"(未核销数量), "open_date"(YYYYMMDD int 或可比较值)}, ...]
返回 [{"lot_id", "qty"}...]; 卖出量超过批次总量 → ValueError (调用方应先对账)。
"""
if sell_qty <= 0:
return []
total = sum(l["qty"] for l in lots)
if sell_qty > total:
raise ValueError(f"卖出超批次总量: sell={sell_qty} > lots={total} (先对账再动作)")
def key(l):
tier = _SELL_ORDER.get(l["lot_type"], 9)
# ADD 批内部按开仓日新→旧 (负号); 其余批按旧→新
date_key = -l["open_date"] if l["lot_type"] == "ADD" else l["open_date"]
return (tier, date_key)
out, remain = [], sell_qty
for l in sorted(lots, key=key):
if remain <= 0:
break
take = min(l["qty"], remain)
if take > 0:
out.append({"lot_id": l["lot_id"], "qty": take})
remain -= take
return out
def dca_stage(loss_pct: float, triggers=(-0.08, -0.15)) -> int:
"""补仓评估档: 返回已触及的最深档序号 (1 起), 未触及任何档返回 0。
triggers 按由浅到深排列 (如 -0.08, -0.15)。"""
stage = 0
for i, t in enumerate(sorted(triggers, reverse=True), start=1): # 浅档在前
if loss_pct <= t + 1e-12:
stage = i
return stage