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