ADR 0007: Two facts, two columns, in trading_status¶
- Status: Accepted
- Date: 2026-08-30
- Relates to: ADR-0006
Context¶
trading_status.status carried two independent facts in one string: whether a
security was trading that day, and whether it was under the exchange's
risk-warning designation (ST / *ST). A security can be both at once, and a
single column cannot say so. The writer resolved the conflict with an
if/elif, and halting won:
000711.SZ (ST京蓝) 2026-08-27 status=st
2026-08-28 status=suspended
The company did not leave risk warning that day — it halted. The designation
was dropped from the stored history, and derive/market_breadth.py, which
reads it to choose the ±5% limit band rather than ±10%, measured that session
against the wrong band.
The same if/elif had an else that called everything remaining normal with
is_trading=True, with no concept of delisting. Measured on a full lake for
2026-08-28: 611 symbols carrying a delist_date — one of them since
1999-07-12 — were published as normally trading, none of them with a bar that
day. A vendor board cannot report on a security that has left the market, so no
snapshot source could ever have fixed this.
Both defects surfaced together through st_labels_vs_exchange, which reported
four symbols the exchanges designate ST that curated did not label. Two were
halted ST names (the first defect) and two were delisted names the exchange
still lists pending deregistration (the second). The check's own module comment
had anticipated that second pair and asserted the shared-universe restriction
handled them — it did not, precisely because the delisting defect kept issuing
them "normal" rows and so kept them inside the shared universe.
Decision¶
-
statusis the trading state alone:normal|suspended|delisted.risk_warningis a separate nullable boolean carrying the ST / *ST designation. Writers set them independently; no precedence rule exists because there is no longer a conflict to resolve. -
Delisted rows are written from
instruments, not requested from a vendor. A delisted symbol is removed from the daily board request and getsstatus=delisted,is_trading=False,source=derived_delisted. Itsrisk_warningcomes from the final 简称 (*ST元成), the only ST evidence left once the boards drop the symbol. Scoping is per session, so a watermark catch-up still treats the sessions before delisting as live. -
risk_warningis nullable and null means "no evidence". A derived bar-gap suspension row proves the security did not trade; it proves nothing about risk warning, and writingFalsethere would invent a fact.quality/st_coverage.pyalready owns the question of where ST evidence is missing. -
Reads work on both encodings.
validate_dataframeupgrades legacy frames (status="st") on the way in, for every read path at once, so a lake is correct before and after migration.scripts/migrate_trading_status_risk_warning.pymakes the physical schema uniform.
Consequences¶
- The question "was this security trading on day X" is now answerable from this dataset alone, in all three states.
market_breadthprices halted ST names against the correct band.- The tradable-universe filter tests risk warning as its own predicate, so a name cannot slip in by being halted-and-ST.
- ST and *ST are not distinguished. No source feeding this dataset ever
made the distinction — Baostock exposes one
isSTflag and the Tushare adapter already collapsed itsST/*STtype — so the boolean is what the evidence supports. The finer designation stays in the exchange 简称, viainstruments.nameandadapters.exchange.st_lists.is_st_name. - The migration does not back-fill
delistedrows into history, and it cannot recover an ST label that a suspended row already destroyed: neither is present in the stored bytes. Correcting a past window means re-running the daily step over it. - The legacy-upgrade branch in
validate_dataframeis deliberate debt with a stated exit: delete it once every lake has run the migration.
Alternatives considered¶
- Keep one column, add composite values (
st_suspended, …). Smallest change, but the enum multiplies with every new dimension and every consumer has to know the product rather than the factors. It also leaves the same question — which fact wins — merely renamed. - Reorder the if/elif so ST wins over suspension. Trades one silent loss for the mirror-image one: halts would vanish for ST names instead.
- Drop delisted symbols from
trading_statusentirely. Cheaper, and "absent" would mean "not in the market". But absence is indistinguishable from a coverage gap, and this dataset's whole job is to answer the trading question explicitly. - Back-fill
delistedrows across history in the migration. Would make old partitions look tidy while stamping today's delist dates onto sessions that never observed them — a manufactured point-in-time fact, which is the exact failure the PIT work exists to prevent.