mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(paper): 卖出下单的 T+1 可卖数量按当前交易日现算, 次日不再被「可卖 0」拒单
create_order 的卖出预检读 positions.json 里的 available_qty, 而该值是上次 _materialize 时按「当时的今天」算出的 T+1 口径 (成交当日即 0); 跨日后只有再次 成交 / 除权 / 手动 rebuild 才会重建, 所以次日 (乃至此后每一天) 下卖单都被 「可卖数量不足 (T+1): 可卖 0」拒绝, 而持仓表 (overview 现算) 显示可卖 100。 改为与 _fill_order / overview 同口径, 用 _available_of(pos, cn_today()) 现算; 待成交卖出占用额度的校验同样以现算值为准。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
252160e46f
commit
71f7a6289f
@@ -392,17 +392,20 @@ def create_order(
|
||||
pos = load_positions(data_dir, account_id).get(symbol)
|
||||
if pos is None or pos["qty"] <= 0:
|
||||
return None, f"无 {symbol} 持仓, 不能卖出"
|
||||
if qty > pos["available_qty"]:
|
||||
return None, f"可卖数量不足 (T+1): 可卖 {pos['available_qty']}, 请求数量 {qty}"
|
||||
# 可卖数量按当前交易日现算 (与 _fill_order / overview 同口径): 物化文件里的
|
||||
# available_qty 是上次重建时的 T+1 口径, 跨日后不会更新, 次日仍会是 0
|
||||
available = _available_of(pos, cn_today().isoformat())
|
||||
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 > pos["available_qty"]:
|
||||
if pending_sell + qty > available:
|
||||
return None, (
|
||||
f"可卖数量不足 (T+1): 可卖 {pos['available_qty']}, "
|
||||
f"可卖数量不足 (T+1): 可卖 {available}, "
|
||||
f"已有待成交卖出 {pending_sell}, 请求数量 {qty}"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""模拟盘 T+1 可卖数量在下单时按当前交易日现算, 不读物化持仓里过期的 available_qty。
|
||||
|
||||
复现: 当日买入成交后 positions.json 里的 available_qty 是成交当日的 T+1 口径 (0);
|
||||
跨日后没有任何路径重建它 (只有再次成交 / 除权 / 手动 rebuild 才会), create_order
|
||||
的卖出预检却一直读这个 0, 而 overview 按当前交易日现算显示可卖 100 ——
|
||||
持仓表写着可卖, 下单却被「可卖数量不足 (T+1): 可卖 0」拒绝。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import polars as pl
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.paper import router
|
||||
from app.strategy import paper
|
||||
from app.tickflow.repository import DataStore, KlineRepository
|
||||
|
||||
SYM = "600519.SH"
|
||||
DAY = date(2026, 9, 24) # 周四, 次日仍是交易日
|
||||
|
||||
|
||||
def _write_daily(tmp_path: Path, rows: list[tuple[date, float, float]]) -> None:
|
||||
"""写 kline_daily 分区: [(day, open, close)] (不复权 raw 价)。"""
|
||||
repo = KlineRepository(DataStore(tmp_path))
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"symbol": [SYM] * len(rows),
|
||||
"date": [r[0] for r in rows],
|
||||
"open": [r[1] for r in rows],
|
||||
"high": [max(r[1], r[2]) for r in rows],
|
||||
"low": [min(r[1], r[2]) for r in rows],
|
||||
"close": [r[2] for r in rows],
|
||||
"volume": [10000.0] * len(rows),
|
||||
"amount": [r[2] * 10000.0 for r in rows],
|
||||
}
|
||||
)
|
||||
repo.append_daily(df)
|
||||
|
||||
|
||||
def _buy_intraday(tmp_path: Path, monkeypatch, qty: int) -> None:
|
||||
"""DAY 盘中即时买入成交; 当日 T+1 不可卖 (两种口径此时一致, 均为 0)。"""
|
||||
monkeypatch.setattr(paper, "cn_today", lambda: DAY)
|
||||
_write_daily(tmp_path, [(DAY - timedelta(days=1), 10.0, 10.0)])
|
||||
paper.create_account(tmp_path, 1_000_000)
|
||||
_, err = paper.create_order(tmp_path, SYM, "buy", qty=qty, ref_price=10.0)
|
||||
assert err is None
|
||||
assert len(paper.evaluate_intraday(tmp_path, {SYM: 10.0})) == 1
|
||||
_, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
|
||||
assert err is not None and "可卖数量不足" in err
|
||||
|
||||
|
||||
def test_sell_next_day_without_rematerialize(tmp_path, monkeypatch):
|
||||
"""次日可卖: 中间不重建物化持仓 (生产环境跨日后没有任何路径会重建)。"""
|
||||
_buy_intraday(tmp_path, monkeypatch, 100)
|
||||
monkeypatch.setattr(paper, "cn_today", lambda: DAY + timedelta(days=1))
|
||||
# 持仓表 (overview) 按当前交易日现算: 可卖 100
|
||||
assert paper.overview(tmp_path)["holdings"][0]["available_qty"] == 100
|
||||
sell, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
|
||||
assert err is None and sell["status"] == "pending"
|
||||
|
||||
|
||||
def test_sell_next_day_after_settle_fill(tmp_path, monkeypatch):
|
||||
"""盘后结算成交 (close 单) 的买入, 次日同样可卖。"""
|
||||
monkeypatch.setattr(paper, "cn_today", lambda: DAY)
|
||||
_write_daily(tmp_path, [(DAY - timedelta(days=1), 10.0, 10.0), (DAY, 10.2, 10.8)])
|
||||
paper.create_account(tmp_path, 1_000_000)
|
||||
_, err = paper.create_order(tmp_path, SYM, "buy", qty=100, order_type="close", ref_price=10.0)
|
||||
assert err is None
|
||||
assert paper.settle_day(tmp_path, DAY.isoformat())["filled"] == 1
|
||||
monkeypatch.setattr(paper, "cn_today", lambda: DAY + timedelta(days=1))
|
||||
sell, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
|
||||
assert err is None and sell["status"] == "pending"
|
||||
|
||||
|
||||
def test_pending_sell_quota_uses_current_day_availability(tmp_path, monkeypatch):
|
||||
"""超卖防护同样按当前交易日可卖数计: 可卖 200, 两张 100 股卖出单都能挂, 第三张拒。"""
|
||||
_buy_intraday(tmp_path, monkeypatch, 200)
|
||||
monkeypatch.setattr(paper, "cn_today", lambda: DAY + timedelta(days=1))
|
||||
s1, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
|
||||
assert err is None and s1["status"] == "pending"
|
||||
s2, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
|
||||
assert err is None and s2["status"] == "pending"
|
||||
_, err = paper.create_order(tmp_path, SYM, "sell", qty=100)
|
||||
assert err is not None and "可卖 200" in err and "待成交卖出 200" in err
|
||||
|
||||
|
||||
def test_api_sell_next_day_returns_200(tmp_path, monkeypatch):
|
||||
"""HTTP 层: 持仓接口显示可卖 100, 卖出下单应 200 而非 400「可卖 0」。"""
|
||||
_buy_intraday(tmp_path, monkeypatch, 100)
|
||||
monkeypatch.setattr(paper, "cn_today", lambda: DAY + timedelta(days=1))
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.state.repo = SimpleNamespace(
|
||||
store=SimpleNamespace(data_dir=tmp_path),
|
||||
resolve_asset_type=lambda s: "stock",
|
||||
)
|
||||
client = TestClient(app)
|
||||
holdings = client.get("/api/paper/positions").json()["holdings"]
|
||||
assert holdings[0]["available_qty"] == 100
|
||||
r = client.post("/api/paper/orders", json={"symbol": SYM, "side": "sell", "qty": 100})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["order"]["status"] == "pending"
|
||||
Reference in New Issue
Block a user