Files
daily_stock_analysis/src/core/trading_calendar.py
zhulinsen ade3b4cb6e feat: add JP KR market review support (#1822)
* feat: add JP KR market review support

* fix(review-feedback-1822): update the Market Light schema/service support before accepting these

* fix(review-feedback-1822): add JP/KR to daily market context before accepting them and update

* fix(review-feedback-1822): add English JP/KR strategy text or make the renderer language-aware

* fix(review-feedback-1822): update the prompt role/shell alongside the new accepted regions

* fix(review-feedback-1822): 修正 PR 描述中的过期验证结论,并补充/澄清 JP/KR Yahoo Finance 指数兼容性证据或在线验证边界

* fix(review-feedback-1822): 收敛 Market Light 告警契约与用户文档同步问题

* fix(review-feedback-1822): 修正后再合入

* fix(review-feedback-1822): 补齐外部 Yahoo Finance 指数接入与运行时配置变更的兼容性/迁移证据,并修正 PR 描述中与当前 CI 状态不一致的内容

* fix(review-feedback-1822): 当前 CI 状态为 failure,且阻断型 backend-gate 失败

* fix(review-feedback-1822): 修复 MARKET REVIEW REGION 逗号值在交易日过滤与配置 schema 中的契约漂移,并补充对应回归测试

* fix(review-feedback-1822): 收敛 PR 描述中的验证状态与用户可见 Web 改动证据

* fix(review-feedback-1822): 修正 PR 描述中的过期验证结论,并补充 Web 设置变更的截图或无法截图时的替代可视证据说明

* fix(review-feedback-1822): 收敛 PR 描述中的验证状态,并补齐 Web 设置变更的截图或替代可视证据

* fix(review-feedback-1822): 收敛 PR 描述后再合入

* fix(review-feedback-1822): 收敛 PR 描述中的验证状态,并补充 Web 设置变更截图或无法截图时的替代可视证据说明

* fix(review-feedback-1822): 收敛验证状态和 Web UI 可视证据,避免合入记录与实际 head 不一致

* fix(review-feedback-1822): 收敛 PR 描述中的验证状态与 Web 可视证据

* fix(review-feedback-1822): 收敛 PR 描述中的验证状态,并补齐 Web 设置变更的截图或替代可视证据说明

* fix(review-feedback-1822): 修正 PR 描述与当前 CI 事实不一致的问题,补充 Web 设置可视证据,并收敛或拆出 PR 模板改动

* fix(review-feedback-1822): 收敛 PR 描述与证据

* fix(review-feedback-1822): 修正 PR 描述与证据,使验证状态、用户可见变更证据、模板改动范围和当前 head 保持一致

* fix(review-feedback-1822): 收敛 PR 描述、补齐可视证据,并澄清/补充外部模型/API 与运行时配置迁移相关兼容性证据

* fix(review-feedback-1822): 收敛 Web/文档契约不一致、同步残留测试,并更新 PR 描述与可视证据
2026-06-28 17:03:15 +08:00

587 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
===================================
交易日历模块 (Issue #373 / Issue #1386 P0)
===================================
职责:
1. 按市场A股/港股/美股/日股/韩股/台股)判断当日是否为交易日
2. 按市场时区取“今日”日期,避免服务器 UTC 导致日期错误
3. 支持 per-stock 过滤:只分析当日开市市场的股票
4. 提供 regular-session 市场阶段推断基线,不改变现有分析入口行为
依赖exchange-calendars可选交易日判断不可用时 fail-open阶段推断不可用时 unknown
"""
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, Dict, List, Optional, Set, Tuple
from zoneinfo import ZoneInfo
import pandas as pd
from src.services.market_symbol_utils import get_suffix_market
logger = logging.getLogger(__name__)
# Exchange-calendars availability
_XCALS_AVAILABLE = False
try:
import exchange_calendars as xcals
_XCALS_AVAILABLE = True
except ImportError:
logger.warning(
"exchange-calendars not installed; trading day check disabled. "
"Run: pip install exchange-calendars"
)
# Market -> exchange code (exchange-calendars)
MARKET_EXCHANGE = {"cn": "XSHG", "hk": "XHKG", "us": "XNYS", "jp": "XTKS", "kr": "XKRX", "tw": "XTAI"}
# Market -> IANA timezone for "today"
MARKET_TIMEZONE = {
"cn": "Asia/Shanghai",
"hk": "Asia/Hong_Kong",
"us": "America/New_York",
"jp": "Asia/Tokyo",
"kr": "Asia/Seoul",
"tw": "Asia/Taipei",
}
# 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}
_SUPPORTED_ANALYSIS_PHASES = {
"auto",
"premarket",
"intraday",
"postmarket",
}
class MarketPhase(str, Enum):
"""Regular-session market phase labels for Issue #1386 P0."""
PREMARKET = "premarket"
INTRADAY = "intraday"
LUNCH_BREAK = "lunch_break"
CLOSING_AUCTION = "closing_auction"
POSTMARKET = "postmarket"
NON_TRADING = "non_trading"
UNKNOWN = "unknown"
@dataclass
class MarketPhaseContext:
"""Runtime market-phase context for stock analysis plumbing."""
market: Optional[str]
phase: MarketPhase
market_local_time: datetime
session_date: date
effective_daily_bar_date: date
is_trading_day: Optional[bool]
is_market_open_now: Optional[bool]
is_partial_bar: Optional[bool]
minutes_to_open: Optional[int] = None
minutes_to_close: Optional[int] = None
trigger_source: str = "system"
analysis_intent: str = "auto"
warnings: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Return a JSON-safe representation for runtime context passing."""
return {
"market": self.market,
"phase": self.phase.value,
"market_local_time": self.market_local_time.isoformat(),
"session_date": self.session_date.isoformat(),
"effective_daily_bar_date": self.effective_daily_bar_date.isoformat(),
"is_trading_day": self.is_trading_day,
"is_market_open_now": self.is_market_open_now,
"is_partial_bar": self.is_partial_bar,
"minutes_to_open": self.minutes_to_open,
"minutes_to_close": self.minutes_to_close,
"trigger_source": self.trigger_source,
"analysis_intent": self.analysis_intent,
"warnings": list(self.warnings),
}
def get_market_for_stock(code: str) -> Optional[str]:
"""
Infer market region for a stock code.
Returns:
'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw' | None (None = unrecognized, fail-open: treat as open)
"""
if not code or not isinstance(code, str):
return None
code = (code or "").strip().upper()
from data_provider import is_us_stock_code, is_us_index_code, is_hk_stock_code
if is_us_stock_code(code) or is_us_index_code(code):
return "us"
if is_hk_stock_code(code):
return "hk"
suffix_market = get_suffix_market(code)
if suffix_market:
return suffix_market
# A-share: 6-digit numeric
if code.isdigit() and len(code) == 6:
return "cn"
return None
def is_market_open(market: str, check_date: date) -> bool:
"""
Check if the given market is open on the given date.
Fail-open: returns True if exchange-calendars unavailable or date out of range.
Args:
market: 'cn' | 'hk' | 'us'
check_date: Date to check
Returns:
True if trading day (or fail-open), False otherwise
"""
if not _XCALS_AVAILABLE:
return True
ex = MARKET_EXCHANGE.get(market)
if not ex:
return True
try:
cal = xcals.get_calendar(ex)
session = datetime(check_date.year, check_date.month, check_date.day)
return cal.is_session(session)
except Exception as e:
logger.warning("trading_calendar.is_market_open fail-open: %s", e)
return True
def get_market_now(
market: Optional[str], current_time: Optional[datetime] = None
) -> datetime:
"""
Return current time in the market's local timezone.
If current_time is naive, treat it as already expressed in the market timezone.
Unknown markets fall back to the given datetime (or local system time).
"""
tz_name = MARKET_TIMEZONE.get(market or "")
if current_time is None:
if tz_name:
return datetime.now(ZoneInfo(tz_name))
return datetime.now()
if not tz_name:
return current_time
tz = ZoneInfo(tz_name)
if current_time.tzinfo is None:
return current_time.replace(tzinfo=tz)
return current_time.astimezone(tz)
def get_effective_trading_date(
market: Optional[str], current_time: Optional[datetime] = None
) -> date:
"""
Resolve the latest reusable daily-bar date for checkpoint/resume logic.
Rules:
- Non-trading day / holiday: previous trading session
- Trading day before market close: previous completed trading session
- Trading day after market close: current trading session
- Calendar lookup failure: fail-open to market-local natural date
"""
market_now = get_market_now(market, current_time=current_time)
fallback_date = market_now.date()
if not _XCALS_AVAILABLE:
return fallback_date
ex = MARKET_EXCHANGE.get(market or "")
tz_name = MARKET_TIMEZONE.get(market or "")
if not ex or not tz_name:
return fallback_date
try:
cal = xcals.get_calendar(ex)
local_date = market_now.date()
if not cal.is_session(local_date):
return cal.date_to_session(local_date, direction="previous").date()
session = cal.date_to_session(local_date, direction="previous")
session_close = cal.session_close(session)
if hasattr(session_close, "tz_convert"):
close_local = session_close.tz_convert(tz_name).to_pydatetime()
elif session_close.tzinfo is not None:
close_local = session_close.astimezone(ZoneInfo(tz_name))
else:
close_local = session_close.replace(tzinfo=ZoneInfo(tz_name))
if market_now >= close_local:
return session.date()
return cal.previous_session(session).date()
except Exception as e:
logger.warning("trading_calendar.get_effective_trading_date fail-open: %s", e)
return fallback_date
def _as_market_datetime(value: Any, tz_name: str) -> Optional[datetime]:
"""
Convert exchange-calendar timestamps into market-local datetimes.
Returns None for missing or pandas NaT-like values. Naive datetimes are
interpreted as already expressed in the target market timezone, matching
get_market_now()'s current_time contract.
"""
if value is None:
return None
if pd.isna(value):
return None
try:
if isinstance(value, pd.Timestamp):
if value.tzinfo is None:
dt = value.to_pydatetime()
else:
dt = value.tz_convert(tz_name).to_pydatetime()
elif isinstance(value, datetime):
dt = value
elif hasattr(value, "to_pydatetime"):
dt = value.to_pydatetime()
else:
return None
except (AttributeError, TypeError, ValueError):
return None
tz = ZoneInfo(tz_name)
if dt.tzinfo is None:
return dt.replace(tzinfo=tz)
return dt.astimezone(tz)
def infer_market_phase(
market: Optional[str], current_time: Optional[datetime] = None
) -> MarketPhase:
"""
Infer the regular-session market phase for a market.
This P0 helper is intentionally fail-closed: unknown markets, unavailable
exchange calendars, and calendar errors return ``MarketPhase.UNKNOWN``.
That differs from ``is_market_open()`` and ``get_effective_trading_date()``,
which keep their existing fail-open behavior for backwards compatibility.
``premarket`` and ``postmarket`` mean before/after the regular trading
session only; they do not imply that extended-hours quote data is available.
``closing_auction`` uses a small per-market near-close heuristic window and
does not model full exchange auction microstructure.
"""
if market not in MARKET_EXCHANGE or market not in MARKET_TIMEZONE:
return MarketPhase.UNKNOWN
if not _XCALS_AVAILABLE:
return MarketPhase.UNKNOWN
ex = MARKET_EXCHANGE[market]
tz_name = MARKET_TIMEZONE[market]
market_now = get_market_now(market, current_time=current_time)
local_date = market_now.date()
try:
cal = xcals.get_calendar(ex)
if not cal.is_session(local_date):
return MarketPhase.NON_TRADING
session = cal.date_to_session(local_date, direction="previous")
session_open = _as_market_datetime(cal.session_open(session), tz_name)
session_close = _as_market_datetime(cal.session_close(session), tz_name)
if session_open is None or session_close is None:
return MarketPhase.UNKNOWN
if market_now < session_open:
return MarketPhase.PREMARKET
if market_now >= session_close:
return MarketPhase.POSTMARKET
# Calendars without session_has_break may still expose break timestamps.
has_break = True
if hasattr(cal, "session_has_break"):
has_break = bool(cal.session_has_break(session))
break_start = None
break_end = None
if has_break:
break_start = _as_market_datetime(cal.session_break_start(session), tz_name)
break_end = _as_market_datetime(cal.session_break_end(session), tz_name)
window_minutes = _CLOSING_AUCTION_WINDOW_MINUTES.get(market, 0)
closing_window_start = session_close - timedelta(minutes=window_minutes)
if break_start is not None and break_end is not None:
if market_now < break_start:
return MarketPhase.INTRADAY
if market_now < break_end:
return MarketPhase.LUNCH_BREAK
if market_now < closing_window_start:
return MarketPhase.INTRADAY
return MarketPhase.CLOSING_AUCTION
if market_now < closing_window_start:
return MarketPhase.INTRADAY
return MarketPhase.CLOSING_AUCTION
except Exception as e:
logger.warning("trading_calendar.infer_market_phase fail-closed: %s", e)
return MarketPhase.UNKNOWN
def _add_warning_code(warnings: List[str], code: str) -> None:
if code not in warnings:
warnings.append(code)
def _phase_booleans(
phase: MarketPhase,
) -> Tuple[Optional[bool], Optional[bool], Optional[bool]]:
if phase == MarketPhase.UNKNOWN:
return None, None, None
is_trading_day = phase != MarketPhase.NON_TRADING
is_market_open_now = phase in {
MarketPhase.INTRADAY,
MarketPhase.CLOSING_AUCTION,
}
is_partial_bar = phase in {
MarketPhase.INTRADAY,
MarketPhase.LUNCH_BREAK,
MarketPhase.CLOSING_AUCTION,
}
return is_trading_day, is_market_open_now, is_partial_bar
def _session_open_close_for_today(
market: str,
market_now: datetime,
) -> Tuple[Optional[datetime], Optional[datetime]]:
ex = MARKET_EXCHANGE.get(market)
tz_name = MARKET_TIMEZONE.get(market)
if not ex or not tz_name or not _XCALS_AVAILABLE:
return None, None
cal = xcals.get_calendar(ex)
local_date = market_now.date()
if not cal.is_session(local_date):
return None, None
session = cal.date_to_session(local_date, direction="previous")
return (
_as_market_datetime(cal.session_open(session), tz_name),
_as_market_datetime(cal.session_close(session), tz_name),
)
def _phase_minutes(
market: Optional[str],
market_now: datetime,
phase: MarketPhase,
) -> Tuple[Optional[int], Optional[int], bool]:
if (
market not in MARKET_EXCHANGE
or phase in {MarketPhase.UNKNOWN, MarketPhase.NON_TRADING, MarketPhase.POSTMARKET}
):
return None, None, False
if not _XCALS_AVAILABLE:
return None, None, False
try:
session_open, session_close = _session_open_close_for_today(market, market_now)
except Exception as e:
logger.warning("trading_calendar.market_phase_context calendar_error: %s", e)
return None, None, True
if session_open is None or session_close is None:
return None, None, False
if phase == MarketPhase.PREMARKET and market_now < session_open:
seconds = (session_open - market_now).total_seconds()
return max(0, int(seconds // 60)), None, False
if phase in {
MarketPhase.INTRADAY,
MarketPhase.LUNCH_BREAK,
MarketPhase.CLOSING_AUCTION,
} and market_now < session_close:
seconds = (session_close - market_now).total_seconds()
return None, max(0, int(seconds // 60)), False
return None, None, False
def _normalize_analysis_phase(
analysis_phase: Optional[str],
analysis_intent: Optional[str],
) -> str:
def _coerce(value: Optional[str]) -> str:
if isinstance(value, MarketPhase):
return value.value
return str(value or "").strip().lower()
requested = _coerce(analysis_phase) or "auto"
legacy_intent = _coerce(analysis_intent)
if requested == "auto" and legacy_intent and legacy_intent != "auto":
requested = legacy_intent
if requested not in _SUPPORTED_ANALYSIS_PHASES:
raise ValueError(
f"invalid analysis_phase: {requested}. "
f"Must be one of {sorted(_SUPPORTED_ANALYSIS_PHASES)}"
)
return requested
def build_market_phase_context(
*,
market: Optional[str],
current_time: Optional[datetime] = None,
trigger_source: str = "system",
analysis_intent: str = "auto",
analysis_phase: str = "auto",
) -> MarketPhaseContext:
"""
Build a JSON-safe runtime market-phase context for analysis plumbing.
``analysis_phase="auto"`` keeps calendar inference. Explicit supported
phases override only the phase and derived flags/minute fields; they do
not rewrite market-local time or the effective daily-bar date. The legacy
``analysis_intent`` argument remains a compatibility alias when
``analysis_phase`` is left as ``auto``.
"""
requested_phase = _normalize_analysis_phase(analysis_phase, analysis_intent)
market_now = get_market_now(market, current_time=current_time)
warnings: List[str] = []
if market not in MARKET_EXCHANGE or market not in MARKET_TIMEZONE:
phase = MarketPhase.UNKNOWN
_add_warning_code(warnings, "unknown_market")
else:
if not _XCALS_AVAILABLE:
_add_warning_code(warnings, "calendar_unavailable")
if requested_phase == "auto":
phase = infer_market_phase(market, current_time=current_time)
if phase == MarketPhase.UNKNOWN and _XCALS_AVAILABLE:
_add_warning_code(warnings, "calendar_error")
else:
phase = MarketPhase(requested_phase)
if requested_phase != "auto" and phase == MarketPhase.UNKNOWN:
phase = MarketPhase(requested_phase)
effective_daily_bar_date = get_effective_trading_date(
market,
current_time=current_time,
)
is_trading_day, is_market_open_now, is_partial_bar = _phase_booleans(phase)
minutes_to_open, minutes_to_close, minutes_calendar_error = _phase_minutes(
market,
market_now,
phase,
)
if minutes_calendar_error:
_add_warning_code(warnings, "calendar_error")
return MarketPhaseContext(
market=market,
phase=phase,
market_local_time=market_now,
session_date=market_now.date(),
effective_daily_bar_date=effective_daily_bar_date,
is_trading_day=is_trading_day,
is_market_open_now=is_market_open_now,
is_partial_bar=is_partial_bar,
minutes_to_open=minutes_to_open,
minutes_to_close=minutes_to_close,
trigger_source=trigger_source or "system",
analysis_intent=requested_phase,
warnings=warnings,
)
def get_open_markets_today() -> Set[str]:
"""
Get markets that are open today (by each market's local timezone).
Returns:
Set of market keys that are trading today
"""
if not _XCALS_AVAILABLE:
return set(MARKET_TIMEZONE)
result: Set[str] = set()
for mkt, tz_name in MARKET_TIMEZONE.items():
try:
tz = ZoneInfo(tz_name)
today = datetime.now(tz).date()
if is_market_open(mkt, today):
result.add(mkt)
except Exception as e:
logger.warning("get_open_markets_today fail-open for %s: %s", mkt, e)
result.add(mkt)
return result
def compute_effective_region(
config_region: str, open_markets: Set[str]
) -> Optional[str]:
"""
Compute effective market review region given config and open markets.
Args:
config_region: From MARKET_REVIEW_REGION ('cn' | 'hk' | 'us' | 'jp' | 'kr' | 'both' or comma subset)
open_markets: Markets open today
Returns:
None: caller uses config default (check disabled)
'': all relevant markets closed, skip market review
'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'both': effective subset for today
"""
markets = ("cn", "hk", "us", "jp", "kr")
normalized = (config_region or "cn").strip().lower()
if not normalized:
normalized = "cn"
requested = {
item.strip() for item in normalized.split(",") if item.strip()
}
if not requested:
requested = {"cn"}
if "both" in requested:
requested = set(markets)
else:
# Ignore invalid tokens and only keep known markets.
requested = {item for item in requested if item in markets}
if not requested:
# No valid market token left after filtering; follow parser fallback behavior.
requested = {"cn"}
# single explicit region: keep single-region return semantics (empty when closed)
if len(requested) == 1:
region = next(iter(requested))
return region if region in open_markets else ""
# multi-region subset: keep only markets open today, in canonical order
open_selected = [m for m in markets if m in requested and m in open_markets]
if not open_selected:
return ""
if len(open_selected) == 1:
return open_selected[0]
return ",".join(open_selected)