Files
daily_stock_analysis/tests/test_decision_signal_extractor.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

393 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""Tests for extracting DecisionSignal assets from completed reports."""
from __future__ import annotations
import os
import pytest
from src.analyzer import AnalysisResult
from src.config import Config
from src.services.decision_signal_extractor import (
build_decision_signal_payload_from_report,
extract_and_persist_from_analysis_result,
)
from src.services.decision_signal_service import DecisionSignalService
from src.storage import DatabaseManager
@pytest.fixture()
def isolated_db(tmp_path):
old_database_path = os.environ.get("DATABASE_PATH")
db_path = tmp_path / "decision_signal_extractor.db"
os.environ["DATABASE_PATH"] = str(db_path)
Config.reset_instance()
DatabaseManager.reset_instance()
db = DatabaseManager.get_instance()
try:
yield db
finally:
DatabaseManager.reset_instance()
Config.reset_instance()
if old_database_path is None:
os.environ.pop("DATABASE_PATH", None)
else:
os.environ["DATABASE_PATH"] = old_database_path
def _result(**overrides) -> AnalysisResult:
result = AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=82,
trend_prediction="看多",
operation_advice="买入",
decision_type="buy",
confidence_level="",
analysis_summary="趋势确认,量价配合。",
risk_warning="跌破支撑需止损",
report_language="zh",
)
result.dashboard = {
"battle_plan": {
"sniper_points": {
"ideal_buy": "理想买入点1700元",
"secondary_buy": "1680-1690回踩MA5附近",
"stop_loss": "止损位1600元",
"take_profit": "目标位1850元",
},
"action_checklist": ["放量突破前高", "回踩不破MA10"],
},
"phase_decision": {
"watch_conditions": ["盘中量能继续放大"],
},
"intelligence": {
"risk_alerts": ["估值偏高"],
"positive_catalysts": ["业绩超预期"],
},
}
for key, value in overrides.items():
setattr(result, key, value)
return result
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).
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="台积电")
payload = build_decision_signal_payload_from_report(
result,
context_snapshot=None,
portfolio_context=None,
source_report_id=None,
trace_id="trace-tw",
query_source="api",
report_type="full",
)
assert payload is not None
assert payload["market"] == "tw"
assert payload["action"] == "buy"
def test_build_payload_maps_report_context_and_price_plan() -> None:
result = _result()
result.market_phase_summary = {"phase": "postmarket"}
result.analysis_context_pack_overview = {"data_quality": {"overall_score": 55, "level": "fair"}}
context_snapshot = {
"market_phase_summary": {
"phase": "intraday",
"session_date": "2026-06-15",
"minutes_to_open": None,
"minutes_to_close": 120,
},
"analysis_context_pack_overview": {
"data_quality": {"overall_score": 91, "level": "good"},
},
}
payload = build_decision_signal_payload_from_report(
result,
context_snapshot=context_snapshot,
portfolio_context={"quantity": "200"},
source_report_id=88,
trace_id="trace-88",
query_source="api",
report_type="full",
)
assert payload is not None
assert payload["stock_code"] == "600519"
assert payload["stock_name"] == "贵州茅台"
assert payload["market"] == "cn"
assert payload["source_type"] == "analysis"
assert payload["source_report_id"] == 88
assert payload["trace_id"] == "trace-88"
assert payload["trigger_source"] == "api"
assert payload["action"] == "buy"
assert payload["confidence"] == 0.8
assert payload["score"] == 82
assert payload["market_phase"] == "intraday"
assert payload["entry_low"] == 1690.0
assert payload["entry_high"] == 1700.0
assert payload["stop_loss"] == 1600.0
assert payload["target_price"] == 1850.0
assert payload["data_quality_summary"]["overall_score"] == 91
assert payload["watch_conditions"] == ["盘中量能继续放大"]
assert payload["risk_summary"] == ["跌破支撑需止损", "估值偏高"]
assert payload["catalyst_summary"] == ["业绩超预期"]
assert payload["metadata"]["report_confidence_level"] == ""
assert payload["metadata"]["market_phase_summary"] == {
"phase": "intraday",
"session_date": "2026-06-15",
"minutes_to_close": 120,
}
assert payload["metadata"]["holding_state"] == "holding"
def test_build_payload_uses_result_fallbacks_and_optional_catalysts() -> None:
result = _result(confidence_level="")
result.dashboard = {
"battle_plan": {
"sniper_points": {"ideal_buy": "1700"},
"action_checklist": ["等待回踩确认"],
},
"intelligence": {},
}
result.market_phase_summary = {"phase": "postmarket"}
result.analysis_context_pack_overview = {"data_quality": {"level": "limited"}}
payload = build_decision_signal_payload_from_report(
result,
context_snapshot=None,
source_report_id=None,
trace_id="trace-fallback",
query_source="",
report_type="simple",
)
assert payload is not None
assert payload["market_phase"] == "postmarket"
assert payload["data_quality_summary"] == {"level": "limited"}
assert payload["entry_low"] == 1700.0
assert "entry_high" not in payload
assert payload["watch_conditions"] == ["等待回踩确认"]
assert "catalyst_summary" not in payload
assert payload["trigger_source"] == "system"
assert payload["confidence"] == 0.4
assert payload["metadata"]["holding_state"] == "unknown"
def test_build_payload_records_empty_holding_state_from_explicit_portfolio_context() -> None:
payload = build_decision_signal_payload_from_report(
_result(),
portfolio_context={"quantity": 0},
trace_id="trace-empty-holding",
query_source="api",
report_type="simple",
)
assert payload is not None
assert payload["metadata"]["holding_state"] == "empty"
def test_runtime_decision_signal_summary_is_not_serialized_by_analysis_result_to_dict() -> None:
result = _result()
setattr(result, "decision_signal_summary", {"action": "sell", "reason": "risk"})
assert "decision_signal_summary" not in result.to_dict()
def test_build_payload_maps_secondary_only_entry_to_entry_high() -> None:
result = _result()
result.dashboard = {
"battle_plan": {
"sniper_points": {"secondary_buy": "次优买入点1680元"},
},
}
payload = build_decision_signal_payload_from_report(
result,
trace_id="trace-secondary-only",
query_source="api",
report_type="simple",
)
assert payload is not None
assert "entry_low" not in payload
assert payload["entry_high"] == 1680.0
def test_build_payload_reuses_shared_sniper_fallback_paths(isolated_db) -> None:
result = _result()
result.dashboard = {}
result.raw_response = {
"dashboard": {
"battle_plan": {
"sniper_points": {
"ideal_buy": "1690",
"secondary_buy": "1705",
"stop_loss": "1620",
"take_profit": "1880",
}
}
}
}
payload = build_decision_signal_payload_from_report(
result,
trace_id="trace-raw-sniper",
query_source="api",
report_type="simple",
)
stored_points = isolated_db._extract_sniper_points(result)
assert payload is not None
assert stored_points == {
"ideal_buy": 1690.0,
"secondary_buy": 1705.0,
"stop_loss": 1620.0,
"take_profit": 1880.0,
}
assert payload["entry_low"] == 1690.0
assert payload["entry_high"] == 1705.0
assert payload["stop_loss"] == 1620.0
assert payload["target_price"] == 1880.0
def test_build_payload_skips_ambiguous_action_non_stock_and_unknown_market() -> None:
ambiguous = _result(operation_advice="买盘增强,继续观察", action=None)
assert build_decision_signal_payload_from_report(
ambiguous,
trace_id="trace-1",
query_source="api",
report_type="simple",
) is None
market_review = _result(operation_advice="买入", action="buy")
assert build_decision_signal_payload_from_report(
market_review,
trace_id="trace-2",
query_source="api",
report_type="market_review",
) is None
unknown_market = _result(code="UNKNOWN", operation_advice="买入", action="buy")
assert build_decision_signal_payload_from_report(
unknown_market,
trace_id="trace-3",
query_source="api",
report_type="simple",
) is None
def test_extract_and_persist_reuses_service_dedup_and_sanitization(isolated_db) -> None:
service = DecisionSignalService(db_manager=isolated_db)
result = _result(
analysis_summary="趋势确认 token=super-secret",
)
first = extract_and_persist_from_analysis_result(
result,
context_snapshot={"market_phase_summary": {"phase": "intraday"}},
portfolio_context={"quantity": 10},
source_report_id=901,
trace_id="trace-901",
query_source="api",
report_type="full",
service=service,
)
second = extract_and_persist_from_analysis_result(
result,
context_snapshot={"market_phase_summary": {"phase": "intraday"}},
portfolio_context={"quantity": 10},
source_report_id=901,
trace_id="trace-901",
query_source="api",
report_type="full",
service=service,
)
assert first is not None
assert second is not None
assert first["created"] is True
assert second["created"] is False
assert first["item"]["reason"] == "趋势确认 token=[REDACTED]"
assert first["item"]["plan_quality"] == "complete"
assert first["item"]["horizon"] == "intraday"
assert first["item"]["expires_at"] is not None
listed = service.list_signals(source_report_id=901)
assert listed["total"] == 1
persisted = listed["items"][0]
assert persisted["source_report_id"] == 901
assert persisted["metadata"]["holding_state"] == "holding"
assert persisted["reason"] == "趋势确认 token=[REDACTED]"
assert persisted["entry_low"] == 1690.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:
service = DecisionSignalService(db_manager=isolated_db)
result = _result()
result.dashboard = {"battle_plan": {"sniper_points": {}}, "intelligence": {}}
created = extract_and_persist_from_analysis_result(
result,
context_snapshot={"market_phase_summary": {"phase": "postmarket"}},
source_report_id=902,
trace_id="trace-902",
query_source="schedule",
report_type="simple",
service=service,
)
assert created is not None
item = created["item"]
assert item["plan_quality"] == "minimal"
assert item["horizon"] == "3d"
assert item["expires_at"] is not None
assert item["entry_low"] is None
assert item["entry_high"] is None
assert item["stop_loss"] is None
assert item["target_price"] is None