diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5d9c89b3b..8399e8aa1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [新功能] 钉钉群机器人通知支持 — 支持通过 `DINGTALK_WEBHOOK_URL` 和 `DINGTALK_SECRET` 配置钉钉推送,并支持长文本自动切片以适配 20KB 限制。 - [文档] 记录 Agent `/chat/stream` progress event 契约,说明新增 `stage_start`、`stage_done`、`pipeline_timeout`、`pipeline_budget_skipped` 的字段语义、Web 兼容边界、验证方式、回滚方式;其中 `pipeline_budget_skipped` 表示剩余预算不足、未启动下一阶段即跳过的语义;本变更不触及 provider/model/Base URL 或运行时配置迁移语义。 +- [修复] 日股/韩股 `market_phase` 补齐收盘集合竞价识别:JP 15:25-15:30 与 KR 15:20-15:30 现在会进入 `closing_auction`,避免临近收盘阶段仍被标记为普通 `intraday`;仅调整阶段标签和派生 `market_phase_summary`,不改变数据源、配置或交易日 fail-open/fail-closed 语义。 - [修复] Discord 长报告推送按 2000 字符上限分片逐段发送,遇到 429 限流会按 `retry_after`/`Retry-After` 有限重试,避免中途失败后只收到前半段报告。 - [改进] #1777 台股三大法人 fetcher(`TwInstitutionalFetcher`)增加缓存防击穿:并发同 (市场, 日期) 调用合并为单次上游请求,保护 TWSE T86 ~3 req/5s 限流额度;不同 key 仍并行;新增并发单次抓取、不同 key 各抓一次、HTTP 错误 fail-open 回归测试。 - [修复] 修复桌面端启动时 `.env` 中 `WEBUI_PORT` 与 Electron 自动选择端口不一致会导致窗口继续等待旧端口并连接超时的问题。 diff --git a/docs/market-support.md b/docs/market-support.md index 14807d0fe..4cea797d6 100644 --- a/docs/market-support.md +++ b/docs/market-support.md @@ -17,7 +17,7 @@ - 日股/韩股日线和基础实时/近实时行情只走 `YfinanceFetcher`,不尝试 AkShare、Tushare、Efinance、Pytdx、Baostock 等 A 股专属数据源;yfinance 报价会尽量带上 `market`、`currency`、`data_quality`、`missing_fields` 等质量元数据。 - 基本面复用既有 offshore yfinance 轻量路径;A 股专属资金流、龙虎榜、板块等能力按 `not_supported` 降级,offshore 基本面上下文也会标记 provider、as_of、data_quality 和缺失块。 - 报告 Prompt 已增加日股/韩股市场语义,避免套用 A 股涨跌停、北向资金、龙虎榜、融资融券等概念。 -- 交易日历注册 `jp: XTKS / Asia/Tokyo` 与 `kr: XKRX / Asia/Seoul`。若本地 `exchange-calendars` 版本缺少对应日历,既有 fail-open/fail-closed 语义保持不变。 +- 交易日历注册 `jp: XTKS / Asia/Tokyo` 与 `kr: XKRX / Asia/Seoul`。日股常规阶段可识别盘前、盘中、午休、15:25-15:30 收盘集合竞价、盘后与非交易日;韩股常规阶段可识别盘前、盘中、15:20-15:30 收盘集合竞价、盘后与非交易日。若本地 `exchange-calendars` 版本缺少对应日历,既有 fail-open/fail-closed 语义保持不变。 兼容性与回退说明(针对结构化检测命中项): @@ -107,7 +107,7 @@ PY - 台股日线和基础实时/近实时行情只走 `YfinanceFetcher`,不尝试 AkShare、Tushare、Efinance、Pytdx、Baostock 等 A 股专属数据源。 - 基本面复用既有 offshore yfinance 轻量路径;`institution` 区块额外消费台股三大法人资料并渲染到报告,A 股专属资金流、龙虎榜、板块等能力按 `not_supported` 降级。 - 报告 Prompt 已增加台股市场语义(新台币、三大法人、TWSE/TPEx ±10% 涨跌停),并将三大法人净买卖超注入 LLM 分析上下文,避免套用 A 股北向资金、龙虎榜等概念。 -- 交易日历注册 `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 语义保持不变。 +- 交易日历注册 `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 也已按常规交易时段补齐收盘集合竞价窗口(JP 15:25-15:30、KR 15:20-15:30)。若本地 `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,允许商用与再散布,需标示来源)。 - 三大法人 fetcher 已具备并发缓存防击穿和按 TWSE/TPEx 分流的熔断保护;TPEx OpenAPI 仅服务最新交易日,传入与服务日期不符的明确日期会 fail-open 返回无数据,避免错日资料静默进入报告。 diff --git a/src/core/trading_calendar.py b/src/core/trading_calendar.py index 67520236a..420a713eb 100644 --- a/src/core/trading_calendar.py +++ b/src/core/trading_calendar.py @@ -53,10 +53,18 @@ 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. -# 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} +# tw: TWSE/TPEx run a 13:25-13:30 closing call auction (5 min). JP/KR use +# regular-session closing auction windows before the 15:30 close (JP 5 min, +# KR 10 min). Without an entry here .get(market, 0) yields a zero-width +# window, so the last regular-session minutes stay INTRADAY until POSTMARKET. +_CLOSING_AUCTION_WINDOW_MINUTES = { + "cn": 3, + "hk": 10, + "us": 5, + "jp": 5, + "kr": 10, + "tw": 5, +} _SUPPORTED_ANALYSIS_PHASES = { "auto", "premarket", diff --git a/tests/test_trading_calendar.py b/tests/test_trading_calendar.py index 24adcd9bb..b426d2a28 100644 --- a/tests/test_trading_calendar.py +++ b/tests/test_trading_calendar.py @@ -115,12 +115,32 @@ 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.""" +class _CloseTimeCalendar(_FakeCalendar): + """Calendar with a minute-level close time; _FakeCalendar only models hour close.""" + + def __init__( + self, + *, + sessions, + close_time: time, + tz_name: str, + open_time: time = time(9, 30), + break_start: Optional[time] = None, + break_end: Optional[time] = None, + ): + super().__init__( + sessions=sessions, + close_hour=close_time.hour, + tz_name=tz_name, + open_time=open_time, + break_start=break_start, + break_end=break_end, + ) + self._close_time = close_time def session_close(self, session: pd.Timestamp) -> pd.Timestamp: local_close = datetime.combine( - session.date(), time(13, 30), tzinfo=ZoneInfo(self._tz_name) + session.date(), self._close_time, tzinfo=ZoneInfo(self._tz_name) ) return pd.Timestamp(local_close).tz_convert("UTC") @@ -397,9 +417,9 @@ class InferMarketPhaseTestCase(unittest.TestCase): 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( + fake_calendar = _CloseTimeCalendar( sessions=[date(2026, 3, 27)], - close_hour=13, # unused: _HalfHourCloseCalendar hard-codes the 13:30 close + close_time=time(13, 30), tz_name="Asia/Taipei", open_time=time(9, 0), break_start=None, @@ -420,6 +440,59 @@ class InferMarketPhaseTestCase(unittest.TestCase): with self.subTest(current_time=current_time): self.assertEqual(self._infer_with_calendar("tw", current_time, fake_calendar), expected) + def test_jp_phase_boundaries_include_lunch_and_five_minute_closing_window(self): + # TSE domestic stocks: 09:00-11:30, 12:30-15:30, closing auction 15:25-15:30. + fake_calendar = _CloseTimeCalendar( + sessions=[date(2026, 3, 27)], + close_time=time(15, 30), + tz_name="Asia/Tokyo", + open_time=time(9, 0), + break_start=time(11, 30), + break_end=time(12, 30), + ) + + tz = ZoneInfo("Asia/Tokyo") + 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, 11, 30, tzinfo=tz), trading_calendar.MarketPhase.LUNCH_BREAK), + (datetime(2026, 3, 27, 12, 30, tzinfo=tz), trading_calendar.MarketPhase.INTRADAY), + (datetime(2026, 3, 27, 15, 24, tzinfo=tz), trading_calendar.MarketPhase.INTRADAY), + (datetime(2026, 3, 27, 15, 25, tzinfo=tz), trading_calendar.MarketPhase.CLOSING_AUCTION), + (datetime(2026, 3, 27, 15, 29, tzinfo=tz), trading_calendar.MarketPhase.CLOSING_AUCTION), + (datetime(2026, 3, 27, 15, 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("jp", current_time, fake_calendar), expected) + + def test_kr_phase_boundaries_include_ten_minute_closing_window_without_lunch(self): + # KRX regular session: 09:00-15:30, closing auction 15:20-15:30. + fake_calendar = _CloseTimeCalendar( + sessions=[date(2026, 3, 27)], + close_time=time(15, 30), + tz_name="Asia/Seoul", + open_time=time(9, 0), + break_start=None, + break_end=None, + ) + + tz = ZoneInfo("Asia/Seoul") + 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, 12, 0, tzinfo=tz), trading_calendar.MarketPhase.INTRADAY), + (datetime(2026, 3, 27, 15, 19, tzinfo=tz), trading_calendar.MarketPhase.INTRADAY), + (datetime(2026, 3, 27, 15, 20, tzinfo=tz), trading_calendar.MarketPhase.CLOSING_AUCTION), + (datetime(2026, 3, 27, 15, 29, tzinfo=tz), trading_calendar.MarketPhase.CLOSING_AUCTION), + (datetime(2026, 3, 27, 15, 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("kr", 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"))