rebuild(stage-11): deliver deterministic heaven workflows

This commit is contained in:
leefer
2026-07-30 07:08:13 +08:00
parent aa3f02bd59
commit 35ae079de7
49 changed files with 7208 additions and 39 deletions
+13 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from backend.bootstrap.settings import Settings
from backend.bootstrap.settings import PROJECT_ROOT, Settings
from backend.data.gateway import DataGateway
from backend.data.policy import DataSourcePolicy
from backend.data.providers import EastmoneyProvider, IfindProvider, TushareProvider
@@ -16,6 +16,8 @@ from backend.features.accounts.service import (
AccountService,
MembershipService,
)
from backend.features.heaven.repository import HeavenRepository
from backend.features.heaven.service import HeavenService
from backend.features.market import MarketService
from backend.features.market.insights import MarketInsightService
from backend.features.market.sync import MarketSnapshotService
@@ -43,6 +45,7 @@ class ApplicationContainer:
screener: ScreenerService
llm: LLMGateway
mentor: MentorService
heaven: HeavenService
def build_container(settings: Settings) -> ApplicationContainer:
@@ -89,6 +92,14 @@ def build_container(settings: Settings) -> ApplicationContainer:
gateway,
llm,
)
heaven = HeavenService(
database,
HeavenRepository(),
gateway,
accounts,
llm,
PROJECT_ROOT / "config" / "heaven" / "iching_zh.json",
)
return ApplicationContainer(
settings=settings,
database=database,
@@ -101,4 +112,5 @@ def build_container(settings: Settings) -> ApplicationContainer:
screener=screener,
llm=llm,
mentor=mentor,
heaven=heaven,
)
+59 -2
View File
@@ -16,6 +16,7 @@ from backend.data.contracts import (
SnapshotState,
TradeContext,
)
from backend.data.heaven import historical_payload, realtime_payload, should_use_realtime
from backend.data.policy import DataSourcePolicy
from backend.data.providers.base import MarketDataProvider, ProviderError
from backend.data.quality import DataQualityError, require_quality
@@ -217,6 +218,34 @@ class DataGateway:
)
return payload
def heaven_trend_inputs(
self, query: str, requested_date: str, now: datetime | None = None
) -> dict[str, Any]:
clock = now or datetime.now(SHANGHAI)
context = self.trade_context(requested_date, clock)
if context.actual_date is None:
raise MarketDataUnavailable("等待管理员首次同步真实收盘行情")
stock = self._resolve_stock_query(query)
provider = self._provider(DataSource.TUSHARE)
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
if should_use_realtime(requested_date, context, clock):
dates = self.trading_dates(requested_date, 2)
if len(dates) < 2 or dates[0] != requested_date:
raise MarketDataUnavailable("目标日期不是有效交易日")
raw = provider.heaven_realtime_inputs(stock.identifier, dates[0], dates[1])
return realtime_payload(
self._database,
self._repository,
stock,
dates[0],
dates[1],
raw,
clock,
)
return historical_payload(
self._database, self._repository, stock, context.actual_date, provider
)
def search(self, query: str) -> tuple[MarketEntity, ...]:
with self._database.read() as connection:
return self._repository.search(connection, query)
@@ -310,6 +339,29 @@ class DataGateway:
return MarketEntity("stock", f"{normalized}.{suffix}", normalized, normalized)
raise MarketDataUnavailable("未找到该行情标的")
def _resolve_stock_query(self, query: str) -> MarketEntity:
normalized = query.strip()
if not normalized:
raise MarketDataUnavailable("请输入股票代码或股票名称")
with self._database.read() as connection:
matches = tuple(
item
for item in self._repository.search(connection, normalized, 16)
if item.entity_type == "stock"
)
exact = [
item
for item in matches
if item.code.casefold() == normalized.casefold()
or item.identifier.casefold() == normalized.casefold()
or item.name.casefold() == normalized.casefold()
]
if len(exact) == 1:
return exact[0]
if len(exact) > 1:
raise MarketDataUnavailable("股票名称存在重名,请输入六位代码")
raise MarketDataUnavailable("未找到该股票,请检查代码或名称")
def _save_chart(self, series: ChartSeries) -> None:
payload = {
"previous_close": series.previous_close,
@@ -485,5 +537,10 @@ def _number(value: Any) -> float:
def _optional_number(value: Any) -> float | None:
number = _number(value)
return number if number > 0 else None
if value is None or value == "":
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number == number else None
+485
View File
@@ -0,0 +1,485 @@
from __future__ import annotations
import json
from datetime import datetime, time
from typing import Any
from backend.data.contracts import MarketEntity, ProviderResult, SnapshotState, TradeContext
from backend.data.providers.base import MarketDataProvider
from backend.data.repository import MarketRepository
from backend.data.sentiment import calculate_sentiment
from backend.database.connection import Database
def should_use_realtime(requested_date: str, context: TradeContext, clock: datetime) -> bool:
today = clock.date().isoformat()
if requested_date != today or clock.time() < time(9, 15):
return False
return not (
context.actual_date == today
and context.state in {SnapshotState.FINAL, SnapshotState.ARCHIVE}
)
def historical_payload(
database: Database,
repository: MarketRepository,
stock: MarketEntity,
trade_date: str,
provider: MarketDataProvider,
) -> dict[str, Any]:
raw = provider.heaven_inputs(stock.identifier, trade_date)
daily_rows = _rows(raw, "daily")
basic_rows = _rows(raw, "daily_basic")
member_rows = _rows(raw, "members")
sector_rows = _rows(raw, "sector_daily")
index_rows = _rows(raw, "indices")
quote = next((row for row in daily_rows if row.get("ts_code") == stock.identifier), {})
basic = next((row for row in basic_rows if row.get("ts_code") == stock.identifier), {})
with database.read() as connection:
summary_row = repository.latest_summary(connection, trade_date)
history_rows = repository.summaries(connection, trade_date, 6)
summary = json.loads(str(summary_row["payload_json"])) if summary_row else {}
overview = summary.get("overview") or {}
sentiment = summary.get("sentiment") or {}
event = _stock_event(summary, stock.identifier)
amount = _number(quote.get("amount"))
member_changes = [_number(row.get("change")) for row in member_rows if bool(row.get("quoted"))]
leader = max(
(row for row in member_rows if bool(row.get("quoted"))),
key=lambda row: _number(row.get("change")),
default={},
)
sector_quote = sector_rows[0] if sector_rows else {}
sector_name = str((member_rows[0] if member_rows else {}).get("sector_name") or "")
return {
"trade_date": trade_date,
"mode": "historical",
"stock": {
"identifier": stock.identifier,
"code": stock.code,
"name": stock.name,
"trade_date": _display_date(quote.get("trade_date")),
"quote_kind": "daily",
"change": _optional_number(quote.get("pct_chg")),
"amount_billion": round(amount / 100_000, 4),
"amount_percentile": _percentile_rank(
amount, sorted(_number(row.get("amount")) for row in daily_rows)
),
"turnover_rate": _optional_number(basic.get("turnover_rate")),
"seal_amount_million": _number(event.get("seal_amount")) / 1_000_000,
"open_times": event.get("open_times", 0),
"streak": event.get("streak", 0),
"status": event.get("status", "普通"),
},
"sector": {
"name": sector_name,
"code": str((member_rows[0] if member_rows else {}).get("sector_code") or ""),
"taxonomy": "申万二级" if sector_name else "",
"trade_date": _display_date(sector_quote.get("trade_date")) or trade_date,
"quote_kind": "daily",
"change": _optional_number(sector_quote.get("pct_change")),
"up_count": sum(value > 0 for value in member_changes),
"down_count": sum(value < 0 for value in member_changes),
"member_count": len(member_rows),
"quoted_count": len(member_changes),
"coverage": len(member_changes) / max(len(member_rows), 1),
"member_equal_change": (
sum(member_changes) / len(member_changes) if member_changes else None
),
"leader": str(leader.get("name") or ""),
"leading_pct": _optional_number(leader.get("change")),
},
"market": _market(
trade_date,
"daily",
overview,
sentiment.get("score"),
_history_amounts(history_rows[:-1]),
),
"indices": [
{
"identifier": str(row.get("ts_code") or ""),
"trade_date": _display_date(row.get("trade_date")),
"quote_kind": "daily",
"change": _optional_number(row.get("pct_chg")),
}
for row in index_rows
],
}
def realtime_payload(
database: Database,
repository: MarketRepository,
stock: MarketEntity,
trade_date: str,
previous_trade_date: str,
raw: dict[str, ProviderResult | None],
clock: datetime,
) -> dict[str, Any]:
realtime_rows = [
row
for row in _rows(raw, "realtime")
if _quote_date(row) == trade_date
and _valid_quote(row)
and (clock.time() < time(15) or _quote_time(row) >= "15:00:00")
]
quote_map = {str(row.get("ts_code") or ""): row for row in realtime_rows}
quote = quote_map.get(stock.identifier, {})
members = _rows(raw, "members")
capital = {str(row.get("ts_code") or ""): row for row in _rows(raw, "capital")}
limits = {str(row.get("ts_code") or ""): row for row in _rows(raw, "price_limits")}
suspended = {str(row.get("ts_code") or "") for row in _rows(raw, "suspensions")}
market_rows = [row for row in realtime_rows if str(row.get("ts_code") or "") in capital]
market_turnover = _average(_turnovers(market_rows, capital))
stock_turnover = _turnover(quote, capital.get(stock.identifier, {}))
history_volumes = sorted(
(
(_display_date(row.get("trade_date")), _number(row.get("vol")))
for row in _rows(raw, "stock_history")
if _number(row.get("vol")) > 0
),
key=lambda item: item[0],
)[-5:]
average_volume = _average([value for _, value in history_volumes])
activity = (
_number(quote.get("vol")) / 100 / (average_volume * _session_progress(clock.time()))
if average_volume
else 0
)
with database.read() as connection:
history_rows = repository.summaries(connection, previous_trade_date, 250)
history = [json.loads(str(row["payload_json"])) for row in history_rows]
prior = history[-1] if history else {}
current_limits = _current_limits(market_rows, limits, prior)
overview = _realtime_overview(market_rows, current_limits)
yesterday = _yesterday(prior.get("limits") or [], quote_map, current_limits)
sentiment = calculate_sentiment(
{"overview": overview, "limits": current_limits["up"], "yesterday_limits": yesterday},
history,
)
member_codes = [str(row.get("ts_code") or "") for row in members]
member_quotes = [quote_map[code] for code in member_codes if code in quote_map]
explained = len(member_quotes) + sum(code in suspended for code in member_codes)
changes = [_quote_change(row) for row in member_quotes]
leader = max(member_quotes, key=_quote_change, default={})
sector_quote = next(
(
row
for row in _rows(raw, "sector_realtime")
if _quote_date(row) == trade_date
and (clock.time() < time(15) or _quote_time(row) >= "15:00:00")
),
{},
)
sector_turnover = _average(_turnovers(member_quotes, capital))
sector_name = str((members[0] if members else {}).get("sector_name") or "")
amount = _number(quote.get("amount"))
status, streak = _status(stock.identifier, quote, limits, prior)
return {
"trade_date": trade_date,
"mode": "intraday",
"stock": {
"identifier": stock.identifier,
"code": stock.code,
"name": stock.name,
"trade_date": _quote_date(quote),
"quote_kind": "realtime",
"change": _optional_number(_quote_change(quote)) if quote else None,
"amount_billion": round(amount / 100_000_000, 4),
"amount_percentile": _percentile_rank(
amount, sorted(_number(row.get("amount")) for row in market_rows)
),
"turnover_rate": stock_turnover or None,
"turnover_relative": stock_turnover / market_turnover if market_turnover else None,
"volume_activity_ratio": activity or None,
"seal_amount_million": 0,
"open_times": 0,
"streak": streak,
"status": status,
},
"sector": {
"name": sector_name,
"code": str((members[0] if members else {}).get("sector_code") or ""),
"taxonomy": "申万二级" if sector_name else "",
"trade_date": _quote_date(sector_quote),
"quote_kind": "realtime",
"change": _optional_number(_quote_change(sector_quote)) if sector_quote else None,
"up_count": sum(value > 0 for value in changes),
"down_count": sum(value < 0 for value in changes),
"member_count": len(member_codes),
"quoted_count": explained,
"coverage": explained / max(len(member_codes), 1),
"member_equal_change": _average(changes) if changes else None,
"relative_turnover": (sector_turnover / market_turnover if market_turnover else None),
"leader": str(leader.get("name") or ""),
"leading_pct": _optional_number(_quote_change(leader)) if leader else None,
},
"market": _market(
trade_date,
"realtime",
overview,
sentiment.get("score"),
_history_amounts(history_rows),
),
"indices": [
{
"identifier": identifier,
"trade_date": _quote_date(quote_map.get(identifier, {})),
"quote_kind": "realtime",
"change": (
_optional_number(_quote_change(quote_map[identifier]))
if identifier in quote_map
else None
),
}
for identifier in ("000001.SH", "399001.SZ", "399006.SZ")
],
}
def _rows(values: dict[str, ProviderResult | None], key: str) -> tuple[dict[str, Any], ...]:
result = values.get(key)
return result.rows if isinstance(result, ProviderResult) else ()
def _history_amounts(rows: tuple[Any, ...]) -> list[float]:
return [
_number((json.loads(str(row["payload_json"])).get("overview") or {}).get("amount"))
for row in rows
if row["payload_json"]
]
def _market(
trade_date: str,
quote_kind: str,
overview: dict[str, Any],
sentiment_score: Any,
history_amounts: list[float],
) -> dict[str, Any]:
amount = _number(overview.get("amount"))
average = _average(history_amounts[-5:]) if history_amounts else amount
return {
"trade_date": trade_date,
"quote_kind": quote_kind,
"sentiment_score": _optional_number(sentiment_score),
"seal_rate": _optional_number(overview.get("seal_rate")),
"amount_billion": amount / 100_000_000,
"average_amount_billion": average / 100_000_000,
"up_count": overview.get("up_count"),
"down_count": overview.get("down_count"),
"limit_up_count": overview.get("limit_up"),
"limit_down_count": overview.get("limit_down"),
}
def _stock_event(summary: dict[str, Any], identifier: str) -> dict[str, Any]:
for key in ("limits", "broken", "down_limits"):
for row in summary.get(key) or []:
if str(row.get("identifier") or "") == identifier:
return dict(row)
return {"status": "普通", "streak": 0, "open_times": 0, "seal_amount": 0}
def _quote_date(row: dict[str, Any]) -> str:
return _display_date(row.get("trade_time") or row.get("trade_date"))
def _quote_time(row: dict[str, Any]) -> str:
value = str(row.get("trade_time") or "")
if " " in value:
return value.split(" ", 1)[1][:8]
compact = "".join(character for character in value if character.isdigit())
return f"{compact[8:10]}:{compact[10:12]}:{compact[12:14]}" if len(compact) >= 14 else ""
def _valid_quote(row: dict[str, Any]) -> bool:
return _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
def _quote_change(row: dict[str, Any]) -> float:
for key in ("pct_chg", "pct_change"):
value = _optional_number(row.get(key))
if value is not None:
return value
close = _number(row.get("close"))
previous = _number(row.get("pre_close"))
return (close / previous - 1) * 100 if close and previous else 0
def _turnover(row: dict[str, Any], capital: dict[str, Any]) -> float:
float_share = _number(capital.get("float_share"))
return _number(row.get("vol")) / float_share / 100 if float_share else 0
def _turnovers(rows: list[dict[str, Any]], capital: dict[str, dict[str, Any]]) -> list[float]:
values = [_turnover(row, capital.get(str(row.get("ts_code") or ""), {})) for row in rows]
return [value for value in values if value > 0]
def _session_progress(current: time) -> float:
if current <= time(9, 30):
return 0.05
if current <= time(11, 30):
return max(0.05, min(0.5, (current.hour * 60 + current.minute - 570) / 240))
if current < time(13):
return 0.5
if current <= time(15):
return max(0.5, min(1.0, 0.5 + (current.hour * 60 + current.minute - 780) / 240))
return 1.0
def _at_price(value: Any, target: Any) -> bool:
price = _number(value)
limit = _number(target)
return bool(limit and abs(price - limit) <= max(0.005, limit * 0.0002))
def _prior_limits(summary: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {
str(row.get("identifier") or ""): row
for row in summary.get("limits") or []
if row.get("identifier")
}
def _current_limits(
market_rows: list[dict[str, Any]],
price_limits: dict[str, dict[str, Any]],
prior: dict[str, Any],
) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = {"up": [], "down": [], "broken": []}
prior_map = _prior_limits(prior)
for quote in market_rows:
identifier = str(quote.get("ts_code") or "")
bounds = price_limits.get(identifier, {})
is_up = _at_price(quote.get("close"), bounds.get("up_limit"))
is_down = _at_price(quote.get("close"), bounds.get("down_limit"))
touched = (
_number(bounds.get("up_limit")) > 0
and _number(quote.get("high")) >= _number(bounds.get("up_limit")) * 0.9998
)
previous_streak = int(_number(prior_map.get(identifier, {}).get("streak")))
row = {
"identifier": identifier,
"code": identifier.split(".")[0],
"name": str(quote.get("name") or "").strip(),
"amount": _number(quote.get("amount")),
"change": _quote_change(quote),
"streak": previous_streak + 1 if is_up and previous_streak else 1 if is_up else 0,
}
if is_up:
result["up"].append(row)
elif is_down:
result["down"].append(row)
elif touched:
result["broken"].append(row)
return result
def _realtime_overview(
market_rows: list[dict[str, Any]], limit_rows: dict[str, list[dict[str, Any]]]
) -> dict[str, Any]:
changes = [_quote_change(row) for row in market_rows]
up_count = sum(value > 0 for value in changes)
down_count = sum(value < 0 for value in changes)
limits = len(limit_rows["up"])
broken = len(limit_rows["broken"])
return {
"up_count": up_count,
"down_count": down_count,
"flat_count": len(changes) - up_count - down_count,
"limit_up": limits,
"limit_down": len(limit_rows["down"]),
"broken": broken,
"seal_rate": round(limits / max(limits + broken, 1) * 100, 1),
"amount": sum(_number(row.get("amount")) for row in market_rows),
}
def _yesterday(
previous_limits: list[dict[str, Any]],
quotes: dict[str, dict[str, Any]],
current: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
up_codes = {str(row["identifier"]) for row in current["up"]}
broken_codes = {str(row["identifier"]) for row in current["broken"]}
down_codes = {str(row["identifier"]) for row in current["down"]}
rows = []
for previous in previous_limits:
identifier = str(previous.get("identifier") or "")
quote = quotes.get(identifier)
if not quote:
continue
change = _quote_change(quote)
outcome = (
"晋级"
if identifier in up_codes
else "炸板"
if identifier in broken_codes
else "跌停"
if identifier in down_codes
else "红盘"
if change > 0
else "断板"
)
rows.append({"identifier": identifier, "current_change": change, "outcome": outcome})
return rows
def _status(
identifier: str,
quote: dict[str, Any],
price_limits: dict[str, dict[str, Any]],
prior: dict[str, Any],
) -> tuple[str, int]:
if not quote:
return "普通", 0
bounds = price_limits.get(identifier, {})
if _at_price(quote.get("close"), bounds.get("up_limit")):
return "涨停", int(_number(_prior_limits(prior).get(identifier, {}).get("streak"))) + 1
if _at_price(quote.get("close"), bounds.get("down_limit")):
return "跌停", 0
if (
_number(bounds.get("up_limit"))
and _number(quote.get("high")) >= _number(bounds.get("up_limit")) * 0.9998
):
return "炸板", 0
return "普通", 0
def _average(values: list[float]) -> float:
return sum(values) / len(values) if values else 0
def _number(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _optional_number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number == number else None
def _display_date(value: Any) -> str:
compact = str(value or "").replace("-", "")[:8]
if len(compact) != 8 or not compact.isdigit():
return ""
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
def _percentile_rank(value: float, ordered: list[float]) -> float | None:
valid = [item for item in ordered if item > 0]
if value <= 0 or not valid:
return None
return round(sum(item <= value for item in valid) / len(valid) * 100, 2)
+8
View File
@@ -29,6 +29,14 @@ class MarketDataProvider(Protocol):
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
def heaven_inputs(
self, representative: str, trade_date: str
) -> dict[str, ProviderResult | None]: ...
def heaven_realtime_inputs(
self, representative: str, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | None]: ...
def market_insight(
self,
kind: str,
+10
View File
@@ -102,6 +102,16 @@ class EastmoneyProvider:
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
raise ProviderError("The display provider is not the constituent authority")
def heaven_inputs(
self, representative: str, trade_date: str
) -> dict[str, ProviderResult | None]:
raise ProviderError("The display provider cannot supply deterministic Heaven inputs")
def heaven_realtime_inputs(
self, representative: str, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | None]:
raise ProviderError("The display provider cannot supply deterministic Heaven inputs")
def market_insight(
self,
kind: str,
+10
View File
@@ -92,6 +92,16 @@ class IfindProvider:
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
raise ProviderError("iFinD is not the Shenwan constituent authority")
def heaven_inputs(
self, representative: str, trade_date: str
) -> dict[str, ProviderResult | None]:
raise ProviderError("iFinD deterministic Heaven inputs are not enabled")
def heaven_realtime_inputs(
self, representative: str, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | None]:
raise ProviderError("iFinD deterministic Heaven inputs are not enabled")
def market_insight(
self,
kind: str,
+145 -29
View File
@@ -132,36 +132,9 @@ class TushareProvider:
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
target = _compact(trade_date)
memberships = self._membership_rows({"ts_code": representative})
active = [row for row in memberships if _active_on(row, target)]
if not active:
raise ProviderError("未找到该股票在目标日期的申万行业")
industry = max(
active,
key=lambda row: (
str(row.get("in_date") or ""),
str(row.get("l2_code") or ""),
),
)
industry, members = self._sector_memberships(representative, target)
sector_code = str(industry.get("l2_code") or "")
sector_name = str(industry.get("l2_name") or "").strip()
if not sector_code:
raise ProviderError("该股票缺少申万二级行业")
members = [
row
for row in self._membership_rows({"l2_code": sector_code})
if _active_on(row, target)
]
deduplicated: dict[str, dict[str, Any]] = {}
for row in members:
code = str(row.get("ts_code") or "")
current = deduplicated.get(code)
if code and (
current is None or str(row.get("in_date") or "") > str(current.get("in_date") or "")
):
deduplicated[code] = row
if not deduplicated:
raise ProviderError("该申万行业没有有效成分股")
daily = self._query(
"daily",
{"trade_date": target},
@@ -170,7 +143,8 @@ class TushareProvider:
)
quote_map = {str(row.get("ts_code") or ""): row for row in daily.rows}
rows = []
for code, member in deduplicated.items():
for member in members:
code = str(member.get("ts_code") or "")
quote = quote_map.get(code) or {}
rows.append(
{
@@ -188,6 +162,114 @@ class TushareProvider:
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
def heaven_inputs(
self, representative: str, trade_date: str
) -> dict[str, ProviderResult | None]:
target = _compact(trade_date)
membership = self.sector_members(representative, trade_date)
sector_code = str(membership.rows[0].get("sector_code") or "") if membership.rows else ""
return {
"members": membership,
"daily": self._optional_query(
"daily",
{"trade_date": target},
"ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount",
),
"daily_basic": self._optional_query(
"daily_basic",
{"trade_date": target},
"ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv",
),
"sector_daily": self._optional_query(
"sw_daily",
{"ts_code": sector_code, "trade_date": target},
"ts_code,trade_date,name,open,high,low,close,pct_change,vol,amount,pe,pb,float_mv,total_mv",
),
"indices": self._index_rows(target),
}
def heaven_realtime_inputs(
self, representative: str, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | None]:
target = _compact(trade_date)
previous = _compact(previous_trade_date)
industry, members = self._sector_memberships(representative, target)
sector_code = str(industry.get("l2_code") or "")
directory = self._optional_query(
"stock_basic",
{"exchange": "", "list_status": "L"},
"ts_code,name,industry,market,list_date",
)
active_codes = tuple(
str(row.get("ts_code") or "")
for row in (directory.rows if directory else ())
if row.get("ts_code")
)
realtime_codes = (*active_codes, "000001.SH", "399001.SZ", "399006.SZ")
member_rows = tuple(
{
"sector_code": sector_code,
"sector_name": str(industry.get("l2_name") or "").strip(),
"ts_code": str(row.get("ts_code") or ""),
"name": str(row.get("name") or "").strip(),
}
for row in members
)
start = (datetime.strptime(target, "%Y%m%d") - timedelta(days=35)).strftime("%Y%m%d")
return {
"directory": directory,
"members": ProviderResult(
member_rows,
_metadata(self.source, "membership", 1 if member_rows else 0),
),
"realtime": self._optional_query(
"rt_k",
{"ts_code": ",".join(realtime_codes)},
"ts_code,name,trade_time,open,high,low,close,pre_close,vol,amount,num,pct_chg",
),
"capital": self._optional_query(
"daily_basic",
{"trade_date": previous},
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
),
"stock_history": self._optional_query(
"daily",
{"ts_code": representative, "start_date": start, "end_date": previous},
"ts_code,trade_date,vol,amount",
),
"price_limits": self._optional_query(
"stk_limit",
{"trade_date": target},
"ts_code,trade_date,up_limit,down_limit",
),
"suspensions": self._optional_query(
"suspend_d",
{"suspend_date": target},
"ts_code,suspend_date,resume_date,suspend_timing,suspend_type",
),
"sector_realtime": self._optional_query(
"rt_sw_k",
{"ts_code": sector_code},
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
),
}
def _index_rows(self, trade_date: str) -> ProviderResult | None:
rows: list[dict[str, Any]] = []
completed = 0
for identifier in ("000001.SH", "399001.SZ", "399006.SZ"):
result = self._optional_query(
"index_daily",
{"ts_code": identifier, "trade_date": trade_date},
"ts_code,trade_date,close,pre_close,pct_chg",
)
if result is not None and result.rows:
rows.extend(result.rows)
completed += 1
if not rows:
return None
return ProviderResult(tuple(rows), _metadata(self.source, "percent", completed / 3))
def market_insight(
self,
kind: str,
@@ -443,6 +525,40 @@ class TushareProvider:
rows.extend(result.rows)
return rows
def _sector_memberships(
self, representative: str, trade_date: str
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
active = [
row
for row in self._membership_rows({"ts_code": representative})
if _active_on(row, trade_date)
]
if not active:
raise ProviderError("未找到该股票在目标日期的申万行业")
industry = max(
active,
key=lambda row: (str(row.get("in_date") or ""), str(row.get("l2_code") or "")),
)
sector_code = str(industry.get("l2_code") or "")
if not sector_code:
raise ProviderError("该股票缺少申万二级行业")
rows = [
row
for row in self._membership_rows({"l2_code": sector_code})
if _active_on(row, trade_date)
]
deduplicated: dict[str, dict[str, Any]] = {}
for row in rows:
code = str(row.get("ts_code") or "")
current = deduplicated.get(code)
if code and (
current is None or str(row.get("in_date") or "") > str(current.get("in_date") or "")
):
deduplicated[code] = row
if not deduplicated:
raise ProviderError("该申万行业没有有效成分股")
return industry, list(deduplicated.values())
def _query(
self,
api_name: str,
@@ -0,0 +1,53 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def upgrade(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE heaven_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('trend', 'fortune', 'heart')),
reading_date TEXT NOT NULL,
subject_key TEXT NOT NULL DEFAULT '',
result_json TEXT NOT NULL,
interpretation TEXT NOT NULL DEFAULT '',
interpretation_status TEXT NOT NULL DEFAULT 'pending' CHECK (
interpretation_status IN ('pending', 'complete', 'stopped', 'error')
),
request_id TEXT REFERENCES llm_requests(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
connection.execute(
"""
CREATE INDEX heaven_readings_scope_idx
ON heaven_readings(user_id, mode, reading_date, id)
"""
)
connection.execute(
"""
CREATE UNIQUE INDEX heaven_fortune_daily_idx
ON heaven_readings(user_id, reading_date)
WHERE mode = 'fortune'
"""
)
def downgrade(connection: sqlite3.Connection) -> None:
connection.execute("DROP TABLE heaven_readings")
MIGRATION = Migration(
version=9,
name="create_heaven_readings",
signature="heaven:v2:account-readings-unique-daily-fortune",
upgrade=upgrade,
downgrade=downgrade,
)
@@ -6,6 +6,7 @@ from backend.database.migrations.m0005_market_insights import MIGRATION as MARKE
from backend.database.migrations.m0006_watchlists import MIGRATION as WATCHLISTS
from backend.database.migrations.m0007_screener import MIGRATION as SCREENER
from backend.database.migrations.m0008_mentor_llm import MIGRATION as MENTOR_LLM
from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN
from backend.database.migrations.runner import Migration
MIGRATIONS: tuple[Migration, ...] = (
@@ -17,4 +18,5 @@ MIGRATIONS: tuple[Migration, ...] = (
WATCHLISTS,
SCREENER,
MENTOR_LLM,
HEAVEN,
)
+3
View File
@@ -0,0 +1,3 @@
from backend.features.heaven.service import HeavenService
__all__ = ["HeavenService"]
+259
View File
@@ -0,0 +1,259 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from lunar_python import Solar
ELEMENTS = ("", "", "", "", "")
STEM_MOVEMENT = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
STEM_ELEMENT = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
BRANCH_ELEMENT = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
SITIAN = {
"": "少阴君火",
"": "少阴君火",
"": "太阴湿土",
"": "太阴湿土",
"": "少阳相火",
"": "少阳相火",
"": "阳明燥金",
"": "阳明燥金",
"": "太阳寒水",
"": "太阳寒水",
"": "厥阴风木",
"": "厥阴风木",
}
ZAIQUAN = {
"少阴君火": "阳明燥金",
"太阴湿土": "太阳寒水",
"少阳相火": "厥阴风木",
"阳明燥金": "少阴君火",
"太阳寒水": "太阴湿土",
"厥阴风木": "少阳相火",
}
QI_SEQUENCE = ("厥阴风木", "少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水")
HOST_SEQUENCE = ("厥阴风木", "少阴君火", "少阳相火", "太阴湿土", "阳明燥金", "太阳寒水")
QI_ELEMENT = {name: name[-1] for name in QI_SEQUENCE}
STEP_NAMES = ("初之气", "二之气", "三之气", "四之气", "五之气", "终之气")
GENERATES = {"": "", "": "", "": "", "": "", "": ""}
CONTROLS = {"": "", "": "", "": "", "": "", "": ""}
CLIMATE = {
"": "风木疏动",
"": "热象渐显",
"": "湿滞偏重",
"": "燥气收敛",
"": "寒意潜行",
}
BEHAVIOR = {
"": ("求新与扩张感增强", "防止把萌芽误作主升", "先写清验证条件"),
"": ("兴奋与急迫感增强", "防止把一致误作确定", "延迟一次冲动决策"),
"": ("对确定性的需求增强", "防止把犹豫误作耐心", "按失效条件做减法"),
"": ("警觉与裁决感增强", "防止过早否定修复", "区分逻辑失效与波动"),
"": ("避险与不确定感增强", "防止放大最坏想象", "降低频率并保留预案"),
}
INDUSTRIES = {
"": ("农业", "林业", "医药", "教育", "纺织", "家居"),
"": ("电力", "新能源", "电子", "半导体", "通信", "传媒"),
"": ("地产", "建筑", "建材", "食品", "零售", "仓储"),
"": ("银行", "证券", "保险", "有色", "机械", "军工"),
"": ("航运", "物流", "水务", "饮料", "化工", "旅游"),
}
def build(trade_date: str, profile: Any | None = None) -> dict[str, Any]:
parsed = date.fromisoformat(trade_date)
solar = Solar.fromYmdHms(parsed.year, parsed.month, parsed.day, 12, 0, 0)
lunar = solar.getLunar()
year_gz = lunar.getYearInGanZhiExact()
month_gz = lunar.getMonthInGanZhiExact()
day_gz = lunar.getDayInGanZhiExact()
sitian = SITIAN[year_gz[1]]
zaiquan = ZAIQUAN[sitian]
step = _qi_step(lunar, solar.toYmd())
host = HOST_SEQUENCE[step - 1]
guest = QI_SEQUENCE[(QI_SEQUENCE.index(sitian) - 2 + step - 1) % 6]
sitian_weight, zaiquan_weight = (15, 5) if step <= 3 else (5, 15)
layers = (
_layer(
"年纲",
(
(STEM_MOVEMENT[year_gz[0]], 30),
(QI_ELEMENT[sitian], sitian_weight),
(QI_ELEMENT[zaiquan], zaiquan_weight),
),
),
_layer("客主加临", ((QI_ELEMENT[host], 20), (QI_ELEMENT[guest], 25))),
_layer("日辰触发", ((STEM_MOVEMENT[day_gz[0]], 2.5), (BRANCH_ELEMENT[day_gz[1]], 2.5))),
)
totals = {element: sum(layer["weights"][element] for layer in layers) for element in ELEMENTS}
balance = sorted(
(
{"element": element, "score": score, "percent": round(score)}
for element, score in totals.items()
),
key=lambda item: item["score"],
reverse=True,
)
primary, secondary = balance[0]["element"], balance[1]["element"]
phrase = f"{CLIMATE[primary]}·{CLIMATE[secondary]}"
behavior = BEHAVIOR[primary]
return {
"date": trade_date,
"lunar_date": f"农历{lunar.getMonthInChinese()}{lunar.getDayInChinese()}",
"pillars": {"year": year_gz, "month": month_gz, "day": day_gz},
"solar_term": {
"current": lunar.getPrevJieQi().getName(),
"next": lunar.getNextJieQi().getName(),
},
"phrase": phrase,
"movement": {
"element": STEM_MOVEMENT[year_gz[0]],
"tendency": "太过" if year_gz[0] in "甲丙戊庚壬" else "不及",
},
"six_qi": {
"sitian": sitian,
"zaiquan": zaiquan,
"step": step,
"step_name": STEP_NAMES[step - 1],
"host": host,
"guest": guest,
},
"layers": [
{
"label": layers[0]["label"],
"dominant": layers[0]["dominant"],
"summary": f"{STEM_MOVEMENT[year_gz[0]]}运为纲,司天{sitian},在泉{zaiquan}",
},
{
"label": layers[1]["label"],
"dominant": layers[1]["dominant"],
"summary": (
f"{guest}加临主{host}{_relation(QI_ELEMENT[guest], QI_ELEMENT[host])}"
),
},
{
"label": layers[2]["label"],
"dominant": layers[2]["dominant"],
"summary": f"{day_gz}日,日干与日支只作轻量触发",
},
],
"balance": balance,
"human_field": {
"emotional_tendency": behavior[0],
"risk": behavior[1],
"balancing_action": behavior[2],
"generation_control": _generation_control(primary, secondary),
},
"personal": _personal(profile, (primary, secondary)),
"sector_catalog": [
{"element": element, "industries": list(INDUSTRIES[element])} for element in ELEMENTS
],
"notice": "五行气场是传统历法与市场行为的象征性观察,不代表可验证的因果关系。",
}
def _personal(profile: Any | None, dominant: tuple[str, str]) -> dict[str, Any] | None:
if profile is None:
return None
born = datetime.strptime(f"{profile.birth_date}T{profile.birth_time}", "%Y-%m-%dT%H:%M")
lunar = Solar.fromYmdHms(born.year, born.month, born.day, born.hour, born.minute, 0).getLunar()
eight = lunar.getEightChar()
pillars = (eight.getYear(), eight.getMonth(), eight.getDay(), eight.getTime())
weights = {element: 0.0 for element in ELEMENTS}
for index, pillar in enumerate(pillars):
weights[STEM_ELEMENT[pillar[0]]] += 1
weights[BRANCH_ELEMENT[pillar[1]]] += 1.5 if index == 1 else 1
day_element = STEM_ELEMENT[eight.getDayGan()]
supportive = {
day_element,
next(element for element, target in GENERATES.items() if target == day_element),
}
hits = [element for element in dominant if element in supportive]
return {
"day_master_element": day_element,
"balance": sorted(weights.items(), key=lambda item: item[1], reverse=True),
"tone": f"当日主气中{''.join(hits)}较合个人生扶倾向"
if hits
else "当日主气与个人生扶倾向交错,宜先察情绪再行动",
"notice": "个人信息只用于本地派生计算,页面不回显出生日期、时辰和性别。",
}
def _layer(label: str, parts: tuple[tuple[str, float], ...]) -> dict[str, Any]:
weights = {element: 0.0 for element in ELEMENTS}
for element, amount in parts:
weights[element] += amount
return {"label": label, "weights": weights, "dominant": max(weights, key=weights.get)}
def _qi_step(lunar: Any, ymd: str) -> int:
current = int(ymd.replace("-", ""))
boundaries = [
int(lunar.getJieQiTable()[name].toYmd().replace("-", ""))
for name in ("大寒", "春分", "小满", "大暑", "秋分", "小雪")
if lunar.getJieQiTable().get(name) is not None
]
if len(boundaries) != 6 or current < boundaries[0] or current >= boundaries[5]:
return 6 if len(boundaries) == 6 else 1
return next(
(index + 1 for index in range(5) if boundaries[index] <= current < boundaries[index + 1]), 6
)
def _relation(guest: str, host: str) -> str:
if guest == host:
return "客主同气"
if GENERATES[guest] == host:
return "客生主,气机相接"
if GENERATES[host] == guest:
return "主生客,时令外泄"
if CONTROLS[guest] == host:
return "客克主,外来变化偏强"
return "主克客,时令与来气相持"
def _generation_control(primary: str, secondary: str) -> str:
if GENERATES[primary] == secondary:
return f"{primary}{secondary},主气向次气流转"
if CONTROLS[primary] == secondary:
return f"{primary}{secondary},主次之气相制"
if GENERATES[secondary] == primary:
return f"{secondary}{primary},次气助主"
if CONTROLS[secondary] == primary:
return f"{secondary}{primary},次气牵制主气"
return f"{primary}{secondary}并见,宜防一端偏盛"
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
TRIGRAM_NAMES = {
(1, 1, 1): "",
(1, 1, 0): "",
(1, 0, 1): "",
(1, 0, 0): "",
(0, 1, 1): "",
(0, 1, 0): "",
(0, 0, 1): "",
(0, 0, 0): "",
}
LINE_POSITIONS = ("初爻", "二爻", "三爻", "四爻", "五爻", "上爻")
def from_lines(values: list[int], data_path: Path) -> dict[str, Any]:
if len(values) != 6 or any(value not in {6, 7, 8, 9} for value in values):
raise ValueError("六爻必须由六、七、八、九组成,且从初爻到上爻排列。")
bits = tuple(1 if value % 2 else 0 for value in values)
transformed_values = [7 if value == 6 else 8 if value == 9 else value for value in values]
transformed_bits = tuple(1 if value % 2 else 0 for value in transformed_values)
data = _load(data_path)
primary = data.get(str(bits))
transformed = data.get(str(transformed_bits))
if not primary or not transformed:
raise ValueError("卦象数据不完整。")
line_texts = list(primary["lines"].values())
lines = [
{
"position": index + 1,
"position_name": LINE_POSITIONS[index],
"value": value,
"yin_yang": "" if value % 2 else "",
"moving": value in {6, 9},
"line_name": line_texts[index]["name"],
"text": line_texts[index]["text"],
"image": line_texts[index].get("image") or "",
}
for index, value in enumerate(values)
]
return {
"name": primary["name"],
"text": primary["text"],
"image": primary.get("image") or "",
"inner_trigram": TRIGRAM_NAMES[bits[:3]],
"outer_trigram": TRIGRAM_NAMES[bits[3:]],
"lines": lines,
"moving_lines": [index + 1 for index, value in enumerate(values) if value in {6, 9}],
"transformed": {
"name": transformed["name"],
"text": transformed["text"],
"image": transformed.get("image") or "",
"inner_trigram": TRIGRAM_NAMES[transformed_bits[:3]],
"outer_trigram": TRIGRAM_NAMES[transformed_bits[3:]],
},
}
def line_for_score(score: float) -> int:
if score >= 0.72:
return 9
if score >= 0:
return 7
if score <= -0.72:
return 6
return 8
@lru_cache(maxsize=2)
def _load(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload.get("hexagrams", payload)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from typing import Any
PROMPT_VERSION = "heaven-deterministic-v1"
def messages(mode: str, result: dict[str, Any]) -> list[dict[str, str]]:
instructions = {
"trend": "解释既有本卦、动爻、之卦、六爻量化依据和势值,不得另起卦或修改行情。",
"fortune": (
"解释既有五运六气三层气机、复合断语、个人派生影响、"
"生克断语与制衡动作,不得修改干支历法。"
),
"heart": "解释既有本卦、动爻、之卦和卦辞,帮助用户观察第一念,不得另起卦或作确定性预测。",
}
safe = _safe_result(mode, result)
return [
{
"role": "system",
"content": (
"你是传统文化观察的文字整理助手。"
+ instructions[mode]
+ "明确说明内容仅供传统文化与娱乐化观察,不构成预测或投资建议。"
),
},
{"role": "user", "content": json.dumps(safe, ensure_ascii=False, separators=(",", ":"))},
]
def _safe_result(mode: str, result: dict[str, Any]) -> dict[str, Any]:
if mode != "fortune":
return result
return {key: value for key, value in result.items() if key != "personal"} | {
"personal_synthesis": (result.get("personal") or {}).get("tone")
}
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import json
import sqlite3
from typing import Any
class HeavenRepository:
def add(
self,
connection: sqlite3.Connection,
*,
user_id: int,
mode: str,
reading_date: str,
subject_key: str,
result: dict[str, Any],
created_at: str,
) -> int:
cursor = connection.execute(
"""
INSERT INTO heaven_readings (
user_id, mode, reading_date, subject_key, result_json,
interpretation_status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
""",
(
user_id,
mode,
reading_date,
subject_key,
json.dumps(result, ensure_ascii=False, separators=(",", ":")),
created_at,
created_at,
),
)
return int(cursor.lastrowid)
def get(
self, connection: sqlite3.Connection, user_id: int, reading_id: int
) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM heaven_readings WHERE id = ? AND user_id = ?",
(reading_id, user_id),
).fetchone()
def latest_fortune(
self, connection: sqlite3.Connection, user_id: int, reading_date: str
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM heaven_readings
WHERE user_id = ? AND mode = 'fortune' AND reading_date = ?
ORDER BY id DESC LIMIT 1
""",
(user_id, reading_date),
).fetchone()
def ensure_fortune(
self,
connection: sqlite3.Connection,
*,
user_id: int,
reading_date: str,
result: dict[str, Any],
created_at: str,
) -> tuple[sqlite3.Row, bool]:
cursor = connection.execute(
"""
INSERT OR IGNORE INTO heaven_readings (
user_id, mode, reading_date, subject_key, result_json,
interpretation_status, created_at, updated_at
) VALUES (?, 'fortune', ?, '', ?, 'pending', ?, ?)
""",
(
user_id,
reading_date,
json.dumps(result, ensure_ascii=False, separators=(",", ":")),
created_at,
created_at,
),
)
row = self.latest_fortune(connection, user_id, reading_date)
if row is None:
raise RuntimeError("每日解运记录写入失败")
return row, cursor.rowcount == 0
def list(
self,
connection: sqlite3.Connection,
user_id: int,
mode: str | None,
reading_date: str | None,
limit: int = 60,
) -> tuple[sqlite3.Row, ...]:
clauses = ["user_id = ?"]
values: list[Any] = [user_id]
if mode:
clauses.append("mode = ?")
values.append(mode)
if reading_date:
clauses.append("reading_date = ?")
values.append(reading_date)
values.append(limit)
statement = (
f"SELECT * FROM heaven_readings WHERE {' AND '.join(clauses)} ORDER BY id DESC LIMIT ?"
)
return tuple(
connection.execute(
statement,
values,
).fetchall()
)
def update_interpretation(
self,
connection: sqlite3.Connection,
reading_id: int,
user_id: int,
content: str,
status: str,
request_id: str | None,
updated_at: str,
) -> None:
connection.execute(
"""
UPDATE heaven_readings
SET interpretation = ?, interpretation_status = ?, request_id = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(content, status, request_id, updated_at, reading_id, user_id),
)
def delete(self, connection: sqlite3.Connection, user_id: int, reading_id: int) -> int:
cursor = connection.execute(
"DELETE FROM heaven_readings WHERE id = ? AND user_id = ?",
(reading_id, user_id),
)
return cursor.rowcount
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from typing import Annotated, Literal
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from backend.data.gateway import MarketDataUnavailable
from backend.features.accounts.auth import SmartAccessPrincipal, SmartWritePrincipal
from backend.features.heaven.schemas import (
DeleteResponse,
FortuneInput,
HeartCompleteInput,
HeartLineInput,
InterpretInput,
TrendInput,
)
from backend.features.heaven.service import HeavenError
from backend.http.errors import AppError
from backend.llm.gateway import LLMGatewayError
router = APIRouter(prefix="/heaven", tags=["heaven"])
@router.get("/setup", response_model=dict)
def setup(
request: Request,
principal: SmartAccessPrincipal,
reading_date: Annotated[str, Query(alias="date", min_length=10, max_length=10)],
) -> dict:
return _call(request, "setup", principal, reading_date)
@router.post("/trend/load", response_model=dict)
def load_trend(payload: TrendInput, request: Request, principal: SmartWritePrincipal) -> dict:
return _call(request, "trend", principal, payload.query, payload.trade_date, payload.manual)
@router.post("/fortune", response_model=dict)
def create_fortune(payload: FortuneInput, request: Request, principal: SmartWritePrincipal) -> dict:
return _call(request, "fortune", principal, payload.trade_date)
@router.post("/heart/line", response_model=dict)
def heart_line(payload: HeartLineInput, request: Request, principal: SmartWritePrincipal) -> dict:
return _call(request, "heart_line", principal, payload.trade_date, payload.values)
@router.post("/heart/complete", response_model=dict)
def complete_heart(
payload: HeartCompleteInput, request: Request, principal: SmartWritePrincipal
) -> dict:
return _call(
request,
"complete_heart",
principal,
payload.trade_date,
payload.values,
payload.first_thought_confirmed,
)
@router.get("/readings", response_model=list[dict])
def readings(
request: Request,
principal: SmartAccessPrincipal,
mode: Annotated[Literal["trend", "fortune", "heart"] | None, Query()] = None,
reading_date: Annotated[str | None, Query(alias="date")] = None,
) -> list[dict]:
return _call(request, "readings", principal, mode, reading_date)
@router.delete("/readings/{reading_id}", response_model=DeleteResponse)
def delete_reading(
reading_id: int, request: Request, principal: SmartWritePrincipal
) -> DeleteResponse:
return DeleteResponse(deleted=_call(request, "delete", principal, reading_id))
@router.post("/interpret")
def interpret(
payload: InterpretInput, request: Request, principal: SmartWritePrincipal
) -> StreamingResponse:
prepared = _call(request, "prepare_interpret", principal, payload.reading_id)
def body() -> Iterator[bytes]:
for event in request.app.state.container.heaven.stream_interpret(prepared):
yield (json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode(
"utf-8"
)
return StreamingResponse(
body(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
)
def _call(request: Request, method: str, *args):
try:
return getattr(request.app.state.container.heaven, method)(*args)
except HeavenError as exc:
raise AppError("heaven_unavailable", str(exc), 409) from exc
except MarketDataUnavailable as exc:
raise AppError("market_data_unavailable", str(exc), 503) from exc
except LLMGatewayError as exc:
status = 403 if exc.code in {"membership_required", "quota_exhausted"} else 503
raise AppError(exc.code, str(exc), status) from exc
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class TrendInput(BaseModel):
query: str = Field(min_length=1, max_length=40)
trade_date: str = Field(min_length=10, max_length=10)
manual: dict[str, Any] = Field(default_factory=dict)
class FortuneInput(BaseModel):
trade_date: str = Field(min_length=10, max_length=10)
class HeartLineInput(BaseModel):
trade_date: str = Field(min_length=10, max_length=10)
values: list[Literal[6, 7, 8, 9]] = Field(default_factory=list, max_length=5)
class HeartCompleteInput(BaseModel):
trade_date: str = Field(min_length=10, max_length=10)
values: list[Literal[6, 7, 8, 9]] = Field(min_length=6, max_length=6)
first_thought_confirmed: bool
class InterpretInput(BaseModel):
reading_id: int = Field(gt=0)
class DeleteResponse(BaseModel):
deleted: int
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
import json
import secrets
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.gateway import DataGateway
from backend.database.connection import Database
from backend.features.accounts.models import Principal
from backend.features.accounts.service import AccountService
from backend.features.heaven import fortune, trend
from backend.features.heaven.hexagram import from_lines
from backend.features.heaven.prompt import PROMPT_VERSION, messages
from backend.features.heaven.repository import HeavenRepository
from backend.llm.gateway import LLMCall, LLMGateway, LLMGatewayError
SHANGHAI = ZoneInfo("Asia/Shanghai")
class HeavenError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class PreparedInterpretation:
reading_id: int
user_id: int
mode: str
prompt: list[dict[str, str]]
call: LLMCall | None
cached: str = ""
class HeavenService:
def __init__(
self,
database: Database,
repository: HeavenRepository,
gateway: DataGateway,
accounts: AccountService,
llm: LLMGateway,
iching_path: Path,
) -> None:
self._database = database
self._repository = repository
self._gateway = gateway
self._accounts = accounts
self._llm = llm
self._iching_path = iching_path
def setup(self, principal: Principal, requested_date: str) -> dict[str, Any]:
reading_date = self._date(requested_date)
field = fortune.build(reading_date, self._accounts.get_profile(principal.user.id))
with self._database.read() as connection:
daily = self._repository.latest_fortune(connection, principal.user.id, reading_date)
rows = self._repository.list(connection, principal.user.id, None, None, 60)
return {
"date": reading_date,
"fortune": field,
"daily_fortune": _public(daily) if daily else None,
"history": [_public(row) for row in rows],
}
def trend(
self,
principal: Principal,
query: str,
requested_date: str,
manual: dict[str, Any],
) -> dict[str, Any]:
reading_date = self._date(requested_date)
payload = self._gateway.heaven_trend_inputs(query, reading_date)
try:
result = trend.calculate(payload, self._iching_path, manual)
except trend.TrendDataError as exc:
return {
"ready": False,
"message": str(exc),
"trade_date": payload.get("trade_date"),
"stock": payload.get("stock"),
"sector": payload.get("sector"),
"checks": exc.checks,
"automatic": exc.payload,
}
reading_id = self._save(
principal.user.id,
"trend",
result["trade_date"],
str(result["stock"]["identifier"]),
result,
)
return {"ready": True, "reading_id": reading_id, "result": result}
def fortune(self, principal: Principal, requested_date: str) -> dict[str, Any]:
reading_date = self._date(requested_date)
result = fortune.build(reading_date, self._accounts.get_profile(principal.user.id))
with self._database.transaction() as connection:
row, reused = self._repository.ensure_fortune(
connection,
user_id=principal.user.id,
reading_date=reading_date,
result=result,
created_at=_now(),
)
return {"reused": reused, "reading": _public(row)}
def heart_line(
self, principal: Principal, requested_date: str, values: list[int]
) -> dict[str, Any]:
self._date(requested_date)
if len(values) >= 6 or any(value not in {6, 7, 8, 9} for value in values):
raise HeavenError("当前起卦进度无效。")
faces = [2 + secrets.randbelow(2) for _ in range(3)]
value = sum(faces)
return {
"position": len(values) + 1,
"value": value,
"faces": ["front" if face == 3 else "back" for face in faces],
"values": [*values, value],
}
def complete_heart(
self,
principal: Principal,
requested_date: str,
values: list[int],
first_thought_confirmed: bool,
) -> dict[str, Any]:
reading_date = self._date(requested_date)
if not first_thought_confirmed:
raise HeavenError("请先确认第一念,再进入解卦。")
result = {
"date": reading_date,
"hexagram": from_lines(values, self._iching_path),
"notice": "卦象仅供传统文化与自我观察,不构成预测或投资建议。",
}
reading_id = self._save(principal.user.id, "heart", reading_date, "", result)
return {"reading_id": reading_id, "result": result}
def readings(
self, principal: Principal, mode: str | None, requested_date: str | None
) -> list[dict[str, Any]]:
reading_date = self._date(requested_date) if requested_date else None
if mode and mode not in {"trend", "fortune", "heart"}:
raise HeavenError("历史类型无效。")
with self._database.read() as connection:
rows = self._repository.list(connection, principal.user.id, mode, reading_date, 60)
return [_public(row) for row in rows]
def delete(self, principal: Principal, reading_id: int) -> int:
with self._database.transaction() as connection:
return self._repository.delete(connection, principal.user.id, reading_id)
def prepare_interpret(self, principal: Principal, reading_id: int) -> PreparedInterpretation:
with self._database.read() as connection:
row = self._repository.get(connection, principal.user.id, reading_id)
if row is None:
raise HeavenError("未找到该问天记录。")
if row["interpretation_status"] == "complete" and row["interpretation"]:
return PreparedInterpretation(
reading_id,
principal.user.id,
str(row["mode"]),
[],
None,
str(row["interpretation"]),
)
result = json.loads(str(row["result_json"]))
prompt = messages(str(row["mode"]), result)
call = self._llm.prepare(
principal,
feature=f"heaven_{row['mode']}",
prompt_version=PROMPT_VERSION,
business_id=f"heaven:{reading_id}",
input_chars=sum(len(item["content"]) for item in prompt),
)
return PreparedInterpretation(reading_id, principal.user.id, str(row["mode"]), prompt, call)
def stream_interpret(self, prepared: PreparedInterpretation) -> Iterator[dict[str, Any]]:
if prepared.cached:
yield {"type": "delta", "content": prepared.cached, "cached": True}
yield {"type": "done", "cached": True}
return
if prepared.call is None:
raise HeavenError("智能解读状态无效。")
answer = ""
saved = False
stream = self._llm.stream(prepared.call, prepared.prompt)
try:
for event in stream:
if event.type == "delta":
answer += event.content
yield {
"type": "delta",
"content": event.content,
"request_id": event.request_id,
}
elif event.type == "done":
self._save_interpretation(prepared, answer, "complete")
saved = True
yield {"type": "done", "request_id": event.request_id}
except GeneratorExit:
stream.close()
if answer and not saved:
self._save_interpretation(prepared, answer, "stopped")
raise
except LLMGatewayError as exc:
if answer:
self._save_interpretation(prepared, answer, "error")
yield {"type": "error", "code": exc.code, "message": str(exc), "partial": exc.partial}
def _save(
self, user_id: int, mode: str, reading_date: str, subject_key: str, result: dict[str, Any]
) -> int:
now = _now()
with self._database.transaction() as connection:
return self._repository.add(
connection,
user_id=user_id,
mode=mode,
reading_date=reading_date,
subject_key=subject_key,
result=result,
created_at=now,
)
def _save_interpretation(
self, prepared: PreparedInterpretation, answer: str, status: str
) -> None:
with self._database.transaction() as connection:
self._repository.update_interpretation(
connection,
prepared.reading_id,
prepared.user_id,
answer,
status,
prepared.call.request_id if prepared.call else None,
_now(),
)
@staticmethod
def _date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise HeavenError("日期格式无效。") from exc
def _public(row: Any) -> dict[str, Any]:
return {
"id": int(row["id"]),
"mode": str(row["mode"]),
"date": str(row["reading_date"]),
"subject_key": str(row["subject_key"]),
"result": json.loads(str(row["result_json"])),
"interpretation": str(row["interpretation"]),
"status": str(row["interpretation_status"]),
"created_at": str(row["created_at"]),
}
def _now() -> str:
return datetime.now(SHANGHAI).isoformat(timespec="seconds")
+353
View File
@@ -0,0 +1,353 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any
from backend.features.heaven.hexagram import LINE_POSITIONS, from_lines, line_for_score
LINE_META = (
("", "", "个股内核"),
("", "", "个股外显"),
("", "", "行业内核"),
("", "", "行业外显"),
("", "", "市场内核"),
("", "", "指数外显"),
)
MANUAL_FIELDS = {
"sector.name",
"sector.change",
"sector.up_count",
"sector.down_count",
"sector.member_count",
"sector.quoted_count",
"sector.coverage",
"sector.member_equal_change",
"sector.relative_turnover",
"sector.leader",
"sector.leading_pct",
}
class TrendDataError(RuntimeError):
def __init__(self, checks: list[dict[str, Any]], payload: dict[str, Any]) -> None:
super().__init__("六爻量化数据未全部通过安全门,暂不成卦。")
self.checks = checks
self.payload = payload
def calculate(
payload: dict[str, Any],
data_path: Path,
manual: dict[str, Any] | None = None,
) -> dict[str, Any]:
normalized, manual_paths = apply_manual(payload, manual or {})
checks = validate(normalized, manual_paths)
if any(not item["passed"] for item in checks):
raise TrendDataError(checks, normalized)
scores = _scores(normalized)
values = [line_for_score(item["score"]) for item in scores]
hexagram = from_lines(values, data_path)
for index, line in enumerate(hexagram["lines"]):
talent, layer, role = LINE_META[index]
line.update(
talent=talent,
layer=layer,
role=role,
score=round(scores[index]["score"], 4),
evidence=scores[index]["evidence"],
validation=checks[index],
)
average = sum(item["score"] for item in scores) / 6
moving_names = [LINE_POSITIONS[index - 1] for index in hexagram["moving_lines"]]
return {
"trade_date": normalized["trade_date"],
"stock": normalized["stock"],
"sector": normalized["sector"],
"hexagram": hexagram,
"movement": {
"moving_names": moving_names,
"label": (
f"{''.join(moving_names)}动,{hexagram['name']}{hexagram['transformed']['name']}"
if moving_names
else f"无动爻,守{hexagram['name']}本势"
),
},
"momentum_score": round(average * 100),
"momentum_label": _momentum_label(average),
"checks": checks,
"manual_fields": sorted(manual_paths),
"notice": "卦象来自客观行情的固定量化映射,仅供传统文化与娱乐化观察。",
}
def apply_manual(
payload: dict[str, Any], manual: dict[str, Any]
) -> tuple[dict[str, Any], set[str]]:
result = deepcopy(payload)
failed_paths = _failed_paths(result)
applied: set[str] = set()
for path, value in _flatten(manual).items():
if path not in MANUAL_FIELDS or path not in failed_paths or value in (None, ""):
continue
section, key = path.split(".", 1)
result.setdefault(section, {})[key] = value
applied.add(path)
return result, applied
def validate(payload: dict[str, Any], manual_paths: set[str] | None = None) -> list[dict[str, Any]]:
manual_paths = manual_paths or set()
trade_date = str(payload.get("trade_date") or "")
stock = payload.get("stock") or {}
sector = payload.get("sector") or {}
market = payload.get("market") or {}
indexes = payload.get("indices") or []
mode = str(payload.get("mode") or "historical")
stock_fields = (
(
"change",
"amount_percentile",
"turnover_rate",
"turnover_relative",
"volume_activity_ratio",
)
if mode == "intraday"
else ("change", "amount_percentile", "turnover_rate")
)
stock_ok = (
str(stock.get("trade_date") or "") == trade_date
and str(stock.get("quote_kind") or "") == ("realtime" if mode == "intraday" else "daily")
and _has(stock, *stock_fields)
)
sector_common = (
str(sector.get("trade_date") or trade_date) == trade_date
and str(sector.get("taxonomy") or "") == "申万二级"
and _has(
sector,
"change",
"up_count",
"down_count",
"member_count",
"quoted_count",
"coverage",
"leading_pct",
)
)
member_count = _number(sector.get("member_count"))
quoted_count = _number(sector.get("quoted_count"))
coverage = _number(sector.get("coverage"))
complete_members = member_count > 0 and quoted_count == member_count and coverage >= 0.98
sufficient_members = (
member_count > 0 and coverage >= 0.98 and quoted_count >= member_count * 0.98
)
sector_mode = str(sector.get("quote_kind") or "") == (
"realtime" if mode == "intraday" else "daily"
)
if mode == "intraday":
sector_inner = sector_common and sector_mode and _has(sector, "relative_turnover")
else:
sector_inner = sector_common and sector_mode and _has(sector, "member_equal_change")
sector_inner = sector_inner and (complete_members or sufficient_members)
sector_outer = sector_common and sector_mode and bool(str(sector.get("name") or ""))
market_ok = (
str(market.get("trade_date") or "") == trade_date
and str(market.get("quote_kind") or "") == ("realtime" if mode == "intraday" else "daily")
and _has(
market,
"sentiment_score",
"seal_rate",
"amount_billion",
"average_amount_billion",
"up_count",
"down_count",
"limit_up_count",
"limit_down_count",
)
)
expected = {"000001.SH", "399001.SZ", "399006.SZ"}
present = {
str(item.get("identifier") or "")
for item in indexes
if str(item.get("trade_date") or "") == trade_date
and str(item.get("quote_kind") or "") == ("realtime" if mode == "intraday" else "daily")
and item.get("change") is not None
}
details = (
(stock_ok, "个股交易日、行情类型及成交活跃数据有效", {"stock"}),
(stock_ok and _has(stock, "streak", "status"), "个股涨跌、连板和事件状态有效", {"stock"}),
(sector_inner, f"申万二级行业有效成分 {int(quoted_count)}/{int(member_count)}", {"sector"}),
(sector_outer, "申万二级行业及领涨股涨跌有效", {"sector"}),
(market_ok, "市场情绪、封板、成交、宽度和涨跌停结构有效", {"market"}),
(present == expected, "上证、深证、创业板三条指数行情完整", {"indices"}),
)
checks = []
for index, (passed, message, sections) in enumerate(details):
used_manual = any(path.split(".", 1)[0] in sections for path in manual_paths)
checks.append(
{
"position": index + 1,
"position_name": LINE_POSITIONS[index],
"role": LINE_META[index][2],
"passed": bool(passed),
"source": "manual" if used_manual else "automatic",
"message": message if passed else _failure_message(index, payload),
}
)
return checks
def _scores(payload: dict[str, Any]) -> list[dict[str, Any]]:
stock = payload["stock"]
sector = payload["sector"]
market = payload["market"]
mode = payload.get("mode") or "historical"
amount = _clamp(_number(stock["amount_percentile"]) / 100, 0, 1)
if mode == "intraday":
relative_turnover = _clamp((_number(stock["turnover_relative"]) - 1) / 1.5)
activity = _clamp((_number(stock["volume_activity_ratio"]) - 1) / 1.5)
stock_inner = (amount * 2 - 1) * 0.35 + relative_turnover * 0.35 + activity * 0.30
else:
turnover = _clamp(_number(stock["turnover_rate"]) / 20, 0, 1)
seal = _clamp(_number(stock.get("seal_amount_million")) / 15000, 0, 1)
stability = 1 - _clamp(_number(stock.get("open_times")) / 6, 0, 1)
stock_inner = (amount * 0.32 + turnover * 0.22 + seal * 0.25 + stability * 0.21) * 2 - 1
adjustment = -0.7 if stock["status"] == "跌停" else -0.25 if stock["status"] == "炸板" else 0.15
stock_outer = _clamp(
_clamp(_number(stock["change"]) / 10) * 0.7
+ _clamp(_number(stock["streak"]) / 5, 0, 1) * 0.2
+ adjustment
)
up = _number(sector["up_count"])
down = _number(sector["down_count"])
breadth = _clamp((up - down) / max(up + down, 1))
leader = _clamp(_number(sector["leading_pct"]) / 10)
if mode == "intraday":
relative = _clamp((_number(sector["relative_turnover"]) - 1) / 1.5)
sector_inner = breadth * 0.6 + relative * 0.4
else:
equal_change = _clamp(_number(sector["member_equal_change"]) / 5)
sector_inner = breadth * 0.6 + equal_change * 0.35 + leader * 0.05
sector_outer = _clamp(_number(sector["change"]) / 5) * 0.9 + leader * 0.1
sentiment = _clamp(_number(market["sentiment_score"]) / 100, 0, 1) * 2 - 1
seal_rate = _clamp(_number(market["seal_rate"]) / 100, 0, 1) * 2 - 1
amount_change = _clamp(
(_number(market["amount_billion"]) / max(_number(market["average_amount_billion"]), 1) - 1)
* 3
)
market_up, market_down = _number(market["up_count"]), _number(market["down_count"])
market_breadth = _clamp((market_up / max(market_up + market_down, 1) - 0.5) * 2)
limit_up = _number(market["limit_up_count"])
limit_down = _number(market["limit_down_count"])
limit_balance = _clamp((limit_up - limit_down) / max(limit_up + limit_down, 1))
market_inner = (
sentiment * 0.35
+ seal_rate * 0.20
+ amount_change * 0.20
+ market_breadth * 0.15
+ limit_balance * 0.10
)
index_change = sum(_number(item["change"]) for item in payload["indices"]) / 3
return [
{
"score": _clamp(stock_inner),
"evidence": (
[
f"成交额分位 {amount * 100:.0f}%",
f"相对换手 {_number(stock['turnover_relative']):.2f}",
f"同进度量能 {_number(stock['volume_activity_ratio']):.2f}",
]
if mode == "intraday"
else [
f"成交额分位 {amount * 100:.0f}%",
f"换手率 {_number(stock['turnover_rate']):.2f}%",
]
),
},
{
"score": _clamp(stock_outer),
"evidence": [
f"涨跌 {_number(stock['change']):+.2f}%",
f"状态 {stock['status'] or '普通'}",
],
},
{
"score": _clamp(sector_inner),
"evidence": [
f"上涨 {int(up)} / 下跌 {int(down)}",
"有效成分 "
f"{int(_number(sector['quoted_count']))}/"
f"{int(_number(sector['member_count']))}",
],
},
{
"score": _clamp(sector_outer),
"evidence": [
f"行业涨跌 {_number(sector['change']):+.2f}%",
f"领涨股 {_number(sector['leading_pct']):+.2f}%",
],
},
{
"score": _clamp(market_inner),
"evidence": [
f"情绪 {_number(market['sentiment_score']):.0f}",
f"封板率 {_number(market['seal_rate']):.1f}%",
],
},
{"score": _clamp(index_change / 3), "evidence": [f"三大指数平均 {index_change:+.2f}%"]},
]
def _failed_paths(payload: dict[str, Any]) -> set[str]:
sector = payload.get("sector") or {}
return {path for path in MANUAL_FIELDS if sector.get(path.split(".", 1)[1]) in (None, "")}
def _flatten(value: dict[str, Any], prefix: str = "") -> dict[str, Any]:
result: dict[str, Any] = {}
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(item, dict):
result.update(_flatten(item, path))
else:
result[path] = item
return result
def _has(value: dict[str, Any], *keys: str) -> bool:
return all(key in value and value[key] is not None and value[key] != "" for key in keys)
def _failure_message(index: int, payload: dict[str, Any]) -> str:
messages = (
"个股成交活跃或交易日期数据缺失",
"个股涨跌、连板或事件状态缺失",
"申万二级行业成分宽度、覆盖率或换手数据缺失",
"申万二级行业涨跌或领涨股涨跌缺失",
"市场情绪、成交或宽度数据缺失",
"指数层缺少三大指数的有效行情",
)
return messages[index]
def _clamp(value: float, lower: float = -1, upper: float = 1) -> float:
return max(lower, min(upper, value))
def _number(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _momentum_label(score: float) -> str:
if score >= 0.45:
return "势盛而动"
if score >= 0.12:
return "势起未极"
if score > -0.12:
return "阴阳相持"
if score > -0.45:
return "势弱宜察"
return "势衰宜守"
+1 -1
View File
@@ -9,8 +9,8 @@ from backend.data.contracts import ProviderResult, SnapshotState
from backend.data.gateway import DataGateway, MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.data.repository import MarketRepository
from backend.data.sentiment import calculate_sentiment
from backend.database.connection import Database
from backend.features.market.sentiment import calculate_sentiment
from backend.features.market.snapshot import build_snapshot
SHANGHAI = ZoneInfo("Asia/Shanghai")
+2
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from backend.features.accounts.routes import router as accounts_router
from backend.features.heaven.routes import router as heaven_router
from backend.features.market.routes import router as market_router
from backend.features.mentor.routes import router as mentor_router
from backend.features.screener.routes import router as screener_router
@@ -12,3 +13,4 @@ api_router.include_router(accounts_router)
api_router.include_router(market_router)
api_router.include_router(screener_router)
api_router.include_router(mentor_router)
api_router.include_router(heaven_router)
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
# 阶段11验收记录
## 完成范围
- 观势:股票代码或名称载入、正式日线与盘中实时模式、六爻确定性公式、安全门、客观行业数据补录和失败关闭。
- 观气:基于`lunar-python`的确定性历法计算、固定100分权重、三层气机、个人合参与五行行业折叠区。
- 观心:40秒呼吸、一次投掷只生成一爻、六次成卦、铜钱正反面、重置与历史。
- 历史:三种模式均按账号隔离;当天已完成解运直接复用,不重复调用LLM或扣减额度。
- 解读:统一使用既有`LLMGateway`,保留问天专属循环加载动画和长方形结果弹窗。
- 前端:PC与移动端、日间与夜间、会员可用态和非会员同结构灰色锁定态。
## 关键行为
- 观势历史日期只使用正式日线;今日盘中只使用实时行情;收盘后正式日线落库前只接受同日15:00及以后的实时快照。
- 股票、申万二级行业、行业成分股和三大指数缺失或过期时失败关闭,不生成模拟数据,也不允许用户直接修改阴阳爻。
- 用户补录的是缺失的客观行业行情字段;通过安全门后再由唯一公式生成卦象。
- 观气的历法、五运六气和基础断语不由LLM计算;LLM只解释已生成的结构化结果。
- 观心未投掷的爻只显示“未得”,投掷一次只显现一爻;全部六爻完成前不能解卦。
- 原始出生日期、时辰和性别不在问天响应或页面中回显。
## 自动验收
- Ruff:通过。
- Pytest89项通过。
- Vue TypeScript:通过。
- Vitest3个文件、7项通过。
- Vite生产构建:通过。
- Playwright:阶段4至11共12项通过;阶段11专属2项通过。
- 数据库:版本9前进、回退和每日解运并发唯一性测试通过。
- Git差异检查:通过。
- 密钥扫描:已提供的账号密码和Token无匹配。
## 视觉证据
- `heaven-dark-1920x1080.jpg`
- `heaven-light-390x844.jpg`
- `heaven-interpret-loading-dark.jpg`
## 减法审计
- 未导入旧`heaven_agent.py``heaven_engine.py`、旧页面脚本或旧样式覆盖层。
- 行情情绪计算从`features/market`迁入唯一数据层,问势实时组装从通用`DataGateway`拆为职责明确的`data/heaven.py`
- 三种解读继续复用唯一LLM网关、配额、流协议、弹窗和错误外壳,没有第二套实现。
- 问天页面按观势、观气、观心拆分,三份页面样式各自受控;最大页面样式408行,超出400行建议值8行,原因是保留已验收的星空、六爻、铜钱和响应式动画规则,不再增加覆盖层。
## 残余边界
- 生产数据能否成卦取决于正式Tushare权限、盘中实时接口的新鲜度和申万二级成分覆盖;质量门不会静默放宽。
- NAS生产容器保持不变,最终切换仍需人工确认。
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+1
View File
@@ -19,6 +19,7 @@
</head>
<body>
<div id="app"></div>
<script src="/heaven-loading.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+723
View File
@@ -0,0 +1,723 @@
(function exposeHeavenLoading(global) {
"use strict";
// Theme palettes share the original animation geometry and timing.
const LOADING_PALETTES = {
dark: {
paper: "#05060d",
paperCenter: "#10142a",
paperMiddle: "#0b0e1e",
nodeText: "#f7e3b4",
ink: "#e6c37a",
inkBright: "#f7e3b4",
gold: "#e6c37a",
goldBright: "#f7e3b4",
cinnabar: "#d8564a",
dim: "rgba(216,205,180,0.55)",
particles: ["#e6c37a", "#d8564a", "#6d7fa8"],
},
light: {
paper: "#eef1f4",
paperCenter: "#fffefa",
paperMiddle: "#f4f2eb",
nodeText: "#493a20",
ink: "#8a641d",
inkBright: "#624612",
gold: "#946b1d",
goldBright: "#765315",
cinnabar: "#b94f46",
dim: "rgba(52,58,67,0.62)",
particles: ["#946b1d", "#b94f46", "#73859c"],
},
};
let PAPER;
let PAPER_CENTER;
let PAPER_MIDDLE;
let NODE_TEXT;
let INK;
let INK_BRIGHT;
let GOLD;
let GOLD_BRIGHT;
let CINNABAR;
let DIM;
let PARTICLE_COLORS;
const applyLoadingPalette = () => {
const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark";
const palette = LOADING_PALETTES[theme];
PAPER = palette.paper;
PAPER_CENTER = palette.paperCenter;
PAPER_MIDDLE = palette.paperMiddle;
NODE_TEXT = palette.nodeText;
INK = palette.ink;
INK_BRIGHT = palette.inkBright;
GOLD = palette.gold;
GOLD_BRIGHT = palette.goldBright;
CINNABAR = palette.cinnabar;
DIM = palette.dim;
PARTICLE_COLORS = palette.particles;
return theme;
};
applyLoadingPalette();
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
const ELEMENT_COLORS = {
: "#4f7a4a",
: "#b3483d",
: "#96702c",
: "#70685b",
: "#496d92",
};
const QI6 = [
{ name: "厥阴风木", element: "木" },
{ name: "少阴君火", element: "火" },
{ name: "少阳相火", element: "火" },
{ name: "太阴湿土", element: "土" },
{ name: "阳明燥金", element: "金" },
{ name: "太阳寒水", element: "水" },
];
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
const TRIGRAMS = [
{ name: "乾", bits: [1, 1, 1], angle: -90 },
{ name: "兑", bits: [1, 1, 0], angle: -135 },
{ name: "离", bits: [1, 0, 1], angle: 180 },
{ name: "震", bits: [1, 0, 0], angle: 135 },
{ name: "巽", bits: [0, 1, 1], angle: -45 },
{ name: "坎", bits: [0, 1, 0], angle: 0 },
{ name: "艮", bits: [0, 0, 1], angle: 45 },
{ name: "坤", bits: [0, 0, 0], angle: 90 },
];
const SIXIANG = [
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
];
const HEXAGRAM_NAMES = [
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
];
const HEX_TOTAL = 12500;
const FORTUNE_TOTAL = 12800;
const HEX_STAGES = [
[0, 1800, "太 极", "无极而太极,动而生阳"],
[1800, 3300, "两 仪", "一阴一阳之谓道"],
[3300, 4700, "四 象", "阴阳消长,太少相生"],
[4700, 6800, "八 卦", "天地定位,山泽通气"],
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
];
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const smooth = (start, end, value) => {
const progress = clamp01((value - start) / Math.max(1, end - start));
return progress * progress * (3 - 2 * progress);
};
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
const point = (cx, cy, radius, degrees) => {
const radians = degrees * Math.PI / 180;
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
};
class HeavenLoadingCanvas {
constructor(canvas) {
this.canvas = canvas;
this.context = canvas.getContext("2d");
this.width = 0;
this.height = 0;
this.dpr = 1;
this.scene = "hexagram";
this.data = {};
this.startedAt = 0;
this.frameId = 0;
this.running = false;
this.completingAt = 0;
this.completionResolve = null;
this.completionTimer = 0;
this.resizeObserver = new ResizeObserver(() => this.resize());
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
this.theme = document.documentElement.dataset.theme || "dark";
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
}
createStars(count) {
let seed = 24681357;
const random = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
return Array.from({ length: count }, () => ({
x: random(),
y: random(),
radius: 0.3 + random() * 1.3,
phase: random() * Math.PI * 2,
speed: 0.00015 + random() * 0.0004,
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
}));
}
start(scene, data = {}) {
this.theme = applyLoadingPalette();
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
if (this.running && this.scene === nextScene) {
this.data = data;
return;
}
this.stop();
this.scene = nextScene;
this.data = data;
this.startedAt = performance.now();
this.running = true;
this.canvas.dataset.scene = this.scene;
this.canvas.dataset.running = "true";
this.canvas.dataset.looping = "true";
this.resizeObserver.observe(this.canvas);
this.resize();
if (this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
} else {
this.frameId = requestAnimationFrame((now) => this.frame(now));
}
}
complete() {
if (!this.running || this.reducedMotion) {
this.stop();
return Promise.resolve();
}
if (this.completionResolve) return this.completionPromise;
this.completingAt = performance.now();
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
return this.completionPromise;
}
stop() {
if (this.frameId) cancelAnimationFrame(this.frameId);
this.frameId = 0;
this.running = false;
this.completingAt = 0;
if (this.completionTimer) global.clearTimeout(this.completionTimer);
this.completionTimer = 0;
this.resizeObserver.disconnect();
this.canvas.dataset.running = "false";
this.canvas.dataset.looping = "false";
if (this.completionResolve) this.completionResolve();
this.completionResolve = null;
this.completionPromise = null;
}
resize() {
const rect = this.canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width === this.width && height === this.height) return;
this.width = width;
this.height = height;
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
if (this.running && this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
}
}
frame(now) {
if (!this.running) return;
if (this.completingAt) {
const duration = this.scene === "fortune" ? 1800 : 1700;
const progress = clamp01((now - this.completingAt) / duration);
this.drawCompletion(progress, now);
if (progress >= 1) {
this.stop();
return;
}
} else {
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
const elapsed = Math.max(0, now - this.startedAt);
const timeline = elapsed % total;
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
this.draw(timeline, now);
}
this.frameId = requestAnimationFrame((time) => this.frame(time));
}
draw(time, now) {
if (this.width <= 1 || this.height <= 1) return;
this.drawBackground(now);
if (this.scene === "fortune") this.drawFortune(time, now);
else this.drawHexagram(time, now);
}
drawBackground(now) {
const currentTheme = document.documentElement.dataset.theme || "dark";
if (currentTheme !== this.theme) this.theme = applyLoadingPalette();
const { context: ctx, width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
gradient.addColorStop(0, PAPER_CENTER);
gradient.addColorStop(0.52, PAPER_MIDDLE);
gradient.addColorStop(1, PAPER);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
for (const star of this.stars) {
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
const alpha = twinkle * 0.5;
ctx.globalAlpha = alpha;
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
const y = ((star.y + now * star.speed) % 1) * height;
ctx.fillRect(star.x * width, y, star.radius, star.radius);
}
ctx.globalAlpha = 1;
}
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
if (!text || alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
else ctx.fillText(text, x, y);
ctx.restore();
}
node(x, y, radius, color, alpha = 1, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = glow;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
}
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
if (alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - my, x2 - mx);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
ctx.closePath();
ctx.fill();
ctx.restore();
}
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = INK;
ctx.shadowColor = GOLD;
ctx.shadowBlur = glow;
if (yang) {
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
} else {
const gap = width * 0.18;
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
}
ctx.restore();
}
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
const gap = lineWidth * 1.7;
const top = cy - (bits.length - 1) * gap / 2;
bits.forEach((bit, index) => {
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
});
}
stageAlpha(time, start, end, fade = 300, hold = false) {
const enter = smooth(start, start + fade, time);
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
}
fortuneStages() {
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
return [
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
];
}
drawFooter(time, now, total, stages, scene) {
const { context: ctx, width, height } = this;
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
* (1 - smooth(stage[1] - 250, stage[1], time));
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
const baseSlotWidth = 34;
const baseSlotHeight = 5;
const baseSlotGap = 12;
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
const fit = Math.min(1, (width - 28) / baseTotalWidth);
const slotWidth = baseSlotWidth * fit;
const slotHeight = baseSlotHeight * fit;
const slotGap = baseSlotGap * fit;
const totalWidth = slotWidth * 6 + slotGap * 5;
const filled = Math.min(6, Math.floor(time / (total / 6)));
for (let index = 0; index < 6; index += 1) {
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
const y = height - 56;
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
ctx.save();
ctx.globalAlpha = 0.16;
ctx.strokeStyle = GOLD;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, slotWidth, slotHeight);
ctx.restore();
if (index < filled) {
ctx.save();
ctx.globalAlpha = 0.9;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.fillRect(x, y, slotWidth, slotHeight);
ctx.restore();
} else if (index === filled) {
ctx.save();
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
ctx.fillStyle = color;
const progress = (time % (total / 6)) / (total / 6);
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
ctx.restore();
}
}
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
}
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
const breath = 1 + 0.006 * Math.sin(now / 620);
TRIGRAMS.forEach((trigram, index) => {
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
if (progress <= 0) return;
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
});
}
drawHexagram(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const scale = Math.min(width, Math.max(1, height - 150));
if (time < 1800) {
const alpha = this.stageAlpha(time, 0, 1800);
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
for (let ring = 0; ring < 3; ring += 1) {
const progress = ((now / 1500) + ring / 3) % 1;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
if (time >= 1800 && time < 3300) {
const alpha = this.stageAlpha(time, 1800, 3300);
const progress = easeOut((time - 1850) / 850);
const yaoWidth = scale * 0.19 * progress;
const yaoLine = Math.max(scale * 0.013, 5);
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
}
if (time >= 3300 && time < 4700) {
const alpha = this.stageAlpha(time, 3300, 4700);
const distance = scale * 0.085;
const yaoWidth = Math.max(scale * 0.055, 28);
const yaoLine = Math.max(scale * 0.009, 3.5);
SIXIANG.forEach((symbol, index) => {
const progress = easeOut((time - 3330 - index * 160) / 520);
if (progress <= 0) return;
const x = cx + symbol.dx * distance;
const y = cy + symbol.dy * distance;
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
});
}
const trigramRadius = scale * 0.215;
const trigramWidth = Math.max(scale * 0.052, 26);
const trigramLine = Math.max(scale * 0.0075, 3);
if (time >= 4700 && time < 6800) {
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
}
if (time >= 6800 && time < 10800) {
const alpha = this.stageAlpha(time, 6800, 10800, 350);
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
const ringRadius = scale * 0.365;
const hexWidth = Math.max(scale * 0.026, 13);
const hexLine = Math.max(scale * 0.0042, 1.6);
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
for (let index = 0; index < 64; index += 1) {
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
this.node(x, y, 1.4, GOLD, alpha * 0.14);
if (index < count) {
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
if (freshness > 0) {
const ctx = this.context;
const gradient = ctx.createLinearGradient(cx, cy, x, y);
gradient.addColorStop(0, "rgba(230,195,122,0)");
gradient.addColorStop(1, GOLD);
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
}
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
}
}
if (count > 0) {
const current = count - 1;
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
const pop = 1 + 0.22 * (1 - popTime);
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
this.label(`${current + 1}`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
}
}
if (time >= 10800) {
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
const progress = easeOut((time - 10850) / 1150);
const radius = scale * 0.365 * (1 - progress);
for (let index = 0; index < 64 && radius >= 8; index += 1) {
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
}
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
}
drawFortune(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const scale = Math.min(width, Math.max(1, height - 150));
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
if (time >= 11000) {
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
const progress = easeOut((time - 11050) / 1200);
const radius = scale * 0.30 * (1 - progress);
QI6.forEach((qi, index) => {
const [x, y] = point(cx, cy, radius, -90 + index * 60);
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
});
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
}
drawFiveMovements(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 0, 2100);
const radius = scale * 0.17;
const nodeRadius = Math.max(scale * 0.018, 9);
const elements = [
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
];
const positions = {};
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
elements.forEach(([element, degrees], index) => {
const progress = easeOut((time - 500 - index * 170) / 500);
if (progress <= 0) return;
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
positions[element] = [x, y];
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
const direction = element === "土" ? "中央土" : { : "东方木", : "南方火", : "西方金", : "北方水" }[element];
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
});
const order = ["木", "火", "土", "金", "水"];
order.forEach((element, index) => {
const from = positions[element];
const to = positions[order[(index + 1) % order.length]];
if (!from || !to) return;
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
});
}
drawStems(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 2100, 3900);
const stems = "甲乙丙丁戊己庚辛壬癸";
const movements = ["土", "金", "水", "木", "火"];
const radius = scale * 0.30;
for (let index = 0; index < 10; index += 1) {
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
if (progress <= 0) continue;
const [x, y] = point(cx, cy, radius, -90 + index * 36);
const element = movements[index % 5];
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
}
for (let index = 0; index < 5; index += 1) {
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
const angle = -90 + index * 36;
const [x1, y1] = point(cx, cy, radius, angle);
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
}
}
drawBranches(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 3900, 5800);
const branches = "子丑寅卯辰巳午未申酉戌亥";
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
const radius = scale * 0.31;
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
for (let index = 0; index < 12; index += 1) {
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
const [x, y] = point(cx, cy, radius, branchAngle(index));
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
}
qiNames.forEach((name, index) => {
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
const element = QI6.find((item) => item.name === name)?.element || "土";
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
});
}
drawSixQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 5800, 7900);
const radius = scale * 0.27;
const drift = now * 0.004;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
QI6.forEach((qi, index) => {
const progress = easeOut((time - 5850 - index * 180) / 550);
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
});
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
}
drawAnnualQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 7900, 11000, 350);
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
const zaiquan = sixQi.zaiquan || "在泉气化";
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
const radius = scale * 0.30;
QI6.forEach((qi, index) => {
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
const [x, y] = point(cx, cy, radius, -90 + index * 60);
const current = index + 1 === currentStep;
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
if (current) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
ctx.strokeStyle = CINNABAR;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
}
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
});
}
drawCompletion(progress, now) {
this.drawBackground(now);
if (this.scene === "fortune") {
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
} else {
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
}
}
}
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
})(window);
@@ -6,6 +6,7 @@ import EmptyState from "../../shared/components/EmptyState.vue";
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
import MentorPage from "../../pages/mentor/MentorPage.vue";
import HeavenPage from "../../pages/heaven/HeavenPage.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -33,6 +34,7 @@ const implementedMarket = computed(() =>
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
<ScreenerPage v-else-if="workspace.key === 'screener'" />
<MentorPage v-else-if="workspace.key === 'mentor'" />
<HeavenPage v-else-if="workspace.key === 'heaven'" />
<main v-else class="page-frame">
<header class="page-header">
<h1>{{ workspace.title }}</h1>
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface Window {
HeavenLoadingCanvas?: new (canvas: HTMLCanvasElement) => {
start: (scene: "hexagram" | "fortune", data?: Record<string, unknown>) => void;
complete: () => Promise<void>;
stop: () => void;
};
}
+3
View File
@@ -15,6 +15,9 @@ import "./shared/styles/market-workspace.css";
import "./shared/styles/market-insights.css";
import "./shared/styles/screener.css";
import "./shared/styles/mentor.css";
import "./shared/styles/heaven.css";
import "./shared/styles/heaven-fortune.css";
import "./shared/styles/heaven-heart.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref } from "vue";
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
import { useUiStore } from "../../shared/stores/ui";
const props = defineProps<{ field: Record<string, any> | null; daily: HeavenReading | null; disabled: boolean }>();
const emit = defineEmits<{ interpret: [reading: HeavenReading]; saved: [reading: HeavenReading] }>();
const ui = useUiStore();
const industriesOpen = ref(false);
const loading = ref(false);
const layerMarks = ["壹", "贰", "叁"];
async function interpret(): Promise<void> {
if (props.disabled || loading.value) return;
if (props.daily?.status === "complete") {
emit("interpret", props.daily);
return;
}
loading.value = true;
try {
const response = await heavenApi.fortune(props.field?.date);
emit("saved", response.reading);
emit("interpret", response.reading);
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "解运准备失败");
} finally {
loading.value = false;
}
}
</script>
<template>
<section v-if="field" class="heaven-panel fortune-panel">
<div class="heaven-fortune-grid">
<article class="card heaven-calendar-card">
<div class="heaven-date-seal"><span>{{ field.date.slice(5) }}</span><small>{{ field.solar_term.current }}</small></div>
<div><p class="muted">{{ field.lunar_date }}</p><h2>{{ field.pillars.year }} · {{ field.pillars.month }} · {{ field.pillars.day }}</h2><p>下一节气 {{ field.solar_term.next }}</p></div>
</article>
<article class="card heaven-phrase-card">
<span>当日断语</span><h2>{{ field.phrase }}</h2><p>{{ field.movement.element }}{{ field.movement.tendency }} · {{ field.six_qi.step_name }} · {{ field.six_qi.guest }}加临主{{ field.six_qi.host }}</p>
</article>
</div>
<section class="heaven-qi-layout">
<article class="card heaven-qi-layers">
<header class="card-header"><h2>三层气机</h2><span class="tag">确定性历法</span></header>
<div class="heaven-layer-list">
<div v-for="(layer, index) in field.layers" :key="layer.label" class="heaven-layer-row"><span>{{ layerMarks[Number(index)] }}</span><div><strong>{{ layer.label }} · {{ layer.dominant }}</strong><p>{{ layer.summary }}</p></div></div>
</div>
</article>
<article class="heaven-personal">
<span class="muted">个人合参</span>
<template v-if="field.personal"><h3>{{ field.personal.day_master_element }}日主 · 当日合参</h3><p>{{ field.personal.tone }}</p><small>{{ field.personal.notice }}</small></template>
<template v-else><h3>尚未设置个人资料</h3><p>可在账户设置的个人资料中补充出生信息</p></template>
</article>
</section>
<section class="card heaven-industries">
<button class="heaven-section-toggle" type="button" @click="industriesOpen = !industriesOpen"><span>五行对应行业</span><span>{{ industriesOpen ? '收起' : '展开' }}</span></button>
<div v-if="industriesOpen" class="heaven-industry-grid">
<div v-for="group in field.sector_catalog" :key="group.element"><strong>{{ group.element }}</strong><p>{{ group.industries.join(' · ') }}</p></div>
</div>
</section>
<div class="heaven-fortune-actions"><p>{{ field.notice }}</p><button class="btn btn-primary" type="button" :disabled="disabled || loading" @click="interpret">{{ daily?.status === 'complete' ? '已解运 · 查看结果' : loading ? '准备中' : '解运' }}</button></div>
</section>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from "vue";
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
import { useMarketStore } from "../../shared/stores/market";
import { useUiStore } from "../../shared/stores/ui";
import HexagramGraphic from "./HexagramGraphic.vue";
import { breathState } from "./heartTiming";
const props = defineProps<{ disabled: boolean }>();
const emit = defineEmits<{ interpret: [reading: HeavenReading] }>();
const market = useMarketStore();
const ui = useUiStore();
const stage = ref<"still" | "breath" | "cast" | "thought" | "result">("still");
const breathWord = ref("静");
const breathPhase = ref("prepare");
const incense = ref(0);
const values = ref<number[]>([]);
const coinFaces = ref<string[]>(["front", "back", "front"]);
const casting = ref(false);
const firstThought = ref(false);
const result = ref<any>(null);
const readingId = ref(0);
const muted = ref(false);
let breathTimer: ReturnType<typeof setInterval> | undefined;
let breathStarted = 0;
const positions = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
const breathClass = computed(() => `is-${breathPhase.value}`);
function startBreath(): void {
if (props.disabled) return;
stage.value = "breath";
breathStarted = performance.now();
updateBreath();
breathTimer = setInterval(updateBreath, 100);
}
function updateBreath(): void {
const elapsed = performance.now() - breathStarted;
const state = breathState(elapsed);
incense.value = state.progress;
breathWord.value = state.word;
breathPhase.value = state.phase;
if (state.phase === "complete") {
clearBreath();
}
}
function clearBreath(): void {
if (breathTimer) clearInterval(breathTimer);
breathTimer = undefined;
}
async function cast(): Promise<void> {
if (casting.value || values.value.length >= 6 || props.disabled) return;
casting.value = true;
try {
const response = await heavenApi.heartLine(market.selectedDate, values.value);
coinFaces.value = response.faces;
values.value = response.values;
if (values.value.length === 6) stage.value = "thought";
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "投掷未完成");
} finally {
window.setTimeout(() => { casting.value = false; }, 450);
}
}
async function confirmThought(): Promise<void> {
if (!firstThought.value || values.value.length !== 6) return;
try {
const response = await heavenApi.completeHeart(market.selectedDate, values.value);
result.value = response.result;
readingId.value = response.reading_id;
stage.value = "result";
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "成卦失败");
}
}
function reading(): HeavenReading {
return {
id: readingId.value,
mode: "heart",
date: result.value.date,
subject_key: "",
result: result.value,
interpretation: "",
status: "pending",
created_at: "",
};
}
function reset(): void {
clearBreath();
stage.value = "still";
breathWord.value = "静";
breathPhase.value = "prepare";
incense.value = 0;
values.value = [];
result.value = null;
readingId.value = 0;
firstThought.value = false;
}
onBeforeUnmount(clearBreath);
</script>
<template>
<section class="heaven-panel heart-panel">
<div class="heaven-heart-toolbar"><button class="btn btn-ghost btn-small" type="button" @click="muted = !muted">{{ muted ? '开启声音' : '静音' }}</button><button v-if="stage !== 'still'" class="btn btn-ghost btn-small" type="button" @click="reset">重新观心</button></div>
<article v-if="stage === 'still'" class="card heart-still">
<span class="heaven-heart-mark"></span><h2>把所问之事留在心里</h2><p>不必输入不必说明先让念头安静下来再看第一念如何浮现</p><button class="btn btn-primary" type="button" :disabled="disabled" @click="startBreath">开始静心</button>
</article>
<article v-else-if="stage === 'breath'" class="card heart-breath">
<div class="heart-ripple" :class="breathClass"><i /><i /><i /><strong>{{ breathWord }}</strong></div>
<div class="heart-incense"><span>一炷香</span><div><i :style="{ width: `${incense * 100}%` }" /></div></div>
<button v-if="breathPhase === 'complete'" class="btn btn-primary" type="button" @click="stage = 'cast'">静心完成开始起卦</button>
</article>
<article v-else-if="stage === 'cast' || stage === 'thought'" class="heart-casting-layout">
<section class="card heart-coins-card">
<p class="muted">依次投掷六次每次只得一爻</p>
<div class="heart-coins" :class="{ 'is-casting': casting }"><span v-for="(face, index) in coinFaces" :key="index" class="heart-coin" :class="face"><i>{{ face === 'front' ? '乾' : '元' }}</i><small>{{ face === 'front' ? '通宝' : '坤仪' }}</small></span></div>
<button v-if="stage === 'cast'" class="btn btn-primary" type="button" :disabled="casting" @click="cast">{{ casting ? '铜钱落定' : `投掷${positions[values.length]}` }}</button>
<div v-else class="heart-thought"><label><input v-model="firstThought" type="checkbox" /> 我已记住此刻浮现的第一念</label><button class="btn btn-primary" type="button" :disabled="!firstThought" @click="confirmThought">确认第一念完成起卦</button></div>
</section>
<section class="card heart-lines-card">
<div v-for="(position, index) in positions" :key="position" class="heart-cast-line" :class="{ 'is-revealed': values[index] }"><span>{{ position }}</span><template v-if="values[index]"><span class="heaven-line-mini" :class="{ yin: values[index] % 2 === 0 }"><i /><i /></span><strong>{{ values[index] % 2 ? '阳爻' : '阴爻' }} · {{ values[index] }}</strong></template><em v-else>未得</em></div>
</section>
</article>
<article v-else-if="result" class="card heart-result">
<HexagramGraphic :hexagram="result.hexagram" />
<div><span class="muted">卦辞</span><h2>{{ result.hexagram.name }}</h2><p>{{ result.hexagram.text }}</p><small>{{ result.notice }}</small></div>
<button class="btn btn-primary" type="button" @click="emit('interpret', reading())">解卦</button>
</article>
</section>
</template>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { heavenApi, type HeavenReading, type HeavenSetup, type ReadingMode } from "../../shared/api/heaven";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
import FortunePanel from "./FortunePanel.vue";
import HeartPanel from "./HeartPanel.vue";
import InterpretDialog from "./InterpretDialog.vue";
import TrendPanel from "./TrendPanel.vue";
const market = useMarketStore();
const session = useSessionStore();
const ui = useUiStore();
const mode = ref<ReadingMode>("trend");
const setup = ref<HeavenSetup | null>(null);
const loading = ref(false);
const error = ref("");
const activeReading = ref<HeavenReading | null>(null);
const locked = computed(() => !session.account?.smart_access);
const labels: Record<ReadingMode, { title: string; subtitle: string }> = {
trend: { title: "观势", subtitle: "以客观行情量化三才六爻" },
fortune: { title: "观气", subtitle: "以五运六气观照当日气机" },
heart: { title: "观心", subtitle: "静心、起卦,察见第一念" },
};
async function load(): Promise<void> {
setup.value = null;
error.value = "";
if (locked.value) return;
loading.value = true;
try {
setup.value = await heavenApi.setup(market.selectedDate);
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "问天数据读取失败";
} finally {
loading.value = false;
}
}
function openInterpret(reading: HeavenReading): void {
activeReading.value = reading;
if (!setup.value?.history.some((item) => item.id === reading.id)) {
setup.value?.history.unshift(reading);
}
}
function saveFortune(reading: HeavenReading): void {
if (!setup.value) return;
setup.value.daily_fortune = reading;
if (!setup.value.history.some((item) => item.id === reading.id)) setup.value.history.unshift(reading);
}
function completed(reading: HeavenReading): void {
if (!setup.value) return;
const index = setup.value.history.findIndex((item) => item.id === reading.id);
if (index >= 0) setup.value.history[index] = reading;
if (reading.mode === "fortune") setup.value.daily_fortune = reading;
}
watch([() => market.selectedDate, locked], load, { immediate: true });
</script>
<template>
<main class="page-frame heaven-page">
<header class="page-header heaven-page-header">
<div><h1>问天</h1><p class="page-subtitle">观天之道 · 执天之行 · 数据日期 {{ setup?.date || market.selectedDate }}</p></div>
<button class="btn btn-small" type="button" :disabled="locked || !setup?.history.length" @click="activeReading = setup?.history[0] || null">历史记录</button>
</header>
<div v-if="locked" class="notice notice-warning membership-lock"><span><strong>问天仅对会员开放</strong>,开通会员后可使用观势、观气与观心。</span><button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button></div>
<nav class="heaven-mode-tabs" aria-label="问天模式">
<button v-for="(item, key) in labels" :key="key" type="button" :class="{ active: mode === key }" @click="mode = key"><strong>{{ item.title }}</strong><span>{{ item.subtitle }}</span></button>
</nav>
<div v-if="loading" class="card workspace-state">正在推演当日基础气机</div>
<div v-else-if="error" class="notice notice-warning">{{ error }}</div>
<div v-else :class="{ 'locked-content': locked }" :aria-disabled="locked">
<TrendPanel v-if="mode === 'trend'" :disabled="locked" @interpret="openInterpret" />
<FortunePanel v-else-if="mode === 'fortune'" :field="setup?.fortune || null" :daily="setup?.daily_fortune || null" :disabled="locked" @interpret="openInterpret" @saved="saveFortune" />
<HeartPanel v-else :disabled="locked" @interpret="openInterpret" />
</div>
<InterpretDialog v-if="activeReading" :reading="activeReading" :history="setup?.history || []" @close="activeReading = null" @completed="completed" />
</main>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
defineProps<{ hexagram: Record<string, any>; compact?: boolean }>();
function transformedValue(value: number): number {
return value === 6 ? 7 : value === 9 ? 8 : value;
}
</script>
<template>
<div class="heaven-hex-pair" :class="{ 'heaven-hex-compact': compact }">
<div class="heaven-hex-symbol">
<strong>{{ hexagram.name }}</strong>
<div class="heaven-hex-lines">
<span v-for="line in [...hexagram.lines].reverse()" :key="line.position" class="heaven-yao" :class="{ yin: line.value % 2 === 0, moving: line.moving }"><i /><i /></span>
</div>
<small>{{ hexagram.outer_trigram }} · {{ hexagram.inner_trigram }}</small>
</div>
<span class="heaven-change-arrow" aria-label="变化为"></span>
<div class="heaven-hex-symbol">
<strong>{{ hexagram.transformed.name }}</strong>
<div class="heaven-hex-lines">
<span v-for="line in [...hexagram.lines].reverse()" :key="line.position" class="heaven-yao" :class="{ yin: transformedValue(line.value) % 2 === 0 }"><i /><i /></span>
</div>
<small>{{ hexagram.transformed.outer_trigram }} · {{ hexagram.transformed.inner_trigram }}</small>
</div>
</div>
</template>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { heavenApi, type HeavenReading, type HeavenStreamEvent } from "../../shared/api/heaven";
import BaseDialog from "../../shared/components/BaseDialog.vue";
const props = defineProps<{ reading: HeavenReading; history: HeavenReading[] }>();
const emit = defineEmits<{ close: []; completed: [reading: HeavenReading] }>();
const tab = ref<"current" | "history">("current");
const active = ref(props.reading);
const answer = ref(props.reading.interpretation || "");
const loading = ref(!props.reading.interpretation);
const error = ref("");
const canvas = ref<HTMLCanvasElement | null>(null);
let animation: any = null;
let controller: AbortController | null = null;
const title = computed(() => ({ trend: "解势", fortune: "解运", heart: "解卦" })[active.value.mode]);
const modeHistory = computed(() => props.history.filter((item) => item.mode === active.value.mode));
async function run(): Promise<void> {
if (active.value.interpretation) {
answer.value = active.value.interpretation;
loading.value = false;
return;
}
answer.value = "";
error.value = "";
loading.value = true;
await nextTick();
startAnimation();
controller = new AbortController();
try {
await heavenApi.interpret(
active.value.id,
(event: HeavenStreamEvent) => {
if (event.type === "delta") answer.value += event.content ?? "";
if (event.type === "error") error.value = event.message ?? "智能解读未完成";
},
controller.signal,
);
await animation?.complete?.();
active.value = { ...active.value, interpretation: answer.value, status: error.value ? "error" : "complete" };
emit("completed", active.value);
} catch (reason) {
if (!(reason instanceof DOMException && reason.name === "AbortError")) {
error.value = reason instanceof Error ? reason.message : "智能解读服务暂不可用";
}
} finally {
loading.value = false;
animation?.stop?.();
controller = null;
}
}
function startAnimation(): void {
if (!canvas.value || !window.HeavenLoadingCanvas) return;
animation = new window.HeavenLoadingCanvas(canvas.value);
const result = active.value.result;
animation.start(active.value.mode === "fortune" ? "fortune" : "hexagram", {
yearPillar: result.pillars?.year ?? "",
movement: result.movement?.element ? `${result.movement.element}${result.movement.tendency}` : "",
sixQi: {
sitian: result.six_qi?.sitian ?? "",
zaiquan: result.six_qi?.zaiquan ?? "",
step: result.six_qi?.step ?? 1,
},
});
}
function selectHistory(reading: HeavenReading): void {
controller?.abort();
animation?.stop?.();
active.value = reading;
tab.value = "current";
void run();
}
function close(): void {
controller?.abort();
animation?.stop?.();
emit("close");
}
onMounted(run);
onBeforeUnmount(() => {
controller?.abort();
animation?.stop?.();
});
watch(() => props.reading, (value) => { active.value = value; void run(); });
</script>
<template>
<BaseDialog :title="title" wide :close-on-backdrop="!loading" @close="close">
<div class="heaven-dialog-tabs"><button type="button" :class="{ active: tab === 'current' }" @click="tab = 'current'">本次解读</button><button type="button" :class="{ active: tab === 'history' }" @click="tab = 'history'">历史记录</button></div>
<div v-if="tab === 'history'" class="heaven-history-list">
<button v-for="item in modeHistory" :key="item.id" type="button" @click="selectHistory(item)"><strong>{{ item.date }} · {{ item.result.stock?.name || item.result.hexagram?.name || item.result.phrase || title }}</strong><span>{{ item.status === 'complete' ? '已完成' : '未完成' }}</span></button>
<p v-if="!modeHistory.length" class="muted">暂无历史记录</p>
</div>
<div v-else class="heaven-dialog-current">
<div v-if="loading" class="heaven-loading-stage"><canvas ref="canvas" /><p>{{ active.mode === 'fortune' ? '气机渐次归位' : '阴阳渐次成象' }}</p></div>
<div v-else class="heaven-interpretation"><p v-if="error" class="notice notice-warning">{{ error }}</p><div class="heaven-answer">{{ answer || '本次解读未生成完整内容' }}</div></div>
</div>
</BaseDialog>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
import { useMarketStore } from "../../shared/stores/market";
import { useUiStore } from "../../shared/stores/ui";
import HexagramGraphic from "./HexagramGraphic.vue";
const props = defineProps<{ disabled: boolean }>();
const emit = defineEmits<{ interpret: [reading: HeavenReading] }>();
const market = useMarketStore();
const ui = useUiStore();
const query = ref("");
const loading = ref(false);
const response = ref<any>(null);
const showChecks = ref(false);
const showManual = ref(false);
const manual = ref<Record<string, string | number>>({});
const result = computed(() => response.value?.result);
const stock = computed(() => result.value?.stock ?? response.value?.stock);
const sector = computed(() => result.value?.sector ?? response.value?.sector);
async function load(withManual = false): Promise<void> {
if (!query.value.trim() || props.disabled) return;
loading.value = true;
try {
response.value = await heavenApi.trend(
query.value,
market.selectedDate,
withManual ? { sector: manual.value } : {},
);
if (!response.value.ready) showChecks.value = true;
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "观势数据读取失败");
} finally {
loading.value = false;
}
}
function reading(): HeavenReading {
return {
id: response.value.reading_id,
mode: "trend",
date: result.value.trade_date,
subject_key: result.value.stock.identifier,
result: result.value,
interpretation: "",
status: "pending",
created_at: "",
};
}
</script>
<template>
<section class="heaven-panel trend-panel">
<div class="card heaven-trend-input">
<div class="field heaven-stock-field">
<label for="heaven-stock">股票代码或名称</label>
<div class="heaven-inline-control">
<input id="heaven-stock" v-model="query" class="input" placeholder="输入六位代码或股票名称" :disabled="disabled || loading" @keydown.enter="load(false)" />
<button class="btn btn-primary" type="button" :disabled="disabled || loading || !query.trim()" @click="load(false)">{{ loading ? '载入中' : '载入' }}</button>
</div>
</div>
<p v-if="stock" class="heaven-current-target">当前标的<strong>{{ stock.name }}</strong>&nbsp;&nbsp;申万二级·<strong>{{ sector?.name || '待核验' }}</strong></p>
</div>
<div v-if="loading" class="card heaven-awaiting"><span class="heaven-bagua" aria-hidden="true"></span><strong>三才六爻正在取象</strong><p>核验个股申万二级行业市场与三大指数</p></div>
<div v-else-if="!response" class="card heaven-awaiting"><span class="heaven-bagua" aria-hidden="true"></span><strong>请输入股票代码或股票名称</strong></div>
<template v-else-if="response.ready">
<div class="heaven-trend-grid">
<article class="card heaven-lines-card">
<header class="card-header"><h2>三才六爻</h2><span class="tag">{{ result.trade_date }}</span></header>
<div class="heaven-line-list">
<div v-for="line in [...result.hexagram.lines].reverse()" :key="line.position" class="heaven-line-row">
<span>{{ line.position_name }}</span><span>{{ line.talent }}·{{ line.layer }}</span>
<span class="heaven-line-mini" :class="{ yin: line.value % 2 === 0 }"><i /><i /></span>
<strong>{{ line.role }}</strong><small class="numeric">{{ (line.score * 100).toFixed(0) }}</small>
</div>
</div>
</article>
<article class="card heaven-outcome-card">
<div class="heaven-momentum"><span>势值</span><strong class="numeric">{{ result.momentum_score }}</strong><small>{{ result.momentum_label }}</small></div>
<HexagramGraphic :hexagram="result.hexagram" />
<p class="heaven-hex-text">{{ result.hexagram.text }}</p>
<button class="btn btn-primary" type="button" @click="emit('interpret', reading())">解势</button>
</article>
</div>
</template>
<div v-else class="card heaven-not-ready"><strong>暂不成卦</strong><p>{{ response.message }}</p></div>
<section v-if="response" class="card heaven-validation">
<button class="heaven-section-toggle" type="button" @click="showChecks = !showChecks"><span>六爻数据校验</span><span>{{ showChecks ? '收起' : '展开' }}</span></button>
<div v-if="showChecks" class="heaven-check-list">
<div v-for="check in response.checks || result?.checks" :key="check.position" class="heaven-check" :class="check.passed ? 'is-pass' : 'is-fail'">
<strong>{{ check.position_name }} · {{ check.role }}</strong><span>{{ check.message }}</span><small>{{ check.source === 'manual' ? '用户补录' : check.passed ? '自动通过' : '需要补充' }}</small>
</div>
<button v-if="!response.ready" class="btn btn-small" type="button" @click="showManual = !showManual">{{ showManual ? '收起手动补录' : '手动补录客观数据' }}</button>
<form v-if="showManual" class="heaven-manual-grid" @submit.prevent="load(true)">
<label class="field"><span class="field-label">行业涨跌 (%)</span><input v-model="manual.change" class="input" type="number" step="0.01" /></label>
<label class="field"><span class="field-label">上涨 / 下跌成分</span><span class="heaven-dual-input"><input v-model="manual.up_count" class="input" type="number" /><input v-model="manual.down_count" class="input" type="number" /></span></label>
<label class="field"><span class="field-label">成员总数 / 有效数</span><span class="heaven-dual-input"><input v-model="manual.member_count" class="input" type="number" /><input v-model="manual.quoted_count" class="input" type="number" /></span></label>
<label class="field"><span class="field-label">覆盖率 (0-1)</span><input v-model="manual.coverage" class="input" type="number" step="0.01" /></label>
<label class="field"><span class="field-label">成分等权涨跌 (%)</span><input v-model="manual.member_equal_change" class="input" type="number" step="0.01" /></label>
<label class="field"><span class="field-label">领涨股 / 涨跌</span><span class="heaven-dual-input"><input v-model="manual.leader" class="input" /><input v-model="manual.leading_pct" class="input" type="number" step="0.01" /></span></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">重新核验并成卦</button><button class="btn" type="button" @click="manual = {}; load(false)">恢复自动数据</button></div>
</form>
</div>
</section>
</section>
</template>
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { breathState } from "./heartTiming";
describe("heart breathing state", () => {
it("uses one second preparation then five 3/2/4 second breaths", () => {
expect(breathState(0).word).toBe("静");
expect(breathState(999).phase).toBe("prepare");
expect(breathState(1_000).word).toBe("吸");
expect(breathState(3_999).phase).toBe("inhale");
expect(breathState(4_000).word).toBe("顿");
expect(breathState(5_999).phase).toBe("pause");
expect(breathState(6_000).word).toBe("呼");
expect(breathState(9_999).phase).toBe("exhale");
expect(breathState(10_000).word).toBe("吸");
expect(breathState(45_999).word).toBe("呼");
expect(breathState(46_000)).toEqual({ word: "定", phase: "complete", progress: 1 });
});
});
@@ -0,0 +1,16 @@
export type BreathState = {
word: "静" | "吸" | "顿" | "呼" | "定";
phase: "prepare" | "inhale" | "pause" | "exhale" | "complete";
progress: number;
};
export function breathState(elapsedMs: number): BreathState {
const elapsed = Math.max(0, elapsedMs);
const progress = Math.min(elapsed / 46_000, 1);
if (elapsed < 1_000) return { word: "静", phase: "prepare", progress };
if (elapsed >= 46_000) return { word: "定", phase: "complete", progress: 1 };
const within = (elapsed - 1_000) % 9_000;
if (within < 3_000) return { word: "吸", phase: "inhale", progress };
if (within < 5_000) return { word: "顿", phase: "pause", progress };
return { word: "呼", phase: "exhale", progress };
}
+63
View File
@@ -0,0 +1,63 @@
import { api } from "./client";
export type ReadingMode = "trend" | "fortune" | "heart";
export type HeavenReading = {
id: number;
mode: ReadingMode;
date: string;
subject_key: string;
result: Record<string, any>;
interpretation: string;
status: "pending" | "complete" | "stopped" | "error";
created_at: string;
};
export type HeavenSetup = {
date: string;
fortune: Record<string, any>;
daily_fortune: HeavenReading | null;
history: HeavenReading[];
};
export type HeavenStreamEvent = {
type: "delta" | "done" | "error";
content?: string;
message?: string;
cached?: boolean;
};
export const heavenApi = {
setup(date: string): Promise<HeavenSetup> {
return api.get(`/heaven/setup?date=${encodeURIComponent(date)}`);
},
trend(query: string, tradeDate: string, manual: Record<string, any> = {}): Promise<any> {
return api.post("/heaven/trend/load", { query, trade_date: tradeDate, manual });
},
fortune(tradeDate: string): Promise<{ reused: boolean; reading: HeavenReading }> {
return api.post("/heaven/fortune", { trade_date: tradeDate });
},
heartLine(tradeDate: string, values: number[]): Promise<any> {
return api.post("/heaven/heart/line", { trade_date: tradeDate, values });
},
completeHeart(tradeDate: string, values: number[]): Promise<any> {
return api.post("/heaven/heart/complete", {
trade_date: tradeDate,
values,
first_thought_confirmed: true,
});
},
readings(mode?: ReadingMode, date?: string): Promise<HeavenReading[]> {
const query = new URLSearchParams();
if (mode) query.set("mode", mode);
if (date) query.set("date", date);
return api.get(`/heaven/readings?${query}`);
},
remove(id: number): Promise<{ deleted: number }> {
return api.delete(`/heaven/readings/${id}`);
},
interpret(
readingId: number,
onEvent: (event: HeavenStreamEvent) => void,
signal?: AbortSignal,
): Promise<void> {
return api.stream("/heaven/interpret", { reading_id: readingId }, onEvent, signal);
},
};
@@ -0,0 +1,127 @@
.heaven-fortune-grid {
display: grid;
grid-template-columns: minmax(var(--s-320), 0.8fr) minmax(0, 1.2fr);
gap: var(--layout-gap);
}
.heaven-calendar-card,
.heaven-phrase-card {
min-height: var(--s-120);
display: flex;
align-items: center;
gap: var(--s-16);
padding: var(--s-16);
}
.heaven-date-seal {
width: var(--s-80);
height: var(--s-80);
display: grid;
place-content: center;
border: var(--s-1) solid var(--color-heaven);
color: var(--color-heaven);
text-align: center;
}
.heaven-date-seal span {
font-family: var(--font-serif);
font-size: var(--font-18);
}
.heaven-calendar-card h2,
.heaven-phrase-card h2 {
margin: var(--s-6) 0;
color: var(--color-heaven);
font-size: var(--font-18);
}
.heaven-phrase-card {
display: grid;
align-content: center;
}
.heaven-qi-layout {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(var(--s-320), 0.6fr);
gap: var(--layout-gap);
}
.heaven-layer-list {
display: grid;
padding: var(--s-8) var(--s-14) var(--s-14);
}
.heaven-layer-row {
min-height: var(--s-64);
display: grid;
grid-template-columns: var(--s-44) minmax(0, 1fr);
align-items: center;
gap: var(--s-12);
border-bottom: var(--s-1) solid var(--color-divider);
}
.heaven-layer-row > span {
color: var(--color-heaven);
font-family: var(--font-serif);
}
.heaven-layer-row p,
.heaven-personal p {
margin-top: var(--s-4);
color: var(--color-text-secondary);
line-height: var(--s-20);
}
.heaven-personal {
align-self: center;
padding: var(--s-16);
}
.heaven-personal h3 {
margin-top: var(--s-8);
color: var(--color-heaven);
font-size: var(--font-17);
}
.heaven-personal small {
display: block;
margin-top: var(--s-12);
color: var(--color-text-faint);
}
.heaven-industry-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--s-8);
padding: 0 var(--s-14) var(--s-14);
}
.heaven-industry-grid > div {
padding: var(--s-10);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
}
.heaven-industry-grid strong {
color: var(--color-heaven);
font-family: var(--font-serif);
font-size: var(--font-17);
}
.heaven-industry-grid p {
margin-top: var(--s-6);
color: var(--color-text-secondary);
font-size: var(--font-12);
line-height: var(--s-20);
}
.heaven-fortune-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-16);
color: var(--color-text-faint);
font-size: var(--font-11-5);
}
@@ -0,0 +1,182 @@
.heaven-heart-toolbar {
display: flex;
justify-content: flex-end;
}
.heart-still,
.heart-breath {
min-height: var(--s-400);
display: grid;
align-content: center;
justify-items: center;
gap: var(--s-14);
padding: var(--s-24);
text-align: center;
}
.heaven-heart-mark {
width: var(--s-80);
height: var(--s-80);
display: grid;
place-items: center;
border: var(--s-1) solid var(--color-heaven);
border-radius: var(--radius-round);
color: var(--color-heaven);
font-family: var(--font-serif);
font-size: var(--font-32);
}
.heart-still p {
max-width: var(--s-480);
color: var(--color-text-secondary);
line-height: var(--s-22);
}
.heart-ripple {
position: relative;
width: var(--s-180);
height: var(--s-180);
display: grid;
place-items: center;
}
.heart-ripple i {
position: absolute;
inset: var(--s-24);
border: var(--s-1) solid var(--color-heaven);
border-radius: var(--radius-round);
opacity: var(--opacity-muted);
transition: transform 3s linear, opacity 2s linear;
}
.heart-ripple i:nth-child(2) { inset: var(--s-16); opacity: 0.42; }
.heart-ripple i:nth-child(3) { inset: var(--s-8); opacity: 0.2; }
.heart-ripple strong { color: var(--color-heaven); font-family: var(--font-serif); font-size: var(--font-32); }
.heart-ripple.is-inhale i { transform: scale(1.18); }
.heart-ripple.is-pause i { transform: scale(1.18); opacity: var(--opacity-muted); }
.heart-ripple.is-exhale i { transform: scale(0.72); opacity: 0.18; }
.heart-incense {
width: min(100%, var(--s-360));
display: grid;
gap: var(--s-8);
color: var(--color-text-secondary);
}
.heart-incense > div {
height: var(--s-2);
overflow: hidden;
background: var(--color-divider);
}
.heart-incense i {
height: 100%;
display: block;
background: var(--color-heaven);
transition: width var(--duration-fast) linear;
}
.heart-casting-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(var(--s-320), 0.8fr);
gap: var(--layout-gap);
}
.heart-coins-card,
.heart-lines-card {
min-height: var(--s-360);
display: grid;
align-content: center;
gap: var(--s-20);
padding: var(--s-24);
}
.heart-coins-card {
justify-items: center;
}
.heart-coins {
display: flex;
gap: var(--s-20);
perspective: var(--s-400);
}
.heart-coin {
width: var(--s-80);
height: var(--s-80);
display: grid;
place-content: center;
border: var(--s-4) double var(--color-heaven);
border-radius: var(--radius-round);
color: var(--color-heaven);
background: var(--color-heaven-soft);
box-shadow: inset 0 0 0 var(--s-6) var(--color-surface), var(--shadow-card);
text-align: center;
transition: transform var(--duration-normal) var(--ease-standard);
}
.heart-coin.back {
transform: rotateY(180deg);
}
.heart-coin i,
.heart-coin small {
font-style: normal;
transform: inherit;
}
.heart-coin i {
font-family: var(--font-serif);
font-size: var(--font-17);
}
.heart-coins.is-casting .heart-coin {
animation: heaven-coin 0.45s ease-in-out;
}
.heart-thought {
display: grid;
justify-items: center;
gap: var(--s-12);
}
.heart-cast-line {
min-height: var(--s-44);
display: grid;
grid-template-columns: var(--s-44) var(--s-96) minmax(0, 1fr);
align-items: center;
gap: var(--s-12);
border-bottom: var(--s-1) solid var(--color-divider);
color: var(--color-text-secondary);
}
.heart-cast-line em {
grid-column: 2 / -1;
color: var(--color-text-faint);
font-style: normal;
}
.heart-cast-line.is-revealed strong {
color: var(--color-heaven);
}
.heart-result {
min-height: var(--s-360);
display: grid;
grid-template-columns: minmax(var(--s-240), 0.8fr) minmax(0, 1fr) auto;
align-items: center;
gap: var(--s-24);
padding: var(--s-24);
}
.heart-result h2 {
margin: var(--s-8) 0;
color: var(--color-heaven);
}
.heart-result p {
margin-bottom: var(--s-12);
line-height: var(--s-22);
}
+408
View File
@@ -0,0 +1,408 @@
.heaven-page {
position: relative;
min-height: 100%;
font-family: var(--font-sans);
}
:root[data-theme="dark"] .heaven-page::before {
position: fixed;
inset: var(--shell-topbar-height) 0 var(--shell-status-height) var(--shell-sidebar-width);
z-index: 0;
pointer-events: none;
content: "";
opacity: 0.12;
background-image: radial-gradient(circle, var(--color-heaven-star) var(--s-1), transparent var(--s-1));
background-size: var(--s-32) var(--s-32);
}
.heaven-page > * {
position: relative;
z-index: 1;
}
.heaven-page-header h1,
.heaven-mode-tabs strong,
.heaven-panel h2,
.heaven-panel h3 {
font-family: var(--font-serif);
}
.heaven-mode-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--s-8);
margin-bottom: var(--layout-gap);
padding: var(--s-4);
border: var(--s-1) solid var(--color-border);
border-radius: var(--card-radius);
background: var(--color-surface);
}
.heaven-mode-tabs button {
display: grid;
gap: var(--s-2);
padding: var(--s-8) var(--s-12);
border-radius: var(--control-radius);
color: var(--color-text-secondary);
background: var(--c-transparent);
text-align: left;
}
.heaven-mode-tabs button:hover,
.heaven-mode-tabs button.active {
color: var(--color-heaven);
background: var(--color-heaven-soft);
}
.heaven-mode-tabs strong {
font-size: var(--font-17);
}
.heaven-mode-tabs span {
font-size: var(--font-11-5);
}
.heaven-panel {
display: grid;
gap: var(--layout-gap);
padding-bottom: var(--s-20);
}
.heaven-trend-input {
display: flex;
align-items: end;
gap: var(--s-24);
padding: var(--s-12) var(--s-16);
}
.heaven-stock-field {
width: min(100%, var(--s-480));
}
.heaven-inline-control,
.heaven-dual-input {
display: flex;
gap: var(--s-8);
}
.heaven-inline-control .input,
.heaven-dual-input .input {
min-width: 0;
}
.heaven-current-target {
align-self: center;
color: var(--color-text-secondary);
font-size: var(--font-13);
}
.heaven-current-target strong {
color: var(--color-heaven);
font-size: inherit;
}
.heaven-awaiting {
min-height: var(--s-240);
display: grid;
align-content: center;
justify-items: center;
gap: var(--s-8);
color: var(--color-text-secondary);
text-align: center;
}
.heaven-bagua {
width: var(--s-80);
height: var(--s-80);
display: grid;
place-items: center;
border: var(--s-1) solid var(--color-heaven);
border-radius: var(--radius-round);
color: var(--color-heaven);
font-family: var(--font-serif);
font-size: var(--font-32);
animation: heaven-turn 12s linear infinite;
}
.heaven-trend-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(var(--s-320), 0.75fr);
gap: var(--layout-gap);
}
.heaven-lines-card,
.heaven-outcome-card {
min-height: var(--s-320);
}
.heaven-lines-card .card-header,
.heaven-qi-layers .card-header {
justify-content: space-between;
}
.heaven-line-list {
display: grid;
padding: var(--s-8) var(--s-14) var(--s-14);
}
.heaven-line-row {
min-height: var(--s-40);
display: grid;
grid-template-columns: var(--s-44) var(--s-56) var(--s-80) minmax(0, 1fr) var(--s-44);
align-items: center;
gap: var(--s-8);
border-bottom: var(--s-1) solid var(--color-divider);
color: var(--color-text-secondary);
}
.heaven-line-row strong {
color: var(--color-text);
}
.heaven-line-row small {
text-align: right;
}
.heaven-line-mini,
.heaven-yao {
display: flex;
justify-content: center;
gap: 0;
}
.heaven-line-mini i,
.heaven-yao i {
width: 50%;
height: var(--s-6);
background: var(--color-heaven);
}
.heaven-line-mini:not(.yin) i + i,
.heaven-yao:not(.yin) i + i {
margin-left: calc(var(--s-1) * -1);
}
.heaven-line-mini.yin,
.heaven-yao.yin {
gap: var(--s-8);
}
.heaven-outcome-card {
display: grid;
grid-template-rows: auto 1fr auto auto;
align-items: center;
justify-items: center;
gap: var(--s-8);
padding: var(--s-16);
}
.heaven-momentum {
display: flex;
align-items: baseline;
gap: var(--s-8);
justify-self: stretch;
color: var(--color-text-secondary);
}
.heaven-momentum strong {
color: var(--color-heaven);
font-size: var(--font-32);
}
.heaven-momentum small {
margin-left: auto;
}
.heaven-hex-pair {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-24);
}
.heaven-hex-symbol {
width: var(--s-96);
display: grid;
justify-items: center;
gap: var(--s-6);
}
.heaven-hex-symbol > strong {
color: var(--color-heaven);
font-family: var(--font-serif);
font-size: var(--font-18);
}
.heaven-hex-lines {
width: var(--s-64);
display: grid;
gap: var(--s-6);
}
.heaven-change-arrow {
color: var(--color-heaven);
font-size: var(--font-24);
}
.heaven-hex-text {
color: var(--color-text-secondary);
line-height: var(--s-20);
text-align: center;
}
.heaven-not-ready {
padding: var(--s-24);
color: var(--color-text-secondary);
text-align: center;
}
.heaven-not-ready strong {
color: var(--color-heaven);
font-family: var(--font-serif);
font-size: var(--font-18);
}
.heaven-section-toggle {
width: 100%;
min-height: var(--s-40);
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-8) var(--s-14);
color: var(--color-text);
background: var(--c-transparent);
}
.heaven-check-list {
display: grid;
gap: var(--s-8);
padding: 0 var(--s-14) var(--s-14);
}
.heaven-check {
display: grid;
grid-template-columns: var(--s-160) minmax(0, 1fr) var(--s-80);
gap: var(--s-12);
padding: var(--s-8) var(--s-10);
border-left: var(--s-2) solid var(--color-up);
color: var(--color-text-secondary);
background: var(--color-up-soft);
}
.heaven-check.is-pass {
border-color: var(--color-down);
background: var(--color-down-soft);
}
.heaven-check small {
text-align: right;
}
.heaven-manual-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--s-12);
padding-top: var(--s-8);
border-top: var(--s-1) solid var(--color-divider);
}
.heaven-manual-grid .form-actions {
grid-column: 1 / -1;
}
.heaven-dialog-tabs {
display: flex;
gap: var(--s-4);
margin-bottom: var(--s-12);
border-bottom: var(--s-1) solid var(--color-divider);
}
.heaven-dialog-tabs button {
padding: var(--s-8) var(--s-12);
border-bottom: var(--s-2) solid var(--c-transparent);
color: var(--color-text-secondary);
background: var(--c-transparent);
}
.heaven-dialog-tabs button.active {
color: var(--color-heaven);
border-color: var(--color-heaven);
}
.heaven-loading-stage {
min-height: var(--s-480);
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: var(--s-8);
text-align: center;
}
.heaven-loading-stage canvas {
width: 100%;
height: 100%;
min-height: var(--s-400);
border-radius: var(--control-radius);
}
.heaven-loading-stage p {
color: var(--color-text-secondary);
font-family: var(--font-serif);
}
.heaven-answer {
min-height: var(--s-240);
color: var(--color-text);
line-height: var(--s-24);
white-space: pre-wrap;
}
.heaven-history-list {
display: grid;
gap: var(--s-8);
}
.heaven-history-list button {
display: flex;
justify-content: space-between;
gap: var(--s-12);
padding: var(--s-10) var(--s-12);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--color-surface);
text-align: left;
}
@keyframes heaven-turn { to { transform: rotate(360deg); } }
@keyframes heaven-coin { 50% { transform: rotateY(180deg) translateY(calc(var(--s-8) * -1)); } }
@media (max-width: 1023px) {
:root[data-theme="dark"] .heaven-page::before { left: 0; bottom: var(--shell-mobile-nav-height); }
.heaven-mode-tabs button { text-align: center; }
.heaven-mode-tabs span { display: none; }
.heaven-trend-input { display: grid; gap: var(--s-8); }
.heaven-trend-grid,
.heaven-fortune-grid,
.heaven-qi-layout,
.heart-casting-layout,
.heart-result { grid-template-columns: minmax(0, 1fr); }
.heaven-manual-grid { grid-template-columns: minmax(0, 1fr); }
.heaven-industry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.heaven-check { grid-template-columns: minmax(0, 1fr); }
.heaven-check small { text-align: left; }
.heart-result { justify-items: center; text-align: center; }
.heaven-loading-stage { min-height: var(--s-360); }
.heaven-loading-stage canvas { min-height: var(--s-320); }
}
@media (max-width: 430px) {
.heaven-mode-tabs { gap: var(--s-2); }
.heaven-mode-tabs button { padding-inline: var(--s-4); }
.heaven-inline-control { display: grid; }
.heaven-line-row { grid-template-columns: var(--s-40) var(--s-44) minmax(var(--s-64), 1fr) var(--s-44); }
.heaven-line-row strong { display: none; }
.heaven-hex-pair { gap: var(--s-8); }
.heaven-industry-grid { grid-template-columns: minmax(0, 1fr); }
.heart-coins { gap: var(--s-8); }
.heart-coin { width: var(--s-64); height: var(--s-64); }
}
@@ -41,6 +41,10 @@
--c-night-amber: #e2ad58;
--c-night-amber-soft: #3d3220;
--c-transparent: transparent;
--c-gold-700: #8b6b2f;
--c-gold-500: #c9a55c;
--c-gold-100: #f4ead4;
--c-night-star: #d8e6f4;
/* Primitive dimensions */
--s-1: 1px;
@@ -71,6 +75,13 @@
--s-46: 46px;
--s-56: 56px;
--s-64: 64px;
--s-80: 80px;
--s-96: 96px;
--s-120: 120px;
--s-160: 160px;
--s-180: 180px;
--s-240: 240px;
--s-480: 480px;
--s-200: 200px;
--s-260: 260px;
--s-320: 320px;
@@ -94,6 +105,8 @@
--font-15: 15px;
--font-17: 17px;
--font-18: 18px;
--font-24: 24px;
--font-32: 32px;
--weight-400: 400;
--weight-500: 500;
--weight-600: 600;
@@ -146,6 +159,10 @@
--color-warning: var(--c-amber-700);
--color-warning-soft: var(--c-amber-050);
--color-overlay: rgba(18, 20, 22, 0.46);
--color-heaven: var(--c-gold-700);
--color-heaven-bright: var(--c-gold-500);
--color-heaven-soft: var(--c-gold-100);
--color-heaven-star: var(--c-night-star);
--shadow-card: var(--shadow-card-light);
/* Component tokens */
@@ -187,5 +204,7 @@
--color-warning: var(--c-night-amber);
--color-warning-soft: var(--c-night-amber-soft);
--color-overlay: rgba(0, 0, 0, 0.64);
--color-heaven: var(--c-night-amber);
--color-heaven-soft: var(--c-night-amber-soft);
--shadow-card: var(--shadow-card-dark);
}
+1
View File
@@ -1,5 +1,6 @@
cryptography==49.0.0
fastapi==0.141.0
lunar-python==1.4.8
pydantic==2.13.4
tzdata==2026.3
uvicorn==0.52.0
+174
View File
@@ -0,0 +1,174 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-11");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page, username, password) {
await page.goto("/");
await page.getByLabel("账号名").fill(username);
await page.getByLabel("密码").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
const lines = Array.from({ length: 6 }, (_, index) => ({
position: index + 1,
position_name: ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"][index],
talent: ["地", "地", "人", "人", "天", "天"][index],
layer: index % 2 ? "外" : "内",
role: ["个股内核", "个股外显", "行业内核", "行业外显", "市场内核", "指数外显"][index],
score: 0.42,
value: index % 2 ? 8 : 7,
moving: false,
}));
const hexagram = {
name: "泰",
text: "天地交而万物通。",
inner_trigram: "乾",
outer_trigram: "坤",
lines,
transformed: { name: "泰", inner_trigram: "乾", outer_trigram: "坤" },
};
const fortune = {
date: "2026-07-30",
lunar_date: "农历六月十七",
pillars: { year: "丙午", month: "乙未", day: "乙巳" },
solar_term: { current: "大暑", next: "立秋" },
phrase: "湿热交蒸·燥中夹滞",
movement: { element: "水", tendency: "太过" },
six_qi: { sitian: "少阴君火", zaiquan: "阳明燥金", step: 4, step_name: "四之气", host: "太阴湿土", guest: "少阳相火" },
layers: [
{ label: "年纲", dominant: "火", summary: "水运为纲,司天少阴君火" },
{ label: "客主加临", dominant: "土", summary: "客少阳相火加临主太阴湿土" },
{ label: "日辰触发", dominant: "木", summary: "乙巳日,只作轻量触发" },
],
personal: { day_master_element: "木", tone: "当日主气与个人生扶倾向交错", notice: "个人信息只用于本地派生计算。" },
sector_catalog: ["木", "火", "土", "金", "水"].map((element) => ({ element, industries: ["行业一", "行业二"] })),
notice: "五行气场是传统历法与市场行为的象征性观察,不代表可验证的因果关系。",
};
const trendResult = {
trade_date: "2026-07-30",
stock: { identifier: "002141.SZ", code: "002141", name: "贤丰控股" },
sector: { name: "元件" },
hexagram,
momentum_score: 42,
momentum_label: "势起未极",
checks: lines.map((line) => ({ position: line.position, position_name: line.position_name, role: line.role, passed: true, source: "automatic", message: "自动数据通过" })),
};
function reading(id, mode, result, interpretation = "") {
return { id, mode, date: "2026-07-30", subject_key: "", result, interpretation, status: interpretation ? "complete" : "pending", created_at: "2026-07-30T10:00:00+08:00" };
}
async function mockHeaven(page) {
const historical = reading(90, "trend", trendResult, "历史解势内容");
await page.route("**/api/heaven/setup?*", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({ date: "2026-07-30", fortune, daily_fortune: null, history: [historical] }),
}));
await page.route("**/api/heaven/trend/load", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({ ready: true, reading_id: 101, result: trendResult }),
}));
await page.route("**/api/heaven/fortune", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({ reused: false, reading: reading(102, "fortune", fortune) }),
}));
await page.route("**/api/heaven/heart/line", async (route) => {
const payload = route.request().postDataJSON();
const values = [...payload.values, payload.values.length % 2 ? 8 : 7];
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ position: values.length, value: values.at(-1), faces: ["front", "back", "front"], values }) });
});
await page.route("**/api/heaven/heart/complete", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({ reading_id: 103, result: { date: "2026-07-30", hexagram, notice: "仅供自我观察。" } }),
}));
await page.route("**/api/heaven/interpret", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 900));
await route.fulfill({
contentType: "application/x-ndjson",
body: `${JSON.stringify({ type: "delta", content: "第一段解读" })}\n${JSON.stringify({ type: "delta", content: ",第二段解读" })}\n${JSON.stringify({ type: "done" })}\n`,
});
});
}
test("stage 11 Heaven workflows, animations and responsive layouts", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => {
if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text());
});
await mockHeaven(page);
await authenticate(page, "stage4admin", "Stage4-pass-123!");
await page.goto("/workspace/heaven");
await page.getByRole("button", { name: "夜间" }).click();
await expect(page.getByText("请输入股票代码或股票名称", { exact: true })).toBeVisible();
await page.getByLabel("股票代码或名称").fill("贤丰控股");
await page.getByRole("button", { name: "载入", exact: true }).click();
await expect(page.getByText("当前标的:")).toContainText("贤丰控股");
await expect(page.getByText("泰", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "解势", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "解势" });
await expect(dialog).toBeVisible();
const box = await dialog.boundingBox();
expect(box.width).toBeGreaterThan(700);
expect(box.width / box.height).toBeGreaterThan(1.15);
const loadingGeometry = await page.locator(".heaven-loading-stage").evaluate((stage) => {
const canvas = stage.querySelector("canvas").getBoundingClientRect();
const caption = stage.querySelector("p").getBoundingClientRect();
return { canvasBottom: canvas.bottom, captionTop: caption.top };
});
expect(loadingGeometry.canvasBottom).toBeLessThanOrEqual(loadingGeometry.captionTop);
await page.screenshot({ path: path.join(evidence, "heaven-interpret-loading-dark.jpg"), type: "jpeg", quality: 82 });
await expect(page.getByText("第一段解读,第二段解读", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "历史记录", exact: true }).last().click();
await page.getByRole("button", { name: /2026-07-30 · 贤丰控股/ }).last().click();
await expect(page.getByText("历史解势内容", { exact: true })).toBeVisible();
await dialog.getByRole("button", { name: "关闭" }).click();
await page.locator(".heaven-mode-tabs button").filter({ hasText: "观气" }).click();
await expect(page.getByText("湿热交蒸·燥中夹滞", { exact: true })).toBeVisible();
await expect(page.getByText("壹", { exact: true })).toBeVisible();
await page.locator(".heaven-section-toggle").click();
await expect(page.getByText("行业一 · 行业二", { exact: true }).first()).toBeVisible();
await page.clock.install();
await page.locator(".heaven-mode-tabs button").filter({ hasText: "观心" }).click();
await page.getByRole("button", { name: "开始静心", exact: true }).click();
await page.clock.fastForward(1200);
await expect(page.getByText("吸", { exact: true })).toBeVisible();
await page.clock.fastForward(45_000);
await page.getByRole("button", { name: "静心完成,开始起卦" }).click();
await expect(page.locator(".heart-cast-line.is-revealed")).toHaveCount(0);
await page.getByRole("button", { name: "投掷初爻" }).click();
await expect(page.locator(".heart-cast-line.is-revealed")).toHaveCount(1);
await expect(page.locator(".heart-cast-line").filter({ hasText: "未得" })).toHaveCount(5);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await page.screenshot({ path: path.join(evidence, "heaven-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("button", { name: "日间" }).click();
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await expect(page.locator(".mobile-nav")).toBeVisible();
await page.screenshot({ path: path.join(evidence, "heaven-light-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
test("nonmembers retain the complete grey Heaven structure", async ({ page }) => {
await mockHeaven(page);
await authenticate(page, "stage4user", "Stage4-user-123!");
await page.goto("/workspace/heaven");
await expect(page.getByText("问天仅对会员开放")).toBeVisible();
await expect(page.locator(".heaven-mode-tabs").getByText("观势", { exact: true })).toBeVisible();
await expect(page.getByLabel("股票代码或名称")).toBeDisabled();
await expect(page.locator(".locked-content")).toBeVisible();
});
+416
View File
@@ -0,0 +1,416 @@
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
+4 -4
View File
@@ -21,9 +21,9 @@ from backend.data.policy import DataPolicyError, DataSourcePolicy
from backend.data.providers.ifind import IfindProvider
from backend.data.providers.tushare import TushareProvider
from backend.data.repository import MarketRepository
from backend.data.sentiment import calculate_sentiment
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.market.sentiment import calculate_sentiment
from backend.features.market.snapshot import build_snapshot
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
from tests.support import run_scenario
@@ -188,9 +188,7 @@ def test_ifind_top_level_tables_and_expired_access_token_are_handled() -> None:
def test_trade_context_keeps_real_snapshot_date(tmp_path) -> None:
market = gateway(tmp_path)
context = market.trade_context(
"2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI)
)
context = market.trade_context("2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI))
assert context.requested_date == "2026-07-30"
assert context.actual_date == "2026-07-29"
assert context.carried_forward is True
@@ -288,6 +286,7 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
}
for index, change in enumerate((10, 4, -10, 2, -2), start=1)
]
def event(index, streak=1):
return {
"ts_code": f"00000{index}.SZ",
@@ -298,6 +297,7 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
"amount": 100,
"limit_times": streak,
}
snapshot = build_snapshot(
"2026-07-29",
"2026-07-28",
+3 -2
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9)
assert {
"users",
"memberships",
@@ -139,8 +139,9 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"mentor_messages",
"llm_requests",
"llm_attempts",
"heaven_readings",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (8, 7, 6, 5, 4, 3, 2, 1)
assert runner.downgrade(MIGRATIONS, target_version=0) == (9, 8, 7, 6, 5, 4, 3, 2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)