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", operation_id="listDecisionSignals",
) )
def list_signals( 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"), stock_code: Optional[str] = Query(None, description="Optional stock code filter"),
action: Optional[str] = Query(None, description="Optional decision action filter"), action: Optional[str] = Query(None, description="Optional decision action filter"),
market_phase: Optional[str] = Query(None, description="Optional market phase filter"), market_phase: Optional[str] = Query(None, description="Optional market phase filter"),
@@ -306,7 +306,7 @@ def get_outcome_stats(
) )
def get_latest_active( def get_latest_active(
stock_code: str, 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), limit: int = Query(1, ge=1, le=100),
) -> DecisionSignalListResponse: ) -> DecisionSignalListResponse:
service = DecisionSignalService() service = DecisionSignalService()

View File

@@ -16,7 +16,7 @@ DecisionSignalSourceType = Literal["analysis", "agent", "alert", "market_review"
DecisionSignalStatus = Literal["active", "expired", "invalidated", "closed", "archived"] DecisionSignalStatus = Literal["active", "expired", "invalidated", "closed", "archived"]
DecisionSignalPlanQuality = Literal["complete", "partial", "minimal", "unknown"] DecisionSignalPlanQuality = Literal["complete", "partial", "minimal", "unknown"]
DecisionSignalHorizon = Literal["intraday", "1d", "3d", "5d", "10d", "swing", "long"] 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"] DecisionSignalOutcomeStatus = Literal["completed", "unable"]
DecisionSignalOutcomeValue = Literal["hit", "miss", "neutral"] DecisionSignalOutcomeValue = Literal["hit", "miss", "neutral"]
DecisionSignalFeedbackValue = Literal["useful", "not_useful"] DecisionSignalFeedbackValue = Literal["useful", "not_useful"]

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
SourceTypeValue = Literal["rss", "atom", "newsnow"] SourceTypeValue = Literal["rss", "atom", "newsnow"]
ScopeTypeValue = Literal["symbol", "market", "sector"] 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): class IntelligenceSourceCreateRequest(BaseModel):

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ type SelectedSignal = {
source: 'list' | 'latest'; 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 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 PHASE_OPTIONS: MarketPhaseValue[] = ['premarket', 'intraday', 'lunch_break', 'closing_auction', 'postmarket', 'non_trading', 'unknown'];
const SOURCE_OPTIONS: DecisionSignalSourceType[] = ['analysis', 'agent', 'alert', 'market_review', 'manual']; 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); return getSignalTime(right) > getSignalTime(left);
} }
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['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'; type PortfolioAccountMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
function toDecisionSignalMarket(value: string | null | undefined): DecisionSignalMarket | undefined { function toDecisionSignalMarket(value: string | null | undefined): DecisionSignalMarket | undefined {
const normalized = String(value || '').toLowerCase(); const normalized = String(value || '').toLowerCase();
@@ -1092,6 +1092,7 @@ const PortfolioPage: React.FC = () => {
<option value="us">us</option> <option value="us">us</option>
<option value="jp">jp</option> <option value="jp">jp</option>
<option value="kr">kr</option> <option value="kr">kr</option>
<option value="tw">tw</option>
</select> </select>
<button type="submit" className="btn-secondary text-sm" disabled={accountCreating}> <button type="submit" className="btn-secondary text-sm" disabled={accountCreating}>
{accountCreating ? '创建中...' : '创建账户'} {accountCreating ? '创建中...' : '创建账户'}

View File

@@ -97,7 +97,7 @@ vi.mock('recharts', () => ({
type AccountItem = { type AccountItem = {
id: number; id: number;
name: string; name: string;
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr'; market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
baseCurrency?: string; 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 DecisionSignalStatus = 'active' | 'expired' | 'invalidated' | 'closed' | 'archived';
export type DecisionSignalPlanQuality = 'complete' | 'partial' | 'minimal' | 'unknown'; export type DecisionSignalPlanQuality = 'complete' | 'partial' | 'minimal' | 'unknown';
export type DecisionSignalHorizon = 'intraday' | '1d' | '3d' | '5d' | '10d' | 'swing' | 'long'; 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 DecisionSignalOutcomeEvalStatus = 'completed' | 'unable';
export type DecisionSignalOutcomeValue = 'hit' | 'miss' | 'neutral'; export type DecisionSignalOutcomeValue = 'hit' | 'miss' | 'neutral';
export type DecisionSignalFeedbackValue = 'useful' | 'not_useful'; export type DecisionSignalFeedbackValue = 'useful' | 'not_useful';

View File

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

View File

@@ -60,6 +60,14 @@ describe('normalizeStockCode', () => {
expect(normalizeStockCode('005930')).toBe('005930'); 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', () => { it('is case-insensitive for prefixes', () => {
expect(normalizeStockCode('sh600519')).toBe('600519'); expect(normalizeStockCode('sh600519')).toBe('600519');
expect(normalizeStockCode('sz000001')).toBe('000001'); expect(normalizeStockCode('sz000001')).toBe('000001');

View File

@@ -15,6 +15,7 @@ const MARKET_LABEL_KEYS: Record<DecisionSignalMarket, UiTextKey> = {
us: 'decisionSignals.market.us', us: 'decisionSignals.market.us',
jp: 'decisionSignals.market.jp', jp: 'decisionSignals.market.jp',
kr: 'decisionSignals.market.kr', kr: 'decisionSignals.market.kr',
tw: 'decisionSignals.market.tw',
}; };
const MARKET_PHASE_LABEL_KEYS: Record<MarketPhaseValue, UiTextKey> = { 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)) { if ((suffix === 'KS' || suffix === 'KQ') && /^\d{6}$/.test(base)) {
return `${base}.${suffix}`; 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 // 00700.HK → HK00700
if (suffix === 'HK' && /^\d{1,5}$/.test(base)) { 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 派生。 - [改进] #1595 P1 新增 prompt cache telemetry / analysis-path hints / diagnostics 最小配置,默认不改变 provider 请求 shape并复用 LLM usage HMAC secret 做 domain-separated cache hint 派生。
- [改进] 将 Docker Compose 默认内存建议从 512M 提升到 1G并补充低配部署说明。 - [改进] 将 Docker Compose 默认内存建议从 512M 提升到 1G并补充低配部署说明。
- [改进] 每日分析 workflow 兼容误将 `STOCK_LIST` 配到同名 Environment variables 的场景,同时保留 Repository variables 作为推荐配置入口。 - [改进] 每日分析 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-only 个股分析 MVP**市场识别与数据路由层**):手输 `.TW`TWSE 上市)/ `.TWO`TPEx 上柜)代码可走 YFinance 日线与近实时行情补充市场识别、交易日历XTAI / Asia/Taipei、Prompt 语义与能力边界文档;加权指数 `^TWII`、柜买指数 `^TWOII`。台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行作为后续 PR。
- [文档] #1772 明确本次为台股 suffix 仅路由兼容改造,对齐 #1718 日韩模式;不涉及 provider/model/base URL/运行时配置变更;DecisionSignal 抽取对 `tw` 优雅跳过;回退方式为 revert 本次改动或移除 tw 入口恢复既有行为。 - [文档] #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--> <!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 --> <!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->

View File

@@ -874,10 +874,10 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional market filter: cn/hk/us/jp/kr", "description": "Optional market filter: cn/hk/us/jp/kr/tw",
"title": "Market" "title": "Market"
}, },
"description": "Optional market filter: cn/hk/us/jp/kr" "description": "Optional market filter: cn/hk/us/jp/kr/tw"
}, },
{ {
"name": "stock_code", "name": "stock_code",
@@ -1239,10 +1239,10 @@
"type": "null" "type": "null"
} }
], ],
"description": "Optional market filter: cn/hk/us/jp/kr", "description": "Optional market filter: cn/hk/us/jp/kr/tw",
"title": "Market" "title": "Market"
}, },
"description": "Optional market filter: cn/hk/us/jp/kr" "description": "Optional market filter: cn/hk/us/jp/kr/tw"
}, },
{ {
"name": "limit", "name": "limit",
@@ -3791,7 +3791,8 @@
"hk", "hk",
"us", "us",
"jp", "jp",
"kr" "kr",
"tw"
], ],
"title": "Market" "title": "Market"
}, },
@@ -5665,7 +5666,8 @@
"hk", "hk",
"us", "us",
"jp", "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` | | `source_type` | `analysis``agent``alert``market_review``manual` |
| `market_phase` | `premarket``intraday``lunch_break``closing_auction``postmarket``non_trading``unknown` | | `market_phase` | `premarket``intraday``lunch_break``closing_auction``postmarket``non_trading``unknown` |
| `action` | `buy``add``hold``reduce``sell``watch``avoid``alert` | | `action` | `buy``add``hold``reduce``sell``watch``avoid``alert` |
@@ -65,7 +65,7 @@ Web 入口位于 `/decision-signals`
- 默认查询 `status=active` - 默认查询 `status=active`
- 支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选。 - 支持按市场、股票代码、动作、市场阶段、来源、来源报告 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。 - 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
- Web 只能把信号标记为 `closed``invalidated``archived`,不提供 terminal 状态恢复为 active。 - 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 懒回填入口。 - 历史报告详情不再内嵌展示报告绑定的 `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、来源、发布时间、拉取时间、市场与作用域。 - 拉取条目落库到 `intelligence_items`保存标题、摘要、URL、来源、发布时间、拉取时间、市场与作用域。
- 按 URL 去重;无 URL 条目使用 `no-url:intel:<hash>` 兜底键。 - 按 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单个源失败不会阻塞其他源或主分析链路。 - 拉取批处理采用 fail-open单个源失败不会阻塞其他源或主分析链路。
- 支持 retention 清理,避免资讯池无限增长。 - 支持 retention 清理,避免资讯池无限增长。

View File

@@ -29,7 +29,7 @@
## 台湾个股 suffix-only MVPIssue #1772Refs #1772 ## 台湾个股 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 数据可能延迟或字段缺失。 - 不承诺实时行情Yahoo Finance 数据可能延迟或字段缺失。
- 不承诺完整基本面、行业/板块、市场宽度、涨跌家数或台股大盘复盘。 - 不承诺完整基本面、行业/板块、市场宽度、涨跌家数或台股大盘复盘。
- 本次未包含台股股票索引/种子、Web 自动补全、前端市场类型、API 市场枚举与 Portfolio/DecisionSignal 服务层放行,均作为后续 PR数据层已识别 `tw`DecisionSignal 抽取对 `tw` 优雅跳过(不产出信号、也不报错或刷 traceback - 台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行仍作为后续 PR告警 MarketRegion 与后端 market_light 仍为 cn/hk/us未含 tw
- 不补齐 Portfolio 的 TWD 汇率、成本、市值完整口径(属上述后续 PR 范围)。 - 不补齐 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) logger.warning("Skip decision signal extraction: unrecognized market stock_code=%s", raw_code)
return None return None
if market not in VALID_MARKETS: if market not in VALID_MARKETS:
# A market the data layer recognizes (e.g. tw) but the decision-signal # A market the data layer recognizes but the decision-signal service
# service layer does not yet support. Skip gracefully instead of letting # layer does not accept (e.g. a market added to detection ahead of
# create_signal raise a swallowed ValueError + noisy traceback. # VALID_MARKETS). Skip gracefully instead of letting create_signal
# raise a swallowed ValueError + noisy traceback.
logger.info( logger.info(
"Skip decision signal extraction: market=%s not yet wired for signals stock_code=%s", "Skip decision signal extraction: market=%s not yet wired for signals stock_code=%s",
market, market,

View File

@@ -866,7 +866,7 @@ class DecisionSignalService:
def _normalize_market(value: Any) -> str: def _normalize_market(value: Any) -> str:
market = str(value or "").strip().lower() market = str(value or "").strip().lower()
if market not in VALID_MARKETS: 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 return market
@classmethod @classmethod

View File

@@ -28,7 +28,7 @@ from src.services.run_diagnostics import sanitize_diagnostic_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ALLOWED_SOURCE_TYPES = {"rss", "atom", "newsnow"} _ALLOWED_SOURCE_TYPES = {"rss", "atom", "newsnow"}
_ALLOWED_SCOPE_TYPES = {"symbol", "market", "sector"} _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"} _PRIVATE_HOSTNAMES = {"localhost", "localhost.localdomain"}
_MAX_FEED_BYTES = 2 * 1024 * 1024 _MAX_FEED_BYTES = 2 * 1024 * 1024
_MAX_FEED_REDIRECTS = 5 _MAX_FEED_REDIRECTS = 5

View File

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

View File

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

View File

@@ -72,14 +72,14 @@ def _result(**overrides) -> AnalysisResult:
return result return result
def test_build_payload_skips_tw_market_gracefully() -> None: def test_build_payload_includes_tw_market() -> None:
"""A Taiwan (`tw`) stock is recognized by the data layer but is intentionally """A Taiwan (`tw`) stock is now first-class on the DecisionSignal write path
not yet wired into the DecisionSignal service layer (a deferred follow-up). (service VALID_MARKETS accepts tw, matching jp/kr).
The payload builder must SKIP it (return None) without raising — locking the Regression guard for the data-layer MVP follow-up: the analysis pipeline
"no swallowed ValueError + noisy traceback on every tw analysis" behavior the auto-extracts a DecisionSignal after history save, so tw must PRODUCE a
data-layer MVP relies on. A plain action ("buy") is set so the skip is the payload (market == "tw") rather than be silently dropped by _normalize_market.
market guard, not the earlier no-action early-return. A plain action ("buy") is set so the path reaches the market mapping.
""" """
result = _result(code="2330.TW", name="台积电") result = _result(code="2330.TW", name="台积电")
@@ -93,7 +93,9 @@ def test_build_payload_skips_tw_market_gracefully() -> None:
report_type="full", 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: 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 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: def test_extract_and_persist_missing_price_plan_does_not_fabricate_fields(isolated_db) -> None:
service = DecisionSignalService(db_manager=isolated_db) service = DecisionSignalService(db_manager=isolated_db)
result = _result() result = _result()

View File

@@ -132,15 +132,21 @@ def test_trading_calendar_registers_tw_exchange_and_timezone() -> None:
assert MARKET_TIMEZONE["tw"] == "Asia/Taipei" assert MARKET_TIMEZONE["tw"] == "Asia/Taipei"
def test_tw_decision_signals_gracefully_skipped_in_data_layer_scope() -> None: def test_tw_is_first_class_on_write_paths() -> None:
"""Data-layer MVP scope (per maintainer): tw is a recognized market, but the """TW is a first-class market on the decision-signal / portfolio / intelligence
decision-signal / portfolio / API service layer is a deliberate follow-up. write paths, matching jp/kr.
The data layer recognizes tw, while the signal service does not yet accept it, Regression guard: the analysis pipeline auto-extracts a DecisionSignal after
so the decision-signal extractor must SKIP tw gracefully (no signal, no noisy history save (pipeline._extract_decision_signal_after_history_save). If `tw`
ValueError traceback) — guarded in build_decision_signal_payload_from_report. 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.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 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