mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
* feat: persist decision profile reassessment signals * fix: align reassess persistence outcomes and lifecycle
2225 lines
81 KiB
Python
2225 lines
81 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""API tests for DecisionSignal P1."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import json
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
try:
|
|
import litellm # noqa: F401
|
|
except ModuleNotFoundError:
|
|
sys.modules["litellm"] = MagicMock()
|
|
|
|
import src.auth as auth
|
|
from api.app import create_app
|
|
from src.analyzer import AnalysisResult
|
|
from src.config import Config
|
|
from src.services.decision_signal_extractor import extract_and_persist_from_analysis_result
|
|
from src.services.decision_signal_service import DecisionSignalService
|
|
from src.storage import AnalysisHistory, DatabaseManager, DecisionSignalRecord, PortfolioAccount, PortfolioPosition, utc_naive_now
|
|
|
|
|
|
@contextmanager
|
|
def _temporary_tz(tz_name: str):
|
|
old_tz = os.environ.get("TZ")
|
|
os.environ["TZ"] = tz_name
|
|
if hasattr(time, "tzset"):
|
|
time.tzset()
|
|
try:
|
|
yield
|
|
finally:
|
|
if old_tz is None:
|
|
os.environ.pop("TZ", None)
|
|
else:
|
|
os.environ["TZ"] = old_tz
|
|
if hasattr(time, "tzset"):
|
|
time.tzset()
|
|
|
|
|
|
def _reset_auth_globals() -> None:
|
|
auth._auth_enabled = None
|
|
auth._session_secret = None
|
|
auth._password_hash_salt = None
|
|
auth._password_hash_stored = None
|
|
auth._rate_limit = {}
|
|
|
|
|
|
@pytest.fixture()
|
|
def client_and_db(tmp_path):
|
|
old_env_file = os.environ.get("ENV_FILE")
|
|
old_database_path = os.environ.get("DATABASE_PATH")
|
|
env_path = tmp_path / ".env"
|
|
db_path = tmp_path / "decision_signal_api.db"
|
|
static_dir = tmp_path / "empty-static"
|
|
static_dir.mkdir()
|
|
env_path.write_text(
|
|
"\n".join(
|
|
[
|
|
"STOCK_LIST=600519",
|
|
"GEMINI_API_KEY=test",
|
|
"ADMIN_AUTH_ENABLED=false",
|
|
f"DATABASE_PATH={db_path}",
|
|
]
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
os.environ["ENV_FILE"] = str(env_path)
|
|
os.environ["DATABASE_PATH"] = str(db_path)
|
|
_reset_auth_globals()
|
|
Config.reset_instance()
|
|
DatabaseManager.reset_instance()
|
|
app = create_app(static_dir=Path(static_dir))
|
|
client = TestClient(app)
|
|
db = DatabaseManager.get_instance()
|
|
try:
|
|
yield client, db
|
|
finally:
|
|
DatabaseManager.reset_instance()
|
|
Config.reset_instance()
|
|
_reset_auth_globals()
|
|
if old_env_file is None:
|
|
os.environ.pop("ENV_FILE", None)
|
|
else:
|
|
os.environ["ENV_FILE"] = old_env_file
|
|
if old_database_path is None:
|
|
os.environ.pop("DATABASE_PATH", None)
|
|
else:
|
|
os.environ["DATABASE_PATH"] = old_database_path
|
|
|
|
|
|
def _payload(**overrides):
|
|
payload = {
|
|
"stock_code": "SH600519",
|
|
"stock_name": "贵州茅台",
|
|
"market": "cn",
|
|
"source_type": "analysis",
|
|
"source_agent": "api-test",
|
|
"source_report_id": 3001,
|
|
"trace_id": "trace-3001",
|
|
"market_phase": "intraday",
|
|
"trigger_source": "api",
|
|
"action": "buy",
|
|
"confidence": 0.75,
|
|
"score": 80,
|
|
"horizon": "3d",
|
|
"entry_low": 1680,
|
|
"stop_loss": 1600,
|
|
"reason": "突破平台",
|
|
"evidence": {"source": "unit-test"},
|
|
"metadata": {"task_id": "task-3001", "alert_trigger_id": "alert-1"},
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
def test_decision_signal_api_requires_session_when_admin_auth_enabled(tmp_path) -> None:
|
|
old_env_file = os.environ.get("ENV_FILE")
|
|
old_database_path = os.environ.get("DATABASE_PATH")
|
|
env_path = tmp_path / ".env"
|
|
db_path = tmp_path / "decision_signal_auth.db"
|
|
static_dir = tmp_path / "empty-static"
|
|
static_dir.mkdir()
|
|
env_path.write_text(
|
|
"\n".join(
|
|
[
|
|
"STOCK_LIST=600519",
|
|
"GEMINI_API_KEY=test",
|
|
"ADMIN_AUTH_ENABLED=true",
|
|
f"DATABASE_PATH={db_path}",
|
|
]
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
os.environ["ENV_FILE"] = str(env_path)
|
|
os.environ["DATABASE_PATH"] = str(db_path)
|
|
_reset_auth_globals()
|
|
Config.reset_instance()
|
|
DatabaseManager.reset_instance()
|
|
|
|
try:
|
|
client = TestClient(create_app(static_dir=Path(static_dir)))
|
|
resp = client.get("/api/v1/decision-signals")
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "unauthorized"
|
|
finally:
|
|
DatabaseManager.reset_instance()
|
|
Config.reset_instance()
|
|
_reset_auth_globals()
|
|
if old_env_file is None:
|
|
os.environ.pop("ENV_FILE", None)
|
|
else:
|
|
os.environ["ENV_FILE"] = old_env_file
|
|
if old_database_path is None:
|
|
os.environ.pop("DATABASE_PATH", None)
|
|
else:
|
|
os.environ["DATABASE_PATH"] = old_database_path
|
|
|
|
|
|
def test_create_duplicate_list_detail_latest_and_status_update(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
created_resp = client.post("/api/v1/decision-signals", json=_payload())
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
created = created_resp.json()
|
|
assert created["created"] is True
|
|
signal_id = created["item"]["id"]
|
|
assert created["item"]["stock_code"] == "600519"
|
|
assert created["item"]["plan_quality"] == "partial"
|
|
assert created["item"]["decision_profile"] == "balanced"
|
|
assert created["item"]["metadata"]["decision_profile"] == "balanced"
|
|
assert created["item"]["expires_at"] is not None
|
|
|
|
duplicate_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(reason="重复报告里不同文案不应覆盖旧信号"),
|
|
)
|
|
assert duplicate_resp.status_code == 200, duplicate_resp.text
|
|
duplicate = duplicate_resp.json()
|
|
assert duplicate["created"] is False
|
|
assert duplicate["item"]["id"] == signal_id
|
|
assert duplicate["item"]["reason"] == "突破平台"
|
|
|
|
list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={
|
|
"market": "cn",
|
|
"stock_code": "600519.SH",
|
|
"action": "buy",
|
|
"market_phase": "intraday",
|
|
"source_type": "analysis",
|
|
"trigger_source": "api",
|
|
"status": "active",
|
|
},
|
|
)
|
|
assert list_resp.status_code == 200, list_resp.text
|
|
listed = list_resp.json()
|
|
assert listed["total"] == 1
|
|
assert listed["items"][0]["id"] == signal_id
|
|
|
|
detail_resp = client.get(f"/api/v1/decision-signals/{signal_id}")
|
|
assert detail_resp.status_code == 200, detail_resp.text
|
|
assert detail_resp.json()["id"] == signal_id
|
|
|
|
latest_resp = client.get("/api/v1/decision-signals/latest/600519", params={"limit": 1})
|
|
assert latest_resp.status_code == 200, latest_resp.text
|
|
assert latest_resp.json()["items"][0]["id"] == signal_id
|
|
|
|
patch_resp = client.patch(
|
|
f"/api/v1/decision-signals/{signal_id}/status",
|
|
json={
|
|
"status": "closed",
|
|
"metadata": {"closed_by": "api-test", "decision_profile": "aggressive"},
|
|
},
|
|
)
|
|
assert patch_resp.status_code == 200, patch_resp.text
|
|
assert patch_resp.json()["status"] == "closed"
|
|
assert patch_resp.json()["metadata"]["closed_by"] == "api-test"
|
|
assert patch_resp.json()["metadata"]["decision_profile"] == "balanced"
|
|
assert "task_id" not in patch_resp.json()["metadata"]
|
|
|
|
clear_metadata_resp = client.patch(
|
|
f"/api/v1/decision-signals/{signal_id}/status",
|
|
json={"status": "archived", "metadata": None},
|
|
)
|
|
assert clear_metadata_resp.status_code == 200, clear_metadata_resp.text
|
|
assert clear_metadata_resp.json()["status"] == "archived"
|
|
assert clear_metadata_resp.json()["metadata"] is None
|
|
|
|
terminal_reactivate_resp = client.patch(
|
|
f"/api/v1/decision-signals/{signal_id}/status",
|
|
json={"status": "active"},
|
|
)
|
|
assert terminal_reactivate_resp.status_code == 400, terminal_reactivate_resp.text
|
|
assert terminal_reactivate_resp.json()["error"] == "validation_error"
|
|
|
|
invalid_status_resp = client.patch(
|
|
f"/api/v1/decision-signals/{signal_id}/status",
|
|
json={"status": "bad_status"},
|
|
)
|
|
assert invalid_status_resp.status_code == 422
|
|
assert invalid_status_resp.json()["error"] == "validation_error"
|
|
|
|
missing_resp = client.get("/api/v1/decision-signals/999999")
|
|
assert missing_resp.status_code == 404
|
|
|
|
|
|
def test_create_rejects_explicit_null_decision_profile_and_accepts_null_metadata(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
null_profile_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3010, trace_id="trace-null-profile", decision_profile=None),
|
|
)
|
|
assert null_profile_resp.status_code == 422, null_profile_resp.text
|
|
assert "decision_profile" in null_profile_resp.text
|
|
|
|
null_metadata_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3011, trace_id="trace-null-metadata", metadata=None),
|
|
)
|
|
assert null_metadata_resp.status_code == 200, null_metadata_resp.text
|
|
null_metadata_item = null_metadata_resp.json()["item"]
|
|
assert null_metadata_item["decision_profile"] == "balanced"
|
|
assert null_metadata_item["metadata"] == {"decision_profile": "balanced"}
|
|
|
|
omitted_metadata_payload = _payload(
|
|
source_report_id=3012,
|
|
trace_id="trace-omitted-metadata",
|
|
)
|
|
omitted_metadata_payload.pop("metadata")
|
|
omitted_metadata_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=omitted_metadata_payload,
|
|
)
|
|
assert omitted_metadata_resp.status_code == 200, omitted_metadata_resp.text
|
|
omitted_metadata_item = omitted_metadata_resp.json()["item"]
|
|
assert omitted_metadata_item["decision_profile"] == "balanced"
|
|
assert omitted_metadata_item["metadata"] == {"decision_profile": "balanced"}
|
|
|
|
|
|
def test_create_treats_null_lifecycle_fields_as_missing(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3002,
|
|
trace_id="trace-null-lifecycle-api",
|
|
horizon=None,
|
|
expires_at=None,
|
|
market_phase="intraday",
|
|
metadata={"market_phase_summary": {"minutes_to_close": 25}},
|
|
),
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
item = response.json()["item"]
|
|
assert item["status"] == "active"
|
|
assert item["horizon"] == "intraday"
|
|
assert item["expires_at"] is not None
|
|
|
|
|
|
def test_status_update_sanitizes_metadata_before_response_and_persistence(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
|
|
created_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3051, trace_id="trace-3051"),
|
|
)
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
signal_id = created_resp.json()["item"]["id"]
|
|
|
|
patch_resp = client.patch(
|
|
f"/api/v1/decision-signals/{signal_id}/status",
|
|
json={
|
|
"status": "closed",
|
|
"metadata": {
|
|
"source_url": "https://news.example.com/article?id=1",
|
|
"ordinary_services_url": "https://example.com/services/research?id=1",
|
|
"ordinary_robot_url": "https://example.com/robot/send/report?id=1",
|
|
"webhook": "https://hooks.slack.com/services/T000/B000/abcdef",
|
|
"feishu": "https://open.feishu.cn/open-apis/bot/v2/hook/abcdef",
|
|
"userinfo": "https://user:pass@example.com/path",
|
|
"fragment": "https://news.example.com/cb#access_token=abc",
|
|
"note": "Bearer abc+/def==",
|
|
},
|
|
},
|
|
)
|
|
assert patch_resp.status_code == 200, patch_resp.text
|
|
response_blob = str(patch_resp.json()["metadata"])
|
|
assert "https://news.example.com/article?id=1" in response_blob
|
|
assert "https://example.com/services/research?id=1" in response_blob
|
|
assert "https://example.com/robot/send/report?id=1" in response_blob
|
|
assert "[REDACTED_URL]" in response_blob
|
|
assert "hooks.slack.com" not in response_blob
|
|
assert "open.feishu.cn" not in response_blob
|
|
assert "user:pass" not in response_blob
|
|
assert "access_token=abc" not in response_blob
|
|
assert "abc+/def==" not in response_blob
|
|
assert "+/def==" not in response_blob
|
|
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter_by(id=signal_id).one()
|
|
stored_blob = str(row.metadata_json)
|
|
assert "https://news.example.com/article?id=1" in stored_blob
|
|
assert "https://example.com/services/research?id=1" in stored_blob
|
|
assert "https://example.com/robot/send/report?id=1" in stored_blob
|
|
assert "[REDACTED_URL]" in stored_blob
|
|
assert "hooks.slack.com" not in stored_blob
|
|
assert "open.feishu.cn" not in stored_blob
|
|
assert "user:pass" not in stored_blob
|
|
assert "access_token=abc" not in stored_blob
|
|
assert "abc+/def==" not in stored_blob
|
|
assert "+/def==" not in stored_blob
|
|
|
|
|
|
def test_create_sanitizes_public_short_fields_and_filters_by_sanitized_trigger_source(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
raw_trigger_source = "Bearer abc+/def=="
|
|
created_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3061,
|
|
trace_id="trace-public-sanitized",
|
|
stock_name="secret=plain-secret",
|
|
source_agent="https://hooks.example.com/send",
|
|
trigger_source=raw_trigger_source,
|
|
action_label="token=abc",
|
|
),
|
|
)
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
item = created_resp.json()["item"]
|
|
assert item["stock_name"] == "secret=[REDACTED]"
|
|
assert item["source_agent"] == "[REDACTED_URL]"
|
|
assert item["trigger_source"] == "Bearer [REDACTED]"
|
|
assert item["action_label"] == "token=[REDACTED]"
|
|
assert "plain-secret" not in str(item)
|
|
assert "abc+/def==" not in str(item)
|
|
assert "hooks.example.com" not in str(item)
|
|
|
|
list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"trigger_source": raw_trigger_source},
|
|
)
|
|
assert list_resp.status_code == 200, list_resp.text
|
|
assert list_resp.json()["total"] == 1
|
|
assert list_resp.json()["items"][0]["id"] == item["id"]
|
|
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter_by(id=item["id"]).one()
|
|
stored_blob = " ".join(
|
|
str(value or "")
|
|
for value in (
|
|
row.stock_name,
|
|
row.source_agent,
|
|
row.trigger_source,
|
|
row.action_label,
|
|
)
|
|
)
|
|
assert "plain-secret" not in stored_blob
|
|
assert "abc+/def==" not in stored_blob
|
|
assert "hooks.example.com" not in stored_blob
|
|
|
|
|
|
def test_detail_endpoint_lazily_expires_active_signal(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
created_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3101,
|
|
trace_id="trace-3101",
|
|
expires_at=(utc_naive_now() - timedelta(minutes=5)).isoformat(),
|
|
),
|
|
)
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
signal_id = created_resp.json()["item"]["id"]
|
|
assert created_resp.json()["item"]["status"] == "expired"
|
|
|
|
detail_resp = client.get(f"/api/v1/decision-signals/{signal_id}")
|
|
assert detail_resp.status_code == 200, detail_resp.text
|
|
assert detail_resp.json()["status"] == "expired"
|
|
|
|
reactivate_resp = client.patch(
|
|
f"/api/v1/decision-signals/{signal_id}/status",
|
|
json={"status": "active"},
|
|
)
|
|
assert reactivate_resp.status_code == 400, reactivate_resp.text
|
|
assert reactivate_resp.json()["error"] == "validation_error"
|
|
|
|
close_resp = client.patch(
|
|
f"/api/v1/decision-signals/{signal_id}/status",
|
|
json={"status": "closed"},
|
|
)
|
|
assert close_resp.status_code == 200, close_resp.text
|
|
assert close_resp.json()["status"] == "closed"
|
|
|
|
latest_resp = client.get("/api/v1/decision-signals/latest/600519")
|
|
assert latest_resp.status_code == 200, latest_resp.text
|
|
assert latest_resp.json()["total"] == 0
|
|
|
|
|
|
def test_patch_status_rejects_expired_signal_without_expires_at_extension(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
created_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=31011,
|
|
trace_id="trace-31011",
|
|
status="expired",
|
|
expires_at=None,
|
|
),
|
|
)
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
item = created_resp.json()["item"]
|
|
assert item["status"] == "expired"
|
|
|
|
reactivate_resp = client.patch(
|
|
f"/api/v1/decision-signals/{item['id']}/status",
|
|
json={"status": "active"},
|
|
)
|
|
assert reactivate_resp.status_code == 400, reactivate_resp.text
|
|
assert reactivate_resp.json()["error"] == "validation_error"
|
|
|
|
|
|
def test_create_accepts_timezone_aware_expires_at_values(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
expired_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3102,
|
|
trace_id="trace-3102",
|
|
expires_at="2020-01-01T00:00:00Z",
|
|
),
|
|
)
|
|
assert expired_resp.status_code == 200, expired_resp.text
|
|
expired_item = expired_resp.json()["item"]
|
|
assert expired_item["status"] == "expired"
|
|
assert expired_item["expires_at"] == "2020-01-01T00:00:00"
|
|
|
|
active_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3103,
|
|
trace_id="trace-3103",
|
|
expires_at="2099-01-01T00:00:00+08:00",
|
|
),
|
|
)
|
|
assert active_resp.status_code == 200, active_resp.text
|
|
active_item = active_resp.json()["item"]
|
|
assert active_item["status"] == "active"
|
|
assert active_item["expires_at"] == "2098-12-31T16:00:00"
|
|
|
|
|
|
def test_create_refreshes_expired_same_source_when_future_expiry_is_supplied(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
expired_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3111,
|
|
trace_id="trace-refresh-original",
|
|
expires_at="2020-01-01T00:00:00Z",
|
|
reason="old reason",
|
|
target_price=1800,
|
|
),
|
|
)
|
|
assert expired_resp.status_code == 200, expired_resp.text
|
|
expired = expired_resp.json()
|
|
signal_id = expired["item"]["id"]
|
|
assert expired["created"] is True
|
|
assert expired["item"]["status"] == "expired"
|
|
|
|
refresh_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3111,
|
|
trace_id="trace-refresh-new",
|
|
expires_at=(utc_naive_now() + timedelta(days=2)).isoformat(),
|
|
reason="fresh reason",
|
|
target_price=1900,
|
|
),
|
|
)
|
|
assert refresh_resp.status_code == 200, refresh_resp.text
|
|
refreshed = refresh_resp.json()
|
|
assert refreshed["created"] is False
|
|
assert refreshed["item"]["id"] == signal_id
|
|
assert refreshed["item"]["status"] == "active"
|
|
assert refreshed["item"]["reason"] == "fresh reason"
|
|
assert refreshed["item"]["target_price"] == 1900
|
|
assert refreshed["item"]["trace_id"] == "trace-refresh-original"
|
|
assert refreshed["item"]["created_at"] == expired["item"]["created_at"]
|
|
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter_by(id=signal_id).one()
|
|
assert row.status == "active"
|
|
assert row.reason == "fresh reason"
|
|
assert row.trace_id == "trace-refresh-original"
|
|
|
|
|
|
def test_create_invalidates_opposing_active_signal_and_latest_filters_it(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
buy_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=31101,
|
|
trace_id="trace-api-opposing-buy",
|
|
action="buy",
|
|
),
|
|
)
|
|
assert buy_resp.status_code == 200, buy_resp.text
|
|
buy = buy_resp.json()["item"]
|
|
|
|
sell_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=31102,
|
|
trace_id="trace-api-opposing-sell",
|
|
action="sell",
|
|
),
|
|
)
|
|
assert sell_resp.status_code == 200, sell_resp.text
|
|
sell = sell_resp.json()["item"]
|
|
|
|
old_resp = client.get(f"/api/v1/decision-signals/{buy['id']}")
|
|
assert old_resp.status_code == 200, old_resp.text
|
|
old = old_resp.json()
|
|
assert old["status"] == "invalidated"
|
|
assert old["metadata"]["invalidated_by_signal_id"] == sell["id"]
|
|
|
|
latest_resp = client.get("/api/v1/decision-signals/latest/600519", params={"limit": 5})
|
|
assert latest_resp.status_code == 200, latest_resp.text
|
|
latest = latest_resp.json()
|
|
assert latest["total"] == 1
|
|
assert latest["items"][0]["id"] == sell["id"]
|
|
|
|
|
|
def test_create_does_not_refresh_expired_same_source_without_future_active_expiry(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
expired_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3112,
|
|
trace_id="trace-refresh-past-original",
|
|
expires_at="2020-01-01T00:00:00Z",
|
|
reason="old reason",
|
|
),
|
|
)
|
|
assert expired_resp.status_code == 200, expired_resp.text
|
|
expired = expired_resp.json()
|
|
|
|
second_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3112,
|
|
trace_id="trace-refresh-past-new",
|
|
expires_at="2020-01-02T00:00:00Z",
|
|
reason="fresh reason",
|
|
),
|
|
)
|
|
assert second_resp.status_code == 200, second_resp.text
|
|
second = second_resp.json()
|
|
assert second["created"] is False
|
|
assert second["item"]["id"] == expired["item"]["id"]
|
|
assert second["item"]["status"] == "expired"
|
|
assert second["item"]["reason"] == "old reason"
|
|
|
|
|
|
@pytest.mark.parametrize("terminal_status", ["closed", "invalidated", "archived"])
|
|
def test_create_does_not_reactivate_terminal_same_source_status(client_and_db, terminal_status) -> None:
|
|
client, _db = client_and_db
|
|
created_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3113,
|
|
trace_id="trace-terminal-original",
|
|
reason="old reason",
|
|
),
|
|
)
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
signal_id = created_resp.json()["item"]["id"]
|
|
|
|
status_resp = client.patch(
|
|
f"/api/v1/decision-signals/{signal_id}/status",
|
|
json={"status": terminal_status},
|
|
)
|
|
assert status_resp.status_code == 200, status_resp.text
|
|
|
|
second_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3113,
|
|
trace_id="trace-terminal-new",
|
|
expires_at=(utc_naive_now() + timedelta(days=2)).isoformat(),
|
|
reason="fresh reason",
|
|
),
|
|
)
|
|
assert second_resp.status_code == 200, second_resp.text
|
|
second = second_resp.json()
|
|
assert second["created"] is False
|
|
assert second["item"]["id"] == signal_id
|
|
assert second["item"]["status"] == terminal_status
|
|
assert second["item"]["reason"] == "old reason"
|
|
|
|
|
|
def test_timezone_aware_future_expiry_stays_active_in_non_utc_runtime(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
with _temporary_tz("Asia/Shanghai"):
|
|
created_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3104,
|
|
trace_id="trace-3104",
|
|
expires_at=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
|
|
),
|
|
)
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
created = created_resp.json()["item"]
|
|
assert created["status"] == "active"
|
|
for field_name in ("expires_at", "created_at", "updated_at"):
|
|
assert datetime.fromisoformat(created[field_name]).tzinfo is None
|
|
|
|
latest_resp = client.get("/api/v1/decision-signals/latest/600519")
|
|
assert latest_resp.status_code == 200, latest_resp.text
|
|
assert latest_resp.json()["total"] == 1
|
|
assert latest_resp.json()["items"][0]["id"] == created["id"]
|
|
|
|
|
|
def test_aware_datetime_range_filters_use_utc_naive_contract(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
with _temporary_tz("Asia/Shanghai"):
|
|
created_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3105, trace_id="trace-3105"),
|
|
)
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
signal_id = created_resp.json()["item"]["id"]
|
|
|
|
list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={
|
|
"created_from": (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(),
|
|
"created_to": (datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat(),
|
|
},
|
|
)
|
|
assert list_resp.status_code == 200, list_resp.text
|
|
assert list_resp.json()["total"] == 1
|
|
assert list_resp.json()["items"][0]["id"] == signal_id
|
|
|
|
|
|
def test_holding_only_uses_cached_positions_and_stock_code_variants(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
stock_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3201, trace_id="trace-3201", stock_code="600519.SH"),
|
|
)
|
|
assert stock_resp.status_code == 200, stock_resp.text
|
|
other_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3202,
|
|
trace_id="trace-3202",
|
|
stock_code="AAPL",
|
|
stock_name="Apple",
|
|
market="us",
|
|
),
|
|
)
|
|
assert other_resp.status_code == 200, other_resp.text
|
|
inactive_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3203,
|
|
trace_id="trace-3203",
|
|
stock_code="TSLA",
|
|
stock_name="Tesla",
|
|
market="us",
|
|
),
|
|
)
|
|
assert inactive_resp.status_code == 200, inactive_resp.text
|
|
zero_only_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3204,
|
|
trace_id="trace-3204",
|
|
stock_code="MSFT",
|
|
stock_name="Microsoft",
|
|
market="us",
|
|
),
|
|
)
|
|
assert zero_only_resp.status_code == 200, zero_only_resp.text
|
|
hk_same_symbol_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3205,
|
|
trace_id="trace-3205",
|
|
stock_code="AAPL",
|
|
stock_name="Apple HK synthetic",
|
|
market="hk",
|
|
),
|
|
)
|
|
assert hk_same_symbol_resp.status_code == 200, hk_same_symbol_resp.text
|
|
|
|
with db.session_scope() as session:
|
|
account = PortfolioAccount(
|
|
name="Test account",
|
|
market="cn",
|
|
base_currency="CNY",
|
|
is_active=True,
|
|
)
|
|
session.add(account)
|
|
session.flush()
|
|
account_id = account.id
|
|
session.add(
|
|
PortfolioPosition(
|
|
account_id=account_id,
|
|
cost_method="fifo",
|
|
symbol="SH600519",
|
|
market="cn",
|
|
currency="CNY",
|
|
quantity=100,
|
|
avg_cost=1600,
|
|
total_cost=160000,
|
|
)
|
|
)
|
|
session.add(
|
|
PortfolioPosition(
|
|
account_id=account_id,
|
|
cost_method="fifo",
|
|
symbol="AAPL",
|
|
market="us",
|
|
currency="USD",
|
|
quantity=0,
|
|
)
|
|
)
|
|
session.add(
|
|
PortfolioPosition(
|
|
account_id=account_id,
|
|
cost_method="fifo",
|
|
symbol="MSFT",
|
|
market="us",
|
|
currency="USD",
|
|
quantity=0,
|
|
)
|
|
)
|
|
session.add(
|
|
PortfolioPosition(
|
|
account_id=account_id,
|
|
cost_method="avg",
|
|
symbol="AAPL",
|
|
market="us",
|
|
currency="USD",
|
|
quantity=5,
|
|
avg_cost=180,
|
|
total_cost=900,
|
|
)
|
|
)
|
|
inactive_account = PortfolioAccount(
|
|
name="Inactive account",
|
|
market="us",
|
|
base_currency="USD",
|
|
is_active=False,
|
|
)
|
|
session.add(inactive_account)
|
|
session.flush()
|
|
inactive_account_id = inactive_account.id
|
|
session.add(
|
|
PortfolioPosition(
|
|
account_id=inactive_account_id,
|
|
cost_method="fifo",
|
|
symbol="TSLA",
|
|
market="us",
|
|
currency="USD",
|
|
quantity=3,
|
|
avg_cost=200,
|
|
total_cost=600,
|
|
)
|
|
)
|
|
|
|
with patch(
|
|
"src.services.portfolio_service.PortfolioService.get_portfolio_snapshot",
|
|
side_effect=AssertionError("holding_only must not replay portfolio snapshots"),
|
|
):
|
|
holding_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"holding_only": "true", "account_id": account_id},
|
|
)
|
|
|
|
assert holding_resp.status_code == 200, holding_resp.text
|
|
payload = holding_resp.json()
|
|
assert payload["total"] == 2
|
|
assert {(item["market"], item["stock_code"]) for item in payload["items"]} == {
|
|
("cn", "600519"),
|
|
("us", "AAPL"),
|
|
}
|
|
|
|
with patch(
|
|
"src.services.portfolio_service.PortfolioService.get_portfolio_snapshot",
|
|
side_effect=AssertionError("holding_only must not replay portfolio snapshots"),
|
|
):
|
|
all_active_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"holding_only": "true"},
|
|
)
|
|
|
|
assert all_active_resp.status_code == 200, all_active_resp.text
|
|
all_active_payload = all_active_resp.json()
|
|
assert all_active_payload["total"] == 2
|
|
assert {(item["market"], item["stock_code"]) for item in all_active_payload["items"]} == {
|
|
("cn", "600519"),
|
|
("us", "AAPL"),
|
|
}
|
|
|
|
with patch(
|
|
"src.services.portfolio_service.PortfolioService.get_portfolio_snapshot",
|
|
side_effect=AssertionError("holding_only must not replay portfolio snapshots"),
|
|
):
|
|
inactive_holding_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"holding_only": "true", "account_id": inactive_account_id},
|
|
)
|
|
assert inactive_holding_resp.status_code == 200, inactive_holding_resp.text
|
|
assert inactive_holding_resp.json()["total"] == 0
|
|
assert inactive_holding_resp.json()["items"] == []
|
|
|
|
variant_resp = client.get("/api/v1/decision-signals", params={"stock_code": "SH600519"})
|
|
assert variant_resp.status_code == 200, variant_resp.text
|
|
assert variant_resp.json()["total"] == 1
|
|
|
|
with db.session_scope() as session:
|
|
empty_account = PortfolioAccount(name="Empty account", market="cn", base_currency="CNY")
|
|
session.add(empty_account)
|
|
session.flush()
|
|
empty_account_id = empty_account.id
|
|
|
|
empty_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"holding_only": "true", "account_id": empty_account_id},
|
|
)
|
|
assert empty_resp.status_code == 200, empty_resp.text
|
|
assert empty_resp.json()["total"] == 0
|
|
assert empty_resp.json()["items"] == []
|
|
|
|
empty_bad_date_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={
|
|
"holding_only": "true",
|
|
"account_id": empty_account_id,
|
|
"created_from": "bad-date",
|
|
},
|
|
)
|
|
assert empty_bad_date_resp.status_code == 400
|
|
assert empty_bad_date_resp.json()["error"] == "validation_error"
|
|
|
|
|
|
def test_query_validation_error_envelope(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
resp = client.get("/api/v1/decision-signals", params={"action": "panic"})
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error"] == "validation_error"
|
|
|
|
page_size_resp = client.get("/api/v1/decision-signals", params={"page_size": 0})
|
|
assert page_size_resp.status_code == 422
|
|
assert page_size_resp.json()["error"] == "validation_error"
|
|
|
|
|
|
def test_internal_errors_do_not_reflect_exception_details(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
with patch("api.v1.endpoints.decision_signals.DecisionSignalService") as service_cls:
|
|
service_cls.return_value.list_signals.side_effect = RuntimeError(
|
|
"secret-token /private/tmp/internal-path"
|
|
)
|
|
resp = client.get("/api/v1/decision-signals")
|
|
|
|
assert resp.status_code == 500
|
|
payload = resp.json()
|
|
assert payload["error"] == "internal_error"
|
|
assert payload["message"] == "List decision signals failed"
|
|
assert "secret-token" not in str(payload)
|
|
assert "internal-path" not in str(payload)
|
|
|
|
|
|
def test_corrupt_persisted_json_returns_internal_error_consistently(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
|
|
created_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3251, trace_id="trace-corrupt-json"),
|
|
)
|
|
assert created_resp.status_code == 200, created_resp.text
|
|
signal_id = created_resp.json()["item"]["id"]
|
|
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter_by(id=signal_id).one()
|
|
row.evidence_json = "{bad persisted json"
|
|
|
|
cases = [
|
|
(
|
|
client.get("/api/v1/decision-signals", params={"stock_code": "600519"}),
|
|
"List decision signals failed",
|
|
),
|
|
(
|
|
client.get(f"/api/v1/decision-signals/{signal_id}"),
|
|
"Get decision signal failed",
|
|
),
|
|
(
|
|
client.get("/api/v1/decision-signals/latest/600519"),
|
|
"Get latest decision signals failed",
|
|
),
|
|
]
|
|
for resp, message in cases:
|
|
assert resp.status_code == 500, resp.text
|
|
payload = resp.json()
|
|
assert payload["error"] == "internal_error"
|
|
assert payload["message"] == message
|
|
assert "bad persisted json" not in str(payload)
|
|
|
|
|
|
def test_create_schema_and_service_validation_errors(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
schema_invalid_cases = [
|
|
{"entry_low": -1},
|
|
{"entry_high": 0},
|
|
{"stop_loss": "nan"},
|
|
{"target_price": "inf"},
|
|
{"metadata": ["not-an-object"]},
|
|
]
|
|
for overrides in schema_invalid_cases:
|
|
resp = client.post("/api/v1/decision-signals", json=_payload(**overrides))
|
|
assert resp.status_code == 422, resp.text
|
|
assert resp.json()["error"] == "validation_error"
|
|
|
|
range_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3301, trace_id="trace-range", entry_low=1700, entry_high=1600),
|
|
)
|
|
assert range_resp.status_code == 400, range_resp.text
|
|
assert range_resp.json()["error"] == "validation_error"
|
|
|
|
long_trace_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3302, trace_id="x" * 65),
|
|
)
|
|
assert long_trace_resp.status_code == 400, long_trace_resp.text
|
|
assert long_trace_resp.json()["error"] == "validation_error"
|
|
assert "trace_id" in long_trace_resp.json()["message"]
|
|
|
|
sensitive_trace_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3303, trace_id="Bearer abc+/def=="),
|
|
)
|
|
assert sensitive_trace_resp.status_code == 400, sensitive_trace_resp.text
|
|
assert sensitive_trace_resp.json()["error"] == "validation_error"
|
|
assert "trace_id" in sensitive_trace_resp.json()["message"]
|
|
assert "abc+/def==" not in str(sensitive_trace_resp.json())
|
|
|
|
for trace_id, leaked in (
|
|
("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz"),
|
|
("cookie=session=abc123", "session=abc123"),
|
|
):
|
|
sensitive_identity_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3304, trace_id=trace_id),
|
|
)
|
|
assert sensitive_identity_resp.status_code == 400, sensitive_identity_resp.text
|
|
assert sensitive_identity_resp.json()["error"] == "validation_error"
|
|
assert "trace_id" in sensitive_identity_resp.json()["message"]
|
|
assert leaked not in str(sensitive_identity_resp.json())
|
|
|
|
|
|
def test_dedup_distinguishes_horizon_and_market_phase(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
first_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3401, trace_id="trace-3401", horizon="1d", market_phase="intraday"),
|
|
)
|
|
assert first_resp.status_code == 200, first_resp.text
|
|
first = first_resp.json()
|
|
assert first["created"] is True
|
|
|
|
duplicate_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3401, trace_id="trace-3401", horizon="1d", market_phase="intraday"),
|
|
)
|
|
assert duplicate_resp.status_code == 200, duplicate_resp.text
|
|
duplicate = duplicate_resp.json()
|
|
assert duplicate["created"] is False
|
|
assert duplicate["item"]["id"] == first["item"]["id"]
|
|
|
|
horizon_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3401, trace_id="trace-3401", horizon="10d", market_phase="intraday"),
|
|
)
|
|
assert horizon_resp.status_code == 200, horizon_resp.text
|
|
assert horizon_resp.json()["created"] is True
|
|
assert horizon_resp.json()["item"]["id"] != first["item"]["id"]
|
|
|
|
phase_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(source_report_id=3401, trace_id="trace-3401", horizon="1d", market_phase="premarket"),
|
|
)
|
|
assert phase_resp.status_code == 200, phase_resp.text
|
|
assert phase_resp.json()["created"] is True
|
|
assert phase_resp.json()["item"]["id"] != first["item"]["id"]
|
|
|
|
list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={
|
|
"stock_code": "600519",
|
|
"source_type": "analysis",
|
|
"source_report_id": 3401,
|
|
"trace_id": "trace-3401",
|
|
"trigger_source": "api",
|
|
},
|
|
)
|
|
assert list_resp.status_code == 200, list_resp.text
|
|
assert list_resp.json()["total"] == 3
|
|
|
|
latest_resp = client.get("/api/v1/decision-signals/latest/600519", params={"limit": 3})
|
|
assert latest_resp.status_code == 200, latest_resp.text
|
|
assert latest_resp.json()["total"] == 3
|
|
|
|
|
|
def test_dedup_distinguishes_source_type_for_weak_report_ids(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
analysis_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3451,
|
|
trace_id="trace-3451-analysis",
|
|
source_type="analysis",
|
|
),
|
|
)
|
|
assert analysis_resp.status_code == 200, analysis_resp.text
|
|
analysis = analysis_resp.json()
|
|
assert analysis["created"] is True
|
|
assert analysis["item"]["source_type"] == "analysis"
|
|
|
|
manual_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3451,
|
|
trace_id="trace-3451-manual",
|
|
source_type="manual",
|
|
),
|
|
)
|
|
assert manual_resp.status_code == 200, manual_resp.text
|
|
manual = manual_resp.json()
|
|
assert manual["created"] is True
|
|
assert manual["item"]["source_type"] == "manual"
|
|
assert manual["item"]["id"] != analysis["item"]["id"]
|
|
|
|
duplicate_manual_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3451,
|
|
trace_id="trace-3451-manual-new",
|
|
source_type="manual",
|
|
),
|
|
)
|
|
assert duplicate_manual_resp.status_code == 200, duplicate_manual_resp.text
|
|
duplicate_manual = duplicate_manual_resp.json()
|
|
assert duplicate_manual["created"] is False
|
|
assert duplicate_manual["item"]["id"] == manual["item"]["id"]
|
|
|
|
list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"stock_code": "600519", "source_report_id": 3451},
|
|
)
|
|
assert list_resp.status_code == 200, list_resp.text
|
|
assert list_resp.json()["total"] == 2
|
|
|
|
analysis_list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"stock_code": "600519", "source_report_id": 3451, "source_type": "analysis"},
|
|
)
|
|
assert analysis_list_resp.status_code == 200, analysis_list_resp.text
|
|
assert analysis_list_resp.json()["total"] == 1
|
|
|
|
manual_list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"stock_code": "600519", "source_report_id": 3451, "source_type": "manual"},
|
|
)
|
|
assert manual_list_resp.status_code == 200, manual_list_resp.text
|
|
assert manual_list_resp.json()["total"] == 1
|
|
|
|
|
|
def test_stock_filter_codes_cover_market_optional_hk_without_widening_other_markets() -> None:
|
|
from src.services.decision_signal_service import DecisionSignalService
|
|
|
|
cases = [
|
|
("00700", None, ["00700", "HK00700"]),
|
|
("HK00700", None, ["HK00700"]),
|
|
("00700.HK", None, ["HK00700"]),
|
|
("00700", "hk", ["HK00700"]),
|
|
("600519", None, ["600519"]),
|
|
("600519.SH", None, ["600519"]),
|
|
("AAPL", None, ["AAPL"]),
|
|
]
|
|
for raw_code, market, expected_codes in cases:
|
|
assert DecisionSignalService._stock_filter_codes(raw_code, market=market) == expected_codes
|
|
|
|
|
|
def test_hk_stock_identity_variants_deduplicate_and_latest_matches(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
first_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3501,
|
|
trace_id="trace-3501-a",
|
|
stock_code="00700",
|
|
stock_name="Tencent",
|
|
market="hk",
|
|
),
|
|
)
|
|
assert first_resp.status_code == 200, first_resp.text
|
|
first = first_resp.json()
|
|
assert first["created"] is True
|
|
assert first["item"]["stock_code"] == "HK00700"
|
|
|
|
for raw_code, trace_id in (("HK00700", "trace-3501-b"), ("00700.HK", "trace-3501-c")):
|
|
duplicate_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3501,
|
|
trace_id=trace_id,
|
|
stock_code=raw_code,
|
|
stock_name="Tencent",
|
|
market="hk",
|
|
),
|
|
)
|
|
assert duplicate_resp.status_code == 200, duplicate_resp.text
|
|
duplicate = duplicate_resp.json()
|
|
assert duplicate["created"] is False
|
|
assert duplicate["item"]["id"] == first["item"]["id"]
|
|
|
|
latest_resp = client.get(
|
|
"/api/v1/decision-signals/latest/00700",
|
|
params={"market": "hk"},
|
|
)
|
|
assert latest_resp.status_code == 200, latest_resp.text
|
|
assert latest_resp.json()["total"] == 1
|
|
assert latest_resp.json()["items"][0]["id"] == first["item"]["id"]
|
|
|
|
latest_cases = [
|
|
("00700", {}),
|
|
("HK00700", {}),
|
|
("00700.HK", {}),
|
|
("00700", {"market": "hk"}),
|
|
]
|
|
for raw_code, params in latest_cases:
|
|
latest_resp = client.get(f"/api/v1/decision-signals/latest/{raw_code}", params=params)
|
|
assert latest_resp.status_code == 200, latest_resp.text
|
|
latest_payload = latest_resp.json()
|
|
assert latest_payload["total"] == 1
|
|
assert latest_payload["items"][0]["id"] == first["item"]["id"]
|
|
|
|
list_cases = [
|
|
("00700", {}),
|
|
("HK00700", {}),
|
|
("00700.HK", {}),
|
|
("00700", {"market": "hk"}),
|
|
]
|
|
for raw_code, params in list_cases:
|
|
list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"stock_code": raw_code, **params},
|
|
)
|
|
assert list_resp.status_code == 200, list_resp.text
|
|
list_payload = list_resp.json()
|
|
assert list_payload["total"] == 1
|
|
assert list_payload["items"][0]["id"] == first["item"]["id"]
|
|
|
|
|
|
def test_dedup_distinguishes_market_for_same_symbol(client_and_db) -> None:
|
|
client, _db = client_and_db
|
|
|
|
us_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3601,
|
|
trace_id="trace-3601-us",
|
|
stock_code="DUPL",
|
|
stock_name="Duplicate US",
|
|
market="us",
|
|
),
|
|
)
|
|
assert us_resp.status_code == 200, us_resp.text
|
|
assert us_resp.json()["created"] is True
|
|
|
|
hk_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3601,
|
|
trace_id="trace-3601-hk",
|
|
stock_code="DUPL",
|
|
stock_name="Duplicate HK",
|
|
market="hk",
|
|
),
|
|
)
|
|
assert hk_resp.status_code == 200, hk_resp.text
|
|
assert hk_resp.json()["created"] is True
|
|
assert hk_resp.json()["item"]["id"] != us_resp.json()["item"]["id"]
|
|
|
|
|
|
def test_list_decision_profile_filter_distinguishes_unknown_from_omitted(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
|
|
balanced_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3611,
|
|
trace_id="trace-profile-api-balanced",
|
|
decision_profile="balanced",
|
|
),
|
|
)
|
|
aggressive_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3612,
|
|
trace_id="trace-profile-api-aggressive",
|
|
decision_profile="aggressive",
|
|
),
|
|
)
|
|
legacy_resp = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
source_report_id=3613,
|
|
trace_id="trace-profile-api-legacy",
|
|
decision_profile="balanced",
|
|
),
|
|
)
|
|
assert balanced_resp.status_code == 200, balanced_resp.text
|
|
assert aggressive_resp.status_code == 200, aggressive_resp.text
|
|
assert legacy_resp.status_code == 200, legacy_resp.text
|
|
legacy_id = legacy_resp.json()["item"]["id"]
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter_by(id=legacy_id).one()
|
|
row.decision_profile = None
|
|
|
|
all_resp = client.get("/api/v1/decision-signals", params={"stock_code": "600519", "status": "active"})
|
|
unknown_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"stock_code": "600519", "status": "active", "decision_profile": "unknown"},
|
|
)
|
|
aggressive_list_resp = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"stock_code": "600519", "status": "active", "decision_profile": "aggressive"},
|
|
)
|
|
|
|
assert all_resp.status_code == 200, all_resp.text
|
|
assert unknown_resp.status_code == 200, unknown_resp.text
|
|
assert aggressive_list_resp.status_code == 200, aggressive_list_resp.text
|
|
assert all_resp.json()["total"] == 3
|
|
assert [item["id"] for item in unknown_resp.json()["items"]] == [legacy_id]
|
|
assert aggressive_list_resp.json()["total"] == 1
|
|
assert aggressive_list_resp.json()["items"][0]["decision_profile"] == "aggressive"
|
|
|
|
|
|
def _decision_signal_count(db: DatabaseManager) -> int:
|
|
with db.session_scope() as session:
|
|
return session.query(DecisionSignalRecord).count()
|
|
|
|
|
|
def _save_reassess_history(
|
|
db: DatabaseManager,
|
|
*,
|
|
code: str = "600519",
|
|
report_type: str = "full",
|
|
operation_advice: str | None = "买入",
|
|
raw_result: dict | str | None = None,
|
|
context_snapshot: dict | str | None = None,
|
|
sentiment_score: int | None = 72,
|
|
stop_loss: float | None = 1600,
|
|
take_profit: float | None = 1850,
|
|
) -> int:
|
|
raw_payload = raw_result
|
|
if isinstance(raw_payload, dict):
|
|
raw_payload = json.dumps(raw_payload, ensure_ascii=False)
|
|
context_payload = context_snapshot
|
|
if isinstance(context_payload, dict):
|
|
context_payload = json.dumps(context_payload, ensure_ascii=False)
|
|
with db.session_scope() as session:
|
|
row = AnalysisHistory(
|
|
query_id="query-reassess-test",
|
|
code=code,
|
|
name="贵州茅台",
|
|
report_type=report_type,
|
|
sentiment_score=sentiment_score,
|
|
operation_advice=operation_advice,
|
|
trend_prediction="震荡上行",
|
|
analysis_summary="趋势改善但需要风控。",
|
|
raw_result=raw_payload,
|
|
context_snapshot=context_payload,
|
|
ideal_buy=1680,
|
|
secondary_buy=1700,
|
|
stop_loss=stop_loss,
|
|
take_profit=take_profit,
|
|
)
|
|
session.add(row)
|
|
session.flush()
|
|
return int(row.id)
|
|
|
|
|
|
def _set_reassess_history_created_at(
|
|
db: DatabaseManager,
|
|
record_id: int,
|
|
created_at: datetime | None,
|
|
) -> None:
|
|
with db.session_scope() as session:
|
|
row = session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id).one()
|
|
row.created_at = created_at
|
|
|
|
|
|
def _valid_reassess_raw(**overrides) -> dict:
|
|
raw = {
|
|
"action": "buy",
|
|
"operation_advice": "买入",
|
|
"sentiment_score": 72,
|
|
"confidence_level": "中",
|
|
"analysis_summary": "趋势改善但需要确认。",
|
|
"risk_warning": "跌破关键支撑需退出。",
|
|
"dashboard": {
|
|
"battle_plan": {
|
|
"sniper_points": {
|
|
"ideal_buy": 1680,
|
|
"secondary_buy": 1700,
|
|
"stop_loss": 1600,
|
|
"take_profit": 1850,
|
|
},
|
|
"action_checklist": ["放量突破", "资金流转正"],
|
|
},
|
|
"phase_decision": {
|
|
"watch_conditions": ["量能维持"],
|
|
},
|
|
},
|
|
}
|
|
raw.update(overrides)
|
|
return raw
|
|
|
|
|
|
def _valid_reassess_context() -> dict:
|
|
return {
|
|
"market_phase_summary": {"phase": "intraday"},
|
|
"analysis_context_pack_overview": {
|
|
"data_quality": {"level": "usable"},
|
|
},
|
|
}
|
|
|
|
|
|
def _persist_auto_balanced_signal(
|
|
db: DatabaseManager,
|
|
*,
|
|
source_report_id: int,
|
|
raw_result: dict | None = None,
|
|
context_snapshot: dict | None = None,
|
|
) -> dict:
|
|
raw = raw_result or _valid_reassess_raw(invalidation="跌破关键支撑")
|
|
result = AnalysisResult(
|
|
code="600519",
|
|
name="贵州茅台",
|
|
sentiment_score=raw.get("sentiment_score", 72),
|
|
trend_prediction="震荡上行",
|
|
operation_advice=raw.get("operation_advice", "买入"),
|
|
decision_type="buy",
|
|
confidence_level=raw.get("confidence_level", "中"),
|
|
analysis_summary=raw.get("analysis_summary", "趋势改善但需要确认。"),
|
|
risk_warning=raw.get("risk_warning", "跌破关键支撑需退出。"),
|
|
report_language="zh",
|
|
action=raw.get("action", "buy"),
|
|
)
|
|
result.dashboard = raw.get("dashboard")
|
|
persisted = extract_and_persist_from_analysis_result(
|
|
result,
|
|
context_snapshot=context_snapshot or _valid_reassess_context(),
|
|
source_report_id=source_report_id,
|
|
trace_id=f"trace-auto-{source_report_id}",
|
|
query_source="api",
|
|
report_type="full",
|
|
profile_source="auto_default",
|
|
service=DecisionSignalService(db_manager=db),
|
|
)
|
|
assert persisted is not None
|
|
return persisted["item"]
|
|
|
|
|
|
def test_reassess_persist_true_inherits_source_report_not_found(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
before = _decision_signal_count(db)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={
|
|
"source_report_id": 999999,
|
|
"decision_profile": "aggressive",
|
|
"persist": True,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404, response.text
|
|
assert response.json()["error"] == "source_report_not_found"
|
|
assert _decision_signal_count(db) == before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"decision_profile": "balanced", "persist": False},
|
|
{"source_report_id": 0, "decision_profile": "balanced", "persist": False},
|
|
{"source_report_id": 1, "decision_profile": "reckless", "persist": False},
|
|
],
|
|
)
|
|
def test_reassess_schema_validation_errors(client_and_db, payload) -> None:
|
|
client, _db = client_and_db
|
|
response = client.post("/api/v1/decision-signals/reassess", json=payload)
|
|
assert response.status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extra_field",
|
|
[
|
|
"signal_id",
|
|
"action",
|
|
"score",
|
|
"confidence",
|
|
"horizon",
|
|
"invalidation",
|
|
"stop_loss",
|
|
"target_price",
|
|
"metadata",
|
|
"scoring_breakdown",
|
|
"guardrail_result",
|
|
],
|
|
)
|
|
def test_reassess_forbids_extra_fields(client_and_db, extra_field) -> None:
|
|
client, _db = client_and_db
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={
|
|
"source_report_id": 1,
|
|
"decision_profile": "balanced",
|
|
"persist": False,
|
|
extra_field: "not-supported",
|
|
},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_reassess_error_mapping(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
|
|
missing = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": 999999, "decision_profile": "balanced", "persist": False},
|
|
)
|
|
assert missing.status_code == 404
|
|
assert missing.json()["error"] == "source_report_not_found"
|
|
|
|
market_review_id = _save_reassess_history(
|
|
db,
|
|
report_type="market_review",
|
|
raw_result=_valid_reassess_raw(),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
non_stock = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": market_review_id, "decision_profile": "balanced", "persist": False},
|
|
)
|
|
assert non_stock.status_code == 400
|
|
assert non_stock.json()["error"] == "unsupported_report_type"
|
|
|
|
insufficient_id = _save_reassess_history(
|
|
db,
|
|
code="600519",
|
|
operation_advice=None,
|
|
raw_result={"analysis_summary": "仅有摘要,不能推断动作"},
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
insufficient = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": insufficient_id, "decision_profile": "balanced", "persist": False},
|
|
)
|
|
assert insufficient.status_code == 400
|
|
assert insufficient.json()["error"] == "unsupported_report_snapshot"
|
|
|
|
unsupported_market_id = _save_reassess_history(
|
|
db,
|
|
code="NOT_A_VALID_US_SYMBOL",
|
|
raw_result=_valid_reassess_raw(),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
unsupported_market = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": unsupported_market_id, "decision_profile": "balanced", "persist": False},
|
|
)
|
|
assert unsupported_market.status_code == 400
|
|
assert unsupported_market.json()["error"] == "unsupported_report_snapshot"
|
|
|
|
|
|
def test_reassess_success_preview_is_read_only_and_uses_opaque_metadata(client_and_db, monkeypatch) -> None:
|
|
client, db = client_and_db
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
monkeypatch.setattr(
|
|
"src.services.decision_signal_service.DecisionSignalService.create_signal_with_outcome",
|
|
lambda *_args, **_kwargs: (
|
|
_ for _ in ()
|
|
).throw(AssertionError("create_signal_with_outcome must not be called")),
|
|
)
|
|
monkeypatch.setattr(
|
|
"src.services.decision_signal_service.DecisionSignalService.create_history_bound_signal_with_outcome",
|
|
lambda *_args, **_kwargs: (
|
|
_ for _ in ()
|
|
).throw(AssertionError("create_history_bound_signal_with_outcome must not be called")),
|
|
)
|
|
monkeypatch.setattr(
|
|
"src.services.decision_signal_service.DecisionSignalService.list_signals",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("list_signals must not be called")),
|
|
)
|
|
monkeypatch.setattr(
|
|
"src.services.analysis_context_builder._build_quote_block",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("reassess must not rebuild quote context")),
|
|
)
|
|
before = _decision_signal_count(db)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": False},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["item"] is None
|
|
assert payload["created"] is False
|
|
assert payload["persist_status"] is None
|
|
assert payload["preview"]["action"] == payload["preview"]["metadata"]["guardrail_result"]["final_action"]
|
|
assert payload["preview"]["metadata"]["decision_profile"] == "balanced"
|
|
assert payload["preview"]["metadata"]["profile_source"] == "user_selected"
|
|
assert payload["preview"]["metadata"]["signal_generation_version"] == "decision-profile-reassess-v1"
|
|
assert payload["preview"]["metadata"]["scoring_version"] == "decision-profile-scoring-v1"
|
|
assert "scoring_breakdown" in payload["preview"]["metadata"]
|
|
assert payload["preview"]["metadata"]["data_quality_level"] == "medium"
|
|
assert payload["preview"]["entry_low"] == 1680
|
|
assert payload["preview"]["stop_loss"] == 1600
|
|
assert _decision_signal_count(db) == before
|
|
|
|
|
|
def test_reassess_preview_prefers_stability_adjusted_score(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
raw_result = _valid_reassess_raw(
|
|
action=None,
|
|
sentiment_score=72,
|
|
operation_advice="观望",
|
|
)
|
|
dashboard = raw_result["dashboard"]
|
|
dashboard["decision_score_calibration"] = {
|
|
"raw_score": 72,
|
|
"adjusted_score": 59,
|
|
"final_action": "watch",
|
|
"guardrail_reason": "资金流偏弱,先观望回撤到 45-59。",
|
|
}
|
|
raw_result["dashboard"] = dashboard
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=raw_result,
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": False},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["preview"]["score"] == 59
|
|
assert payload["preview"]["action"] == "watch"
|
|
assert payload["preview"]["metadata"]["guardrail_result"]["final_action"] == "watch"
|
|
assert _decision_signal_count(db) == 0
|
|
|
|
|
|
def test_reassess_service_has_no_live_market_provider_imports() -> None:
|
|
source = (Path(__file__).resolve().parents[1] / "src/services/decision_signal_reassess_service.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
assert "data_provider" not in source
|
|
assert "yfinance" not in source
|
|
assert "akshare" not in source
|
|
assert "build_decision_signal_payload_from_report" not in source
|
|
|
|
|
|
def test_reassess_confidence_missing_buy_is_safe_non_actionable_preview(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(confidence_level=None),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
before = _decision_signal_count(db)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": False},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
guardrail = payload["preview"]["metadata"]["guardrail_result"]
|
|
assert guardrail["raw_action"] == "buy"
|
|
assert guardrail["final_action"] in {"watch", "alert"}
|
|
assert guardrail["passed"] is True
|
|
assert guardrail["adjusted"] is True
|
|
assert "missing_confidence" in guardrail["violations"]
|
|
assert payload["blocked_reason"] is None
|
|
assert payload["warnings"]
|
|
assert {warning["code"] for warning in payload["warnings"]} == {"action_adjusted_by_guardrail"}
|
|
assert all(warning["message"] for warning in payload["warnings"])
|
|
assert _decision_signal_count(db) == before
|
|
|
|
|
|
def test_reassess_persist_writes_authoritative_item_and_deduplicates(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑且资金流转负"),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
request = {
|
|
"source_report_id": record_id,
|
|
"decision_profile": "aggressive",
|
|
"persist": True,
|
|
}
|
|
|
|
first = client.post("/api/v1/decision-signals/reassess", json=request)
|
|
|
|
assert first.status_code == 200, first.text
|
|
first_payload = first.json()
|
|
assert first_payload["preview"] is None
|
|
assert first_payload["created"] is True
|
|
assert first_payload["persist_status"] == "created"
|
|
item = first_payload["item"]
|
|
assert item["decision_profile"] == "aggressive"
|
|
assert item["source_type"] == "analysis"
|
|
assert item["source_report_id"] == record_id
|
|
assert item["source_agent"] == "decision_profile_reassess"
|
|
assert item["trigger_source"] == "web:decision_profile_reassess"
|
|
assert item["action"] == item["metadata"]["guardrail_result"]["final_action"]
|
|
assert item["metadata"]["profile_source"] == "user_selected"
|
|
assert item["metadata"]["profile_policy_version"] == "decision-profile-v1"
|
|
assert item["metadata"]["signal_generation_version"] == "decision-profile-reassess-v1"
|
|
assert item["metadata"]["scoring_version"] == "decision-profile-scoring-v1"
|
|
assert item["metadata"]["scoring_breakdown"]
|
|
assert item["metadata"]["data_quality_level"] == "medium"
|
|
assert item["metadata"]["guardrail_result"]["passed"] is True
|
|
|
|
second = client.post("/api/v1/decision-signals/reassess", json=request)
|
|
assert second.status_code == 200, second.text
|
|
assert second.json()["created"] is False
|
|
assert second.json()["persist_status"] == "existing"
|
|
assert second.json()["item"]["id"] == item["id"]
|
|
assert _decision_signal_count(db) == 1
|
|
|
|
timeline = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"stock_code": "600519", "decision_profile": "aggressive", "page_size": 100},
|
|
)
|
|
assert timeline.status_code == 200, timeline.text
|
|
assert [signal["id"] for signal in timeline.json()["items"]] == [item["id"]]
|
|
|
|
|
|
def test_reassess_persist_anchors_expired_signal_to_report_lifecycle(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
report_created_at = utc_naive_now().replace(microsecond=0) - timedelta(days=30)
|
|
context = _valid_reassess_context()
|
|
context["market_phase_summary"] = {
|
|
"phase": "intraday",
|
|
"session_date": "2026-06-15",
|
|
"minutes_to_close": 5,
|
|
"ignored_private_field": "must-not-persist",
|
|
}
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(
|
|
horizon="intraday",
|
|
invalidation="跌破关键支撑且资金流转负",
|
|
),
|
|
context_snapshot=context,
|
|
)
|
|
_set_reassess_history_created_at(db, record_id, report_created_at)
|
|
service = DecisionSignalService(db_manager=db)
|
|
expected_created_at = service._coerce_history_created_at_to_utc_naive(report_created_at)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
item = payload["item"]
|
|
assert payload["persist_status"] == "created"
|
|
assert item["status"] == "expired"
|
|
assert datetime.fromisoformat(item["created_at"]) == expected_created_at
|
|
assert datetime.fromisoformat(item["expires_at"]) == expected_created_at + timedelta(minutes=5)
|
|
assert item["metadata"]["market_phase_summary"] == {
|
|
"phase": "intraday",
|
|
"session_date": "2026-06-15",
|
|
"minutes_to_close": 5,
|
|
}
|
|
|
|
listed = client.get(
|
|
"/api/v1/decision-signals",
|
|
params={"source_type": "analysis", "source_report_id": record_id, "page_size": 100},
|
|
)
|
|
assert listed.status_code == 200, listed.text
|
|
assert [listed_item["id"] for listed_item in listed.json()["items"]] == [item["id"]]
|
|
latest = client.get("/api/v1/decision-signals/latest/600519", params={"limit": 5})
|
|
assert latest.status_code == 200, latest.text
|
|
assert latest.json()["items"] == []
|
|
|
|
|
|
def test_reassess_persist_uses_saved_raw_phase_summary_when_context_summary_is_missing(
|
|
client_and_db,
|
|
) -> None:
|
|
client, db = client_and_db
|
|
report_created_at = utc_naive_now().replace(microsecond=0) - timedelta(days=30)
|
|
raw = _valid_reassess_raw(
|
|
horizon="intraday",
|
|
invalidation="跌破关键支撑",
|
|
market_phase_summary={"phase": "intraday", "minutes_to_close": 7},
|
|
)
|
|
context = _valid_reassess_context()
|
|
context.pop("market_phase_summary")
|
|
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
|
|
_set_reassess_history_created_at(db, record_id, report_created_at)
|
|
expected_created_at = DecisionSignalService(
|
|
db_manager=db
|
|
)._coerce_history_created_at_to_utc_naive(report_created_at)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
item = response.json()["item"]
|
|
assert item["status"] == "expired"
|
|
assert datetime.fromisoformat(item["expires_at"]) == expected_created_at + timedelta(minutes=7)
|
|
assert item["metadata"]["market_phase_summary"] == {
|
|
"phase": "intraday",
|
|
"minutes_to_close": 7,
|
|
}
|
|
|
|
|
|
def test_reassess_persist_returns_final_invalidated_item_without_harming_newer_signal(
|
|
client_and_db,
|
|
) -> None:
|
|
client, db = client_and_db
|
|
report_created_at = utc_naive_now().replace(microsecond=0) - timedelta(days=1)
|
|
context = _valid_reassess_context()
|
|
context["market_phase_summary"] = {"phase": "postmarket"}
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(
|
|
horizon="3d",
|
|
invalidation="跌破关键支撑且资金流转负",
|
|
),
|
|
context_snapshot=context,
|
|
)
|
|
_set_reassess_history_created_at(db, record_id, report_created_at)
|
|
newer_sell = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
action="sell",
|
|
score=20,
|
|
decision_profile="aggressive",
|
|
source_report_id=record_id + 1000,
|
|
trace_id="trace-newer-aggressive-sell",
|
|
market_phase="postmarket",
|
|
horizon="3d",
|
|
),
|
|
).json()["item"]
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
item = payload["item"]
|
|
assert payload["persist_status"] == "created"
|
|
assert item["status"] == "invalidated"
|
|
assert item["metadata"]["invalidated_by_signal_id"] == newer_sell["id"]
|
|
assert client.get(f"/api/v1/decision-signals/{newer_sell['id']}").json()["status"] == "active"
|
|
latest = client.get("/api/v1/decision-signals/latest/600519", params={"limit": 5})
|
|
assert [latest_item["id"] for latest_item in latest.json()["items"]] == [newer_sell["id"]]
|
|
|
|
|
|
def test_reassess_history_refresh_uses_created_at_not_updated_at_for_invalidation(
|
|
client_and_db,
|
|
) -> None:
|
|
client, db = client_and_db
|
|
report_created_at = utc_naive_now().replace(microsecond=0) - timedelta(days=1)
|
|
raw = _valid_reassess_raw(horizon="3d", invalidation="跌破关键支撑")
|
|
context = _valid_reassess_context()
|
|
context["market_phase_summary"] = {"phase": "postmarket"}
|
|
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
|
|
_set_reassess_history_created_at(db, record_id, report_created_at)
|
|
auto_item = _persist_auto_balanced_signal(
|
|
db,
|
|
source_report_id=record_id,
|
|
raw_result=raw,
|
|
context_snapshot=context,
|
|
)
|
|
expected_created_at = DecisionSignalService(
|
|
db_manager=db
|
|
)._coerce_history_created_at_to_utc_naive(report_created_at)
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter(DecisionSignalRecord.id == auto_item["id"]).one()
|
|
row.created_at = expected_created_at
|
|
row.status = "expired"
|
|
row.expires_at = utc_naive_now() - timedelta(minutes=1)
|
|
row.updated_at = utc_naive_now() - timedelta(minutes=1)
|
|
newer_sell = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
action="sell",
|
|
score=20,
|
|
decision_profile="balanced",
|
|
source_report_id=record_id + 2000,
|
|
trace_id="trace-newer-balanced-sell",
|
|
market_phase="postmarket",
|
|
horizon="3d",
|
|
),
|
|
).json()["item"]
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["persist_status"] == "refreshed"
|
|
assert payload["item"]["id"] == auto_item["id"]
|
|
assert payload["item"]["status"] == "invalidated"
|
|
assert payload["item"]["metadata"]["invalidated_by_signal_id"] == newer_sell["id"]
|
|
assert client.get(f"/api/v1/decision-signals/{newer_sell['id']}").json()["status"] == "active"
|
|
|
|
|
|
def test_reassess_persist_requires_report_time_without_breaking_preview(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
_set_reassess_history_created_at(db, record_id, None)
|
|
before = _decision_signal_count(db)
|
|
|
|
preview = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": False},
|
|
)
|
|
persisted = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
|
|
)
|
|
|
|
assert preview.status_code == 200, preview.text
|
|
assert preview.json()["preview"] is not None
|
|
assert persisted.status_code == 400, persisted.text
|
|
assert persisted.json()["error"] == "unsupported_report_snapshot"
|
|
assert _decision_signal_count(db) == before
|
|
|
|
|
|
def test_reassess_balanced_persist_creates_when_auto_extraction_has_no_signal(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["persist_status"] == "created"
|
|
assert payload["created"] is True
|
|
assert payload["item"]["decision_profile"] == "balanced"
|
|
assert payload["item"]["source_agent"] == "decision_profile_reassess"
|
|
assert payload["item"]["trigger_source"] == "web:decision_profile_reassess"
|
|
assert payload["item"]["metadata"]["profile_source"] == "user_selected"
|
|
assert payload["item"]["metadata"]["scoring_breakdown"]
|
|
assert _decision_signal_count(db) == 1
|
|
|
|
|
|
def test_reassess_balanced_persist_reuses_actual_auto_generated_signal(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
raw = _valid_reassess_raw(invalidation="跌破关键支撑")
|
|
context = _valid_reassess_context()
|
|
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
|
|
auto_item = _persist_auto_balanced_signal(
|
|
db,
|
|
source_report_id=record_id,
|
|
raw_result=raw,
|
|
context_snapshot=context,
|
|
)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["persist_status"] == "existing"
|
|
assert payload["created"] is False
|
|
assert payload["item"]["id"] == auto_item["id"]
|
|
assert payload["item"]["source_agent"] == auto_item["source_agent"]
|
|
assert payload["item"]["trigger_source"] == "api"
|
|
assert payload["item"]["metadata"]["profile_source"] == "auto_default"
|
|
assert payload["item"]["metadata"]["signal_generation_version"] == "legacy-report-extractor-v1"
|
|
assert "scoring_breakdown" not in payload["item"]["metadata"]
|
|
assert _decision_signal_count(db) == 1
|
|
|
|
|
|
def test_reassess_balanced_persist_refreshes_expired_auto_generated_signal(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
raw = _valid_reassess_raw(invalidation="跌破关键支撑")
|
|
context = _valid_reassess_context()
|
|
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
|
|
auto_item = _persist_auto_balanced_signal(
|
|
db,
|
|
source_report_id=record_id,
|
|
raw_result=raw,
|
|
context_snapshot=context,
|
|
)
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter(DecisionSignalRecord.id == auto_item["id"]).one()
|
|
row.status = "expired"
|
|
row.expires_at = utc_naive_now() - timedelta(minutes=1)
|
|
row.updated_at = utc_naive_now() - timedelta(minutes=1)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["persist_status"] == "refreshed"
|
|
assert payload["created"] is False
|
|
assert payload["item"]["id"] == auto_item["id"]
|
|
assert payload["item"]["status"] == "active"
|
|
assert payload["item"]["created_at"] == auto_item["created_at"]
|
|
assert payload["item"]["source_agent"] == auto_item["source_agent"]
|
|
assert payload["item"]["trigger_source"] == "api"
|
|
assert payload["item"]["metadata"]["profile_source"] == "user_selected"
|
|
assert payload["item"]["metadata"]["signal_generation_version"] == "decision-profile-reassess-v1"
|
|
assert payload["item"]["metadata"]["scoring_breakdown"]
|
|
assert payload["item"]["action"] == payload["item"]["metadata"]["guardrail_result"]["final_action"]
|
|
assert _decision_signal_count(db) == 1
|
|
|
|
|
|
def test_reassess_balanced_persist_reports_relaxed_phase_fill_without_overwriting_auto_metadata(
|
|
client_and_db,
|
|
) -> None:
|
|
client, db = client_and_db
|
|
raw = _valid_reassess_raw(invalidation="跌破关键支撑")
|
|
context = _valid_reassess_context()
|
|
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
|
|
auto_item = _persist_auto_balanced_signal(
|
|
db,
|
|
source_report_id=record_id,
|
|
raw_result=raw,
|
|
context_snapshot=context,
|
|
)
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter(DecisionSignalRecord.id == auto_item["id"]).one()
|
|
row.market_phase = None
|
|
row.updated_at = utc_naive_now()
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["persist_status"] == "refreshed"
|
|
assert payload["created"] is False
|
|
assert payload["item"]["id"] == auto_item["id"]
|
|
assert payload["item"]["market_phase"] == "intraday"
|
|
assert payload["item"]["source_agent"] == auto_item["source_agent"]
|
|
assert payload["item"]["trigger_source"] == "api"
|
|
assert payload["item"]["metadata"]["profile_source"] == "auto_default"
|
|
assert payload["item"]["metadata"]["signal_generation_version"] == "legacy-report-extractor-v1"
|
|
assert "scoring_breakdown" not in payload["item"]["metadata"]
|
|
assert _decision_signal_count(db) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("terminal_status", ["closed", "invalidated", "archived"])
|
|
def test_reassess_balanced_persist_returns_terminal_auto_signal_without_reactivation(
|
|
client_and_db,
|
|
terminal_status,
|
|
) -> None:
|
|
client, db = client_and_db
|
|
raw = _valid_reassess_raw(invalidation="跌破关键支撑")
|
|
context = _valid_reassess_context()
|
|
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
|
|
auto_item = _persist_auto_balanced_signal(
|
|
db,
|
|
source_report_id=record_id,
|
|
raw_result=raw,
|
|
context_snapshot=context,
|
|
)
|
|
with db.session_scope() as session:
|
|
row = session.query(DecisionSignalRecord).filter(DecisionSignalRecord.id == auto_item["id"]).one()
|
|
row.status = terminal_status
|
|
row.updated_at = utc_naive_now()
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["persist_status"] == "existing"
|
|
assert payload["created"] is False
|
|
assert payload["item"]["id"] == auto_item["id"]
|
|
assert payload["item"]["status"] == terminal_status
|
|
assert payload["item"]["metadata"]["profile_source"] == "auto_default"
|
|
assert _decision_signal_count(db) == 1
|
|
|
|
|
|
def test_reassess_persist_distinguishes_profiles(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
|
|
aggressive = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
|
|
)
|
|
conservative = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "conservative", "persist": True},
|
|
)
|
|
|
|
assert aggressive.status_code == 200, aggressive.text
|
|
assert conservative.status_code == 200, conservative.text
|
|
assert aggressive.json()["item"]["id"] != conservative.json()["item"]["id"]
|
|
assert aggressive.json()["item"]["decision_profile"] == "aggressive"
|
|
assert conservative.json()["item"]["decision_profile"] == "conservative"
|
|
assert _decision_signal_count(db) == 2
|
|
|
|
|
|
def test_reassess_persist_invalidates_only_same_profile_opposing_signal(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
balanced_sell = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
action="sell",
|
|
score=20,
|
|
decision_profile="balanced",
|
|
source_report_id=4101,
|
|
trace_id="trace-balanced-sell",
|
|
),
|
|
).json()["item"]
|
|
aggressive_sell = client.post(
|
|
"/api/v1/decision-signals",
|
|
json=_payload(
|
|
action="sell",
|
|
score=20,
|
|
decision_profile="aggressive",
|
|
source_report_id=4102,
|
|
trace_id="trace-aggressive-sell",
|
|
),
|
|
).json()["item"]
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
|
|
persisted = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
|
|
)
|
|
|
|
assert persisted.status_code == 200, persisted.text
|
|
assert persisted.json()["item"]["action"] == "buy"
|
|
assert client.get(f"/api/v1/decision-signals/{aggressive_sell['id']}").json()["status"] == "invalidated"
|
|
assert client.get(f"/api/v1/decision-signals/{balanced_sell['id']}").json()["status"] == "active"
|
|
|
|
|
|
def test_reassess_persist_saves_safe_guardrail_downgrade_with_audit_metadata(client_and_db) -> None:
|
|
client, db = client_and_db
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(confidence_level=None),
|
|
context_snapshot=_valid_reassess_context(),
|
|
)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
guardrail = payload["item"]["metadata"]["guardrail_result"]
|
|
assert payload["item"]["action"] == "watch"
|
|
assert guardrail["raw_action"] == "buy"
|
|
assert guardrail["final_action"] == "watch"
|
|
assert guardrail["passed"] is True
|
|
assert guardrail["adjusted"] is True
|
|
assert guardrail["violations"]
|
|
assert guardrail["adjustments"]
|
|
assert payload["warnings"]
|
|
assert all(warning["message"] for warning in payload["warnings"])
|
|
assert _decision_signal_count(db) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("decision_profile", ["balanced", "aggressive"])
|
|
def test_reassess_persist_guardrail_block_returns_structured_error_without_write(
|
|
client_and_db,
|
|
decision_profile,
|
|
) -> None:
|
|
client, db = client_and_db
|
|
record_id = _save_reassess_history(
|
|
db,
|
|
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
|
|
context_snapshot=_valid_reassess_context(),
|
|
stop_loss=1900,
|
|
take_profit=1800,
|
|
)
|
|
before = _decision_signal_count(db)
|
|
|
|
response = client.post(
|
|
"/api/v1/decision-signals/reassess",
|
|
json={"source_report_id": record_id, "decision_profile": decision_profile, "persist": True},
|
|
)
|
|
|
|
assert response.status_code == 400, response.text
|
|
payload = response.json()
|
|
assert payload["error"] == "guardrail_blocked"
|
|
assert payload["blocked_reason"]
|
|
assert {warning["code"] for warning in payload["warnings"]} >= {
|
|
"action_adjusted_by_guardrail",
|
|
"action_blocked_by_guardrail",
|
|
}
|
|
assert all(warning["message"] for warning in payload["warnings"])
|
|
assert "created" not in payload
|
|
assert _decision_signal_count(db) == before
|