fix(market-phase): detect the tw closing auction (13:25-13:30) (#1869)

_CLOSING_AUCTION_WINDOW_MINUTES had no tw entry, so `.get(market, 0)` gave a
zero-width window and infer_market_phase("tw") could never return CLOSING_AUCTION:
the last tick before 13:30 stayed INTRADAY and 13:30 flipped straight to POSTMARKET.

TWSE/TPEx run a 5-minute closing call auction (13:25-13:30), so add "tw": 5.
+ a phase-boundary regression test (no lunch break, 13:30 half-hour close):
13:24 INTRADAY / 13:25-13:29 CLOSING_AUCTION / 13:30 POSTMARKET.
This commit is contained in:
Wenyu Chiou
2026-07-01 19:11:55 +08:00
committed by GitHub
parent 7230465da5
commit 9433f610cc
6 changed files with 44 additions and 4 deletions

View File

@@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 台股财务金额币别标示TWD 金额此前落入默认「元」(在 A 股语境易误读为人民币)`_CURRENCY_SUFFIX` 补入 TWD→「新台币」营业收入/归母净利润/经营现金流/每股现金分红均正确标注新台币。
- [改进] 台股三大法人 fetcher 韧性加固:(1) 接入熔断器(复用 `realtime_types.CircuitBreaker`,按市场 twse/tpex 分流,连续失败 3 次→冷却 ~5min→半开探测TWSE/TPEx 端点异常时快速跳过网络往返并 fail-open避免端点故障时每档个股都付 timeout+throttle(2) TPEx OpenAPI 仅服务最新交易日,调用方传入与服务日期不符的明确日期时改为 fail-open返回无数据避免静默返回错日资料。
- [修复] 台股tw市场阶段`market_phase`)新增收盘集合竞价识别:`_CLOSING_AUCTION_WINDOW_MINUTES``tw` 键时 `.get(market, 0)` 得零宽窗口TWSE/TPEx 13:2513:30 的 5 分钟收盘竞价此前永远无法判定为 `closing_auction`(收盘前一刻仍 `intraday`、13:30 直接 `postmarket`);补 `"tw": 5` 修正,附阶段边界回归测试。仅 tw 加项cn/hk/us 与 jp/kr 行为不变。
## [3.24.1] - 2026-06-28
### 修复

View File

@@ -797,7 +797,7 @@ P0 只新增内部市场阶段推断基线,不改变现有每日收盘报告
| `premarket` | 常规交易时段开盘前;不代表已经获取盘前扩展时段行情 |
| `intraday` | 常规交易时段内,且不处于午休或临近收盘窗口 |
| `lunch_break` | 市场日历提供的午间休市窗口;无午休市场不会进入此阶段 |
| `closing_auction` | 临近收盘启发式窗口A 股 3 分钟、港股 10 分钟、美股 5 分钟;不代表完整交易所竞价制度 |
| `closing_auction` | 临近收盘启发式窗口A 股 3 分钟、港股 10 分钟、美股 5 分钟、台股 5 分钟13:2513:30;不代表完整交易所竞价制度 |
| `postmarket` | 常规交易时段收盘后;不代表已经获取盘后扩展时段行情 |
| `non_trading` | 当前市场本地日期不是交易日 |
| `unknown` | 未知市场、日历不可用或日历异常,无法可靠推断阶段 |

View File

@@ -664,7 +664,7 @@ The phase labels describe regular-session state:
| `premarket` | Before the regular session opens; does not mean extended-hours quotes were fetched |
| `intraday` | Inside the regular session and outside lunch break or the near-close window |
| `lunch_break` | Lunch break window supplied by the market calendar; markets without lunch breaks skip this phase |
| `closing_auction` | Near-close heuristic window: 3 minutes for CN, 10 minutes for HK, and 5 minutes for US; this is not a full exchange auction model |
| `closing_auction` | Near-close heuristic window: 3 minutes for CN, 10 minutes for HK, 5 minutes for US, and 5 minutes for TW (13:2513:30); this is not a full exchange auction model |
| `postmarket` | After the regular session closes; does not mean post-market quotes were fetched |
| `non_trading` | The current market-local date is not a trading session |
| `unknown` | Unknown market, calendar unavailable, or calendar error, so the phase cannot be inferred reliably |

View File

@@ -105,7 +105,7 @@ PY
- 台股日线和基础实时/近实时行情只走 `YfinanceFetcher`,不尝试 AkShare、Tushare、Efinance、Pytdx、Baostock 等 A 股专属数据源。
- 基本面复用既有 offshore yfinance 轻量路径A 股专属资金流、龙虎榜、板块等能力按 `not_supported` 降级。
- 报告 Prompt 已增加台股市场语义新台币、三大法人、TWSE/TPEx ±10% 涨跌停),避免套用 A 股北向资金、龙虎榜等概念。
- 交易日历注册 `tw: XTAI / Asia/Taipei`。TWSE 为 09:0013:30 连续交易、无午休;收盘集合竞价暂不建模,与 jp/kr 一致。若本地 `exchange-calendars` 版本缺少对应日历,既有 fail-open/fail-closed 语义保持不变。
- 交易日历注册 `tw: XTAI / Asia/Taipei`。TWSE 为 09:0013:30 连续交易、无午休;收盘集合竞价 13:2513:30 已按 5 分钟启发式窗口建模(`_CLOSING_AUCTION_WINDOW_MINUTES["tw"]=5``market_phase` 可返回 `closing_auction`jp/kr 暂未建模。若本地 `exchange-calendars` 版本缺少对应日历,既有 fail-open/fail-closed 语义保持不变。
- 主要指数提供加权指数 `^TWII` 与柜买指数 `^TWOII`
- 三大法人买卖超institutional flows资料层`TwInstitutionalFetcher``data_provider/tw_institutional_fetcher.py`提供上市TWSE T86legacy `rwd` 端点)/ 上柜TPEx OpenAPI每日外资·投信·自营商·三大法人买卖超单位**股数**;按日期+市场做单日全市场缓存再过滤个股TPEx 民国年转西元有单测覆盖)。接口失败/限流/空响应/字段缺失一律 **fail-open** 返回无数据,不中断分析;仅对 `.TW`/`.TWO` 生效,不改动现有市场流程。资料来源为政府开放资料,采「政府资料开放授权条款第 1 版」(OGDL v1允许商用与再散布需标示来源)。**三大法人已接入台股报告的 `institution` 区块展示原始买卖超净额默认开启、fail-open取不到数据维持 `not_supported`Web 展示、评分权重与 `capital_flow_signal` 派生仍为后续。**

