mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
Merge pull request #423 from kevin9327/fix/paper-settle-price-time
fix(paper): 盘后结算不按下单前已打印的价格成交, 盘中下的次日开盘单改在次一交易日开盘成交
This commit is contained in:
@@ -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"
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user