feat(market): bring tw to first-class on decision-signal / portfolio / intelligence (service + API + frontend) (#1801)

Follow-up to the #1773 data-layer MVP (Taiwan suffix-only detection + routing,
merged in 2086e3c). That MVP deferred the service/API/frontend layers, leaving a
live defect: tw was absent from the DecisionSignal/Portfolio service VALID_MARKETS,
so _normalize_market("tw") raised ValueError on the decision-signal write path.
The analysis pipeline auto-extracts a DecisionSignal after history save
(_extract_decision_signal_after_history_save), so every tw analysis silently
failed to persist a signal while jp/kr succeeded -- tw was the only
yfinance-supported market that could be analyzed but never produced a signal.

Converge the tw market contract for DecisionSignal + Portfolio + Intelligence in
one pass (mirroring jp/kr #1720), per the human review on #1801 asking not to
land it piecemeal:

Backend service + API:
- src/services/{portfolio,intelligence}_service.py: VALID_MARKETS /
  _ALLOWED_MARKETS + _normalize_market error strings accept tw
- src/services/decision_signal_service.py: _normalize_market error string
  (VALID_MARKETS is imported from portfolio_service, so the set change propagates)
- src/services/decision_signal_extractor.py: drop the now-stale "(e.g. tw)" guard
  comment (tw is supported; the guard still protects genuinely-unsupported markets)
- api/v1/schemas/{decision_signals,intelligence,portfolio}.py: Pydantic Literals + tw
- api/v1/endpoints/decision_signals.py + docs/architecture/api_spec.json: market
  filter description + DecisionSignalMarket enum gain tw; test_api_schema_pydantic
  exact-match vs create_app().openapi() passes (api_spec kept CRLF)

Frontend (DecisionSignal + Portfolio typed consumers only; tsc + vitest pass):
- apps/dsa-web/src/types/{decisionSignals,portfolio}.ts + pages/{DecisionSignalsPage,
  PortfolioPage}.tsx + utils/{decisionSignalLabels,stockCode}.ts + i18n/uiText.ts:
  add tw to the DecisionSignalMarket / portfolio market unions, the market filter
  options, the tw display label, and .TW/.TWO stock-code normalization
- the alert Market-Light surface (types/alerts.ts MarketRegion, featureText
  ALERT_MARKET_REGION_*) is intentionally LEFT OUT: the backend market_light_service
  is cn/hk/us only, so exposing tw there would be a front/back mismatch

Tests:
- flip the two #1773 graceful-skip regressions to first-class assertions and add
  test_extract_and_persist_writes_tw_signal (end-to-end persist guard)
- frontend: PortfolioPage + stockCode vitest gain tw cases

Docs (reconcile the tw contract so changelog/topic docs/code state one fact):
- docs/CHANGELOG.md: rewrite the #1772 [Unreleased] entries so they no longer say
  "service/API deferred" + "tw gracefully skipped" alongside "tw now supported"
- docs/market-support.md, docs/decision-signals.md, docs/intelligence-sources.md:
  sync the tw market enum / filter / examples; keep the boundary note

Still deferred (separate follow-ups): the Taiwan stock-index/seed + Web autocomplete,
and the alert (大盘红绿灯) Market-Light tw support (needs a market_light backend change).

Refs #1772
This commit is contained in:
Wenyu Chiou
2026-06-26 22:21:38 +09:00
committed by GitHub
parent e7182d7faf
commit cb72be7408
25 changed files with 114 additions and 55 deletions

View File

@@ -123,7 +123,7 @@ def create_signal(request: DecisionSignalCreateRequest) -> DecisionSignalMutatio
operation_id="listDecisionSignals",
)
def list_signals(
market: Optional[str] = Query(None, description="Optional market filter: cn/hk/us/jp/kr"),
market: Optional[str] = Query(None, description="Optional market filter: cn/hk/us/jp/kr/tw"),
stock_code: Optional[str] = Query(None, description="Optional stock code filter"),
action: Optional[str] = Query(None, description="Optional decision action filter"),
market_phase: Optional[str] = Query(None, description="Optional market phase filter"),
@@ -306,7 +306,7 @@ def get_outcome_stats(
)
def get_latest_active(
stock_code: str,
market: Optional[str] = Query(None, description="Optional market filter: cn/hk/us/jp/kr"),
market: Optional[str] = Query(None, description="Optional market filter: cn/hk/us/jp/kr/tw"),
limit: int = Query(1, ge=1, le=100),
) -> DecisionSignalListResponse:
service = DecisionSignalService()

View File

@@ -16,7 +16,7 @@ DecisionSignalSourceType = Literal["analysis", "agent", "alert", "market_review"
DecisionSignalStatus = Literal["active", "expired", "invalidated", "closed", "archived"]
DecisionSignalPlanQuality = Literal["complete", "partial", "minimal", "unknown"]
DecisionSignalHorizon = Literal["intraday", "1d", "3d", "5d", "10d", "swing", "long"]
DecisionSignalMarket = Literal["cn", "hk", "us", "jp", "kr"]
DecisionSignalMarket = Literal["cn", "hk", "us", "jp", "kr", "tw"]
DecisionSignalOutcomeStatus = Literal["completed", "unable"]
DecisionSignalOutcomeValue = Literal["hit", "miss", "neutral"]
DecisionSignalFeedbackValue = Literal["useful", "not_useful"]

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
SourceTypeValue = Literal["rss", "atom", "newsnow"]
ScopeTypeValue = Literal["symbol", "market", "sector"]
MarketValue = Literal["cn", "hk", "us", "jp", "kr", "global"]
MarketValue = Literal["cn", "hk", "us", "jp", "kr", "tw", "global"]
class IntelligenceSourceCreateRequest(BaseModel):

View File

@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field
class PortfolioAccountCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
broker: Optional[str] = Field(None, max_length=64)
market: Literal["cn", "hk", "us", "jp", "kr"] = "cn"
market: Literal["cn", "hk", "us", "jp", "kr", "tw"] = "cn"
base_currency: str = Field("CNY", min_length=3, max_length=8)
owner_id: Optional[str] = Field(None, max_length=64)
@@ -20,7 +20,7 @@ class PortfolioAccountCreateRequest(BaseModel):
class PortfolioAccountUpdateRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
broker: Optional[str] = Field(None, max_length=64)
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
market: Optional[Literal["cn", "hk", "us", "jp", "kr", "tw"]] = None
base_currency: Optional[str] = Field(None, min_length=3, max_length=8)
owner_id: Optional[str] = Field(None, max_length=64)
is_active: Optional[bool] = None
@@ -51,7 +51,7 @@ class PortfolioTradeCreateRequest(BaseModel):
price: float = Field(..., gt=0)
fee: float = Field(0.0, ge=0)
tax: float = Field(0.0, ge=0)
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
market: Optional[Literal["cn", "hk", "us", "jp", "kr", "tw"]] = None
currency: Optional[str] = Field(None, min_length=3, max_length=8)
trade_uid: Optional[str] = Field(None, max_length=128)
note: Optional[str] = Field(None, max_length=255)
@@ -71,7 +71,7 @@ class PortfolioCorporateActionCreateRequest(BaseModel):
symbol: str = Field(..., min_length=1, max_length=16)
effective_date: date
action_type: Literal["cash_dividend", "split_adjustment"]
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
market: Optional[Literal["cn", "hk", "us", "jp", "kr", "tw"]] = None
currency: Optional[str] = Field(None, min_length=3, max_length=8)
cash_dividend_per_share: Optional[float] = Field(None, ge=0)
split_ratio: Optional[float] = Field(None, gt=0)

View File

@@ -245,6 +245,7 @@ const zh = {
'decisionSignals.market.hk': '港股',
'decisionSignals.market.jp': '日股',
'decisionSignals.market.kr': '韩股',
'decisionSignals.market.tw': '台股',
'decisionSignals.market.us': '美股',
'decisionSignals.marketPhase': '阶段',
'decisionSignals.marketPhase.closing_auction': '集合竞价',
@@ -965,6 +966,7 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.market.hk': 'Hong Kong',
'decisionSignals.market.jp': 'Japan',
'decisionSignals.market.kr': 'Korea',
'decisionSignals.market.tw': 'Taiwan',
'decisionSignals.market.us': 'US',
'decisionSignals.marketPhase': 'Phase',
'decisionSignals.marketPhase.closing_auction': 'Closing auction',

View File

@@ -63,7 +63,7 @@ type SelectedSignal = {
source: 'list' | 'latest';
};
const MARKET_OPTIONS: DecisionSignalMarket[] = ['cn', 'hk', 'us', 'jp', 'kr'];
const MARKET_OPTIONS: DecisionSignalMarket[] = ['cn', 'hk', 'us', 'jp', 'kr', 'tw'];
const ACTION_OPTIONS: DecisionAction[] = ['buy', 'add', 'hold', 'reduce', 'sell', 'watch', 'avoid', 'alert'];
const PHASE_OPTIONS: MarketPhaseValue[] = ['premarket', 'intraday', 'lunch_break', 'closing_auction', 'postmarket', 'non_trading', 'unknown'];
const SOURCE_OPTIONS: DecisionSignalSourceType[] = ['analysis', 'agent', 'alert', 'market_review', 'manual'];

View File

@@ -112,8 +112,8 @@ function isNewerSignal(left: DecisionSignalItem | undefined, right: DecisionSign
return getSignalTime(right) > getSignalTime(left);
}
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['cn', 'hk', 'us', 'jp', 'kr']);
type PortfolioAccountMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr';
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['cn', 'hk', 'us', 'jp', 'kr', 'tw']);
type PortfolioAccountMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
function toDecisionSignalMarket(value: string | null | undefined): DecisionSignalMarket | undefined {
const normalized = String(value || '').toLowerCase();
@@ -1092,6 +1092,7 @@ const PortfolioPage: React.FC = () => {
<option value="us">us</option>
<option value="jp">jp</option>
<option value="kr">kr</option>
<option value="tw">tw</option>
</select>
<button type="submit" className="btn-secondary text-sm" disabled={accountCreating}>
{accountCreating ? '创建中...' : '创建账户'}

View File

@@ -97,7 +97,7 @@ vi.mock('recharts', () => ({
type AccountItem = {
id: number;
name: string;
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
baseCurrency?: string;
};

View File

@@ -10,7 +10,7 @@ export type DecisionSignalSourceType = 'analysis' | 'agent' | 'alert' | 'market_
export type DecisionSignalStatus = 'active' | 'expired' | 'invalidated' | 'closed' | 'archived';
export type DecisionSignalPlanQuality = 'complete' | 'partial' | 'minimal' | 'unknown';
export type DecisionSignalHorizon = 'intraday' | '1d' | '3d' | '5d' | '10d' | 'swing' | 'long';
export type DecisionSignalMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr';
export type DecisionSignalMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
export type DecisionSignalOutcomeEvalStatus = 'completed' | 'unable';
export type DecisionSignalOutcomeValue = 'hit' | 'miss' | 'neutral';
export type DecisionSignalFeedbackValue = 'useful' | 'not_useful';

View File

@@ -10,7 +10,7 @@ export interface PortfolioAccountItem {
ownerId?: string | null;
name: string;
broker?: string | null;
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
baseCurrency: string;
isActive: boolean;
createdAt?: string | null;
@@ -24,7 +24,7 @@ export interface PortfolioAccountListResponse {
export interface PortfolioAccountCreateRequest {
name: string;
broker?: string;
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
baseCurrency: string;
ownerId?: string;
}
@@ -181,7 +181,7 @@ export interface PortfolioTradeCreateRequest {
price: number;
fee?: number;
tax?: number;
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
currency?: string;
tradeUid?: string;
note?: string;
@@ -201,7 +201,7 @@ export interface PortfolioCorporateActionCreateRequest {
symbol: string;
effectiveDate: string;
actionType: PortfolioCorporateActionType;
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
currency?: string;
cashDividendPerShare?: number;
splitRatio?: number;

View File

@@ -60,6 +60,14 @@ describe('normalizeStockCode', () => {
expect(normalizeStockCode('005930')).toBe('005930');
});
it('keeps TW Yahoo suffix codes (.TW / .TWO) in canonical uppercase suffix form', () => {
expect(normalizeStockCode('2330.tw')).toBe('2330.TW');
expect(normalizeStockCode('0050.TW')).toBe('0050.TW');
expect(normalizeStockCode('006208.tw')).toBe('006208.TW');
expect(normalizeStockCode('6505.two')).toBe('6505.TWO');
expect(normalizeStockCode('2330')).toBe('2330');
});
it('is case-insensitive for prefixes', () => {
expect(normalizeStockCode('sh600519')).toBe('600519');
expect(normalizeStockCode('sz000001')).toBe('000001');

View File

@@ -15,6 +15,7 @@ const MARKET_LABEL_KEYS: Record<DecisionSignalMarket, UiTextKey> = {
us: 'decisionSignals.market.us',
jp: 'decisionSignals.market.jp',
kr: 'decisionSignals.market.kr',
tw: 'decisionSignals.market.tw',
};
const MARKET_PHASE_LABEL_KEYS: Record<MarketPhaseValue, UiTextKey> = {

View File

@@ -75,6 +75,10 @@ export function normalizeStockCode(stockCode: string): string {
if ((suffix === 'KS' || suffix === 'KQ') && /^\d{6}$/.test(base)) {
return `${base}.${suffix}`;
}
// TW Yahoo suffix-only codes (TWSE `.TW` / TPEx `.TWO`), base 4-6 digits.
if ((suffix === 'TW' || suffix === 'TWO') && /^\d{4,6}$/.test(base)) {
return `${base}.${suffix}`;
}
// 00700.HK → HK00700
if (suffix === 'HK' && /^\d{1,5}$/.test(base)) {

View File

@@ -15,8 +15,9 @@ 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 入口恢复既有行为。
- [新功能] #1772 新增台湾台股suffix-only 个股分析 MVP**市场识别与数据路由层**):手输 `.TW`TWSE 上市)/ `.TWO`TPEx 上柜)代码可走 YFinance 日线与近实时行情补充市场识别、交易日历XTAI / Asia/Taipei、Prompt 语义与能力边界文档;加权指数 `^TWII`、柜买指数 `^TWOII`。台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行作为后续 PR。
- [文档] #1772 明确本次为台股 suffix 仅路由兼容改造,对齐 #1718 日韩模式;不涉及 provider/model/base URL/运行时配置变更;回退方式为 revert 本次改动或移除 tw 入口恢复既有行为。
- [新功能] #1772 台股 `tw` 纳入 DecisionSignal / Portfolio / Intelligence 服务层与 API 市场枚举VALID_MARKETS / _ALLOWED_MARKETS + Pydantic Literal + api_spec.json修复数据层 MVP 下 tw 分析在 pipeline 自动抽取 DecisionSignal 时被 _normalize_market 静默丢弃的缺陷,并同步放行 DecisionSignal/Portfolio 前端市场类型与筛选及相关专题文档,对齐 #1720 日韩;告警(大盘红绿灯)市场仍为 cn/hk/us。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->

View File

@@ -874,10 +874,10 @@
"type": "null"
}
],
"description": "Optional market filter: cn/hk/us/jp/kr",
"description": "Optional market filter: cn/hk/us/jp/kr/tw",
"title": "Market"
},
"description": "Optional market filter: cn/hk/us/jp/kr"
"description": "Optional market filter: cn/hk/us/jp/kr/tw"
},
{
"name": "stock_code",
@@ -1239,10 +1239,10 @@
"type": "null"
}
],
"description": "Optional market filter: cn/hk/us/jp/kr",
"description": "Optional market filter: cn/hk/us/jp/kr/tw",
"title": "Market"
},
"description": "Optional market filter: cn/hk/us/jp/kr"
"description": "Optional market filter: cn/hk/us/jp/kr/tw"
},
{
"name": "limit",
@@ -3791,7 +3791,8 @@
"hk",
"us",
"jp",
"kr"
"kr",
"tw"
],
"title": "Market"
},
@@ -5665,7 +5666,8 @@
"hk",
"us",
"jp",
"kr"
"kr",
"tw"
]
},
{

View File

@@ -23,7 +23,7 @@
| 字段 | 取值 |
| --- | --- |
| `market` | `cn``hk``us``jp``kr` |
| `market` | `cn``hk``us``jp``kr``tw` |
| `source_type` | `analysis``agent``alert``market_review``manual` |
| `market_phase` | `premarket``intraday``lunch_break``closing_auction``postmarket``non_trading``unknown` |
| `action` | `buy``add``hold``reduce``sell``watch``avoid``alert` |
@@ -65,7 +65,7 @@ Web 入口位于 `/decision-signals`
- 默认查询 `status=active`
- 支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选。
- market filter 已包含 `cn/hk/us/jp/kr`P7 只补齐 `jp/kr` 的本地化标签,不改变筛选范围
- market filter 在 API / 服务层与 Web 前端均已支持 `cn/hk/us/jp/kr/tw``jp/kr/tw`前端本地化标签均已补齐,`tw` 信号可经 API 正常写入、按 `market=tw` 查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股tw告警大盘红绿灯市场仍为 cn/hk/us
- 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
- Web 只能把信号标记为 `closed``invalidated``archived`,不提供 terminal 状态恢复为 active。
- 历史报告详情不再内嵌展示报告绑定的 `source_type=analysis` 信号,也不会因打开报告详情触发 `source_report_id` 信号查询;需要查看报告来源信号时统一进入 `/decision-signals` 页面按来源报告 ID 精确筛选,或打开 `/decision-signals?sourceReportId=<recordId>` deep link。该筛选和 deep link 都会使用 `source_type=analysis + source_report_id` 的精确查询,以保留旧报告的 best-effort 懒回填入口。

View File

@@ -10,7 +10,7 @@ Issue #1707 的首版能力聚焦“合规资讯源采集、本地沉淀、可
- 保存资讯源配置、启用状态、作用域和最近一次拉取状态。
- 拉取条目落库到 `intelligence_items`保存标题、摘要、URL、来源、发布时间、拉取时间、市场与作用域。
- 按 URL 去重;无 URL 条目使用 `no-url:intel:<hash>` 兜底键。
- 支持 `symbol` / `market` / `sector` 作用域,以及 `cn` / `hk` / `us` / `global` 市场标记。
- 支持 `symbol` / `market` / `sector` 作用域,以及 `cn` / `hk` / `us` / `jp` / `kr` / `tw` / `global` 市场标记。
- 拉取批处理采用 fail-open单个源失败不会阻塞其他源或主分析链路。
- 支持 retention 清理,避免资讯池无限增长。

View File

@@ -29,7 +29,7 @@
## 台湾个股 suffix-only MVPIssue #1772Refs #1772
当前阶段支持手动输入台湾股票的 Yahoo Finance 后缀代码进入既有个股分析、历史保存和基础报告展示链路。TWSE 上市股票使用 `.TW` 后缀TPEx 上柜(柜买)股票使用 `.TWO` 后缀,二者折叠为同一 `tw` 市场标签。**本次覆盖市场识别detection数据路由层**;台股股票索引/种子、Web 自动补全、前端市场类型、API 市场枚举与 Portfolio/DecisionSignal 服务层均作为后续 PR。对齐 #1718 日韩 MVP 模式。
当前阶段支持手动输入台湾股票的 Yahoo Finance 后缀代码进入既有个股分析、历史保存和基础报告展示链路。TWSE 上市股票使用 `.TW` 后缀TPEx 上柜(柜买)股票使用 `.TWO` 后缀,二者折叠为同一 `tw` 市场标签。**本次覆盖市场识别detection数据路由层、DecisionSignal/Portfolio/Intelligence 服务层与 API 市场枚举,以及 DecisionSignal/Portfolio 前端市场类型与筛选**;台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行仍作为后续 PR。对齐 #1718 日韩 MVP 模式。
支持格式:
@@ -50,7 +50,7 @@
- 不承诺实时行情Yahoo Finance 数据可能延迟或字段缺失。
- 不承诺完整基本面、行业/板块、市场宽度、涨跌家数或台股大盘复盘。
- 本次未包含台股股票索引/种子、Web 自动补全、前端市场类型、API 市场枚举与 Portfolio/DecisionSignal 服务层放行,均作为后续 PR数据层已识别 `tw`DecisionSignal 抽取对 `tw` 优雅跳过(不产出信号、也不报错或刷 traceback
- 台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行仍作为后续 PR告警 MarketRegion 与后端 market_light 仍为 cn/hk/us未含 tw
- 不补齐 Portfolio 的 TWD 汇率、成本、市值完整口径(属上述后续 PR 范围)。
回滚方式:移除 `tw` 市场识别、交易日历注册YFinance 路由扩展,并删除本文档中的能力声明。
回滚方式:移除 `tw` 市场识别、交易日历注册YFinance 路由扩展与服务层/API 市场枚举及前端市场类型放行,并删除本文档中的能力声明。

View File

@@ -61,9 +61,10 @@ def build_decision_signal_payload_from_report(
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.
# A market the data layer recognizes but the decision-signal service
# layer does not accept (e.g. a market added to detection ahead of
# VALID_MARKETS). 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,

View File

@@ -866,7 +866,7 @@ class DecisionSignalService:
def _normalize_market(value: Any) -> str:
market = str(value or "").strip().lower()
if market not in VALID_MARKETS:
raise ValueError("market must be one of cn, hk, us, jp, kr")
raise ValueError("market must be one of cn, hk, us, jp, kr, tw")
return market
@classmethod

View File

@@ -28,7 +28,7 @@ from src.services.run_diagnostics import sanitize_diagnostic_text
logger = logging.getLogger(__name__)
_ALLOWED_SOURCE_TYPES = {"rss", "atom", "newsnow"}
_ALLOWED_SCOPE_TYPES = {"symbol", "market", "sector"}
_ALLOWED_MARKETS = {"cn", "hk", "us", "jp", "kr", "global"}
_ALLOWED_MARKETS = {"cn", "hk", "us", "jp", "kr", "tw", "global"}
_PRIVATE_HOSTNAMES = {"localhost", "localhost.localdomain"}
_MAX_FEED_BYTES = 2 * 1024 * 1024
_MAX_FEED_REDIRECTS = 5

View File

@@ -29,7 +29,7 @@ except Exception: # pragma: no cover - optional dependency path
yf = None
EPS = 1e-8
VALID_MARKETS = {"cn", "hk", "us", "jp", "kr"}
VALID_MARKETS = {"cn", "hk", "us", "jp", "kr", "tw"}
VALID_COST_METHODS = {"fifo", "avg"}
VALID_SIDES = {"buy", "sell"}
VALID_CASH_DIRECTIONS = {"in", "out"}
@@ -1584,7 +1584,7 @@ class PortfolioService:
def _normalize_market(value: str) -> str:
market = (value or "").strip().lower()
if market not in VALID_MARKETS:
raise ValueError("market must be one of: cn, hk, us, jp, kr")
raise ValueError("market must be one of: cn, hk, us, jp, kr, tw")
return market
@staticmethod

View File

@@ -63,8 +63,8 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
if parameter["name"] == "market"
]
assert market_descriptions == [
"Optional market filter: cn/hk/us/jp/kr",
"Optional market filter: cn/hk/us/jp/kr",
"Optional market filter: cn/hk/us/jp/kr/tw",
"Optional market filter: cn/hk/us/jp/kr/tw",
]

View File

@@ -72,14 +72,14 @@ 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).
def test_build_payload_includes_tw_market() -> None:
"""A Taiwan (`tw`) stock is now first-class on the DecisionSignal write path
(service VALID_MARKETS accepts tw, matching jp/kr).
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.
Regression guard for the data-layer MVP follow-up: the analysis pipeline
auto-extracts a DecisionSignal after history save, so tw must PRODUCE a
payload (market == "tw") rather than be silently dropped by _normalize_market.
A plain action ("buy") is set so the path reaches the market mapping.
"""
result = _result(code="2330.TW", name="台积电")
@@ -93,7 +93,9 @@ def test_build_payload_skips_tw_market_gracefully() -> None:
report_type="full",
)
assert payload is None
assert payload is not None
assert payload["market"] == "tw"
assert payload["action"] == "buy"
def test_build_payload_maps_report_context_and_price_plan() -> None:
@@ -333,6 +335,37 @@ def test_extract_and_persist_reuses_service_dedup_and_sanitization(isolated_db)
assert persisted["entry_high"] == 1700.0
def test_extract_and_persist_writes_tw_signal(isolated_db) -> None:
"""End-to-end write-leg guard: a tw analysis must PERSIST a DecisionSignal
through create_signal -> _normalize_market -> DB, not merely build the payload.
Closes the silent-failure leg where _normalize_market("tw") raised ValueError
inside extract_and_persist and was swallowed by its broad except -> return None,
so every tw analysis produced no signal while jp/kr did.
"""
service = DecisionSignalService(db_manager=isolated_db)
result = _result(code="2330.TW", name="台积电")
created = extract_and_persist_from_analysis_result(
result,
context_snapshot={"market_phase_summary": {"phase": "intraday"}},
portfolio_context={"quantity": 10},
source_report_id=2330,
trace_id="trace-tw-persist",
query_source="api",
report_type="full",
service=service,
)
assert created is not None
assert created["created"] is True
assert created["item"]["market"] == "tw"
listed = service.list_signals(source_report_id=2330)
assert listed["total"] == 1
assert listed["items"][0]["market"] == "tw"
def test_extract_and_persist_missing_price_plan_does_not_fabricate_fields(isolated_db) -> None:
service = DecisionSignalService(db_manager=isolated_db)
result = _result()

View File

@@ -132,15 +132,21 @@ def test_trading_calendar_registers_tw_exchange_and_timezone() -> None:
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.
def test_tw_is_first_class_on_write_paths() -> None:
"""TW is a first-class market on the decision-signal / portfolio / intelligence
write paths, matching jp/kr.
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.
Regression guard: the analysis pipeline auto-extracts a DecisionSignal after
history save (pipeline._extract_decision_signal_after_history_save). If `tw`
were absent from VALID_MARKETS, _normalize_market("tw") would raise ValueError
on that main path and the signal would be silently dropped — making tw the
only yfinance-supported market that can be analyzed but never produces a signal.
"""
from src.services.decision_signal_service import DecisionSignalService
from src.services.portfolio_service import VALID_MARKETS
from src.services.intelligence_service import _ALLOWED_MARKETS
assert get_market_for_stock("2330.TW") == "tw" # data layer recognizes tw
assert "tw" not in VALID_MARKETS # signal/service layer intentionally deferred
assert DecisionSignalService._normalize_market("tw") == "tw"
assert "tw" in VALID_MARKETS
assert "tw" in _ALLOWED_MARKETS