fix(paper): 自动跟单卖出按可卖持仓封顶, 金额超出持仓不再整单被拒 (#462)

# Conflicts:
#	backend/app/strategy/paper_auto.py
This commit is contained in:
shy3130
2026-10-16 10:07:31 +08:00
3 changed files with 121 additions and 11 deletions
+23 -11
View File
@@ -423,20 +423,12 @@ def create_order(
if est_price > 0 and qty * est_price * (1 + acc["slippage_bps"] / 10000) + buy_fee(qty, est_price, acc["commission_pct"]) > acc_cash:
return None, f"可用资金不足 (需约 {qty * est_price:.0f}, 可用 {acc_cash:.0f})"
else:
pos = load_positions(data_dir, account_id).get(symbol)
if pos is None or pos["qty"] <= 0:
held = sell_capacity(data_dir, symbol, account_id)
if held is None:
return None, f"无 {symbol} 持仓, 不能卖出"
# 可卖数量按当前交易日现算 (与 _fill_order / overview 同口径): 物化文件里的
# available_qty 是上次重建时的 T+1 口径, 跨日后不会更新, 次日仍会是 0
available = _available_of(pos, cn_today().isoformat())
available, pending_sell = held
if qty > available:
return None, f"可卖数量不足 (T+1): 可卖 {available}, 请求数量 {qty}"
# 超卖防护: pending 卖出单占用可卖额度 — 同 symbol 的 pending 卖出合计
# 不得超过可卖数量, 否则多张单各自通过校验、成交时逐张扣减会超额
pending_sell = sum(
int(o["qty"]) for o in load_orders(data_dir, account_id)
if o["status"] == "pending" and o["side"] == "sell" and o["symbol"] == symbol
)
if pending_sell + qty > available:
return None, (
f"可卖数量不足 (T+1): 可卖 {available}, "
@@ -584,6 +576,26 @@ def replay_positions(
return positions, round(cash, 2)
def sell_capacity(
data_dir: Path, symbol: str, account_id: str = DEFAULT_ACCOUNT_ID,
) -> tuple[int, int] | None:
"""(T+1 可卖数量, 同 symbol 已挂 pending 卖出合计); 无持仓返回 None。
可卖数量按当前交易日现算 (与 _fill_order / overview 同口径): 物化文件里的
available_qty 是上次重建时的 T+1 口径, 跨日后不会更新, 次日仍会是 0。
pending 卖出单占用可卖额度, 否则多张单各自通过校验、成交时逐张扣减会超额。
"""
pos = load_positions(data_dir, account_id).get(symbol)
if pos is None or pos["qty"] <= 0:
return None
available = _available_of(pos, cn_today().isoformat())
pending_sell = sum(
int(o["qty"]) for o in load_orders(data_dir, account_id)
if o["status"] == "pending" and o["side"] == "sell" and o["symbol"] == symbol
)
return available, pending_sell
def _available_of(pos: dict, today: str | None = None) -> int:
"""T+1 可卖数量: buy_date < today 的批次之和。today 为空则全部可卖 (重建口径)。"""
if today is None:
+6
View File
@@ -255,6 +255,12 @@ def on_rule_events(data_dir: Path, events: list[dict], account_id: str = paper.D
continue # 未持仓, 出场信号无意义
else:
qty = _sizing_qty(data_dir, rule, float(price), account_id)
# 金额换算的股数与实际持仓对不上: 以可卖余量封顶, 金额设足够大即清仓
held = paper.sell_capacity(data_dir, symbol, account_id)
if held is None:
continue
available, pending_sell = held
qty = min(qty, paper.normalize_qty(available - pending_sell))
if qty <= 0:
logger.info("paper auto %s: %s 金额不足以一手 (价 %s)", rule["name"], symbol, price)
continue
+92
View File
@@ -0,0 +1,92 @@
"""自动跟单卖出按可卖持仓封顶。
卖出规则的仓位只能按金额/权益换算股数, 与实际持仓对不上: 换算多了整单被
create_order 以「可卖数量不足」拒绝, 换算少了只卖一部分。自动卖出应以可卖
数量(扣除已挂 pending 卖单)为上限, 金额设足够大即等价于清仓。
"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
import pytest
from app.strategy import paper, paper_auto
from app.tickflow.repository import DataStore, KlineRepository
SYM = "002893.SZ"
DAY = date(2026, 10, 8)
def _write_prev_close(tmp_path) -> None:
repo = KlineRepository(DataStore(tmp_path))
repo.append_daily(pl.DataFrame({
"symbol": [SYM], "date": [DAY - timedelta(days=1)],
"open": [10.0], "high": [10.0], "low": [10.0], "close": [10.0],
"volume": [10000.0], "amount": [100000.0],
}))
@pytest.fixture
def holding_500(tmp_path, monkeypatch):
"""昨日买入 500 股, 今日可卖。"""
monkeypatch.setattr(paper, "cn_today", lambda: DAY - timedelta(days=1))
_write_prev_close(tmp_path)
paper.create_account(tmp_path, 100_000.0)
_, err = paper.create_order(tmp_path, SYM, "buy", qty=500, ref_price=10.0)
assert err is None
assert len(paper.evaluate_intraday(tmp_path, {SYM: 10.0})) == 1
monkeypatch.setattr(paper, "cn_today", lambda: DAY)
paper._materialize(tmp_path)
return tmp_path
def _sell_rule(**overrides) -> dict:
rule = {
"name": "12元清仓", "match_kind": "rule", "match_id": "price_ge_12",
"side": "sell", "size_mode": "fixed_amount", "size_value": 10_000_000.0,
"order_type": "market", "cooldown_days": 0, "enabled": True,
}
rule.update(overrides)
return rule
def _event(price: float = 12.0) -> dict:
return {"source": "price", "rule_id": "price_ge_12", "symbol": SYM, "price": price}
def test_auto_sell_amount_above_holding_sells_whole_position(holding_500):
paper_auto.create_auto_rule(holding_500, _sell_rule())
orders = paper_auto.on_rule_events(holding_500, [_event()])
assert len(orders) == 1
assert orders[0]["side"] == "sell"
assert orders[0]["qty"] == 500
def test_auto_sell_cap_excludes_pending_sell_orders(holding_500):
_, err = paper.create_order(holding_500, SYM, "sell", qty=200, ref_price=12.0)
assert err is None
paper_auto.create_auto_rule(holding_500, _sell_rule())
orders = paper_auto.on_rule_events(holding_500, [_event()])
assert [o["qty"] for o in orders] == [300]
def test_auto_sell_amount_below_holding_keeps_partial_size(holding_500):
paper_auto.create_auto_rule(holding_500, _sell_rule(size_value=2400.0))
orders = paper_auto.on_rule_events(holding_500, [_event()])
assert [o["qty"] for o in orders] == [200]
def test_auto_sell_without_position_creates_no_order(tmp_path, monkeypatch):
monkeypatch.setattr(paper, "cn_today", lambda: DAY)
_write_prev_close(tmp_path)
paper.create_account(tmp_path, 100_000.0)
paper_auto.create_auto_rule(tmp_path, _sell_rule())
assert paper_auto.on_rule_events(tmp_path, [_event()]) == []