Merge pull request #423 from kevin9327/fix/paper-settle-price-time

fix(paper): 盘后结算不按下单前已打印的价格成交, 盘中下的次日开盘单改在次一交易日开盘成交
This commit is contained in:
wshy
2026-09-25 21:21:58 +08:00
committed by GitHub
3 changed files with 234 additions and 7 deletions
+33 -1
View File
@@ -34,11 +34,12 @@ import threading
import uuid
from datetime import date as _date
from datetime import datetime
from datetime import time as _time
from pathlib import Path
import polars as pl
from app.market_time import cn_now, cn_today
from app.market_time import CN_TZ, cn_now, cn_today
from app.services.fs_utils import atomic_write_text
logger = logging.getLogger(__name__)
@@ -53,6 +54,10 @@ DEFAULT_STAMP_TAX_PCT = 0.001 # 印花税, 仅卖出
DEFAULT_SLIPPAGE_BPS = 5.0 # 滑点 (bps, 万分之5)
MAX_POSTPONE_DAYS = 3 # 顺延上限: 连续 N 个交易日无法成交自动过期
# 结算价格的打印时刻 (北京墙钟): 开盘价 09:30 / 收盘价 15:00。下单晚于该时刻的单
# 不能按当日这个价成交 (下单时价格已知), 留到下一交易日结算
_SESSION_OPEN = _time(9, 30)
_SESSION_CLOSE = _time(15, 0)
LOT_SIZE = 100 # 一手 100 股 (股票/ETF 同)
MAX_POSITION_SYMBOLS = 50 # 持仓标的数上限 (防误操作)
DEFAULT_ACCOUNT_ID = "default"
@@ -725,6 +730,8 @@ def _queue_or_expire(data_dir: Path, order: dict, acc: dict, reason: str, accoun
order["order_type"] = "next_open"
order["postponed"] = postponed + 1
order["reason"] = f"{reason}; 排队次日重试 ({postponed + 1}/{MAX_POSTPONE_DAYS})"
# 排队时刻: 盘中排队的单不能按当日 (排队之前已打印的) 开盘价成交
order["queued_at"] = _now_iso()
save_order(data_dir, order, account_id)
return
reason = f"{reason}; 排队 {MAX_POSTPONE_DAYS} 日未成交, 过期"
@@ -878,11 +885,34 @@ def _factor_on(data_dir: Path, symbol: str, asset_type: str, day: str) -> float
# ── 盘后结算 ────────────────────────────────────────────
def _placed_after_price_time(order: dict, day: str) -> bool:
"""订单 (盘中排队的按排队时刻) 是否晚于 day 结算价格的打印时刻。
next_open 用当日 09:30 开盘价, close / market 兜底用当日 15:00 收盘价。下单或
排队发生在同一交易日该时刻之后, 说明这个价格在下单时已经打印过, 按它成交等于
拿已知价格回填 (次日开盘单的口径是次一交易日 raw_open), 应留到下一交易日结算。
更早日期的订单不受影响; 时间戳缺失或无法解析时不拦截 (沿用原行为)。
"""
stamp = order.get("queued_at") or order.get("created_at")
try:
ts = datetime.fromisoformat(str(stamp))
except (TypeError, ValueError):
return False
if ts.tzinfo is not None:
ts = ts.astimezone(CN_TZ)
if ts.date().isoformat() != day:
return False
price_time = _SESSION_OPEN if order.get("order_type") == "next_open" else _SESSION_CLOSE
return ts.time() >= price_time
def settle_day(data_dir: Path, day: str, account_id: str = DEFAULT_ACCOUNT_ID) -> dict:
"""盘后管道阶段: 撮合顺延单 → 除权调整 → 定版净值。幂等 (重跑同日安全)。
撮合顺序: close 单用 raw_close; next_open 单用 raw_open; 仍 pending 的
market 单 (当日无快照) 也按 raw_close 兜底成交 — 避免停牌外无限顺延。
当日开盘后才下 / 排队的 next_open 单, 以及收盘后才下的 close / market 单,
当日不成交也不计顺延, 留到下一交易日 (见 _placed_after_price_time)。
"""
summary = {"filled": 0, "expired": 0, "corp_actions": 0, "nav": None, "account": account_id}
with PAPER_LOCK:
@@ -905,6 +935,8 @@ def settle_day(data_dir: Path, day: str, account_id: str = DEFAULT_ACCOUNT_ID) -
else:
save_order(data_dir, order, account_id)
continue
if _placed_after_price_time(order, day):
continue # 下单/排队时该价已打印, 留到下一交易日 (不计顺延)
# next_open 用开盘价; close 与 market 兜底用收盘价
raw = bar["open"] if order["order_type"] == "next_open" else bar["close"]
before = order["status"]
@@ -0,0 +1,188 @@
"""模拟盘盘后结算不按下单时刻之前已经打印的价格成交。
次日开盘单 = 次一交易日 raw_open 成交 (docs/paper-trading-plan.md 撮合规则表)。
settle_day(当日) 却把当日开盘之后才创建 (或涨跌停排队) 的 next_open 单按当日
09:30 的开盘价成交, 收盘后才下的 close / 即时单也按当日 15:00 的收盘价兜底成交 ——
都是拿下单时已经知道的价格回填成交。盘中自动跟单 (缺省 next_open) 因此系统性
按当日开盘价买入。
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from pathlib import Path
import polars as pl
import pytest
from app.market_time import CN_TZ
from app.strategy import paper, paper_auto
from app.tickflow.repository import DataStore, KlineRepository
SYM = "600519.SH"
ETF = "510300.SH"
DAY = date(2026, 9, 24)
NEXT = DAY + timedelta(days=1)
# (日期, open, close): 当日开 10.2 收 10.8; 次日开 10.5 收 10.9 (均在涨跌幅内)
BARS = [(DAY - timedelta(days=1), 10.0, 10.0), (DAY, 10.2, 10.8), (NEXT, 10.5, 10.9)]
def _frame(symbol: str, rows: list[tuple[date, float, float]]) -> pl.DataFrame:
return pl.DataFrame(
{
"symbol": [symbol] * 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],
}
)
def _write_bars(tmp_path: Path) -> None:
repo = KlineRepository(DataStore(tmp_path))
repo.append_daily(_frame(SYM, BARS))
repo.append_etf_daily(_frame(ETF, BARS))
def _freeze(monkeypatch, day: date, hhmm: time) -> None:
"""把域模块的北京时钟钉在某交易日某时刻 (created_at / queued_at 由此产生)。"""
now = datetime.combine(day, hhmm, tzinfo=CN_TZ)
monkeypatch.setattr(paper, "cn_now", lambda: now)
monkeypatch.setattr(paper, "cn_today", lambda: now.date())
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
_write_bars(tmp_path)
return tmp_path
def test_next_open_placed_after_open_waits_for_next_trading_day(data_dir, monkeypatch):
"""14:00 下的次日开盘单: 当日结算不成交 (不计顺延), 次日按次日开盘价成交。"""
_freeze(monkeypatch, DAY, time(14, 0))
paper.create_account(data_dir, 1_000_000)
order, err = paper.create_order(data_dir, SYM, "buy", qty=100, order_type="next_open", ref_price=10.8)
assert err is None
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 0
got = paper.get_order(data_dir, order["id"])
assert got["status"] == "pending" and got["postponed"] == 0
assert paper.settle_day(data_dir, NEXT.isoformat())["filled"] == 1
got = paper.get_order(data_dir, order["id"])
assert got["status"] == "filled"
assert got["fill_price"] == pytest.approx(paper.apply_slippage(10.5, "buy", 5.0))
def test_next_open_placed_before_open_fills_same_day_open(data_dir, monkeypatch):
"""开盘前 (08:00) 下的次日开盘单: 当日开盘价就是下一次开盘, 当日结算照常成交。"""
_freeze(monkeypatch, DAY, time(8, 0))
paper.create_account(data_dir, 1_000_000)
order, err = paper.create_order(data_dir, SYM, "buy", qty=100, order_type="next_open", ref_price=10.0)
assert err is None
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 1
got = paper.get_order(data_dir, order["id"])
assert got["fill_price"] == pytest.approx(paper.apply_slippage(10.2, "buy", 5.0))
def test_etf_market_order_intraday_waits_for_next_open(data_dir, monkeypatch):
"""ETF 即时单盘中自动转次日开盘: 同样不能按当日已过去的开盘价成交。"""
_freeze(monkeypatch, DAY, time(10, 30))
paper.create_account(data_dir, 1_000_000)
order, err = paper.create_order(data_dir, ETF, "buy", qty=100, asset_type="etf", ref_price=10.5)
assert err is None and order["order_type"] == "next_open"
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 0
assert paper.settle_day(data_dir, NEXT.isoformat())["filled"] == 1
assert paper.get_order(data_dir, order["id"])["fill_price"] == pytest.approx(paper.apply_slippage(10.5, "buy", 5.0))
def test_limit_queue_intraday_waits_for_next_open(data_dir, monkeypatch):
"""盘中触及涨停转排队 (10:00): 当日开盘价 10.2 在排队之前已打印, 当日结算不得成交。"""
_freeze(monkeypatch, DAY, time(10, 0))
paper.create_account(data_dir, 1_000_000, queue_limit_orders=True)
order, err = paper.create_order(data_dir, SYM, "buy", qty=100, ref_price=10.5)
assert err is None
assert paper.evaluate_intraday(data_dir, {SYM: 11.0}) == [] # 涨停 11.0 → 排队
queued = paper.get_order(data_dir, order["id"])
assert queued["status"] == "pending" and queued["order_type"] == "next_open" and queued["postponed"] == 1
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 0
got = paper.get_order(data_dir, order["id"])
assert got["status"] == "pending" and got["postponed"] == 1
assert paper.settle_day(data_dir, NEXT.isoformat())["filled"] == 1
assert paper.get_order(data_dir, order["id"])["fill_price"] == pytest.approx(paper.apply_slippage(10.5, "buy", 5.0))
def test_auto_rule_intraday_order_waits_for_next_open(data_dir, monkeypatch):
"""自动跟单在盘中 (13:30) 触发的次日开盘单: 不按当日开盘价成交。"""
_freeze(monkeypatch, DAY, time(13, 30))
paper.create_account(data_dir, 1_000_000)
paper_auto.create_auto_rule(data_dir, {
"name": "跟策略", "match_kind": "strategy", "match_id": "strat_1",
"side": "buy", "size_mode": "fixed_amount", "size_value": 5000,
"order_type": "next_open", "cooldown_days": 5,
})
created = paper_auto.on_rule_events(data_dir, [
{"source": "strategy", "strategy_id": "strat_1", "symbol": SYM, "price": 10.6},
])
assert len(created) == 1 and created[0]["order_type"] == "next_open"
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 0
assert paper.settle_day(data_dir, NEXT.isoformat())["filled"] == 1
got = paper.get_order(data_dir, created[0]["id"])
assert got["fill_price"] == pytest.approx(paper.apply_slippage(10.5, "buy", 5.0))
def test_close_order_placed_after_close_waits_for_next_close(data_dir, monkeypatch):
"""收盘后 (15:05) 下的收盘单: 当日收盘价已知, 留到次日按次日收盘价成交。"""
_freeze(monkeypatch, DAY, time(15, 5))
paper.create_account(data_dir, 1_000_000)
order, err = paper.create_order(data_dir, SYM, "buy", qty=100, order_type="close", ref_price=10.8)
assert err is None
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 0
assert paper.settle_day(data_dir, NEXT.isoformat())["filled"] == 1
assert paper.get_order(data_dir, order["id"])["fill_price"] == pytest.approx(paper.apply_slippage(10.9, "buy", 5.0))
def test_close_order_placed_in_session_fills_same_day_close(data_dir, monkeypatch):
"""盘中 (14:00) 下的收盘单照常按当日收盘价成交。"""
_freeze(monkeypatch, DAY, time(14, 0))
paper.create_account(data_dir, 1_000_000)
order, err = paper.create_order(data_dir, SYM, "buy", qty=100, order_type="close", ref_price=10.8)
assert err is None
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 1
assert paper.get_order(data_dir, order["id"])["fill_price"] == pytest.approx(paper.apply_slippage(10.8, "buy", 5.0))
def test_market_order_placed_after_close_not_filled_by_close_fallback(data_dir, monkeypatch):
"""收盘后 (15:05) 下的即时单: 当日结算不按收盘价兜底, 留到次日盘中按快照成交。"""
_freeze(monkeypatch, DAY, time(15, 5))
paper.create_account(data_dir, 1_000_000)
order, err = paper.create_order(data_dir, SYM, "buy", qty=100, ref_price=10.8)
assert err is None
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 0
assert paper.get_order(data_dir, order["id"])["status"] == "pending"
_freeze(monkeypatch, NEXT, time(9, 35))
assert len(paper.evaluate_intraday(data_dir, {SYM: 10.6})) == 1
assert paper.get_order(data_dir, order["id"])["fill_price"] == pytest.approx(paper.apply_slippage(10.6, "buy", 5.0))
def test_naive_stamp_is_beijing_and_unparseable_stamp_keeps_old_behavior(data_dir, monkeypatch):
"""兼容: 无时区的时间戳按北京墙钟判定; 时间戳缺失/损坏的旧订单不拦截 (沿用原行为)。"""
_freeze(monkeypatch, DAY, time(14, 0))
paper.create_account(data_dir, 1_000_000)
naive, _ = paper.create_order(data_dir, SYM, "buy", qty=100, order_type="next_open", ref_price=10.8)
broken, _ = paper.create_order(data_dir, SYM, "buy", qty=100, order_type="next_open", ref_price=10.8)
naive["created_at"] = f"{DAY.isoformat()}T14:00:00"
paper.save_order(data_dir, naive)
broken["created_at"] = ""
paper.save_order(data_dir, broken)
assert paper.settle_day(data_dir, DAY.isoformat())["filled"] == 1
assert paper.get_order(data_dir, naive["id"])["status"] == "pending"
assert paper.get_order(data_dir, broken["id"])["status"] == "filled"
+13 -6
View File
@@ -5,11 +5,12 @@
"""
from __future__ import annotations
from datetime import date, timedelta
from datetime import date, datetime, time, timedelta
import polars as pl
import pytest
from app.market_time import CN_TZ
from app.strategy import paper
from app.tickflow.repository import DataStore, KlineRepository
@@ -507,6 +508,8 @@ def test_limit_queue_retries_next_open_then_expires(tmp_path, monkeypatch):
"""排队开启: 涨停买不进 → 转 next_open 次日重试, 连续涨停计顺延, 超限过期。"""
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
# 盘中 10:00 下单并排队: 当日开盘价在排队前已打印, 当日结算不成交, 从次日开盘起重试
monkeypatch.setattr(paper, "cn_now", lambda: datetime.combine(day, time(10, 0), tzinfo=CN_TZ))
paper.create_account(tmp_path, 1_000_000, queue_limit_orders=True)
_write_daily(tmp_path, [(day - timedelta(days=1), 10.0, 10.0)])
order, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, ref_price=10.0)
@@ -519,11 +522,12 @@ def test_limit_queue_retries_next_open_then_expires(tmp_path, monkeypatch):
# day2..day4 开盘连续一字涨停 (open == 涨停价) → 顺延到 3, 第 4 次超限过期
_write_daily(tmp_path, [
(day, 12.10, 12.10), # vs 前收 11.0 涨停 12.10
(day, 12.10, 12.10), # day1 收盘 (排队当日, 结算跳过)
(day + timedelta(days=1), 13.31, 13.31), # vs 12.10 涨停 13.31
(day + timedelta(days=2), 14.64, 14.64), # vs 13.31 涨停 14.64 → 第 4 次
(day + timedelta(days=2), 14.64, 14.64), # vs 13.31 涨停 14.64
(day + timedelta(days=3), 16.10, 16.10), # vs 14.64 涨停 16.10 → 第 4 次
])
for i in range(3):
for i in range(4):
paper.settle_day(tmp_path, (day + timedelta(days=i)).isoformat())
got = paper.get_order(tmp_path, order["id"])
assert got["status"] == "expired" and "排队" in got["reason"]
@@ -534,15 +538,18 @@ def test_limit_queue_fills_when_open_below_limit(tmp_path, monkeypatch):
"""排队开启后次日开盘回落 → 按 next_open 正常成交。"""
day = date(2026, 9, 24)
monkeypatch.setattr(paper, "cn_today", lambda: day)
monkeypatch.setattr(paper, "cn_now", lambda: datetime.combine(day, time(10, 0), tzinfo=CN_TZ))
paper.create_account(tmp_path, 1_000_000, queue_limit_orders=True)
_write_daily(tmp_path, [
(day - timedelta(days=1), 10.0, 10.0),
(day, 10.5, 10.9), # 次日开盘 10.5 未涨停
(day, 10.6, 11.0), # 排队当日 (盘中触及涨停 11.0)
(day + timedelta(days=1), 10.5, 10.9), # 次日开盘 10.5 未涨停
])
order, _ = paper.create_order(tmp_path, SYM, "buy", qty=100, ref_price=10.0)
assert paper.evaluate_intraday(tmp_path, {SYM: 11.0}) == [] # 涨停排队
assert paper.get_order(tmp_path, order["id"])["status"] == "pending"
summary = paper.settle_day(tmp_path, day.isoformat())
assert paper.settle_day(tmp_path, day.isoformat())["filled"] == 0 # 当日开盘价在排队前已打印
summary = paper.settle_day(tmp_path, (day + timedelta(days=1)).isoformat())
assert summary["filled"] == 1
got = paper.get_order(tmp_path, order["id"])
assert got["status"] == "filled" and got["fill_price"] == pytest.approx(paper.apply_slippage(10.5, "buy", 5.0))