mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(paper): 自动跟单卖出按可卖持仓封顶, 金额超出持仓不再整单被拒 (#462)
# Conflicts: # backend/app/strategy/paper_auto.py
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()]) == []
|
||||
Reference in New Issue
Block a user