Files
daily_stock_analysis/tests/test_decision_signal_docs.py
Wenyu Chiou cb72be7408 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
2026-06-26 21:21:38 +08:00

94 lines
3.1 KiB
Python

# -*- coding: utf-8 -*-
"""Documentation and closeout contract tests for #1390 DecisionSignal P7."""
from __future__ import annotations
import json
from pathlib import Path
from src.services.system_config_service import SystemConfigService
ROOT = Path(__file__).resolve().parents[1]
def _read(relative_path: str) -> str:
return (ROOT / relative_path).read_text(encoding="utf-8")
def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
topic = _read("docs/decision-signals.md")
alerts = _read("docs/alerts.md")
notifications = _read("docs/notifications.md")
full_guide = _read("docs/full-guide.md")
full_guide_en = _read("docs/full-guide_EN.md")
index = _read("docs/INDEX.md")
index_en = _read("docs/INDEX_EN.md")
api_spec = json.loads(_read("docs/architecture/api_spec.json"))
for path in (
"/api/v1/decision-signals",
"/api/v1/decision-signals/latest/{stock_code}",
"/api/v1/decision-signals/outcomes/run",
"/api/v1/decision-signals/{signal_id}/feedback",
):
assert path in topic
assert path in api_spec["paths"]
for schema_name in (
"DecisionSignalCreateRequest",
"DecisionSignalItem",
"DecisionSignalOutcomeItem",
"DecisionSignalFeedbackRequest",
"PortfolioDecisionSignalRiskBlock",
):
assert schema_name in api_spec["components"]["schemas"]
assert "sanitize_decision_signal_text()" in topic
assert "sanitize_decision_signal_payload()" in topic
assert "DECISION_SIGNAL_*" in topic
assert "revert" in topic
assert "decision-signals.md" in full_guide
assert "decision-signals.md" in full_guide_en
assert "decision-signals.md" in index
assert "decision-signals.md" in index_en
assert "decision-signals.md" in alerts
assert "decision-signals.md" in notifications
list_parameters = api_spec["paths"]["/api/v1/decision-signals"]["get"]["parameters"]
latest_parameters = api_spec["paths"]["/api/v1/decision-signals/latest/{stock_code}"]["get"]["parameters"]
market_descriptions = [
parameter["description"]
for parameter in [*list_parameters, *latest_parameters]
if parameter["name"] == "market"
]
assert market_descriptions == [
"Optional market filter: cn/hk/us/jp/kr/tw",
"Optional market filter: cn/hk/us/jp/kr/tw",
]
def test_decision_signal_topic_source_anchors_exist() -> None:
topic = _read("docs/decision-signals.md")
for source_path in (
"api/v1/schemas/decision_signals.py",
"api/v1/endpoints/decision_signals.py",
"src/services/decision_signal_service.py",
"src/utils/sanitize.py",
):
assert source_path in topic
assert (ROOT / source_path).exists()
def test_decision_signal_has_no_web_settings_schema_entry() -> None:
schema = SystemConfigService().get_schema()
field_keys = {
field["key"]
for category in schema["categories"]
for field in category["fields"]
}
assert not any(key.startswith("DECISION_SIGNAL") for key in field_keys)
assert "DECISION_SIGNAL_ENABLED" not in _read("docs/decision-signals.md")