View File

@@ -53,7 +53,10 @@ MARKET_TIMEZONE = {
# P0 market phase baseline (Issue #1386). This is an intentionally small
# regular-session inference layer; it does not change existing fail-open
# trading-day filtering or effective-date behavior.
_CLOSING_AUCTION_WINDOW_MINUTES = {"cn": 3, "hk": 10, "us": 5}
# tw: TWSE/TPEx run a 13:2513:30 closing call auction (5 min). Without an entry
# here .get(market, 0) yields a zero-width window, so the tw closing auction was
# never labelled CLOSING_AUCTION (the last tick stayed INTRADAY, then POSTMARKET).
_CLOSING_AUCTION_WINDOW_MINUTES = {"cn": 3, "hk": 10, "us": 5, "tw": 5}
_SUPPORTED_ANALYSIS_PHASES = {
"auto",
"premarket",

View File

@@ -115,6 +115,16 @@ class _NaiveTimestampCalendar(_FakeCalendar):
return pd.Timestamp(datetime.combine(session.date(), time(self._close_hour, 0)))
class _HalfHourCloseCalendar(_FakeCalendar):
"""TWSE closes at 13:30 (half-hour); _FakeCalendar only models on-the-hour close."""
def session_close(self, session: pd.Timestamp) -> pd.Timestamp:
local_close = datetime.combine(
session.date(), time(13, 30), tzinfo=ZoneInfo(self._tz_name)
)
return pd.Timestamp(local_close).tz_convert("UTC")
class EffectiveTradingDateTestCase(unittest.TestCase):
def test_weekend_returns_previous_session(self):
fake_calendar = _FakeCalendar(
@@ -385,6 +395,31 @@ class InferMarketPhaseTestCase(unittest.TestCase):
with self.subTest(current_time=current_time):
self.assertEqual(self._infer_with_calendar("us", current_time, fake_calendar), expected)
def test_tw_phase_boundaries_include_five_minute_closing_window(self):
# TWSE: continuous 09:00-13:30, no lunch break, 13:25-13:30 closing auction.
fake_calendar = _HalfHourCloseCalendar(
sessions=[date(2026, 3, 27)],
close_hour=13, # unused: _HalfHourCloseCalendar hard-codes the 13:30 close
tz_name="Asia/Taipei",
open_time=time(9, 0),
break_start=None,
break_end=None,
)
tz = ZoneInfo("Asia/Taipei")
cases = (
(datetime(2026, 3, 27, 8, 59, tzinfo=tz), trading_calendar.MarketPhase.PREMARKET),
(datetime(2026, 3, 27, 9, 0, tzinfo=tz), trading_calendar.MarketPhase.INTRADAY),
(datetime(2026, 3, 27, 13, 24, tzinfo=tz), trading_calendar.MarketPhase.INTRADAY),
(datetime(2026, 3, 27, 13, 25, tzinfo=tz), trading_calendar.MarketPhase.CLOSING_AUCTION),
(datetime(2026, 3, 27, 13, 29, tzinfo=tz), trading_calendar.MarketPhase.CLOSING_AUCTION),
(datetime(2026, 3, 27, 13, 30, tzinfo=tz), trading_calendar.MarketPhase.POSTMARKET),
)
for current_time, expected in cases:
with self.subTest(current_time=current_time):
self.assertEqual(self._infer_with_calendar("tw", current_time, fake_calendar), expected)
def test_unknown_market_and_calendar_failures_return_unknown(self):
current_time = datetime(2026, 3, 27, 10, 0, tzinfo=ZoneInfo("Asia/Shanghai"))