417 lines
13 KiB
Python
417 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from backend.bootstrap.settings import PROJECT_ROOT
|
|
from backend.data.contracts import (
|
|
DataSource,
|
|
DataUsage,
|
|
MarketEntity,
|
|
ObservationMetadata,
|
|
ProviderResult,
|
|
SnapshotState,
|
|
)
|
|
from backend.data.heaven import realtime_payload
|
|
from backend.data.repository import MarketRepository
|
|
from backend.database import MIGRATIONS, Database, MigrationRunner
|
|
from backend.features.accounts.models import MembershipRecord, Principal, UserRecord
|
|
from backend.features.heaven import fortune, trend
|
|
from backend.features.heaven.hexagram import from_lines
|
|
from backend.features.heaven.prompt import messages
|
|
from backend.features.heaven.repository import HeavenRepository
|
|
from backend.features.heaven.service import HeavenService
|
|
|
|
ICHING = PROJECT_ROOT / "config" / "heaven" / "iching_zh.json"
|
|
|
|
|
|
def _payload() -> dict:
|
|
return {
|
|
"trade_date": "2026-07-22",
|
|
"mode": "historical",
|
|
"stock": {
|
|
"identifier": "601318.SH",
|
|
"code": "601318",
|
|
"name": "中国平安",
|
|
"trade_date": "2026-07-22",
|
|
"quote_kind": "daily",
|
|
"change": 2.5,
|
|
"amount_percentile": 88,
|
|
"turnover_rate": 1.2,
|
|
"seal_amount_million": 0,
|
|
"open_times": 0,
|
|
"streak": 0,
|
|
"status": "普通",
|
|
},
|
|
"sector": {
|
|
"name": "保险Ⅱ",
|
|
"code": "801194.SI",
|
|
"taxonomy": "申万二级",
|
|
"trade_date": "2026-07-22",
|
|
"quote_kind": "daily",
|
|
"change": 2.2,
|
|
"up_count": 5,
|
|
"down_count": 0,
|
|
"member_count": 5,
|
|
"quoted_count": 5,
|
|
"coverage": 1,
|
|
"member_equal_change": 1.8,
|
|
"leader": "新华保险",
|
|
"leading_pct": 4.5,
|
|
},
|
|
"market": {
|
|
"trade_date": "2026-07-22",
|
|
"quote_kind": "daily",
|
|
"sentiment_score": 42,
|
|
"seal_rate": 73.9,
|
|
"amount_billion": 11800,
|
|
"average_amount_billion": 10500,
|
|
"up_count": 3180,
|
|
"down_count": 1730,
|
|
"limit_up_count": 68,
|
|
"limit_down_count": 6,
|
|
},
|
|
"indices": [
|
|
{
|
|
"identifier": "000001.SH",
|
|
"trade_date": "2026-07-22",
|
|
"quote_kind": "daily",
|
|
"change": 0.6,
|
|
},
|
|
{
|
|
"identifier": "399001.SZ",
|
|
"trade_date": "2026-07-22",
|
|
"quote_kind": "daily",
|
|
"change": 1.1,
|
|
},
|
|
{
|
|
"identifier": "399006.SZ",
|
|
"trade_date": "2026-07-22",
|
|
"quote_kind": "daily",
|
|
"change": 1.4,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _intraday_payload() -> dict:
|
|
payload = _payload()
|
|
payload["mode"] = "intraday"
|
|
payload["stock"].update(
|
|
quote_kind="realtime",
|
|
turnover_relative=1.2,
|
|
volume_activity_ratio=1.15,
|
|
)
|
|
payload["sector"].update(quote_kind="realtime", relative_turnover=1.1)
|
|
payload["market"]["quote_kind"] = "realtime"
|
|
for row in payload["indices"]:
|
|
row["quote_kind"] = "realtime"
|
|
return payload
|
|
|
|
|
|
def _database(tmp_path: Path) -> Database:
|
|
database = Database(tmp_path / "heaven.db")
|
|
MigrationRunner(database).upgrade(MIGRATIONS)
|
|
with database.transaction() as connection:
|
|
for user_id in (1, 2):
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO users (
|
|
id, username, username_key, password_hash, is_admin,
|
|
status, created_at, updated_at
|
|
) VALUES (?, ?, ?, 'hash', 0, 'active', 'now', 'now')
|
|
""",
|
|
(user_id, f"user{user_id}", f"user{user_id}"),
|
|
)
|
|
return database
|
|
|
|
|
|
def _principal() -> Principal:
|
|
now = datetime.now(UTC)
|
|
return Principal(
|
|
"token",
|
|
"csrf",
|
|
UserRecord(1, "user1", "user1", "hash", False, "active", now, now),
|
|
MembershipRecord(1, "active", None, True, 50, now, 1),
|
|
)
|
|
|
|
|
|
def _result(rows: list[dict]) -> ProviderResult:
|
|
return ProviderResult(
|
|
tuple(rows),
|
|
ObservationMetadata(
|
|
source=DataSource.TUSHARE,
|
|
observed_at=datetime(2026, 7, 30, 10, tzinfo=UTC),
|
|
unit="mixed",
|
|
adjustment="not_applicable",
|
|
freshness_seconds=0,
|
|
coverage=1,
|
|
state=SnapshotState.REALTIME,
|
|
usage=DataUsage.CALCULATION,
|
|
),
|
|
)
|
|
|
|
|
|
def test_small_sector_with_five_of_five_quotes_passes_all_gates() -> None:
|
|
result = trend.calculate(_payload(), ICHING)
|
|
|
|
assert len(result["hexagram"]["lines"]) == 6
|
|
assert all(item["passed"] for item in result["checks"])
|
|
assert result["checks"][2]["message"].endswith("5/5")
|
|
|
|
|
|
def test_missing_one_formal_index_fails_closed() -> None:
|
|
payload = _payload()
|
|
payload["indices"].pop()
|
|
|
|
with pytest.raises(trend.TrendDataError) as captured:
|
|
trend.calculate(payload, ICHING)
|
|
|
|
assert captured.value.checks[5]["passed"] is False
|
|
assert "三大指数" in captured.value.checks[5]["message"]
|
|
|
|
|
|
def test_intraday_payload_requires_real_activity_inputs() -> None:
|
|
result = trend.calculate(_intraday_payload(), ICHING)
|
|
assert all(item["passed"] for item in result["checks"])
|
|
assert result["hexagram"]["lines"][0]["evidence"][1].startswith("相对换手")
|
|
|
|
missing = _intraday_payload()
|
|
missing["stock"]["volume_activity_ratio"] = None
|
|
with pytest.raises(trend.TrendDataError) as captured:
|
|
trend.calculate(missing, ICHING)
|
|
assert captured.value.checks[0]["passed"] is False
|
|
|
|
|
|
def test_realtime_inputs_use_one_trade_date_and_official_sector_quote(tmp_path) -> None:
|
|
database = _database(tmp_path)
|
|
repository = MarketRepository()
|
|
with database.transaction() as connection:
|
|
repository.save_summary(
|
|
connection,
|
|
trade_date="2026-07-29",
|
|
observed_at="2026-07-29T15:00:00+08:00",
|
|
state="final",
|
|
source="tushare",
|
|
coverage=1,
|
|
payload={
|
|
"overview": {
|
|
"up_count": 2500,
|
|
"down_count": 2000,
|
|
"limit_up": 40,
|
|
"limit_down": 5,
|
|
"broken": 10,
|
|
"seal_rate": 80,
|
|
"amount": 1_000_000_000_000,
|
|
},
|
|
"sentiment": {"score": 50},
|
|
"limits": [],
|
|
"yesterday_limits": [],
|
|
},
|
|
)
|
|
stock_codes = ("601318.SH", "601319.SH", "601336.SH", "601601.SH")
|
|
realtime = [
|
|
{
|
|
"ts_code": code,
|
|
"name": f"保险{index}",
|
|
"trade_time": "2026-07-30 10:00:00",
|
|
"close": 10 + index,
|
|
"pre_close": 10,
|
|
"high": 10 + index,
|
|
"low": 9.8,
|
|
"open": 10,
|
|
"vol": 1_000_000 + index * 100_000,
|
|
"amount": 100_000_000 + index * 10_000_000,
|
|
}
|
|
for index, code in enumerate(stock_codes)
|
|
]
|
|
realtime.extend(
|
|
{
|
|
"ts_code": code,
|
|
"name": code,
|
|
"trade_time": "2026-07-30 10:00:00",
|
|
"close": 101,
|
|
"pre_close": 100,
|
|
"high": 101,
|
|
"low": 99,
|
|
"open": 100,
|
|
"vol": 1,
|
|
"amount": 1,
|
|
}
|
|
for code in ("000001.SH", "399001.SZ", "399006.SZ")
|
|
)
|
|
raw = {
|
|
"realtime": _result(realtime),
|
|
"members": _result(
|
|
[
|
|
{
|
|
"sector_code": "801194.SI",
|
|
"sector_name": "保险Ⅱ",
|
|
"ts_code": code,
|
|
"name": f"保险{index}",
|
|
}
|
|
for index, code in enumerate(stock_codes)
|
|
]
|
|
),
|
|
"capital": _result([{"ts_code": code, "float_share": 100_000} for code in stock_codes]),
|
|
"stock_history": _result(
|
|
[
|
|
{"ts_code": "601318.SH", "trade_date": f"2026072{day}", "vol": 10_000}
|
|
for day in range(5, 10)
|
|
]
|
|
),
|
|
"price_limits": _result(
|
|
[{"ts_code": code, "up_limit": 20, "down_limit": 5} for code in stock_codes]
|
|
),
|
|
"suspensions": _result([]),
|
|
"sector_realtime": _result(
|
|
[
|
|
{
|
|
"ts_code": "801194.SI",
|
|
"name": "保险Ⅱ",
|
|
"trade_time": "2026-07-30 10:00:00",
|
|
"close": 102,
|
|
"pre_close": 100,
|
|
"pct_change": 2,
|
|
}
|
|
]
|
|
),
|
|
}
|
|
payload = realtime_payload(
|
|
database,
|
|
repository,
|
|
MarketEntity("stock", "601318.SH", "601318", "中国平安"),
|
|
"2026-07-30",
|
|
"2026-07-29",
|
|
raw,
|
|
datetime(2026, 7, 30, 10, tzinfo=UTC),
|
|
)
|
|
|
|
assert payload["mode"] == "intraday"
|
|
assert payload["stock"]["trade_date"] == "2026-07-30"
|
|
assert payload["sector"]["change"] == 2
|
|
assert payload["sector"]["quoted_count"] == 4
|
|
assert len([row for row in payload["indices"] if row["change"] is not None]) == 3
|
|
|
|
|
|
def test_manual_objective_sector_value_recomputes_without_overwriting_valid_data() -> None:
|
|
payload = _payload()
|
|
payload["sector"]["change"] = None
|
|
original_leader_change = payload["sector"]["leading_pct"]
|
|
|
|
result = trend.calculate(
|
|
payload,
|
|
ICHING,
|
|
{"sector": {"change": 2.8, "leading_pct": -9.9}},
|
|
)
|
|
|
|
assert result["sector"]["change"] == 2.8
|
|
assert result["sector"]["leading_pct"] == original_leader_change
|
|
assert result["checks"][3]["source"] == "manual"
|
|
|
|
|
|
def test_hexagram_is_deterministic_and_contains_only_six_lines() -> None:
|
|
first = from_lines([7, 8, 9, 6, 7, 8], ICHING)
|
|
second = from_lines([7, 8, 9, 6, 7, 8], ICHING)
|
|
|
|
assert first == second
|
|
assert len(first["lines"]) == 6
|
|
assert first["moving_lines"] == [3, 4]
|
|
|
|
|
|
def test_fortune_uses_fixed_weight_total_and_composite_phrase() -> None:
|
|
field = fortune.build("2026-07-30")
|
|
|
|
assert sum(item["score"] for item in field["balance"]) == 100
|
|
assert "·" in field["phrase"]
|
|
assert [item["label"] for item in field["layers"]] == ["年纲", "客主加临", "日辰触发"]
|
|
|
|
|
|
def test_repository_history_is_account_isolated(tmp_path) -> None:
|
|
database = _database(tmp_path)
|
|
repository = HeavenRepository()
|
|
with database.transaction() as connection:
|
|
repository.add(
|
|
connection,
|
|
user_id=1,
|
|
mode="heart",
|
|
reading_date="2026-07-30",
|
|
subject_key="",
|
|
result={"a": 1},
|
|
created_at="now",
|
|
)
|
|
repository.add(
|
|
connection,
|
|
user_id=2,
|
|
mode="heart",
|
|
reading_date="2026-07-30",
|
|
subject_key="",
|
|
result={"a": 2},
|
|
created_at="now",
|
|
)
|
|
with database.read() as connection:
|
|
first = repository.list(connection, 1, None, None)
|
|
second = repository.list(connection, 2, None, None)
|
|
|
|
assert json.loads(first[0]["result_json"]) == {"a": 1}
|
|
assert json.loads(second[0]["result_json"]) == {"a": 2}
|
|
|
|
|
|
def test_daily_fortune_is_created_once_even_before_interpretation(tmp_path) -> None:
|
|
database = _database(tmp_path)
|
|
repository = HeavenRepository()
|
|
with database.transaction() as connection:
|
|
first, first_reused = repository.ensure_fortune(
|
|
connection,
|
|
user_id=1,
|
|
reading_date="2026-07-30",
|
|
result={"phrase": "初次结果"},
|
|
created_at="now",
|
|
)
|
|
second, second_reused = repository.ensure_fortune(
|
|
connection,
|
|
user_id=1,
|
|
reading_date="2026-07-30",
|
|
result={"phrase": "不应覆盖"},
|
|
created_at="later",
|
|
)
|
|
|
|
assert first_reused is False
|
|
assert second_reused is True
|
|
assert first["id"] == second["id"]
|
|
assert json.loads(second["result_json"])["phrase"] == "初次结果"
|
|
|
|
|
|
def test_each_heart_cast_appends_exactly_one_line() -> None:
|
|
service = HeavenService(
|
|
SimpleNamespace(),
|
|
SimpleNamespace(),
|
|
SimpleNamespace(),
|
|
SimpleNamespace(),
|
|
SimpleNamespace(),
|
|
ICHING,
|
|
)
|
|
|
|
first = service.heart_line(_principal(), "2026-07-30", [])
|
|
second = service.heart_line(_principal(), "2026-07-30", first["values"])
|
|
|
|
assert len(first["values"]) == 1
|
|
assert len(second["values"]) == 2
|
|
assert len(first["faces"]) == 3
|
|
|
|
|
|
def test_fortune_prompt_never_contains_raw_birth_fields() -> None:
|
|
result = fortune.build(
|
|
"2026-07-30",
|
|
SimpleNamespace(birth_date="1990-01-02", birth_time="03:04", gender="male"),
|
|
)
|
|
prompt = json.dumps(messages("fortune", result), ensure_ascii=False)
|
|
|
|
assert "1990-01-02" not in prompt
|
|
assert "03:04" not in prompt
|
|
assert '"gender"' not in prompt
|