rebuild(stage-11): deliver deterministic heaven workflows
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
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.gateway import DataGateway
|
||||||
from backend.data.policy import DataSourcePolicy
|
from backend.data.policy import DataSourcePolicy
|
||||||
from backend.data.providers import EastmoneyProvider, IfindProvider, TushareProvider
|
from backend.data.providers import EastmoneyProvider, IfindProvider, TushareProvider
|
||||||
@@ -16,6 +16,8 @@ from backend.features.accounts.service import (
|
|||||||
AccountService,
|
AccountService,
|
||||||
MembershipService,
|
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 import MarketService
|
||||||
from backend.features.market.insights import MarketInsightService
|
from backend.features.market.insights import MarketInsightService
|
||||||
from backend.features.market.sync import MarketSnapshotService
|
from backend.features.market.sync import MarketSnapshotService
|
||||||
@@ -43,6 +45,7 @@ class ApplicationContainer:
|
|||||||
screener: ScreenerService
|
screener: ScreenerService
|
||||||
llm: LLMGateway
|
llm: LLMGateway
|
||||||
mentor: MentorService
|
mentor: MentorService
|
||||||
|
heaven: HeavenService
|
||||||
|
|
||||||
|
|
||||||
def build_container(settings: Settings) -> ApplicationContainer:
|
def build_container(settings: Settings) -> ApplicationContainer:
|
||||||
@@ -89,6 +92,14 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
|||||||
gateway,
|
gateway,
|
||||||
llm,
|
llm,
|
||||||
)
|
)
|
||||||
|
heaven = HeavenService(
|
||||||
|
database,
|
||||||
|
HeavenRepository(),
|
||||||
|
gateway,
|
||||||
|
accounts,
|
||||||
|
llm,
|
||||||
|
PROJECT_ROOT / "config" / "heaven" / "iching_zh.json",
|
||||||
|
)
|
||||||
return ApplicationContainer(
|
return ApplicationContainer(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
database=database,
|
database=database,
|
||||||
@@ -101,4 +112,5 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
|||||||
screener=screener,
|
screener=screener,
|
||||||
llm=llm,
|
llm=llm,
|
||||||
mentor=mentor,
|
mentor=mentor,
|
||||||
|
heaven=heaven,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from backend.data.contracts import (
|
|||||||
SnapshotState,
|
SnapshotState,
|
||||||
TradeContext,
|
TradeContext,
|
||||||
)
|
)
|
||||||
|
from backend.data.heaven import historical_payload, realtime_payload, should_use_realtime
|
||||||
from backend.data.policy import DataSourcePolicy
|
from backend.data.policy import DataSourcePolicy
|
||||||
from backend.data.providers.base import MarketDataProvider, ProviderError
|
from backend.data.providers.base import MarketDataProvider, ProviderError
|
||||||
from backend.data.quality import DataQualityError, require_quality
|
from backend.data.quality import DataQualityError, require_quality
|
||||||
@@ -217,6 +218,34 @@ class DataGateway:
|
|||||||
)
|
)
|
||||||
return payload
|
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, ...]:
|
def search(self, query: str) -> tuple[MarketEntity, ...]:
|
||||||
with self._database.read() as connection:
|
with self._database.read() as connection:
|
||||||
return self._repository.search(connection, query)
|
return self._repository.search(connection, query)
|
||||||
@@ -310,6 +339,29 @@ class DataGateway:
|
|||||||
return MarketEntity("stock", f"{normalized}.{suffix}", normalized, normalized)
|
return MarketEntity("stock", f"{normalized}.{suffix}", normalized, normalized)
|
||||||
raise MarketDataUnavailable("未找到该行情标的")
|
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:
|
def _save_chart(self, series: ChartSeries) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"previous_close": series.previous_close,
|
"previous_close": series.previous_close,
|
||||||
@@ -485,5 +537,10 @@ def _number(value: Any) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _optional_number(value: Any) -> float | None:
|
def _optional_number(value: Any) -> float | None:
|
||||||
number = _number(value)
|
if value is None or value == "":
|
||||||
return number if number > 0 else None
|
return None
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number == number else None
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -29,6 +29,14 @@ class MarketDataProvider(Protocol):
|
|||||||
|
|
||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
|
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(
|
def market_insight(
|
||||||
self,
|
self,
|
||||||
kind: str,
|
kind: str,
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ class EastmoneyProvider:
|
|||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||||
raise ProviderError("The display provider is not the constituent authority")
|
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(
|
def market_insight(
|
||||||
self,
|
self,
|
||||||
kind: str,
|
kind: str,
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ class IfindProvider:
|
|||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||||
raise ProviderError("iFinD is not the Shenwan constituent authority")
|
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(
|
def market_insight(
|
||||||
self,
|
self,
|
||||||
kind: str,
|
kind: str,
|
||||||
|
|||||||
@@ -132,36 +132,9 @@ class TushareProvider:
|
|||||||
|
|
||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||||
target = _compact(trade_date)
|
target = _compact(trade_date)
|
||||||
memberships = self._membership_rows({"ts_code": representative})
|
industry, members = self._sector_memberships(representative, target)
|
||||||
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 ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
sector_code = str(industry.get("l2_code") or "")
|
sector_code = str(industry.get("l2_code") or "")
|
||||||
sector_name = str(industry.get("l2_name") or "").strip()
|
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 = self._query(
|
||||||
"daily",
|
"daily",
|
||||||
{"trade_date": target},
|
{"trade_date": target},
|
||||||
@@ -170,7 +143,8 @@ class TushareProvider:
|
|||||||
)
|
)
|
||||||
quote_map = {str(row.get("ts_code") or ""): row for row in daily.rows}
|
quote_map = {str(row.get("ts_code") or ""): row for row in daily.rows}
|
||||||
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 {}
|
quote = quote_map.get(code) or {}
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
@@ -188,6 +162,114 @@ class TushareProvider:
|
|||||||
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
|
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
|
||||||
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
|
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(
|
def market_insight(
|
||||||
self,
|
self,
|
||||||
kind: str,
|
kind: str,
|
||||||
@@ -443,6 +525,40 @@ class TushareProvider:
|
|||||||
rows.extend(result.rows)
|
rows.extend(result.rows)
|
||||||
return 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(
|
def _query(
|
||||||
self,
|
self,
|
||||||
api_name: str,
|
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.m0006_watchlists import MIGRATION as WATCHLISTS
|
||||||
from backend.database.migrations.m0007_screener import MIGRATION as SCREENER
|
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.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
|
from backend.database.migrations.runner import Migration
|
||||||
|
|
||||||
MIGRATIONS: tuple[Migration, ...] = (
|
MIGRATIONS: tuple[Migration, ...] = (
|
||||||
@@ -17,4 +18,5 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
WATCHLISTS,
|
WATCHLISTS,
|
||||||
SCREENER,
|
SCREENER,
|
||||||
MENTOR_LLM,
|
MENTOR_LLM,
|
||||||
|
HEAVEN,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from backend.features.heaven.service import HeavenService
|
||||||
|
|
||||||
|
__all__ = ["HeavenService"]
|
||||||
@@ -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}并见,宜防一端偏盛"
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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 "势衰宜守"
|
||||||
@@ -9,8 +9,8 @@ from backend.data.contracts import ProviderResult, SnapshotState
|
|||||||
from backend.data.gateway import DataGateway, MarketDataUnavailable
|
from backend.data.gateway import DataGateway, MarketDataUnavailable
|
||||||
from backend.data.providers.base import ProviderError
|
from backend.data.providers.base import ProviderError
|
||||||
from backend.data.repository import MarketRepository
|
from backend.data.repository import MarketRepository
|
||||||
|
from backend.data.sentiment import calculate_sentiment
|
||||||
from backend.database.connection import Database
|
from backend.database.connection import Database
|
||||||
from backend.features.market.sentiment import calculate_sentiment
|
|
||||||
from backend.features.market.snapshot import build_snapshot
|
from backend.features.market.snapshot import build_snapshot
|
||||||
|
|
||||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from backend.features.accounts.routes import router as accounts_router
|
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.market.routes import router as market_router
|
||||||
from backend.features.mentor.routes import router as mentor_router
|
from backend.features.mentor.routes import router as mentor_router
|
||||||
from backend.features.screener.routes import router as screener_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(market_router)
|
||||||
api_router.include_router(screener_router)
|
api_router.include_router(screener_router)
|
||||||
api_router.include_router(mentor_router)
|
api_router.include_router(mentor_router)
|
||||||
|
api_router.include_router(heaven_router)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
# 阶段11验收记录
|
||||||
|
|
||||||
|
## 完成范围
|
||||||
|
|
||||||
|
- 观势:股票代码或名称载入、正式日线与盘中实时模式、六爻确定性公式、安全门、客观行业数据补录和失败关闭。
|
||||||
|
- 观气:基于`lunar-python`的确定性历法计算、固定100分权重、三层气机、个人合参与五行行业折叠区。
|
||||||
|
- 观心:40秒呼吸、一次投掷只生成一爻、六次成卦、铜钱正反面、重置与历史。
|
||||||
|
- 历史:三种模式均按账号隔离;当天已完成解运直接复用,不重复调用LLM或扣减额度。
|
||||||
|
- 解读:统一使用既有`LLMGateway`,保留问天专属循环加载动画和长方形结果弹窗。
|
||||||
|
- 前端:PC与移动端、日间与夜间、会员可用态和非会员同结构灰色锁定态。
|
||||||
|
|
||||||
|
## 关键行为
|
||||||
|
|
||||||
|
- 观势历史日期只使用正式日线;今日盘中只使用实时行情;收盘后正式日线落库前只接受同日15:00及以后的实时快照。
|
||||||
|
- 股票、申万二级行业、行业成分股和三大指数缺失或过期时失败关闭,不生成模拟数据,也不允许用户直接修改阴阳爻。
|
||||||
|
- 用户补录的是缺失的客观行业行情字段;通过安全门后再由唯一公式生成卦象。
|
||||||
|
- 观气的历法、五运六气和基础断语不由LLM计算;LLM只解释已生成的结构化结果。
|
||||||
|
- 观心未投掷的爻只显示“未得”,投掷一次只显现一爻;全部六爻完成前不能解卦。
|
||||||
|
- 原始出生日期、时辰和性别不在问天响应或页面中回显。
|
||||||
|
|
||||||
|
## 自动验收
|
||||||
|
|
||||||
|
- Ruff:通过。
|
||||||
|
- Pytest:89项通过。
|
||||||
|
- Vue TypeScript:通过。
|
||||||
|
- Vitest:3个文件、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 |
@@ -19,6 +19,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<script src="/heaven-loading.js"></script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
|
||||||
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
|
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
|
||||||
import MentorPage from "../../pages/mentor/MentorPage.vue";
|
import MentorPage from "../../pages/mentor/MentorPage.vue";
|
||||||
|
import HeavenPage from "../../pages/heaven/HeavenPage.vue";
|
||||||
import { useMarketStore } from "../../shared/stores/market";
|
import { useMarketStore } from "../../shared/stores/market";
|
||||||
import { useSessionStore } from "../../shared/stores/session";
|
import { useSessionStore } from "../../shared/stores/session";
|
||||||
import { useUiStore } from "../../shared/stores/ui";
|
import { useUiStore } from "../../shared/stores/ui";
|
||||||
@@ -33,6 +34,7 @@ const implementedMarket = computed(() =>
|
|||||||
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
|
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
|
||||||
<ScreenerPage v-else-if="workspace.key === 'screener'" />
|
<ScreenerPage v-else-if="workspace.key === 'screener'" />
|
||||||
<MentorPage v-else-if="workspace.key === 'mentor'" />
|
<MentorPage v-else-if="workspace.key === 'mentor'" />
|
||||||
|
<HeavenPage v-else-if="workspace.key === 'heaven'" />
|
||||||
<main v-else class="page-frame">
|
<main v-else class="page-frame">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>{{ workspace.title }}</h1>
|
<h1>{{ workspace.title }}</h1>
|
||||||
|
|||||||
Vendored
+8
@@ -1 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
HeavenLoadingCanvas?: new (canvas: HTMLCanvasElement) => {
|
||||||
|
start: (scene: "hexagram" | "fortune", data?: Record<string, unknown>) => void;
|
||||||
|
complete: () => Promise<void>;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import "./shared/styles/market-workspace.css";
|
|||||||
import "./shared/styles/market-insights.css";
|
import "./shared/styles/market-insights.css";
|
||||||
import "./shared/styles/screener.css";
|
import "./shared/styles/screener.css";
|
||||||
import "./shared/styles/mentor.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/system.css";
|
||||||
import "./shared/styles/mobile.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> 申万二级·<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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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: #e2ad58;
|
||||||
--c-night-amber-soft: #3d3220;
|
--c-night-amber-soft: #3d3220;
|
||||||
--c-transparent: transparent;
|
--c-transparent: transparent;
|
||||||
|
--c-gold-700: #8b6b2f;
|
||||||
|
--c-gold-500: #c9a55c;
|
||||||
|
--c-gold-100: #f4ead4;
|
||||||
|
--c-night-star: #d8e6f4;
|
||||||
|
|
||||||
/* Primitive dimensions */
|
/* Primitive dimensions */
|
||||||
--s-1: 1px;
|
--s-1: 1px;
|
||||||
@@ -71,6 +75,13 @@
|
|||||||
--s-46: 46px;
|
--s-46: 46px;
|
||||||
--s-56: 56px;
|
--s-56: 56px;
|
||||||
--s-64: 64px;
|
--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-200: 200px;
|
||||||
--s-260: 260px;
|
--s-260: 260px;
|
||||||
--s-320: 320px;
|
--s-320: 320px;
|
||||||
@@ -94,6 +105,8 @@
|
|||||||
--font-15: 15px;
|
--font-15: 15px;
|
||||||
--font-17: 17px;
|
--font-17: 17px;
|
||||||
--font-18: 18px;
|
--font-18: 18px;
|
||||||
|
--font-24: 24px;
|
||||||
|
--font-32: 32px;
|
||||||
--weight-400: 400;
|
--weight-400: 400;
|
||||||
--weight-500: 500;
|
--weight-500: 500;
|
||||||
--weight-600: 600;
|
--weight-600: 600;
|
||||||
@@ -146,6 +159,10 @@
|
|||||||
--color-warning: var(--c-amber-700);
|
--color-warning: var(--c-amber-700);
|
||||||
--color-warning-soft: var(--c-amber-050);
|
--color-warning-soft: var(--c-amber-050);
|
||||||
--color-overlay: rgba(18, 20, 22, 0.46);
|
--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);
|
--shadow-card: var(--shadow-card-light);
|
||||||
|
|
||||||
/* Component tokens */
|
/* Component tokens */
|
||||||
@@ -187,5 +204,7 @@
|
|||||||
--color-warning: var(--c-night-amber);
|
--color-warning: var(--c-night-amber);
|
||||||
--color-warning-soft: var(--c-night-amber-soft);
|
--color-warning-soft: var(--c-night-amber-soft);
|
||||||
--color-overlay: rgba(0, 0, 0, 0.64);
|
--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);
|
--shadow-card: var(--shadow-card-dark);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
cryptography==49.0.0
|
cryptography==49.0.0
|
||||||
fastapi==0.141.0
|
fastapi==0.141.0
|
||||||
|
lunar-python==1.4.8
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
tzdata==2026.3
|
tzdata==2026.3
|
||||||
uvicorn==0.52.0
|
uvicorn==0.52.0
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -21,9 +21,9 @@ from backend.data.policy import DataPolicyError, DataSourcePolicy
|
|||||||
from backend.data.providers.ifind import IfindProvider
|
from backend.data.providers.ifind import IfindProvider
|
||||||
from backend.data.providers.tushare import TushareProvider
|
from backend.data.providers.tushare import TushareProvider
|
||||||
from backend.data.repository import MarketRepository
|
from backend.data.repository import MarketRepository
|
||||||
|
from backend.data.sentiment import calculate_sentiment
|
||||||
from backend.database.connection import Database
|
from backend.database.connection import Database
|
||||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
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.snapshot import build_snapshot
|
||||||
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
|
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
|
||||||
from tests.support import run_scenario
|
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:
|
def test_trade_context_keeps_real_snapshot_date(tmp_path) -> None:
|
||||||
market = gateway(tmp_path)
|
market = gateway(tmp_path)
|
||||||
context = market.trade_context(
|
context = market.trade_context("2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI))
|
||||||
"2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI)
|
|
||||||
)
|
|
||||||
assert context.requested_date == "2026-07-30"
|
assert context.requested_date == "2026-07-30"
|
||||||
assert context.actual_date == "2026-07-29"
|
assert context.actual_date == "2026-07-29"
|
||||||
assert context.carried_forward is True
|
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)
|
for index, change in enumerate((10, 4, -10, 2, -2), start=1)
|
||||||
]
|
]
|
||||||
|
|
||||||
def event(index, streak=1):
|
def event(index, streak=1):
|
||||||
return {
|
return {
|
||||||
"ts_code": f"00000{index}.SZ",
|
"ts_code": f"00000{index}.SZ",
|
||||||
@@ -298,6 +297,7 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
|||||||
"amount": 100,
|
"amount": 100,
|
||||||
"limit_times": streak,
|
"limit_times": streak,
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot = build_snapshot(
|
snapshot = build_snapshot(
|
||||||
"2026-07-29",
|
"2026-07-29",
|
||||||
"2026-07-28",
|
"2026-07-28",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
|||||||
database = Database(tmp_path / "app.db")
|
database = Database(tmp_path / "app.db")
|
||||||
runner = MigrationRunner(database)
|
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 {
|
assert {
|
||||||
"users",
|
"users",
|
||||||
"memberships",
|
"memberships",
|
||||||
@@ -139,8 +139,9 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
|||||||
"mentor_messages",
|
"mentor_messages",
|
||||||
"llm_requests",
|
"llm_requests",
|
||||||
"llm_attempts",
|
"llm_attempts",
|
||||||
|
"heaven_readings",
|
||||||
} <= table_names(database)
|
} <= 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 "users" not in table_names(database)
|
||||||
assert "llm_models" not in table_names(database)
|
assert "llm_models" not in table_names(database)
|
||||||
|
|||||||
Reference in New Issue
Block a user