mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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
94 lines
3.1 KiB
Python
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")
|