test_gate.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. """批次门禁:开跑条件、日切报表提前出现、同一输入重复调用。"""
  2. import asyncio
  3. from datetime import date, datetime
  4. import pytest
  5. from post_clear_check.gate import (
  6. CARD_KIND_BATCH,
  7. FORMULA_EARLY_REPORT,
  8. FORMULA_ISSUE_BASELINE,
  9. FORMULA_ISSUE_PUBLISH,
  10. FORMULA_SALES_PUBLISH,
  11. FORMULA_SALES_UPSTREAM,
  12. FORMULA_TRS_DATE,
  13. FORMULA_TRS_VERSION,
  14. HEDGE_SQL,
  15. ISSUE_REPORT_TABLES,
  16. NO_HEDGE_BATCH_ID,
  17. NO_HEDGE_END_TIME,
  18. NO_HEDGE_REVISION,
  19. SALES_REPORT_TABLES,
  20. VERDICT_ERROR,
  21. Batch,
  22. BASIS_EARLY_REPORT,
  23. BatchRowError,
  24. GateScan,
  25. IssueRound,
  26. PublishedFacts,
  27. ReadyRun,
  28. batch_sql,
  29. evaluate_gate,
  30. evaluate_published,
  31. parse_batch_row,
  32. report_date_sql,
  33. scan_gate,
  34. )
  35. from post_clear_check.session import StatementRejected, assert_business_statement
  36. TRADE_DAY = date(2026, 10, 17)
  37. BUSINESS_DAY = date(2026, 10, 17)
  38. ISSUE_END = datetime(2026, 10, 17, 16, 5, 0)
  39. SALES_END = datetime(2026, 10, 17, 16, 8, 0)
  40. TRS_END = datetime(2026, 10, 17, 16, 12, 0)
  41. OLDER_END = datetime(2026, 10, 17, 15, 0, 0)
  42. def _batch(
  43. *,
  44. batch_id: int,
  45. batch_no: str,
  46. status: str,
  47. end_time: datetime | None,
  48. revision: int | None,
  49. market: str = "a_share",
  50. trade_date: date = TRADE_DAY,
  51. business_date: date = BUSINESS_DAY,
  52. ) -> Batch:
  53. return Batch(
  54. id=batch_id,
  55. batch_no=batch_no,
  56. market=market,
  57. trade_date=trade_date,
  58. business_date=business_date,
  59. status=status,
  60. end_time=end_time,
  61. applied_revision=revision,
  62. )
  63. def _issue_done(batch_id: int = 9, revision: int = 3) -> Batch:
  64. return _batch(
  65. batch_id=batch_id,
  66. batch_no=f"IB{batch_id}",
  67. status="completed",
  68. end_time=ISSUE_END,
  69. revision=revision,
  70. )
  71. def _sales_done(batch_id: int = 4, revision: int = 2) -> Batch:
  72. return _batch(
  73. batch_id=batch_id,
  74. batch_no=f"SB{batch_id}",
  75. status="completed",
  76. end_time=SALES_END,
  77. revision=revision,
  78. )
  79. def test_issue_completed_sales_open_does_not_start() -> None:
  80. """发行已完成、销售未完成:不开跑。已完成一侧的报表不单独构成错误。"""
  81. scan = evaluate_gate(
  82. issue_batches=(
  83. _issue_done(),
  84. _batch(
  85. batch_id=1,
  86. batch_no="IB1",
  87. status="completed",
  88. end_time=OLDER_END,
  89. revision=1,
  90. ),
  91. ),
  92. sales_batches=(
  93. _batch(
  94. batch_id=8,
  95. batch_no="SB-OPEN",
  96. status="running",
  97. end_time=None,
  98. revision=None,
  99. ),
  100. ),
  101. trs_batches=(),
  102. hedge_trade_dates=set(),
  103. issue_report_dates={BUSINESS_DAY},
  104. sales_report_dates=set(),
  105. )
  106. assert scan.ready == ()
  107. assert scan.lines == ()
  108. def test_both_completed_without_hedge_starts_with_zero_trs() -> None:
  109. """两端都完成、无对冲:开跑,TRS 批次 id 为 0。同日取 id 最大的完成批次。"""
  110. scan = evaluate_gate(
  111. issue_batches=(
  112. _batch(
  113. batch_id=1,
  114. batch_no="IB1",
  115. status="completed",
  116. end_time=OLDER_END,
  117. revision=1,
  118. ),
  119. _issue_done(batch_id=9, revision=3),
  120. _batch(
  121. batch_id=12,
  122. batch_no="IB-NEWER",
  123. status="running",
  124. end_time=None,
  125. revision=None,
  126. ),
  127. ),
  128. sales_batches=(
  129. _batch(
  130. batch_id=2,
  131. batch_no="SB2",
  132. status="completed",
  133. end_time=OLDER_END,
  134. revision=1,
  135. ),
  136. _sales_done(batch_id=4, revision=2),
  137. ),
  138. trs_batches=(
  139. _batch(
  140. batch_id=7,
  141. batch_no="TB7",
  142. status="completed",
  143. end_time=TRS_END,
  144. revision=5,
  145. ),
  146. ),
  147. hedge_trade_dates=set(),
  148. issue_report_dates={BUSINESS_DAY},
  149. sales_report_dates={BUSINESS_DAY},
  150. )
  151. assert scan.lines == ()
  152. assert len(scan.ready) == 1
  153. ready = scan.ready[0]
  154. assert ready.market == "a_share"
  155. assert ready.trade_date == TRADE_DAY
  156. assert ready.business_date == BUSINESS_DAY
  157. assert ready.issue_batch_id == 9
  158. assert ready.sales_batch_id == 4
  159. assert ready.trs_batch_id == NO_HEDGE_BATCH_ID
  160. assert ready.issue_end_time == ISSUE_END
  161. assert ready.sales_end_time == SALES_END
  162. assert ready.trs_end_time == NO_HEDGE_END_TIME
  163. assert ready.issue_applied_revision == 3
  164. assert ready.sales_applied_revision == 2
  165. assert ready.trs_applied_revision == NO_HEDGE_REVISION
  166. def test_hedge_without_trs_does_not_start() -> None:
  167. """有对冲且 TRS 未完成:不开跑。"""
  168. scan = evaluate_gate(
  169. issue_batches=(_issue_done(),),
  170. sales_batches=(_sales_done(),),
  171. trs_batches=(
  172. _batch(
  173. batch_id=3,
  174. batch_no="TB-OPEN",
  175. status="running",
  176. end_time=None,
  177. revision=None,
  178. ),
  179. ),
  180. hedge_trade_dates={TRADE_DAY},
  181. issue_report_dates=set(),
  182. sales_report_dates=set(),
  183. )
  184. assert scan.ready == ()
  185. assert scan.lines == ()
  186. assert len(scan.blocked) == 1
  187. assert scan.blocked[0].market == "a_share"
  188. assert scan.blocked[0].trade_date == TRADE_DAY
  189. def test_hedge_with_completed_trs_uses_trs_batch() -> None:
  190. """有对冲且 TRS 已完成:开跑结构带上 TRS 的批次、结束时间和修订号。"""
  191. scan = evaluate_gate(
  192. issue_batches=(_issue_done(),),
  193. sales_batches=(_sales_done(),),
  194. trs_batches=(
  195. _batch(
  196. batch_id=3,
  197. batch_no="TB3",
  198. status="completed",
  199. end_time=TRS_END,
  200. revision=5,
  201. ),
  202. ),
  203. hedge_trade_dates={TRADE_DAY},
  204. issue_report_dates=set(),
  205. sales_report_dates=set(),
  206. )
  207. assert scan.lines == ()
  208. ready = scan.ready[0]
  209. assert ready.trs_batch_id == 3
  210. assert ready.trs_end_time == TRS_END
  211. assert ready.trs_applied_revision == 5
  212. def test_report_without_completed_batch_is_error_and_does_not_start() -> None:
  213. """报表已有行且批次未完成:一条 error,没有开跑结构。"""
  214. scan = evaluate_gate(
  215. issue_batches=(_issue_done(),),
  216. sales_batches=(
  217. _batch(
  218. batch_id=6,
  219. batch_no="SB-OPEN",
  220. status="running",
  221. end_time=None,
  222. revision=None,
  223. ),
  224. ),
  225. trs_batches=(),
  226. hedge_trade_dates=set(),
  227. issue_report_dates=set(),
  228. sales_report_dates={BUSINESS_DAY},
  229. )
  230. assert scan.ready == ()
  231. assert len(scan.lines) == 1
  232. line = scan.lines[0]
  233. assert line.verdict == VERDICT_ERROR
  234. assert line.card_kind == CARD_KIND_BATCH
  235. assert line.biz_key == "SB-OPEN"
  236. assert line.formula_text == FORMULA_EARLY_REPORT
  237. assert line.calc_text == "营业日 2026-10-17 已有报表行,market = a_share"
  238. assert line.calc_value is None
  239. assert line.check_value is None
  240. assert line.check_text == "db_sales.clearing_batch.status = running,销售,batch_no = SB-OPEN"
  241. assert line.basis == BASIS_EARLY_REPORT
  242. assert line.market == "a_share"
  243. assert line.business_date == BUSINESS_DAY
  244. def test_report_without_any_batch_names_the_gap() -> None:
  245. """还没有批次时,说明里写没有完成批次,业务键用营业日。"""
  246. scan = evaluate_gate(
  247. issue_batches=(),
  248. sales_batches=(),
  249. trs_batches=(),
  250. hedge_trade_dates=set(),
  251. issue_report_dates={BUSINESS_DAY},
  252. sales_report_dates=set(),
  253. )
  254. assert scan.ready == ()
  255. assert len(scan.lines) == 1
  256. line = scan.lines[0]
  257. assert line.verdict == VERDICT_ERROR
  258. assert line.biz_key == "2026-10-17"
  259. assert line.check_text == "db_issue.clearing_batch.status,发行,没有完成批次"
  260. assert "销售" not in line.check_text
  261. def test_other_market_does_not_satisfy_the_same_day() -> None:
  262. """同一交易日的另一个市场完成,不能算本市场开跑。"""
  263. scan = evaluate_gate(
  264. issue_batches=(_issue_done(),),
  265. sales_batches=(_sales_done(batch_id=4, revision=2),),
  266. trs_batches=(),
  267. hedge_trade_dates=set(),
  268. issue_report_dates=set(),
  269. sales_report_dates=set(),
  270. )
  271. sales_other = _batch(
  272. batch_id=4,
  273. batch_no="SB4",
  274. status="completed",
  275. end_time=SALES_END,
  276. revision=2,
  277. market="hk",
  278. )
  279. blocked = evaluate_gate(
  280. issue_batches=(_issue_done(),),
  281. sales_batches=(sales_other,),
  282. trs_batches=(),
  283. hedge_trade_dates=set(),
  284. issue_report_dates=set(),
  285. sales_report_dates=set(),
  286. )
  287. assert len(scan.ready) == 1
  288. assert blocked.ready == ()
  289. assert blocked.lines == ()
  290. def test_same_inputs_return_the_same_scan() -> None:
  291. """同一组 end_time 调用两次:返回内容相同。"""
  292. arguments = dict(
  293. issue_batches=(_issue_done(),),
  294. sales_batches=(_sales_done(),),
  295. trs_batches=(),
  296. hedge_trade_dates=set(),
  297. issue_report_dates=set(),
  298. sales_report_dates=set(),
  299. )
  300. first = evaluate_gate(**arguments)
  301. second = evaluate_gate(**arguments)
  302. assert first == second
  303. assert first.ready[0].issue_end_time == ISSUE_END
  304. assert first.ready[0].sales_end_time == SALES_END
  305. assert first.ready[0].trs_end_time == NO_HEDGE_END_TIME
  306. def test_completed_batch_missing_clock_is_rejected() -> None:
  307. """完成批次没有结束时间时不能编出幂等键。"""
  308. with pytest.raises(BatchRowError, match="end_time"):
  309. evaluate_gate(
  310. issue_batches=(
  311. _batch(
  312. batch_id=9,
  313. batch_no="IB9",
  314. status="completed",
  315. end_time=None,
  316. revision=3,
  317. ),
  318. ),
  319. sales_batches=(_sales_done(),),
  320. trs_batches=(),
  321. hedge_trade_dates=set(),
  322. issue_report_dates=set(),
  323. sales_report_dates=set(),
  324. )
  325. def test_gate_statements_are_single_reads() -> None:
  326. """门禁语句都是单条 SELECT,表名来自清单。"""
  327. issue_sql = batch_sql("db_issue.clearing_batch")
  328. sales_sql = batch_sql("db_sales.clearing_batch")
  329. trs_sql = batch_sql("db_trs.clearing_batch")
  330. # 销售、TRS 没有 applied_revision 列。TRS 也没有 market 列。
  331. assert "applied_revision" in issue_sql
  332. assert "0 AS applied_revision" in sales_sql
  333. assert "market," in sales_sql
  334. assert "'a_share' AS market" in trs_sql
  335. assert "0 AS applied_revision" in trs_sql
  336. assert "WHERE business_date = %s" in issue_sql
  337. assert "WHERE business_date = %s" in sales_sql
  338. assert "WHERE business_date = %s" in trs_sql
  339. statements = [
  340. issue_sql,
  341. sales_sql,
  342. trs_sql,
  343. HEDGE_SQL,
  344. *(report_date_sql(table) for table in ISSUE_REPORT_TABLES),
  345. *(report_date_sql(table) for table in SALES_REPORT_TABLES),
  346. ]
  347. for sql in statements:
  348. assert_business_statement(sql)
  349. with pytest.raises(BatchRowError):
  350. batch_sql("db_issue.clearing_batch; SELECT 1")
  351. with pytest.raises(StatementRejected):
  352. assert_business_statement("INSERT INTO db_issue.clearing_batch (id) VALUES (1)")
  353. def test_parse_batch_row_keeps_null_clock_on_open_batch() -> None:
  354. """未完成批次的结束时间可以空着,不能把整行丢弃。"""
  355. parsed = parse_batch_row((6, "SB-OPEN", "a_share", TRADE_DAY, BUSINESS_DAY, "running", None, None))
  356. assert parsed.id == 6
  357. assert parsed.end_time is None
  358. assert parsed.applied_revision is None
  359. class _ScriptedGateway:
  360. """按语句返回预设行,并记下实际发出的语句。"""
  361. def __init__(self) -> None:
  362. self.statements: list[tuple[str, object]] = []
  363. async def execute(self, sql: str, args: object = None) -> list[tuple[object, ...]]:
  364. assert_business_statement(sql)
  365. self.statements.append((sql, args))
  366. if sql == HEDGE_SQL:
  367. return []
  368. if sql.startswith("SELECT DISTINCT business_date FROM "):
  369. return []
  370. if sql.startswith("SELECT id, batch_no"):
  371. if "db_issue.clearing_batch" in sql:
  372. return [(9, "IB9", "a_share", TRADE_DAY, BUSINESS_DAY, "completed", ISSUE_END, 3)]
  373. if "db_sales.clearing_batch" in sql:
  374. return [(4, "SB4", "a_share", TRADE_DAY, BUSINESS_DAY, "completed", SALES_END, 2)]
  375. return []
  376. # 已完成轮次上的是/否检查另行取数。这例只钉开跑条件,那些表按空表返回。
  377. if sql.lstrip().upper().startswith("SELECT"):
  378. return []
  379. raise AssertionError(sql)
  380. def test_scan_gate_reads_batches_and_starts_without_hedge() -> None:
  381. """扫描只发只读语句。无对冲行时 TRS 批次 id 为 0。"""
  382. gateway = _ScriptedGateway()
  383. scan = asyncio.run(scan_gate(gateway, BUSINESS_DAY))
  384. assert scan.lines == ()
  385. assert scan.checks
  386. assert all(line.verdict == VERDICT_ERROR for line in scan.checks)
  387. assert all("completed" not in line.formula_text for line in scan.checks)
  388. assert scan.ready[0].trs_batch_id == NO_HEDGE_BATCH_ID
  389. assert scan.ready[0].trs_end_time == NO_HEDGE_END_TIME
  390. issued = [sql for sql, _args in gateway.statements]
  391. assert HEDGE_SQL in issued
  392. assert any(sql.startswith("SELECT DISTINCT business_date FROM db_issue.report_trade_order") for sql in issued)
  393. dated = [sql for sql in issued if sql.startswith(("SELECT id, batch_no", "SELECT DISTINCT business_date"))]
  394. assert dated
  395. assert all("WHERE business_date = %s" in sql for sql in dated)
  396. assert all(not sql.lstrip().upper().startswith("INSERT") for sql in issued)
  397. hedge_args = [args for sql, args in gateway.statements if sql == HEDGE_SQL]
  398. assert hedge_args == [(TRADE_DAY, "ORD%", 0)]
  399. scoped = [args for sql, args in gateway.statements if "WHERE business_date = %s" in sql]
  400. assert scoped
  401. assert scoped == [(BUSINESS_DAY,)] * len(scoped)
  402. def _ready(*, trs_batch_id: int = NO_HEDGE_BATCH_ID) -> ReadyRun:
  403. return ReadyRun(
  404. market="a_share",
  405. trade_date=TRADE_DAY,
  406. business_date=BUSINESS_DAY,
  407. issue_batch_id=9,
  408. sales_batch_id=4,
  409. trs_batch_id=trs_batch_id,
  410. issue_end_time=ISSUE_END,
  411. sales_end_time=SALES_END,
  412. trs_end_time=NO_HEDGE_END_TIME if trs_batch_id == NO_HEDGE_BATCH_ID else TRS_END,
  413. issue_applied_revision=3,
  414. sales_applied_revision=0,
  415. trs_applied_revision=0,
  416. )
  417. def _round(**overrides: object) -> IssueRound:
  418. payload: dict[str, object] = {
  419. "dataset_id": 7,
  420. "batch_id": 9,
  421. "revision": 3,
  422. "baseline_kind": "opening",
  423. "base_dataset_id": None,
  424. "base_trade_date": None,
  425. "base_row_trade_date": None,
  426. "base_missing": False,
  427. "upstream_fixed": 1,
  428. "upstream_batch_id": None,
  429. "upstream_dataset_id": None,
  430. }
  431. payload.update(overrides)
  432. return IssueRound(**payload) # type: ignore[arg-type]
  433. def _facts(**overrides: object) -> PublishedFacts:
  434. payload: dict[str, object] = {
  435. "issue_rounds": (_round(),),
  436. "issue_business_date": date(2026, 10, 20),
  437. "sales_versions": ("9:3:IB9",),
  438. "sales_dataset_id": 11,
  439. "sales_kind": "cleared",
  440. "sales_status": "active",
  441. "sales_dataset_batch_id": 4,
  442. "trs_dataset_id": None,
  443. "trs_kind": "",
  444. "trs_copy_no": None,
  445. "trs_status": "",
  446. "trs_dataset_batch_id": None,
  447. "trs_business_date": None,
  448. }
  449. payload.update(overrides)
  450. return PublishedFacts(**payload) # type: ignore[arg-type]
  451. def test_published_checks_stay_quiet_when_the_round_matches() -> None:
  452. """基线、唯一发布、清单版本和销售发布都对上时,不另出一张完成卡。"""
  453. lines = evaluate_published(_ready(), _facts())
  454. assert lines == ()
  455. def test_published_checks_name_the_mismatch() -> None:
  456. """每一类不一致只出对应公式,公式里不出现批次 completed。"""
  457. missing = evaluate_published(_ready(), _facts(issue_rounds=()))
  458. assert {line.formula_text for line in missing} >= {FORMULA_ISSUE_PUBLISH, FORMULA_ISSUE_BASELINE}
  459. opening = evaluate_published(_ready(), _facts(issue_rounds=(_round(base_dataset_id=1),)))
  460. assert [line.formula_text for line in opening] == [FORMULA_ISSUE_BASELINE]
  461. stalled = evaluate_published(_ready(), _facts(issue_business_date=BUSINESS_DAY))
  462. assert FORMULA_ISSUE_PUBLISH in {line.formula_text for line in stalled}
  463. version = evaluate_published(_ready(), _facts(sales_versions=("8:3:IB9",)))
  464. assert [line.formula_text for line in version] == [FORMULA_SALES_UPSTREAM]
  465. draft = evaluate_published(_ready(), _facts(sales_status="backing_up"))
  466. assert [line.formula_text for line in draft] == [FORMULA_SALES_PUBLISH]
  467. hedged = _ready(trs_batch_id=8)
  468. late = _facts(
  469. issue_rounds=(_round(baseline_kind="published", base_dataset_id=2, base_trade_date=date(2026, 10, 16), base_row_trade_date=date(2026, 10, 16), upstream_batch_id=8, upstream_dataset_id=15),),
  470. trs_dataset_id=15,
  471. trs_kind="cleared",
  472. trs_copy_no=1,
  473. trs_status="backing_up",
  474. trs_dataset_batch_id=8,
  475. trs_business_date=BUSINESS_DAY,
  476. )
  477. named = {line.formula_text for line in evaluate_published(hedged, late)}
  478. assert named == {FORMULA_TRS_DATE, FORMULA_TRS_VERSION}
  479. quiet = evaluate_published(
  480. hedged,
  481. _facts(
  482. issue_rounds=late.issue_rounds,
  483. trs_dataset_id=15,
  484. trs_kind="cleared",
  485. trs_copy_no=1,
  486. trs_status="available",
  487. trs_dataset_batch_id=8,
  488. trs_business_date=date(2026, 10, 20),
  489. ),
  490. )
  491. assert quiet == ()
  492. for group in (missing, opening, stalled, version, draft, evaluate_published(hedged, late)):
  493. assert all("completed" not in line.formula_text for line in group)