mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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:
@@ -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:25–13:30 的 5 分钟收盘竞价此前永远无法判定为 `closing_auction`(收盘前一刻仍 `intraday`、13:30 直接 `postmarket`);补 `"tw": 5` 修正,附阶段边界回归测试。仅 tw 加项,cn/hk/us 与 jp/kr 行为不变。
|
||||
|
||||
## [3.24.1] - 2026-06-28
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -797,7 +797,7 @@ P0 只新增内部市场阶段推断基线,不改变现有每日收盘报告
|
||||
| `premarket` | 常规交易时段开盘前;不代表已经获取盘前扩展时段行情 |
|
||||
| `intraday` | 常规交易时段内,且不处于午休或临近收盘窗口 |
|
||||
| `lunch_break` | 市场日历提供的午间休市窗口;无午休市场不会进入此阶段 |
|
||||
| `closing_auction` | 临近收盘启发式窗口:A 股 3 分钟、港股 10 分钟、美股 5 分钟;不代表完整交易所竞价制度 |
|
||||
| `closing_auction` | 临近收盘启发式窗口:A 股 3 分钟、港股 10 分钟、美股 5 分钟、台股 5 分钟(13:25–13:30);不代表完整交易所竞价制度 |
|
||||
| `postmarket` | 常规交易时段收盘后;不代表已经获取盘后扩展时段行情 |
|
||||
| `non_trading` | 当前市场本地日期不是交易日 |
|
||||
| `unknown` | 未知市场、日历不可用或日历异常,无法可靠推断阶段 |
|
||||
|
||||
@@ -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:25–13: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 |
|
||||
|
||||
@@ -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:00–13:30 连续交易、无午休;收盘集合竞价暂不建模,与 jp/kr 一致。若本地 `exchange-calendars` 版本缺少对应日历,既有 fail-open/fail-closed 语义保持不变。
|
||||
- 交易日历注册 `tw: XTAI / Asia/Taipei`。TWSE 为 09:00–13:30 连续交易、无午休;收盘集合竞价 13:25–13: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 T86,legacy `rwd` 端点)/ 上柜(TPEx OpenAPI)每日外资·投信·自营商·三大法人买卖超(单位:**股数**;按日期+市场做单日全市场缓存再过滤个股,TPEx 民国年转西元有单测覆盖)。接口失败/限流/空响应/字段缺失一律 **fail-open** 返回无数据,不中断分析;仅对 `.TW`/`.TWO` 生效,不改动现有市场流程。资料来源为政府开放资料,采「政府资料开放授权条款第 1 版」(OGDL v1,允许商用与再散布,需标示来源)。**三大法人已接入台股报告的 `institution` 区块(展示原始买卖超净额,默认开启、fail-open,取不到数据维持 `not_supported`);Web 展示、评分权重与 `capital_flow_signal` 派生仍为后续。**
|
||||
|
||||
|
||||
@@ -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:25–13: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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user