mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-07 06:44:15 +08:00
feat(market): Taiwan (台股) suffix-only market detection + routing (#1773)
Per the maintainer review on #1772/#1773, scope this PR to market detection
and data routing only — no Taiwan stock-index / seed / pool-resolution, so a
bare code can never be silently rewritten to a .TW suffix (the index path is a
deferred follow-up with the jp/kr-style honest doc + entry-point tests).
- data_provider/base.py: _is_tw_market / _is_tw_suffix_stock, _market_tag,
normalize_stock_code, get_daily_data + get_realtime_quote routing,
_DAILY_MARKET_FETCHER_SUPPORT (YfinanceFetcher only), offshore fundamental path
- data_provider/yfinance_fetcher.py: _get_tw_main_indices (^TWII / ^TWOII) +
.TW/.TWO suffix passthrough
- src/core/trading_calendar.py: XTAI / Asia/Taipei + get_market_for_stock
- src/market_context.py: detect_market + Taiwan prompt guidelines
- src/{market_phase_summary, services/stock_code_utils, core/pipeline}: tw plumbing
(stock_code_utils only recognizes .TW/.TWO codes; no bare->suffix rewrite)
- src/services/decision_signal_extractor.py: gracefully skip tw (recognized by
the data layer but not yet supported by the signal service) — no swallowed
ValueError + traceback
- tests: test_tw_market_support.py + test_decision_signal_extractor.py
(test_build_payload_skips_tw_market_gracefully — real tw skip regression)
- docs/market-support.md + CHANGELOG
Deferred follow-ups: stock index/seed + autocomplete + frontend, API market
enums + api_spec, and the service-layer market enums.
Bare numeric codes keep A-share semantics; only an explicit .TW/.TWO suffix
opts into Taiwan.
Refs #1772
This commit is contained in:
+29
-8
@@ -86,6 +86,8 @@ def normalize_stock_code(stock_code: str) -> str:
|
||||
- '1810.HK' -> 'HK01810' (normalize HK suffix to canonical prefix form)
|
||||
- '7203.T' -> '7203.T' (keep Japan Yahoo suffix form)
|
||||
- '005930.KS' -> '005930.KS' (keep Korea Yahoo suffix form)
|
||||
- '2330.TW' -> '2330.TW' (keep Taiwan TWSE Yahoo suffix form)
|
||||
- '6505.TWO' -> '6505.TWO' (keep Taiwan TPEx Yahoo suffix form)
|
||||
- 'AAPL' -> 'AAPL' (keep US stock ticker as-is)
|
||||
|
||||
This function is applied at the DataProviderManager layer so that
|
||||
@@ -126,13 +128,15 @@ def normalize_stock_code(stock_code: str) -> str:
|
||||
return candidate
|
||||
|
||||
# Strip .SH/.SZ/.BJ suffix (e.g. 600519.SH -> 600519, 920748.BJ -> 920748)
|
||||
# while preserving explicit Yahoo suffix forms for JP/KR.
|
||||
# while preserving explicit Yahoo suffix forms for JP/KR/TW.
|
||||
if '.' in code:
|
||||
base, suffix = code.rsplit('.', 1)
|
||||
if suffix.upper() == 'T' and base.isdigit() and len(base) in (4, 5):
|
||||
return f"{base}.{suffix.upper()}"
|
||||
if suffix.upper() in ('KS', 'KQ') and base.isdigit() and len(base) == 6:
|
||||
return f"{base}.{suffix.upper()}"
|
||||
if suffix.upper() in ('TW', 'TWO') and base.isdigit() and 4 <= len(base) <= 6:
|
||||
return f"{base}.{suffix.upper()}"
|
||||
if suffix.upper() == 'HK' and base.isdigit() and 1 <= len(base) <= 5:
|
||||
return f"HK{base.zfill(5)}"
|
||||
if base.upper() in ('SH', 'SS', 'SZ', 'BJ') and suffix.isdigit():
|
||||
@@ -190,6 +194,19 @@ def _is_kr_market(code: str) -> bool:
|
||||
return base.isdigit() and len(base) == 6
|
||||
|
||||
|
||||
def _is_tw_market(code: str) -> bool:
|
||||
"""判定是否为台湾 Yahoo Finance suffix 代码(TWSE 上市 2330.TW / TPEx 上柜 6505.TWO)。
|
||||
|
||||
台股 base 为 4-6 位(普通股 4 位,ETF/其他至 6 位,如 00878 / 006208)。
|
||||
仅带 .TW/.TWO 后缀的代码才识别为台股,裸 6 位代码仍按 A 股语义处理。
|
||||
"""
|
||||
normalized = (code or "").strip().upper()
|
||||
if not normalized.endswith((".TW", ".TWO")):
|
||||
return False
|
||||
base = normalized.rsplit(".", 1)[0]
|
||||
return base.isdigit() and 4 <= len(base) <= 6
|
||||
|
||||
|
||||
def _is_etf_code(code: str) -> bool:
|
||||
"""判定 A 股 ETF 基金代码(保守规则)。"""
|
||||
normalized = normalize_stock_code(code)
|
||||
@@ -230,7 +247,7 @@ def _is_meaningful_chip_distribution(chip: Any) -> bool:
|
||||
|
||||
|
||||
def _market_tag(code: str) -> str:
|
||||
"""返回市场标签: cn/us/hk/jp/kr."""
|
||||
"""返回市场标签: cn/us/hk/jp/kr/tw."""
|
||||
if _is_us_market(code):
|
||||
return "us"
|
||||
if _is_hk_market(code):
|
||||
@@ -239,6 +256,8 @@ def _market_tag(code: str) -> str:
|
||||
return "jp"
|
||||
if _is_kr_market(code):
|
||||
return "kr"
|
||||
if _is_tw_market(code):
|
||||
return "tw"
|
||||
return "cn"
|
||||
|
||||
|
||||
@@ -610,7 +629,7 @@ class DataFetcherManager:
|
||||
"TushareFetcher": {"cn", "hk"},
|
||||
"PytdxFetcher": {"cn"},
|
||||
"BaostockFetcher": {"cn"},
|
||||
"YfinanceFetcher": {"cn", "hk", "us", "jp", "kr"},
|
||||
"YfinanceFetcher": {"cn", "hk", "us", "jp", "kr", "tw"},
|
||||
"LongbridgeFetcher": {"hk", "us"},
|
||||
"FinnhubFetcher": {"us"},
|
||||
"AlphaVantageFetcher": {"us"},
|
||||
@@ -1247,14 +1266,15 @@ class DataFetcherManager:
|
||||
is_hk = (not is_us) and _is_hk_market(stock_code)
|
||||
is_jp = (not is_us) and (not is_hk) and _is_jp_market(stock_code)
|
||||
is_kr = (not is_us) and (not is_hk) and _is_kr_market(stock_code)
|
||||
market = "us" if is_us else "hk" if is_hk else "jp" if is_jp else "kr" if is_kr else "cn"
|
||||
is_tw = (not is_us) and (not is_hk) and _is_tw_market(stock_code)
|
||||
market = "us" if is_us else "hk" if is_hk else "jp" if is_jp else "kr" if is_kr else "tw" if is_tw else "cn"
|
||||
if market != "cn":
|
||||
fetchers = self._filter_daily_fetchers_for_market(fetchers, market)
|
||||
fetchers = self._filter_fetchers_by_capability(fetchers, capability="daily_data")
|
||||
total_fetchers = len(fetchers)
|
||||
|
||||
if total_fetchers == 0:
|
||||
market_label = "美股指数" if is_us_index else "美股" if is_us else "港股" if is_hk else "A股"
|
||||
market_label = "美股指数" if is_us_index else "美股" if is_us else "港股" if is_hk else "台股" if is_tw else "A股"
|
||||
error_summary = f"{market_label} {stock_code} 获取失败:\n暂无可用数据源"
|
||||
logger.error(f"[数据源终止] {stock_code} 获取失败: {error_summary}")
|
||||
raise DataFetchError(error_summary)
|
||||
@@ -1683,9 +1703,10 @@ class DataFetcherManager:
|
||||
is_hk = (not is_us) and _is_hk_market(stock_code)
|
||||
is_jp = (not is_us) and (not is_hk) and _is_jp_market(stock_code)
|
||||
is_kr = (not is_us) and (not is_hk) and _is_kr_market(stock_code)
|
||||
is_tw = (not is_us) and (not is_hk) and _is_tw_market(stock_code)
|
||||
|
||||
if is_jp or is_kr:
|
||||
market_label = "日股" if is_jp else "韩股"
|
||||
if is_jp or is_kr or is_tw:
|
||||
market_label = "日股" if is_jp else "韩股" if is_kr else "台股"
|
||||
quote = self._try_fetcher_quote(stock_code, "YfinanceFetcher")
|
||||
if quote is not None:
|
||||
logger.info(f"[实时行情] {market_label} {stock_code} 成功获取 (来源: YfinanceFetcher)")
|
||||
@@ -2947,7 +2968,7 @@ class DataFetcherManager:
|
||||
stock_code = normalize_stock_code(stock_code)
|
||||
market = _market_tag(stock_code)
|
||||
is_etf = _is_etf_code(stock_code)
|
||||
if market in {"us", "hk", "jp", "kr"}:
|
||||
if market in {"us", "hk", "jp", "kr", "tw"}:
|
||||
return self._build_offshore_fundamental_context(
|
||||
stock_code,
|
||||
market=market,
|
||||
|
||||
@@ -90,6 +90,19 @@ class YfinanceFetcher(BaseFetcher):
|
||||
return base.isdigit() and len(base) == 6
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_tw_suffix_stock(stock_code: str) -> bool:
|
||||
"""Return True for supported Taiwan suffix-only Yahoo symbols (TWSE `.TW` / TPEx `.TWO`).
|
||||
|
||||
Taiwan base codes are 4-6 digits (common stocks 4, ETFs/others up to 6,
|
||||
e.g. 00878 / 006208), wider than the JP `.T` range.
|
||||
"""
|
||||
code = (stock_code or "").strip().upper()
|
||||
if code.endswith((".TW", ".TWO")):
|
||||
base = code.rsplit(".", 1)[0]
|
||||
return base.isdigit() and 4 <= len(base) <= 6
|
||||
return False
|
||||
|
||||
def _convert_stock_code(self, stock_code: str) -> str:
|
||||
"""
|
||||
转换股票代码为 Yahoo Finance 格式
|
||||
@@ -127,9 +140,9 @@ class YfinanceFetcher(BaseFetcher):
|
||||
logger.debug(f"识别为美股代码: {code}")
|
||||
return code
|
||||
|
||||
# 日股/韩股 MVP:显式 Yahoo Finance suffix-only 代码,原样传给 Yahoo。
|
||||
if self._is_jp_kr_suffix_stock(code):
|
||||
logger.debug(f"识别为日韩 Yahoo suffix 代码: {code}")
|
||||
# 日股/韩股/台股 MVP:显式 Yahoo Finance suffix-only 代码,原样传给 Yahoo。
|
||||
if self._is_jp_kr_suffix_stock(code) or self._is_tw_suffix_stock(code):
|
||||
logger.debug(f"识别为日韩台 Yahoo suffix 代码: {code}")
|
||||
return code
|
||||
|
||||
# 港股:hk前缀 -> .HK后缀
|
||||
@@ -350,6 +363,8 @@ class YfinanceFetcher(BaseFetcher):
|
||||
return self._get_jp_main_indices(yf)
|
||||
if region == "kr":
|
||||
return self._get_kr_main_indices(yf)
|
||||
if region == "tw":
|
||||
return self._get_tw_main_indices(yf)
|
||||
|
||||
# A 股指数:akshare 代码 -> (yfinance 代码, 显示名称)
|
||||
yf_mapping = {
|
||||
@@ -486,6 +501,29 @@ class YfinanceFetcher(BaseFetcher):
|
||||
logger.error(f"[Yfinance] 获取韩国指数行情失败: {e}")
|
||||
return None
|
||||
|
||||
def _get_tw_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
|
||||
"""获取台湾主要指数行情(加权指数 ^TWII、柜买指数 ^TWOII),复用 _fetch_yf_ticker_data。"""
|
||||
tw_indices = {
|
||||
'TWII': ('^TWII', '台湾加权指数'),
|
||||
'TWOII': ('^TWOII', '台湾柜买指数'),
|
||||
}
|
||||
results = []
|
||||
try:
|
||||
for code, (yf_symbol, name) in tw_indices.items():
|
||||
try:
|
||||
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
|
||||
if item:
|
||||
results.append(item)
|
||||
logger.debug(f"[Yfinance] 获取台湾指数 {name} 成功")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Yfinance] 获取台湾指数 {name} 失败: {e}")
|
||||
if results:
|
||||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个台湾指数行情")
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"[Yfinance] 获取台湾指数行情失败: {e}")
|
||||
return None
|
||||
|
||||
def _is_us_stock(self, stock_code: str) -> bool:
|
||||
"""
|
||||
判断代码是否为美股股票(排除美股指数)。
|
||||
@@ -754,8 +792,12 @@ class YfinanceFetcher(BaseFetcher):
|
||||
index_name=index_name,
|
||||
)
|
||||
|
||||
# 仅处理美股股票或 JP/KR suffix-only 股票
|
||||
if not (self._is_us_stock(stock_code) or self._is_jp_kr_suffix_stock(stock_code)):
|
||||
# 仅处理美股股票或 JP/KR/TW suffix-only 股票
|
||||
if not (
|
||||
self._is_us_stock(stock_code)
|
||||
or self._is_jp_kr_suffix_stock(stock_code)
|
||||
or self._is_tw_suffix_stock(stock_code)
|
||||
):
|
||||
logger.debug(f"[Yfinance] {stock_code} 不是美股或日韩 suffix 代码,跳过")
|
||||
return None
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [改进] #1595 P1 新增 prompt cache telemetry / analysis-path hints / diagnostics 最小配置,默认不改变 provider 请求 shape,并复用 LLM usage HMAC secret 做 domain-separated cache hint 派生。
|
||||
- [改进] 将 Docker Compose 默认内存建议从 512M 提升到 1G,并补充低配部署说明。
|
||||
- [改进] 每日分析 workflow 兼容误将 `STOCK_LIST` 配到同名 Environment variables 的场景,同时保留 Repository variables 作为推荐配置入口。
|
||||
- [新功能] #1772 新增台湾(台股)suffix-only 个股分析 MVP(**市场识别与数据路由层**):手输 `.TW`(TWSE 上市)/ `.TWO`(TPEx 上柜)代码可走 YFinance 日线与近实时行情,补充市场识别、交易日历(XTAI / Asia/Taipei)、Prompt 语义与能力边界文档;加权指数 `^TWII`、柜买指数 `^TWOII`。台股股票索引/种子、Web 自动补全、API 市场枚举与 Portfolio/DecisionSignal 服务层放行作为后续 PR。
|
||||
- [文档] #1772 明确本次为台股 suffix 仅路由兼容改造,对齐 #1718 日韩模式;不涉及 provider/model/base URL/运行时配置变更;DecisionSignal 抽取对 `tw` 优雅跳过;回退方式为 revert 本次改动或移除 tw 入口恢复既有行为。
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
|
||||
|
||||
@@ -26,3 +26,31 @@
|
||||
- 不补齐 Portfolio 的 JPY/KRW 汇率、成本、市值完整口径;相关字段仅放开市场类型以避免前后端校验拒绝。
|
||||
|
||||
回滚方式:移除 `jp/kr` 市场识别、交易日历注册、YFinance 路由扩展、Web/API 类型放行、`scripts/stock_index_seeds/` 日韩种子索引,并删除本文档中的能力声明。
|
||||
|
||||
## 台湾个股 suffix-only MVP(Issue #1772,Refs #1772)
|
||||
|
||||
当前阶段支持手动输入台湾股票的 Yahoo Finance 后缀代码,进入既有个股分析、历史保存和基础报告展示链路。TWSE 上市股票使用 `.TW` 后缀,TPEx 上柜(柜买)股票使用 `.TWO` 后缀,二者折叠为同一 `tw` 市场标签。**本次仅覆盖市场识别(detection)与数据路由层**;台股股票索引/种子、Web 自动补全、前端市场类型、API 市场枚举与 Portfolio/DecisionSignal 服务层均作为后续 PR。对齐 #1718 日韩 MVP 模式。
|
||||
|
||||
支持格式:
|
||||
|
||||
- 上市(TWSE):`2330.TW`、`0050.TW`
|
||||
- 上柜(TPEx / 柜买):`6488.TWO`、`5483.TWO`
|
||||
- 代码 base 为 4-6 位数字(普通股 4 位,ETF/其他至 6 位,如 `00878.TW`、`006208.TW`),较日股 `.T` 的 4-5 位更宽。
|
||||
|
||||
约束与边界:
|
||||
|
||||
- **严格 suffix-only**:裸 `2330`、`00878` 等不带后缀的代码不会进入台股语义(`detect_market` / `get_market_for_stock` 仅在显式 `.TW`/`.TWO` 后缀时返回 `tw`)。本次**不引入任何台股股票索引/种子解析**,故裸码不可能经本地/远程股票池被改写为台股 suffix;该索引解析(与 jp/kr 同款的裸码命中行为)属后续 PR。
|
||||
- 台股日线和基础实时/近实时行情只走 `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 语义保持不变。
|
||||
- 主要指数提供加权指数 `^TWII` 与柜买指数 `^TWOII`。
|
||||
|
||||
不承诺项:
|
||||
|
||||
- 不承诺实时行情;Yahoo Finance 数据可能延迟或字段缺失。
|
||||
- 不承诺完整基本面、行业/板块、市场宽度、涨跌家数或台股大盘复盘。
|
||||
- 本次未包含台股股票索引/种子、Web 自动补全、前端市场类型、API 市场枚举与 Portfolio/DecisionSignal 服务层放行,均作为后续 PR;数据层已识别 `tw`,DecisionSignal 抽取对 `tw` 优雅跳过(不产出信号、也不报错或刷 traceback)。
|
||||
- 不补齐 Portfolio 的 TWD 汇率、成本、市值完整口径(属上述后续 PR 范围)。
|
||||
|
||||
回滚方式:移除 `tw` 市场识别、交易日历注册与 YFinance 路由扩展,并删除本文档中的能力声明。
|
||||
|
||||
@@ -1462,13 +1462,13 @@ class StockAnalysisPipeline:
|
||||
}
|
||||
|
||||
def _get_analysis_context_with_market_fallback(self, code: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load analysis context, fetching JP/KR daily bars when DB has no context."""
|
||||
"""Load analysis context, fetching JP/KR/TW daily bars when DB has no context."""
|
||||
context = self.db.get_analysis_context(code)
|
||||
if isinstance(context, dict) and context:
|
||||
return context
|
||||
|
||||
market = get_market_for_stock(normalize_stock_code(code))
|
||||
if market not in {"jp", "kr"}:
|
||||
if market not in {"jp", "kr", "tw"}:
|
||||
return context
|
||||
|
||||
try:
|
||||
|
||||
@@ -36,7 +36,7 @@ except ImportError:
|
||||
)
|
||||
|
||||
# Market -> exchange code (exchange-calendars)
|
||||
MARKET_EXCHANGE = {"cn": "XSHG", "hk": "XHKG", "us": "XNYS", "jp": "XTKS", "kr": "XKRX"}
|
||||
MARKET_EXCHANGE = {"cn": "XSHG", "hk": "XHKG", "us": "XNYS", "jp": "XTKS", "kr": "XKRX", "tw": "XTAI"}
|
||||
|
||||
# Market -> IANA timezone for "today"
|
||||
MARKET_TIMEZONE = {
|
||||
@@ -45,6 +45,7 @@ MARKET_TIMEZONE = {
|
||||
"us": "America/New_York",
|
||||
"jp": "Asia/Tokyo",
|
||||
"kr": "Asia/Seoul",
|
||||
"tw": "Asia/Taipei",
|
||||
}
|
||||
|
||||
# P0 market phase baseline (Issue #1386). This is an intentionally small
|
||||
@@ -113,7 +114,7 @@ def get_market_for_stock(code: str) -> Optional[str]:
|
||||
Infer market region for a stock code.
|
||||
|
||||
Returns:
|
||||
'cn' | 'hk' | 'us' | 'jp' | 'kr' | None (None = unrecognized, fail-open: treat as open)
|
||||
'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw' | None (None = unrecognized, fail-open: treat as open)
|
||||
"""
|
||||
if not code or not isinstance(code, str):
|
||||
return None
|
||||
@@ -133,6 +134,16 @@ def get_market_for_stock(code: str) -> Optional[str]:
|
||||
base = code.rsplit(".", 1)[0]
|
||||
if base.isdigit() and len(base) == 6:
|
||||
return "kr"
|
||||
# Taiwan: TWSE `.TW` / TPEx `.TWO`, base 4-6 digits. Checked before the
|
||||
# bare 6-digit A-share fallback so only the explicit suffix opts into TW.
|
||||
if code.endswith(".TWO"):
|
||||
base = code[:-4]
|
||||
if base.isdigit() and len(base) in (4, 5, 6):
|
||||
return "tw"
|
||||
if code.endswith(".TW"):
|
||||
base = code[:-3]
|
||||
if base.isdigit() and len(base) in (4, 5, 6):
|
||||
return "tw"
|
||||
# A-share: 6-digit numeric
|
||||
if code.isdigit() and len(code) == 6:
|
||||
return "cn"
|
||||
|
||||
@@ -41,6 +41,15 @@ def detect_market(stock_code: Optional[str]) -> str:
|
||||
if re.match(r'^\d{6}\.(KS|KQ)$', code):
|
||||
return "kr"
|
||||
|
||||
# Taiwan suffix-only symbols supported by Yahoo Finance (TWSE `.TW`, TPEx `.TWO`).
|
||||
# Base is 4-6 digits (common stocks 4, ETFs/others up to 6, e.g. 00878, 006208).
|
||||
# Bare 4-digit codes remain A-share fallback to avoid collision; only the
|
||||
# explicit `.TW`/`.TWO` suffix opts a code into the Taiwan market.
|
||||
if re.match(r'^\d{4,6}\.TWO$', code):
|
||||
return "tw"
|
||||
if re.match(r'^\d{4,6}\.TW$', code):
|
||||
return "tw"
|
||||
|
||||
# US stocks: 1-5 uppercase letters (AAPL, TSLA, GOOGL)
|
||||
# Also handles suffixed forms like BRK.B
|
||||
if re.match(r'^[A-Z]{1,5}(\.[A-Z]{1,2})?$', code):
|
||||
@@ -73,6 +82,10 @@ _MARKET_ROLES = {
|
||||
"zh": "韩股",
|
||||
"en": "Korea stock",
|
||||
},
|
||||
"tw": {
|
||||
"zh": "台股",
|
||||
"en": "Taiwan stock",
|
||||
},
|
||||
}
|
||||
|
||||
_MARKET_GUIDELINES = {
|
||||
@@ -126,6 +139,21 @@ _MARKET_GUIDELINES = {
|
||||
"- Use Korea-market context: KRW FX, Bank of Korea policy, semiconductor/internet cycles, and local trading rules; do not apply China A-share concepts such as daily price-limit boards, Northbound flows, Dragon Tiger lists, or margin-financing narratives."
|
||||
),
|
||||
},
|
||||
"tw": {
|
||||
"zh": (
|
||||
"- 本次分析对象为 **台股**(台湾证券交易所上市 `.TW`,或台湾柜买中心上柜 `.TWO`)。\n"
|
||||
"- 请按台湾市场语境分析,关注新台币(TWD)汇率、台湾央行政策、半导体/电子代工产业链、"
|
||||
"三大法人(外资/投信/自营商)买卖超、融资融券与当冲,以及 TWSE/TPEx ±10% 涨跌停制度;"
|
||||
"不要套用 A 股专属的北向资金、龙虎榜等概念(台股的法人结构与资金流口径与 A 股不同)。"
|
||||
),
|
||||
"en": (
|
||||
"- This analysis covers a **Taiwan stock** (TWSE-listed `.TW`, or TPEx/OTC `.TWO`).\n"
|
||||
"- Use Taiwan-market context: TWD FX, Central Bank of the ROC policy, the semiconductor/"
|
||||
"electronics-foundry supply chain, the three institutional investor groups (foreign / "
|
||||
"investment-trust / dealer), margin trading and day trading, and the TWSE/TPEx ±10% daily "
|
||||
"price limit; do not apply China A-share-specific concepts such as Northbound flows or Dragon Tiger lists."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -56,11 +56,13 @@ _MARKET_LABELS_ZH = {
|
||||
"cn": "A股",
|
||||
"hk": "港股",
|
||||
"us": "美股",
|
||||
"tw": "台股",
|
||||
}
|
||||
_MARKET_LABELS_EN = {
|
||||
"cn": "A-shares",
|
||||
"hk": "Hong Kong",
|
||||
"us": "US",
|
||||
"tw": "Taiwan",
|
||||
}
|
||||
_PHASE_LABELS_ZH = {
|
||||
"premarket": "盘前",
|
||||
@@ -140,7 +142,7 @@ def rebuild_market_phase_summary_for_stock_code(
|
||||
return None
|
||||
|
||||
market = get_market_for_stock(str(stock_code or "").strip())
|
||||
if market not in {"jp", "kr"}:
|
||||
if market not in {"jp", "kr", "tw"}:
|
||||
return dict(summary)
|
||||
|
||||
phase = str(summary.get("phase", "")).strip()
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.analyzer import AnalysisResult
|
||||
from src.core.trading_calendar import get_market_for_stock
|
||||
from src.schemas.decision_action import build_action_fields
|
||||
from src.services.decision_signal_service import DecisionSignalService
|
||||
from src.services.portfolio_service import VALID_MARKETS
|
||||
from src.utils.sniper_points import extract_sniper_points
|
||||
|
||||
|
||||
@@ -59,6 +60,16 @@ def build_decision_signal_payload_from_report(
|
||||
if not market:
|
||||
logger.warning("Skip decision signal extraction: unrecognized market stock_code=%s", raw_code)
|
||||
return None
|
||||
if market not in VALID_MARKETS:
|
||||
# A market the data layer recognizes (e.g. tw) but the decision-signal
|
||||
# service layer does not yet support. Skip gracefully instead of letting
|
||||
# create_signal raise a swallowed ValueError + noisy traceback.
|
||||
logger.info(
|
||||
"Skip decision signal extraction: market=%s not yet wired for signals stock_code=%s",
|
||||
market,
|
||||
raw_code,
|
||||
)
|
||||
return None
|
||||
|
||||
dashboard = _as_mapping(getattr(result, "dashboard", None))
|
||||
sniper_points = extract_sniper_points(result)
|
||||
|
||||
@@ -30,9 +30,13 @@ _SUFFIX_DIGIT_LENS: dict = {
|
||||
".T": (4, 5),
|
||||
".KS": (6,),
|
||||
".KQ": (6,),
|
||||
# Taiwan: TWSE `.TW` and TPEx `.TWO`; base is 4-6 digits (ETFs up to 6).
|
||||
# `.TWO` listed before `.TW` as a defensive ordering convention.
|
||||
".TWO": (4, 5, 6),
|
||||
".TW": (4, 5, 6),
|
||||
}
|
||||
|
||||
_PRESERVE_SUFFIXES = {".T", ".KS", ".KQ"}
|
||||
_PRESERVE_SUFFIXES = {".T", ".KS", ".KQ", ".TW", ".TWO"}
|
||||
|
||||
|
||||
def _infer_cn_exchange(base: str) -> str:
|
||||
|
||||
@@ -72,6 +72,30 @@ def _result(**overrides) -> AnalysisResult:
|
||||
return result
|
||||
|
||||
|
||||
def test_build_payload_skips_tw_market_gracefully() -> None:
|
||||
"""A Taiwan (`tw`) stock is recognized by the data layer but is intentionally
|
||||
not yet wired into the DecisionSignal service layer (a deferred follow-up).
|
||||
|
||||
The payload builder must SKIP it (return None) without raising — locking the
|
||||
"no swallowed ValueError + noisy traceback on every tw analysis" behavior the
|
||||
data-layer MVP relies on. A plain action ("buy") is set so the skip is the
|
||||
market guard, not the earlier no-action early-return.
|
||||
"""
|
||||
result = _result(code="2330.TW", name="台积电")
|
||||
|
||||
payload = build_decision_signal_payload_from_report(
|
||||
result,
|
||||
context_snapshot=None,
|
||||
portfolio_context=None,
|
||||
source_report_id=None,
|
||||
trace_id="trace-tw",
|
||||
query_source="api",
|
||||
report_type="full",
|
||||
)
|
||||
|
||||
assert payload is None
|
||||
|
||||
|
||||
def test_build_payload_maps_report_context_and_price_plan() -> None:
|
||||
result = _result()
|
||||
result.market_phase_summary = {"phase": "postmarket"}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Regression tests for Taiwan (台股) suffix-only market support.
|
||||
|
||||
Mirrors tests/test_jp_kr_market_support.py (Issue #1718). Taiwan stocks use the
|
||||
Yahoo Finance suffix forms ``NNNN.TW`` (TWSE-listed) and ``NNNN.TWO`` (TPEx/OTC),
|
||||
with a 4-6 digit base (wider than JP ``.T``'s 4-5 to cover ETFs like 00878 /
|
||||
006208). Bare numeric codes keep their existing A-share semantics.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
from data_provider.base import BaseFetcher, DataFetchError, DataFetcherManager, normalize_stock_code
|
||||
from data_provider.yfinance_fetcher import YfinanceFetcher
|
||||
from src.core.trading_calendar import MARKET_EXCHANGE, MARKET_TIMEZONE, get_market_for_stock
|
||||
from src.market_context import detect_market, get_market_guidelines
|
||||
from src.services.stock_code_utils import is_code_like, normalize_code
|
||||
|
||||
|
||||
class _FakeFetcher(BaseFetcher):
|
||||
def __init__(self, name: str, should_fail: bool = False):
|
||||
self.name = name
|
||||
self.priority = 0 if name != "YfinanceFetcher" else 4
|
||||
self.calls = []
|
||||
self.should_fail = should_fail
|
||||
|
||||
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
raise NotImplementedError
|
||||
|
||||
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_daily_data(self, stock_code, start_date=None, end_date=None, days=30):
|
||||
self.calls.append(stock_code)
|
||||
if self.should_fail:
|
||||
raise DataFetchError(f"{self.name} should not be called for {stock_code}")
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"date": [pd.Timestamp("2026-06-23")],
|
||||
"open": [1.0],
|
||||
"high": [1.0],
|
||||
"low": [1.0],
|
||||
"close": [1.0],
|
||||
"volume": [100],
|
||||
"amount": [100.0],
|
||||
"pct_chg": [0.0],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_and_detect_tw_suffix_codes() -> None:
|
||||
assert normalize_stock_code("2330.tw") == "2330.TW"
|
||||
assert normalize_stock_code("6505.two") == "6505.TWO"
|
||||
assert normalize_stock_code("00878.tw") == "00878.TW"
|
||||
assert normalize_stock_code("006208.tw") == "006208.TW"
|
||||
|
||||
assert detect_market("2330.TW") == "tw"
|
||||
assert detect_market("0050.TW") == "tw"
|
||||
assert detect_market("006208.TW") == "tw" # 6-digit ETF
|
||||
assert detect_market("6505.TWO") == "tw"
|
||||
# Bare numeric codes must stay A-share to avoid collision with .TW opt-in.
|
||||
assert detect_market("2330") == "cn"
|
||||
|
||||
assert get_market_for_stock("2330.TW") == "tw"
|
||||
assert get_market_for_stock("6505.TWO") == "tw"
|
||||
# A bare 6-digit code that doubles as a TW ETF (e.g. 006208) must stay
|
||||
# A-share without the explicit .TW suffix. (Bare 4-digit codes like "2330"
|
||||
# are unrecognized -> None / fail-open, which is existing behavior.)
|
||||
assert get_market_for_stock("006208") == "cn"
|
||||
|
||||
assert is_code_like("2330.TW") is True
|
||||
assert is_code_like("6505.TWO") is True
|
||||
assert normalize_code("6505.TWO") == "6505.TWO"
|
||||
assert normalize_code("2330.TW") == "2330.TW"
|
||||
|
||||
|
||||
def test_market_guidelines_for_tw_keep_taiwan_context() -> None:
|
||||
tw_guidelines = get_market_guidelines("2330.TW")
|
||||
|
||||
assert "台股" in tw_guidelines
|
||||
# Taiwan keeps its own positive framing (±10% limit + three institutional
|
||||
# groups); do NOT copy the JP/KR "no A-share price-limit board" wording.
|
||||
assert "三大法人" in tw_guidelines
|
||||
assert ("±10%" in tw_guidelines) or ("涨跌停" in tw_guidelines)
|
||||
# Only China A-share-specific concepts are excluded.
|
||||
assert "北向资金" in tw_guidelines
|
||||
assert "龙虎榜" in tw_guidelines
|
||||
|
||||
|
||||
def test_yfinance_keeps_tw_suffix_codes_and_indices() -> None:
|
||||
fetcher = YfinanceFetcher()
|
||||
|
||||
assert fetcher._convert_stock_code("2330.TW") == "2330.TW"
|
||||
assert fetcher._convert_stock_code("6505.TWO") == "6505.TWO"
|
||||
assert fetcher._convert_stock_code("006208.TW") == "006208.TW"
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_fetch(_yf, yf_code, name, return_code):
|
||||
captured.append((yf_code, name, return_code))
|
||||
return {"code": return_code, "name": name, "current": 1.0}
|
||||
|
||||
fetcher._fetch_yf_ticker_data = fake_fetch # type: ignore[method-assign]
|
||||
|
||||
tw_indices = fetcher.get_main_indices("tw") or []
|
||||
|
||||
assert {item["code"] for item in tw_indices} == {"TWII", "TWOII"}
|
||||
assert ("^TWII", "台湾加权指数", "TWII") in captured
|
||||
assert ("^TWOII", "台湾柜买指数", "TWOII") in captured
|
||||
|
||||
|
||||
def test_data_fetcher_manager_routes_tw_daily_only_to_yfinance() -> None:
|
||||
efinance = _FakeFetcher("EfinanceFetcher", should_fail=True)
|
||||
akshare = _FakeFetcher("AkshareFetcher", should_fail=True)
|
||||
yfinance = _FakeFetcher("YfinanceFetcher")
|
||||
manager = DataFetcherManager(fetchers=[efinance, akshare, yfinance])
|
||||
|
||||
with patch("data_provider.base.record_provider_run_started"), patch("data_provider.base.record_provider_run"):
|
||||
tw_df, tw_source = manager.get_daily_data("2330.TW")
|
||||
two_df, two_source = manager.get_daily_data("6505.TWO")
|
||||
|
||||
assert tw_source == "YfinanceFetcher"
|
||||
assert two_source == "YfinanceFetcher"
|
||||
assert not tw_df.empty and not two_df.empty
|
||||
assert efinance.calls == []
|
||||
assert akshare.calls == []
|
||||
assert yfinance.calls == ["2330.TW", "6505.TWO"]
|
||||
|
||||
|
||||
def test_trading_calendar_registers_tw_exchange_and_timezone() -> None:
|
||||
assert MARKET_EXCHANGE["tw"] == "XTAI"
|
||||
assert MARKET_TIMEZONE["tw"] == "Asia/Taipei"
|
||||
|
||||
|
||||
def test_tw_decision_signals_gracefully_skipped_in_data_layer_scope() -> None:
|
||||
"""Data-layer MVP scope (per maintainer): tw is a recognized market, but the
|
||||
decision-signal / portfolio / API service layer is a deliberate follow-up.
|
||||
|
||||
The data layer recognizes tw, while the signal service does not yet accept it,
|
||||
so the decision-signal extractor must SKIP tw gracefully (no signal, no noisy
|
||||
ValueError traceback) — guarded in build_decision_signal_payload_from_report.
|
||||
"""
|
||||
from src.services.portfolio_service import VALID_MARKETS
|
||||
|
||||
assert get_market_for_stock("2330.TW") == "tw" # data layer recognizes tw
|
||||
assert "tw" not in VALID_MARKETS # signal/service layer intentionally deferred
|
||||
Reference in New Issue
Block a user