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)