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 backend.bootstrap.settings import Settings
|
||||
from backend.bootstrap.settings import PROJECT_ROOT, Settings
|
||||
from backend.data.gateway import DataGateway
|
||||
from backend.data.policy import DataSourcePolicy
|
||||
from backend.data.providers import EastmoneyProvider, IfindProvider, TushareProvider
|
||||
@@ -16,6 +16,8 @@ from backend.features.accounts.service import (
|
||||
AccountService,
|
||||
MembershipService,
|
||||
)
|
||||
from backend.features.heaven.repository import HeavenRepository
|
||||
from backend.features.heaven.service import HeavenService
|
||||
from backend.features.market import MarketService
|
||||
from backend.features.market.insights import MarketInsightService
|
||||
from backend.features.market.sync import MarketSnapshotService
|
||||
@@ -43,6 +45,7 @@ class ApplicationContainer:
|
||||
screener: ScreenerService
|
||||
llm: LLMGateway
|
||||
mentor: MentorService
|
||||
heaven: HeavenService
|
||||
|
||||
|
||||
def build_container(settings: Settings) -> ApplicationContainer:
|
||||
@@ -89,6 +92,14 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
||||
gateway,
|
||||
llm,
|
||||
)
|
||||
heaven = HeavenService(
|
||||
database,
|
||||
HeavenRepository(),
|
||||
gateway,
|
||||
accounts,
|
||||
llm,
|
||||
PROJECT_ROOT / "config" / "heaven" / "iching_zh.json",
|
||||
)
|
||||
return ApplicationContainer(
|
||||
settings=settings,
|
||||
database=database,
|
||||
@@ -101,4 +112,5 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
||||
screener=screener,
|
||||
llm=llm,
|
||||
mentor=mentor,
|
||||
heaven=heaven,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from backend.data.contracts import (
|
||||
SnapshotState,
|
||||
TradeContext,
|
||||
)
|
||||
from backend.data.heaven import historical_payload, realtime_payload, should_use_realtime
|
||||
from backend.data.policy import DataSourcePolicy
|
||||
from backend.data.providers.base import MarketDataProvider, ProviderError
|
||||
from backend.data.quality import DataQualityError, require_quality
|
||||
@@ -217,6 +218,34 @@ class DataGateway:
|
||||
)
|
||||
return payload
|
||||
|
||||
def heaven_trend_inputs(
|
||||
self, query: str, requested_date: str, now: datetime | None = None
|
||||
) -> dict[str, Any]:
|
||||
clock = now or datetime.now(SHANGHAI)
|
||||
context = self.trade_context(requested_date, clock)
|
||||
if context.actual_date is None:
|
||||
raise MarketDataUnavailable("等待管理员首次同步真实收盘行情")
|
||||
stock = self._resolve_stock_query(query)
|
||||
provider = self._provider(DataSource.TUSHARE)
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
if should_use_realtime(requested_date, context, clock):
|
||||
dates = self.trading_dates(requested_date, 2)
|
||||
if len(dates) < 2 or dates[0] != requested_date:
|
||||
raise MarketDataUnavailable("目标日期不是有效交易日")
|
||||
raw = provider.heaven_realtime_inputs(stock.identifier, dates[0], dates[1])
|
||||
return realtime_payload(
|
||||
self._database,
|
||||
self._repository,
|
||||
stock,
|
||||
dates[0],
|
||||
dates[1],
|
||||
raw,
|
||||
clock,
|
||||
)
|
||||
return historical_payload(
|
||||
self._database, self._repository, stock, context.actual_date, provider
|
||||
)
|
||||
|
||||
def search(self, query: str) -> tuple[MarketEntity, ...]:
|
||||
with self._database.read() as connection:
|
||||
return self._repository.search(connection, query)
|
||||
@@ -310,6 +339,29 @@ class DataGateway:
|
||||
return MarketEntity("stock", f"{normalized}.{suffix}", normalized, normalized)
|
||||
raise MarketDataUnavailable("未找到该行情标的")
|
||||
|
||||
def _resolve_stock_query(self, query: str) -> MarketEntity:
|
||||
normalized = query.strip()
|
||||
if not normalized:
|
||||
raise MarketDataUnavailable("请输入股票代码或股票名称")
|
||||
with self._database.read() as connection:
|
||||
matches = tuple(
|
||||
item
|
||||
for item in self._repository.search(connection, normalized, 16)
|
||||
if item.entity_type == "stock"
|
||||
)
|
||||
exact = [
|
||||
item
|
||||
for item in matches
|
||||
if item.code.casefold() == normalized.casefold()
|
||||
or item.identifier.casefold() == normalized.casefold()
|
||||
or item.name.casefold() == normalized.casefold()
|
||||
]
|
||||
if len(exact) == 1:
|
||||
return exact[0]
|
||||
if len(exact) > 1:
|
||||
raise MarketDataUnavailable("股票名称存在重名,请输入六位代码")
|
||||
raise MarketDataUnavailable("未找到该股票,请检查代码或名称")
|
||||
|
||||
def _save_chart(self, series: ChartSeries) -> None:
|
||||
payload = {
|
||||
"previous_close": series.previous_close,
|
||||
@@ -485,5 +537,10 @@ def _number(value: Any) -> float:
|
||||
|
||||
|
||||
def _optional_number(value: Any) -> float | None:
|
||||
number = _number(value)
|
||||
return number if number > 0 else None
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number == number else None
|
||||
|
||||
@@ -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 heaven_inputs(
|
||||
self, representative: str, trade_date: str
|
||||
) -> dict[str, ProviderResult | None]: ...
|
||||
|
||||
def heaven_realtime_inputs(
|
||||
self, representative: str, trade_date: str, previous_trade_date: str
|
||||
) -> dict[str, ProviderResult | None]: ...
|
||||
|
||||
def market_insight(
|
||||
self,
|
||||
kind: str,
|
||||
|
||||
@@ -102,6 +102,16 @@ class EastmoneyProvider:
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||
raise ProviderError("The display provider is not the constituent authority")
|
||||
|
||||
def heaven_inputs(
|
||||
self, representative: str, trade_date: str
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
raise ProviderError("The display provider cannot supply deterministic Heaven inputs")
|
||||
|
||||
def heaven_realtime_inputs(
|
||||
self, representative: str, trade_date: str, previous_trade_date: str
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
raise ProviderError("The display provider cannot supply deterministic Heaven inputs")
|
||||
|
||||
def market_insight(
|
||||
self,
|
||||
kind: str,
|
||||
|
||||
@@ -92,6 +92,16 @@ class IfindProvider:
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||
raise ProviderError("iFinD is not the Shenwan constituent authority")
|
||||
|
||||
def heaven_inputs(
|
||||
self, representative: str, trade_date: str
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
raise ProviderError("iFinD deterministic Heaven inputs are not enabled")
|
||||
|
||||
def heaven_realtime_inputs(
|
||||
self, representative: str, trade_date: str, previous_trade_date: str
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
raise ProviderError("iFinD deterministic Heaven inputs are not enabled")
|
||||
|
||||
def market_insight(
|
||||
self,
|
||||
kind: str,
|
||||
|
||||
@@ -132,36 +132,9 @@ class TushareProvider:
|
||||
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||
target = _compact(trade_date)
|
||||
memberships = self._membership_rows({"ts_code": representative})
|
||||
active = [row for row in memberships if _active_on(row, target)]
|
||||
if not active:
|
||||
raise ProviderError("未找到该股票在目标日期的申万行业")
|
||||
industry = max(
|
||||
active,
|
||||
key=lambda row: (
|
||||
str(row.get("in_date") or ""),
|
||||
str(row.get("l2_code") or ""),
|
||||
),
|
||||
)
|
||||
industry, members = self._sector_memberships(representative, target)
|
||||
sector_code = str(industry.get("l2_code") or "")
|
||||
sector_name = str(industry.get("l2_name") or "").strip()
|
||||
if not sector_code:
|
||||
raise ProviderError("该股票缺少申万二级行业")
|
||||
members = [
|
||||
row
|
||||
for row in self._membership_rows({"l2_code": sector_code})
|
||||
if _active_on(row, target)
|
||||
]
|
||||
deduplicated: dict[str, dict[str, Any]] = {}
|
||||
for row in members:
|
||||
code = str(row.get("ts_code") or "")
|
||||
current = deduplicated.get(code)
|
||||
if code and (
|
||||
current is None or str(row.get("in_date") or "") > str(current.get("in_date") or "")
|
||||
):
|
||||
deduplicated[code] = row
|
||||
if not deduplicated:
|
||||
raise ProviderError("该申万行业没有有效成分股")
|
||||
daily = self._query(
|
||||
"daily",
|
||||
{"trade_date": target},
|
||||
@@ -170,7 +143,8 @@ class TushareProvider:
|
||||
)
|
||||
quote_map = {str(row.get("ts_code") or ""): row for row in daily.rows}
|
||||
rows = []
|
||||
for code, member in deduplicated.items():
|
||||
for member in members:
|
||||
code = str(member.get("ts_code") or "")
|
||||
quote = quote_map.get(code) or {}
|
||||
rows.append(
|
||||
{
|
||||
@@ -188,6 +162,114 @@ class TushareProvider:
|
||||
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
|
||||
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
|
||||
|
||||
def heaven_inputs(
|
||||
self, representative: str, trade_date: str
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
target = _compact(trade_date)
|
||||
membership = self.sector_members(representative, trade_date)
|
||||
sector_code = str(membership.rows[0].get("sector_code") or "") if membership.rows else ""
|
||||
return {
|
||||
"members": membership,
|
||||
"daily": self._optional_query(
|
||||
"daily",
|
||||
{"trade_date": target},
|
||||
"ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount",
|
||||
),
|
||||
"daily_basic": self._optional_query(
|
||||
"daily_basic",
|
||||
{"trade_date": target},
|
||||
"ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv",
|
||||
),
|
||||
"sector_daily": self._optional_query(
|
||||
"sw_daily",
|
||||
{"ts_code": sector_code, "trade_date": target},
|
||||
"ts_code,trade_date,name,open,high,low,close,pct_change,vol,amount,pe,pb,float_mv,total_mv",
|
||||
),
|
||||
"indices": self._index_rows(target),
|
||||
}
|
||||
|
||||
def heaven_realtime_inputs(
|
||||
self, representative: str, trade_date: str, previous_trade_date: str
|
||||
) -> dict[str, ProviderResult | None]:
|
||||
target = _compact(trade_date)
|
||||
previous = _compact(previous_trade_date)
|
||||
industry, members = self._sector_memberships(representative, target)
|
||||
sector_code = str(industry.get("l2_code") or "")
|
||||
directory = self._optional_query(
|
||||
"stock_basic",
|
||||
{"exchange": "", "list_status": "L"},
|
||||
"ts_code,name,industry,market,list_date",
|
||||
)
|
||||
active_codes = tuple(
|
||||
str(row.get("ts_code") or "")
|
||||
for row in (directory.rows if directory else ())
|
||||
if row.get("ts_code")
|
||||
)
|
||||
realtime_codes = (*active_codes, "000001.SH", "399001.SZ", "399006.SZ")
|
||||
member_rows = tuple(
|
||||
{
|
||||
"sector_code": sector_code,
|
||||
"sector_name": str(industry.get("l2_name") or "").strip(),
|
||||
"ts_code": str(row.get("ts_code") or ""),
|
||||
"name": str(row.get("name") or "").strip(),
|
||||
}
|
||||
for row in members
|
||||
)
|
||||
start = (datetime.strptime(target, "%Y%m%d") - timedelta(days=35)).strftime("%Y%m%d")
|
||||
return {
|
||||
"directory": directory,
|
||||
"members": ProviderResult(
|
||||
member_rows,
|
||||
_metadata(self.source, "membership", 1 if member_rows else 0),
|
||||
),
|
||||
"realtime": self._optional_query(
|
||||
"rt_k",
|
||||
{"ts_code": ",".join(realtime_codes)},
|
||||
"ts_code,name,trade_time,open,high,low,close,pre_close,vol,amount,num,pct_chg",
|
||||
),
|
||||
"capital": self._optional_query(
|
||||
"daily_basic",
|
||||
{"trade_date": previous},
|
||||
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
|
||||
),
|
||||
"stock_history": self._optional_query(
|
||||
"daily",
|
||||
{"ts_code": representative, "start_date": start, "end_date": previous},
|
||||
"ts_code,trade_date,vol,amount",
|
||||
),
|
||||
"price_limits": self._optional_query(
|
||||
"stk_limit",
|
||||
{"trade_date": target},
|
||||
"ts_code,trade_date,up_limit,down_limit",
|
||||
),
|
||||
"suspensions": self._optional_query(
|
||||
"suspend_d",
|
||||
{"suspend_date": target},
|
||||
"ts_code,suspend_date,resume_date,suspend_timing,suspend_type",
|
||||
),
|
||||
"sector_realtime": self._optional_query(
|
||||
"rt_sw_k",
|
||||
{"ts_code": sector_code},
|
||||
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
|
||||
),
|
||||
}
|
||||
|
||||
def _index_rows(self, trade_date: str) -> ProviderResult | None:
|
||||
rows: list[dict[str, Any]] = []
|
||||
completed = 0
|
||||
for identifier in ("000001.SH", "399001.SZ", "399006.SZ"):
|
||||
result = self._optional_query(
|
||||
"index_daily",
|
||||
{"ts_code": identifier, "trade_date": trade_date},
|
||||
"ts_code,trade_date,close,pre_close,pct_chg",
|
||||
)
|
||||
if result is not None and result.rows:
|
||||
rows.extend(result.rows)
|
||||
completed += 1
|
||||
if not rows:
|
||||
return None
|
||||
return ProviderResult(tuple(rows), _metadata(self.source, "percent", completed / 3))
|
||||
|
||||
def market_insight(
|
||||
self,
|
||||
kind: str,
|
||||
@@ -443,6 +525,40 @@ class TushareProvider:
|
||||
rows.extend(result.rows)
|
||||
return rows
|
||||
|
||||
def _sector_memberships(
|
||||
self, representative: str, trade_date: str
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
active = [
|
||||
row
|
||||
for row in self._membership_rows({"ts_code": representative})
|
||||
if _active_on(row, trade_date)
|
||||
]
|
||||
if not active:
|
||||
raise ProviderError("未找到该股票在目标日期的申万行业")
|
||||
industry = max(
|
||||
active,
|
||||
key=lambda row: (str(row.get("in_date") or ""), str(row.get("l2_code") or "")),
|
||||
)
|
||||
sector_code = str(industry.get("l2_code") or "")
|
||||
if not sector_code:
|
||||
raise ProviderError("该股票缺少申万二级行业")
|
||||
rows = [
|
||||
row
|
||||
for row in self._membership_rows({"l2_code": sector_code})
|
||||
if _active_on(row, trade_date)
|
||||
]
|
||||
deduplicated: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
code = str(row.get("ts_code") or "")
|
||||
current = deduplicated.get(code)
|
||||
if code and (
|
||||
current is None or str(row.get("in_date") or "") > str(current.get("in_date") or "")
|
||||
):
|
||||
deduplicated[code] = row
|
||||
if not deduplicated:
|
||||
raise ProviderError("该申万行业没有有效成分股")
|
||||
return industry, list(deduplicated.values())
|
||||
|
||||
def _query(
|
||||
self,
|
||||
api_name: str,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from backend.database.migrations.runner import Migration
|
||||
|
||||
|
||||
def upgrade(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE heaven_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('trend', 'fortune', 'heart')),
|
||||
reading_date TEXT NOT NULL,
|
||||
subject_key TEXT NOT NULL DEFAULT '',
|
||||
result_json TEXT NOT NULL,
|
||||
interpretation TEXT NOT NULL DEFAULT '',
|
||||
interpretation_status TEXT NOT NULL DEFAULT 'pending' CHECK (
|
||||
interpretation_status IN ('pending', 'complete', 'stopped', 'error')
|
||||
),
|
||||
request_id TEXT REFERENCES llm_requests(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE INDEX heaven_readings_scope_idx
|
||||
ON heaven_readings(user_id, mode, reading_date, id)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX heaven_fortune_daily_idx
|
||||
ON heaven_readings(user_id, reading_date)
|
||||
WHERE mode = 'fortune'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade(connection: sqlite3.Connection) -> None:
|
||||
connection.execute("DROP TABLE heaven_readings")
|
||||
|
||||
|
||||
MIGRATION = Migration(
|
||||
version=9,
|
||||
name="create_heaven_readings",
|
||||
signature="heaven:v2:account-readings-unique-daily-fortune",
|
||||
upgrade=upgrade,
|
||||
downgrade=downgrade,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from backend.database.migrations.m0005_market_insights import MIGRATION as MARKE
|
||||
from backend.database.migrations.m0006_watchlists import MIGRATION as WATCHLISTS
|
||||
from backend.database.migrations.m0007_screener import MIGRATION as SCREENER
|
||||
from backend.database.migrations.m0008_mentor_llm import MIGRATION as MENTOR_LLM
|
||||
from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN
|
||||
from backend.database.migrations.runner import Migration
|
||||
|
||||
MIGRATIONS: tuple[Migration, ...] = (
|
||||
@@ -17,4 +18,5 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
WATCHLISTS,
|
||||
SCREENER,
|
||||
MENTOR_LLM,
|
||||
HEAVEN,
|
||||
)
|
||||
|
||||
@@ -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.providers.base import ProviderError
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.data.sentiment import calculate_sentiment
|
||||
from backend.database.connection import Database
|
||||
from backend.features.market.sentiment import calculate_sentiment
|
||||
from backend.features.market.snapshot import build_snapshot
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.features.accounts.routes import router as accounts_router
|
||||
from backend.features.heaven.routes import router as heaven_router
|
||||
from backend.features.market.routes import router as market_router
|
||||
from backend.features.mentor.routes import router as mentor_router
|
||||
from backend.features.screener.routes import router as screener_router
|
||||
@@ -12,3 +13,4 @@ api_router.include_router(accounts_router)
|
||||
api_router.include_router(market_router)
|
||||
api_router.include_router(screener_router)
|
||||
api_router.include_router(mentor_router)
|
||||
api_router.include_router(heaven_router)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/heaven-loading.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</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 ScreenerPage from "../../pages/screener/ScreenerPage.vue";
|
||||
import MentorPage from "../../pages/mentor/MentorPage.vue";
|
||||
import HeavenPage from "../../pages/heaven/HeavenPage.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
@@ -33,6 +34,7 @@ const implementedMarket = computed(() =>
|
||||
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
|
||||
<ScreenerPage v-else-if="workspace.key === 'screener'" />
|
||||
<MentorPage v-else-if="workspace.key === 'mentor'" />
|
||||
<HeavenPage v-else-if="workspace.key === 'heaven'" />
|
||||
<main v-else class="page-frame">
|
||||
<header class="page-header">
|
||||
<h1>{{ workspace.title }}</h1>
|
||||
|
||||
Vendored
+8
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
HeavenLoadingCanvas?: new (canvas: HTMLCanvasElement) => {
|
||||
start: (scene: "hexagram" | "fortune", data?: Record<string, unknown>) => void;
|
||||
complete: () => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import "./shared/styles/market-workspace.css";
|
||||
import "./shared/styles/market-insights.css";
|
||||
import "./shared/styles/screener.css";
|
||||
import "./shared/styles/mentor.css";
|
||||
import "./shared/styles/heaven.css";
|
||||
import "./shared/styles/heaven-fortune.css";
|
||||
import "./shared/styles/heaven-heart.css";
|
||||
import "./shared/styles/system.css";
|
||||
import "./shared/styles/mobile.css";
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const props = defineProps<{ field: Record<string, any> | null; daily: HeavenReading | null; disabled: boolean }>();
|
||||
const emit = defineEmits<{ interpret: [reading: HeavenReading]; saved: [reading: HeavenReading] }>();
|
||||
const ui = useUiStore();
|
||||
const industriesOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
const layerMarks = ["壹", "贰", "叁"];
|
||||
|
||||
async function interpret(): Promise<void> {
|
||||
if (props.disabled || loading.value) return;
|
||||
if (props.daily?.status === "complete") {
|
||||
emit("interpret", props.daily);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await heavenApi.fortune(props.field?.date);
|
||||
emit("saved", response.reading);
|
||||
emit("interpret", response.reading);
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "解运准备失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="field" class="heaven-panel fortune-panel">
|
||||
<div class="heaven-fortune-grid">
|
||||
<article class="card heaven-calendar-card">
|
||||
<div class="heaven-date-seal"><span>{{ field.date.slice(5) }}</span><small>{{ field.solar_term.current }}后</small></div>
|
||||
<div><p class="muted">{{ field.lunar_date }}</p><h2>{{ field.pillars.year }}年 · {{ field.pillars.month }}月 · {{ field.pillars.day }}日</h2><p>下一节气 {{ field.solar_term.next }}</p></div>
|
||||
</article>
|
||||
<article class="card heaven-phrase-card">
|
||||
<span>当日断语</span><h2>{{ field.phrase }}</h2><p>{{ field.movement.element }}运{{ field.movement.tendency }} · {{ field.six_qi.step_name }} · 客{{ field.six_qi.guest }}加临主{{ field.six_qi.host }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="heaven-qi-layout">
|
||||
<article class="card heaven-qi-layers">
|
||||
<header class="card-header"><h2>三层气机</h2><span class="tag">确定性历法</span></header>
|
||||
<div class="heaven-layer-list">
|
||||
<div v-for="(layer, index) in field.layers" :key="layer.label" class="heaven-layer-row"><span>{{ layerMarks[Number(index)] }}</span><div><strong>{{ layer.label }} · {{ layer.dominant }}</strong><p>{{ layer.summary }}</p></div></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="heaven-personal">
|
||||
<span class="muted">个人合参</span>
|
||||
<template v-if="field.personal"><h3>{{ field.personal.day_master_element }}日主 · 当日合参</h3><p>{{ field.personal.tone }}</p><small>{{ field.personal.notice }}</small></template>
|
||||
<template v-else><h3>尚未设置个人资料</h3><p>可在账户设置的个人资料中补充出生信息。</p></template>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card heaven-industries">
|
||||
<button class="heaven-section-toggle" type="button" @click="industriesOpen = !industriesOpen"><span>五行对应行业</span><span>{{ industriesOpen ? '收起' : '展开' }}</span></button>
|
||||
<div v-if="industriesOpen" class="heaven-industry-grid">
|
||||
<div v-for="group in field.sector_catalog" :key="group.element"><strong>{{ group.element }}</strong><p>{{ group.industries.join(' · ') }}</p></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="heaven-fortune-actions"><p>{{ field.notice }}</p><button class="btn btn-primary" type="button" :disabled="disabled || loading" @click="interpret">{{ daily?.status === 'complete' ? '已解运 · 查看结果' : loading ? '准备中' : '解运' }}</button></div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import HexagramGraphic from "./HexagramGraphic.vue";
|
||||
import { breathState } from "./heartTiming";
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>();
|
||||
const emit = defineEmits<{ interpret: [reading: HeavenReading] }>();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const stage = ref<"still" | "breath" | "cast" | "thought" | "result">("still");
|
||||
const breathWord = ref("静");
|
||||
const breathPhase = ref("prepare");
|
||||
const incense = ref(0);
|
||||
const values = ref<number[]>([]);
|
||||
const coinFaces = ref<string[]>(["front", "back", "front"]);
|
||||
const casting = ref(false);
|
||||
const firstThought = ref(false);
|
||||
const result = ref<any>(null);
|
||||
const readingId = ref(0);
|
||||
const muted = ref(false);
|
||||
let breathTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let breathStarted = 0;
|
||||
const positions = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
|
||||
const breathClass = computed(() => `is-${breathPhase.value}`);
|
||||
|
||||
function startBreath(): void {
|
||||
if (props.disabled) return;
|
||||
stage.value = "breath";
|
||||
breathStarted = performance.now();
|
||||
updateBreath();
|
||||
breathTimer = setInterval(updateBreath, 100);
|
||||
}
|
||||
|
||||
function updateBreath(): void {
|
||||
const elapsed = performance.now() - breathStarted;
|
||||
const state = breathState(elapsed);
|
||||
incense.value = state.progress;
|
||||
breathWord.value = state.word;
|
||||
breathPhase.value = state.phase;
|
||||
if (state.phase === "complete") {
|
||||
clearBreath();
|
||||
}
|
||||
}
|
||||
|
||||
function clearBreath(): void {
|
||||
if (breathTimer) clearInterval(breathTimer);
|
||||
breathTimer = undefined;
|
||||
}
|
||||
|
||||
async function cast(): Promise<void> {
|
||||
if (casting.value || values.value.length >= 6 || props.disabled) return;
|
||||
casting.value = true;
|
||||
try {
|
||||
const response = await heavenApi.heartLine(market.selectedDate, values.value);
|
||||
coinFaces.value = response.faces;
|
||||
values.value = response.values;
|
||||
if (values.value.length === 6) stage.value = "thought";
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "投掷未完成");
|
||||
} finally {
|
||||
window.setTimeout(() => { casting.value = false; }, 450);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmThought(): Promise<void> {
|
||||
if (!firstThought.value || values.value.length !== 6) return;
|
||||
try {
|
||||
const response = await heavenApi.completeHeart(market.selectedDate, values.value);
|
||||
result.value = response.result;
|
||||
readingId.value = response.reading_id;
|
||||
stage.value = "result";
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "成卦失败");
|
||||
}
|
||||
}
|
||||
|
||||
function reading(): HeavenReading {
|
||||
return {
|
||||
id: readingId.value,
|
||||
mode: "heart",
|
||||
date: result.value.date,
|
||||
subject_key: "",
|
||||
result: result.value,
|
||||
interpretation: "",
|
||||
status: "pending",
|
||||
created_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
clearBreath();
|
||||
stage.value = "still";
|
||||
breathWord.value = "静";
|
||||
breathPhase.value = "prepare";
|
||||
incense.value = 0;
|
||||
values.value = [];
|
||||
result.value = null;
|
||||
readingId.value = 0;
|
||||
firstThought.value = false;
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearBreath);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="heaven-panel heart-panel">
|
||||
<div class="heaven-heart-toolbar"><button class="btn btn-ghost btn-small" type="button" @click="muted = !muted">{{ muted ? '开启声音' : '静音' }}</button><button v-if="stage !== 'still'" class="btn btn-ghost btn-small" type="button" @click="reset">重新观心</button></div>
|
||||
<article v-if="stage === 'still'" class="card heart-still">
|
||||
<span class="heaven-heart-mark">心</span><h2>把所问之事留在心里</h2><p>不必输入,不必说明。先让念头安静下来,再看第一念如何浮现。</p><button class="btn btn-primary" type="button" :disabled="disabled" @click="startBreath">开始静心</button>
|
||||
</article>
|
||||
<article v-else-if="stage === 'breath'" class="card heart-breath">
|
||||
<div class="heart-ripple" :class="breathClass"><i /><i /><i /><strong>{{ breathWord }}</strong></div>
|
||||
<div class="heart-incense"><span>一炷香</span><div><i :style="{ width: `${incense * 100}%` }" /></div></div>
|
||||
<button v-if="breathPhase === 'complete'" class="btn btn-primary" type="button" @click="stage = 'cast'">静心完成,开始起卦</button>
|
||||
</article>
|
||||
<article v-else-if="stage === 'cast' || stage === 'thought'" class="heart-casting-layout">
|
||||
<section class="card heart-coins-card">
|
||||
<p class="muted">依次投掷六次,每次只得一爻</p>
|
||||
<div class="heart-coins" :class="{ 'is-casting': casting }"><span v-for="(face, index) in coinFaces" :key="index" class="heart-coin" :class="face"><i>{{ face === 'front' ? '乾' : '元' }}</i><small>{{ face === 'front' ? '通宝' : '坤仪' }}</small></span></div>
|
||||
<button v-if="stage === 'cast'" class="btn btn-primary" type="button" :disabled="casting" @click="cast">{{ casting ? '铜钱落定' : `投掷${positions[values.length]}` }}</button>
|
||||
<div v-else class="heart-thought"><label><input v-model="firstThought" type="checkbox" /> 我已记住此刻浮现的第一念</label><button class="btn btn-primary" type="button" :disabled="!firstThought" @click="confirmThought">确认第一念,完成起卦</button></div>
|
||||
</section>
|
||||
<section class="card heart-lines-card">
|
||||
<div v-for="(position, index) in positions" :key="position" class="heart-cast-line" :class="{ 'is-revealed': values[index] }"><span>{{ position }}</span><template v-if="values[index]"><span class="heaven-line-mini" :class="{ yin: values[index] % 2 === 0 }"><i /><i /></span><strong>{{ values[index] % 2 ? '阳爻' : '阴爻' }} · {{ values[index] }}</strong></template><em v-else>未得</em></div>
|
||||
</section>
|
||||
</article>
|
||||
<article v-else-if="result" class="card heart-result">
|
||||
<HexagramGraphic :hexagram="result.hexagram" />
|
||||
<div><span class="muted">卦辞</span><h2>{{ result.hexagram.name }}</h2><p>{{ result.hexagram.text }}</p><small>{{ result.notice }}</small></div>
|
||||
<button class="btn btn-primary" type="button" @click="emit('interpret', reading())">解卦</button>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading, type HeavenSetup, type ReadingMode } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import FortunePanel from "./FortunePanel.vue";
|
||||
import HeartPanel from "./HeartPanel.vue";
|
||||
import InterpretDialog from "./InterpretDialog.vue";
|
||||
import TrendPanel from "./TrendPanel.vue";
|
||||
|
||||
const market = useMarketStore();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const mode = ref<ReadingMode>("trend");
|
||||
const setup = ref<HeavenSetup | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const activeReading = ref<HeavenReading | null>(null);
|
||||
const locked = computed(() => !session.account?.smart_access);
|
||||
const labels: Record<ReadingMode, { title: string; subtitle: string }> = {
|
||||
trend: { title: "观势", subtitle: "以客观行情量化三才六爻" },
|
||||
fortune: { title: "观气", subtitle: "以五运六气观照当日气机" },
|
||||
heart: { title: "观心", subtitle: "静心、起卦,察见第一念" },
|
||||
};
|
||||
|
||||
async function load(): Promise<void> {
|
||||
setup.value = null;
|
||||
error.value = "";
|
||||
if (locked.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
setup.value = await heavenApi.setup(market.selectedDate);
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "问天数据读取失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openInterpret(reading: HeavenReading): void {
|
||||
activeReading.value = reading;
|
||||
if (!setup.value?.history.some((item) => item.id === reading.id)) {
|
||||
setup.value?.history.unshift(reading);
|
||||
}
|
||||
}
|
||||
|
||||
function saveFortune(reading: HeavenReading): void {
|
||||
if (!setup.value) return;
|
||||
setup.value.daily_fortune = reading;
|
||||
if (!setup.value.history.some((item) => item.id === reading.id)) setup.value.history.unshift(reading);
|
||||
}
|
||||
|
||||
function completed(reading: HeavenReading): void {
|
||||
if (!setup.value) return;
|
||||
const index = setup.value.history.findIndex((item) => item.id === reading.id);
|
||||
if (index >= 0) setup.value.history[index] = reading;
|
||||
if (reading.mode === "fortune") setup.value.daily_fortune = reading;
|
||||
}
|
||||
|
||||
watch([() => market.selectedDate, locked], load, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame heaven-page">
|
||||
<header class="page-header heaven-page-header">
|
||||
<div><h1>问天</h1><p class="page-subtitle">观天之道 · 执天之行 · 数据日期 {{ setup?.date || market.selectedDate }}</p></div>
|
||||
<button class="btn btn-small" type="button" :disabled="locked || !setup?.history.length" @click="activeReading = setup?.history[0] || null">历史记录</button>
|
||||
</header>
|
||||
<div v-if="locked" class="notice notice-warning membership-lock"><span><strong>问天仅对会员开放</strong>,开通会员后可使用观势、观气与观心。</span><button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button></div>
|
||||
<nav class="heaven-mode-tabs" aria-label="问天模式">
|
||||
<button v-for="(item, key) in labels" :key="key" type="button" :class="{ active: mode === key }" @click="mode = key"><strong>{{ item.title }}</strong><span>{{ item.subtitle }}</span></button>
|
||||
</nav>
|
||||
<div v-if="loading" class="card workspace-state">正在推演当日基础气机</div>
|
||||
<div v-else-if="error" class="notice notice-warning">{{ error }}</div>
|
||||
<div v-else :class="{ 'locked-content': locked }" :aria-disabled="locked">
|
||||
<TrendPanel v-if="mode === 'trend'" :disabled="locked" @interpret="openInterpret" />
|
||||
<FortunePanel v-else-if="mode === 'fortune'" :field="setup?.fortune || null" :daily="setup?.daily_fortune || null" :disabled="locked" @interpret="openInterpret" @saved="saveFortune" />
|
||||
<HeartPanel v-else :disabled="locked" @interpret="openInterpret" />
|
||||
</div>
|
||||
<InterpretDialog v-if="activeReading" :reading="activeReading" :history="setup?.history || []" @close="activeReading = null" @completed="completed" />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ hexagram: Record<string, any>; compact?: boolean }>();
|
||||
|
||||
function transformedValue(value: number): number {
|
||||
return value === 6 ? 7 : value === 9 ? 8 : value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="heaven-hex-pair" :class="{ 'heaven-hex-compact': compact }">
|
||||
<div class="heaven-hex-symbol">
|
||||
<strong>{{ hexagram.name }}</strong>
|
||||
<div class="heaven-hex-lines">
|
||||
<span v-for="line in [...hexagram.lines].reverse()" :key="line.position" class="heaven-yao" :class="{ yin: line.value % 2 === 0, moving: line.moving }"><i /><i /></span>
|
||||
</div>
|
||||
<small>{{ hexagram.outer_trigram }}上 · {{ hexagram.inner_trigram }}下</small>
|
||||
</div>
|
||||
<span class="heaven-change-arrow" aria-label="变化为">→</span>
|
||||
<div class="heaven-hex-symbol">
|
||||
<strong>{{ hexagram.transformed.name }}</strong>
|
||||
<div class="heaven-hex-lines">
|
||||
<span v-for="line in [...hexagram.lines].reverse()" :key="line.position" class="heaven-yao" :class="{ yin: transformedValue(line.value) % 2 === 0 }"><i /><i /></span>
|
||||
</div>
|
||||
<small>{{ hexagram.transformed.outer_trigram }}上 · {{ hexagram.transformed.inner_trigram }}下</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading, type HeavenStreamEvent } from "../../shared/api/heaven";
|
||||
import BaseDialog from "../../shared/components/BaseDialog.vue";
|
||||
|
||||
const props = defineProps<{ reading: HeavenReading; history: HeavenReading[] }>();
|
||||
const emit = defineEmits<{ close: []; completed: [reading: HeavenReading] }>();
|
||||
const tab = ref<"current" | "history">("current");
|
||||
const active = ref(props.reading);
|
||||
const answer = ref(props.reading.interpretation || "");
|
||||
const loading = ref(!props.reading.interpretation);
|
||||
const error = ref("");
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
let animation: any = null;
|
||||
let controller: AbortController | null = null;
|
||||
const title = computed(() => ({ trend: "解势", fortune: "解运", heart: "解卦" })[active.value.mode]);
|
||||
const modeHistory = computed(() => props.history.filter((item) => item.mode === active.value.mode));
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (active.value.interpretation) {
|
||||
answer.value = active.value.interpretation;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
answer.value = "";
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
await nextTick();
|
||||
startAnimation();
|
||||
controller = new AbortController();
|
||||
try {
|
||||
await heavenApi.interpret(
|
||||
active.value.id,
|
||||
(event: HeavenStreamEvent) => {
|
||||
if (event.type === "delta") answer.value += event.content ?? "";
|
||||
if (event.type === "error") error.value = event.message ?? "智能解读未完成";
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
await animation?.complete?.();
|
||||
active.value = { ...active.value, interpretation: answer.value, status: error.value ? "error" : "complete" };
|
||||
emit("completed", active.value);
|
||||
} catch (reason) {
|
||||
if (!(reason instanceof DOMException && reason.name === "AbortError")) {
|
||||
error.value = reason instanceof Error ? reason.message : "智能解读服务暂不可用";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
animation?.stop?.();
|
||||
controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startAnimation(): void {
|
||||
if (!canvas.value || !window.HeavenLoadingCanvas) return;
|
||||
animation = new window.HeavenLoadingCanvas(canvas.value);
|
||||
const result = active.value.result;
|
||||
animation.start(active.value.mode === "fortune" ? "fortune" : "hexagram", {
|
||||
yearPillar: result.pillars?.year ?? "",
|
||||
movement: result.movement?.element ? `${result.movement.element}运${result.movement.tendency}` : "",
|
||||
sixQi: {
|
||||
sitian: result.six_qi?.sitian ?? "",
|
||||
zaiquan: result.six_qi?.zaiquan ?? "",
|
||||
step: result.six_qi?.step ?? 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function selectHistory(reading: HeavenReading): void {
|
||||
controller?.abort();
|
||||
animation?.stop?.();
|
||||
active.value = reading;
|
||||
tab.value = "current";
|
||||
void run();
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
controller?.abort();
|
||||
animation?.stop?.();
|
||||
emit("close");
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
onBeforeUnmount(() => {
|
||||
controller?.abort();
|
||||
animation?.stop?.();
|
||||
});
|
||||
watch(() => props.reading, (value) => { active.value = value; void run(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog :title="title" wide :close-on-backdrop="!loading" @close="close">
|
||||
<div class="heaven-dialog-tabs"><button type="button" :class="{ active: tab === 'current' }" @click="tab = 'current'">本次解读</button><button type="button" :class="{ active: tab === 'history' }" @click="tab = 'history'">历史记录</button></div>
|
||||
<div v-if="tab === 'history'" class="heaven-history-list">
|
||||
<button v-for="item in modeHistory" :key="item.id" type="button" @click="selectHistory(item)"><strong>{{ item.date }} · {{ item.result.stock?.name || item.result.hexagram?.name || item.result.phrase || title }}</strong><span>{{ item.status === 'complete' ? '已完成' : '未完成' }}</span></button>
|
||||
<p v-if="!modeHistory.length" class="muted">暂无历史记录</p>
|
||||
</div>
|
||||
<div v-else class="heaven-dialog-current">
|
||||
<div v-if="loading" class="heaven-loading-stage"><canvas ref="canvas" /><p>{{ active.mode === 'fortune' ? '气机渐次归位' : '阴阳渐次成象' }}</p></div>
|
||||
<div v-else class="heaven-interpretation"><p v-if="error" class="notice notice-warning">{{ error }}</p><div class="heaven-answer">{{ answer || '本次解读未生成完整内容。' }}</div></div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import HexagramGraphic from "./HexagramGraphic.vue";
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>();
|
||||
const emit = defineEmits<{ interpret: [reading: HeavenReading] }>();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const query = ref("");
|
||||
const loading = ref(false);
|
||||
const response = ref<any>(null);
|
||||
const showChecks = ref(false);
|
||||
const showManual = ref(false);
|
||||
const manual = ref<Record<string, string | number>>({});
|
||||
const result = computed(() => response.value?.result);
|
||||
const stock = computed(() => result.value?.stock ?? response.value?.stock);
|
||||
const sector = computed(() => result.value?.sector ?? response.value?.sector);
|
||||
|
||||
async function load(withManual = false): Promise<void> {
|
||||
if (!query.value.trim() || props.disabled) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
response.value = await heavenApi.trend(
|
||||
query.value,
|
||||
market.selectedDate,
|
||||
withManual ? { sector: manual.value } : {},
|
||||
);
|
||||
if (!response.value.ready) showChecks.value = true;
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "观势数据读取失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reading(): HeavenReading {
|
||||
return {
|
||||
id: response.value.reading_id,
|
||||
mode: "trend",
|
||||
date: result.value.trade_date,
|
||||
subject_key: result.value.stock.identifier,
|
||||
result: result.value,
|
||||
interpretation: "",
|
||||
status: "pending",
|
||||
created_at: "",
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="heaven-panel trend-panel">
|
||||
<div class="card heaven-trend-input">
|
||||
<div class="field heaven-stock-field">
|
||||
<label for="heaven-stock">股票代码或名称</label>
|
||||
<div class="heaven-inline-control">
|
||||
<input id="heaven-stock" v-model="query" class="input" placeholder="输入六位代码或股票名称" :disabled="disabled || loading" @keydown.enter="load(false)" />
|
||||
<button class="btn btn-primary" type="button" :disabled="disabled || loading || !query.trim()" @click="load(false)">{{ loading ? '载入中' : '载入' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="stock" class="heaven-current-target">当前标的:<strong>{{ stock.name }}</strong> 申万二级·<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-soft: #3d3220;
|
||||
--c-transparent: transparent;
|
||||
--c-gold-700: #8b6b2f;
|
||||
--c-gold-500: #c9a55c;
|
||||
--c-gold-100: #f4ead4;
|
||||
--c-night-star: #d8e6f4;
|
||||
|
||||
/* Primitive dimensions */
|
||||
--s-1: 1px;
|
||||
@@ -71,6 +75,13 @@
|
||||
--s-46: 46px;
|
||||
--s-56: 56px;
|
||||
--s-64: 64px;
|
||||
--s-80: 80px;
|
||||
--s-96: 96px;
|
||||
--s-120: 120px;
|
||||
--s-160: 160px;
|
||||
--s-180: 180px;
|
||||
--s-240: 240px;
|
||||
--s-480: 480px;
|
||||
--s-200: 200px;
|
||||
--s-260: 260px;
|
||||
--s-320: 320px;
|
||||
@@ -94,6 +105,8 @@
|
||||
--font-15: 15px;
|
||||
--font-17: 17px;
|
||||
--font-18: 18px;
|
||||
--font-24: 24px;
|
||||
--font-32: 32px;
|
||||
--weight-400: 400;
|
||||
--weight-500: 500;
|
||||
--weight-600: 600;
|
||||
@@ -146,6 +159,10 @@
|
||||
--color-warning: var(--c-amber-700);
|
||||
--color-warning-soft: var(--c-amber-050);
|
||||
--color-overlay: rgba(18, 20, 22, 0.46);
|
||||
--color-heaven: var(--c-gold-700);
|
||||
--color-heaven-bright: var(--c-gold-500);
|
||||
--color-heaven-soft: var(--c-gold-100);
|
||||
--color-heaven-star: var(--c-night-star);
|
||||
--shadow-card: var(--shadow-card-light);
|
||||
|
||||
/* Component tokens */
|
||||
@@ -187,5 +204,7 @@
|
||||
--color-warning: var(--c-night-amber);
|
||||
--color-warning-soft: var(--c-night-amber-soft);
|
||||
--color-overlay: rgba(0, 0, 0, 0.64);
|
||||
--color-heaven: var(--c-night-amber);
|
||||
--color-heaven-soft: var(--c-night-amber-soft);
|
||||
--shadow-card: var(--shadow-card-dark);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
cryptography==49.0.0
|
||||
fastapi==0.141.0
|
||||
lunar-python==1.4.8
|
||||
pydantic==2.13.4
|
||||
tzdata==2026.3
|
||||
uvicorn==0.52.0
|
||||
|
||||
@@ -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.tushare import TushareProvider
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.data.sentiment import calculate_sentiment
|
||||
from backend.database.connection import Database
|
||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||
from backend.features.market.sentiment import calculate_sentiment
|
||||
from backend.features.market.snapshot import build_snapshot
|
||||
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
|
||||
from tests.support import run_scenario
|
||||
@@ -188,9 +188,7 @@ def test_ifind_top_level_tables_and_expired_access_token_are_handled() -> None:
|
||||
|
||||
def test_trade_context_keeps_real_snapshot_date(tmp_path) -> None:
|
||||
market = gateway(tmp_path)
|
||||
context = market.trade_context(
|
||||
"2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI)
|
||||
)
|
||||
context = market.trade_context("2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI))
|
||||
assert context.requested_date == "2026-07-30"
|
||||
assert context.actual_date == "2026-07-29"
|
||||
assert context.carried_forward is True
|
||||
@@ -288,6 +286,7 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
||||
}
|
||||
for index, change in enumerate((10, 4, -10, 2, -2), start=1)
|
||||
]
|
||||
|
||||
def event(index, streak=1):
|
||||
return {
|
||||
"ts_code": f"00000{index}.SZ",
|
||||
@@ -298,6 +297,7 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
||||
"amount": 100,
|
||||
"limit_times": streak,
|
||||
}
|
||||
|
||||
snapshot = build_snapshot(
|
||||
"2026-07-29",
|
||||
"2026-07-28",
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
runner = MigrationRunner(database)
|
||||
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8)
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
assert {
|
||||
"users",
|
||||
"memberships",
|
||||
@@ -139,8 +139,9 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
"mentor_messages",
|
||||
"llm_requests",
|
||||
"llm_attempts",
|
||||
"heaven_readings",
|
||||
} <= table_names(database)
|
||||
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (8, 7, 6, 5, 4, 3, 2, 1)
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (9, 8, 7, 6, 5, 4, 3, 2, 1)
|
||||
assert "users" not in table_names(database)
|
||||
assert "llm_models" not in table_names(database)
|
||||
|
||||
Reference in New Issue
Block a user