mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: add jp kr closing auction phase windows (#1875)
This commit is contained in:
@@ -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 自动选择端口不一致会导致窗口继续等待旧端口并连接超时的问题。
|
||||
|
||||
@@ -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 返回无数据,避免错日资料静默进入报告。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user