diff --git a/next/backend/bootstrap/container.py b/next/backend/bootstrap/container.py index f1aa8af..5e2d1bc 100644 --- a/next/backend/bootstrap/container.py +++ b/next/backend/bootstrap/container.py @@ -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, ) diff --git a/next/backend/data/gateway.py b/next/backend/data/gateway.py index faa310b..a1226df 100644 --- a/next/backend/data/gateway.py +++ b/next/backend/data/gateway.py @@ -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 diff --git a/next/backend/data/heaven.py b/next/backend/data/heaven.py new file mode 100644 index 0000000..991e415 --- /dev/null +++ b/next/backend/data/heaven.py @@ -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) diff --git a/next/backend/data/providers/base.py b/next/backend/data/providers/base.py index d7c9c98..ca90b30 100644 --- a/next/backend/data/providers/base.py +++ b/next/backend/data/providers/base.py @@ -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, diff --git a/next/backend/data/providers/eastmoney.py b/next/backend/data/providers/eastmoney.py index 0533638..c259c22 100644 --- a/next/backend/data/providers/eastmoney.py +++ b/next/backend/data/providers/eastmoney.py @@ -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, diff --git a/next/backend/data/providers/ifind.py b/next/backend/data/providers/ifind.py index f72fef6..0525e42 100644 --- a/next/backend/data/providers/ifind.py +++ b/next/backend/data/providers/ifind.py @@ -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, diff --git a/next/backend/data/providers/tushare.py b/next/backend/data/providers/tushare.py index 40c915d..9d77dc2 100644 --- a/next/backend/data/providers/tushare.py +++ b/next/backend/data/providers/tushare.py @@ -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, diff --git a/next/backend/features/market/sentiment.py b/next/backend/data/sentiment.py similarity index 100% rename from next/backend/features/market/sentiment.py rename to next/backend/data/sentiment.py diff --git a/next/backend/database/migrations/m0009_heaven.py b/next/backend/database/migrations/m0009_heaven.py new file mode 100644 index 0000000..14ae8fe --- /dev/null +++ b/next/backend/database/migrations/m0009_heaven.py @@ -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, +) diff --git a/next/backend/database/migrations/registry.py b/next/backend/database/migrations/registry.py index 2a381a4..6efee63 100644 --- a/next/backend/database/migrations/registry.py +++ b/next/backend/database/migrations/registry.py @@ -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, ) diff --git a/next/backend/features/heaven/__init__.py b/next/backend/features/heaven/__init__.py new file mode 100644 index 0000000..fc981c0 --- /dev/null +++ b/next/backend/features/heaven/__init__.py @@ -0,0 +1,3 @@ +from backend.features.heaven.service import HeavenService + +__all__ = ["HeavenService"] diff --git a/next/backend/features/heaven/fortune.py b/next/backend/features/heaven/fortune.py new file mode 100644 index 0000000..9304488 --- /dev/null +++ b/next/backend/features/heaven/fortune.py @@ -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}并见,宜防一端偏盛" diff --git a/next/backend/features/heaven/hexagram.py b/next/backend/features/heaven/hexagram.py new file mode 100644 index 0000000..b80569c --- /dev/null +++ b/next/backend/features/heaven/hexagram.py @@ -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) diff --git a/next/backend/features/heaven/prompt.py b/next/backend/features/heaven/prompt.py new file mode 100644 index 0000000..7fef677 --- /dev/null +++ b/next/backend/features/heaven/prompt.py @@ -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") + } diff --git a/next/backend/features/heaven/repository.py b/next/backend/features/heaven/repository.py new file mode 100644 index 0000000..ca125b2 --- /dev/null +++ b/next/backend/features/heaven/repository.py @@ -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 diff --git a/next/backend/features/heaven/routes.py b/next/backend/features/heaven/routes.py new file mode 100644 index 0000000..1191422 --- /dev/null +++ b/next/backend/features/heaven/routes.py @@ -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 diff --git a/next/backend/features/heaven/schemas.py b/next/backend/features/heaven/schemas.py new file mode 100644 index 0000000..e7a4f05 --- /dev/null +++ b/next/backend/features/heaven/schemas.py @@ -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 diff --git a/next/backend/features/heaven/service.py b/next/backend/features/heaven/service.py new file mode 100644 index 0000000..b6456e3 --- /dev/null +++ b/next/backend/features/heaven/service.py @@ -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") diff --git a/next/backend/features/heaven/trend.py b/next/backend/features/heaven/trend.py new file mode 100644 index 0000000..26ab7fe --- /dev/null +++ b/next/backend/features/heaven/trend.py @@ -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 "势衰宜守" diff --git a/next/backend/features/market/sync.py b/next/backend/features/market/sync.py index 70b852a..98aceae 100644 --- a/next/backend/features/market/sync.py +++ b/next/backend/features/market/sync.py @@ -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") diff --git a/next/backend/http/router.py b/next/backend/http/router.py index 4cce4a8..c44d1cf 100644 --- a/next/backend/http/router.py +++ b/next/backend/http/router.py @@ -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) diff --git a/next/config/heaven/iching_zh.json b/next/config/heaven/iching_zh.json new file mode 100644 index 0000000..cf7f468 --- /dev/null +++ b/next/config/heaven/iching_zh.json @@ -0,0 +1,2393 @@ +{ + "hexagrams": { + "(1, 1, 1, 1, 1, 1)": { + "name": "乾", + "text": "元亨,利贞。", + "image": "天行健,君子以自强不息。", + "lines": { + "1": { + "name": "初九", + "text": "潜龙勿用。", + "image": "潜龙勿用,阳在下也。" + }, + "2": { + "name": "九二", + "text": "见龙在田,利见大人。", + "image": "见龙在田,德施普也。" + }, + "3": { + "name": "九三", + "text": "君子终日乾乾,夕惕若厉,无咎。", + "image": "终日乾乾,反复道也。" + }, + "4": { + "name": "九四", + "text": "或跃在渊,无咎。", + "image": "或跃在渊,进无咎也。" + }, + "5": { + "name": "九五", + "text": "飞龙在天,利见大人。", + "image": "飞龙在天,大人造也。" + }, + "6": { + "name": "上九", + "text": "亢龙有悔。", + "image": "亢龙有悔,盈不可久也。" + } + }, + "use": { + "name": "用九", + "text": "见群龙无首,吉。", + "image": "天德不可为首也。" + } + }, + "(0, 0, 0, 0, 0, 0)": { + "name": "坤", + "text": "元亨,利牝马之贞。君子有攸往,先迷后得主,利西南得朋,东北丧朋。安贞,吉。", + "image": "地势坤,君子以厚德载物。", + "lines": { + "1": { + "name": "初六", + "text": "履霜,坚冰至。", + "image": "履霜坚冰至,阴始凝也。" + }, + "2": { + "name": "六二", + "text": "直,方,大,不习无不利。", + "image": "直方大不习,中正无邪也。" + }, + "3": { + "name": "六三", + "text": "含章可贞。或从王事,无成有终。", + "image": "含章可贞,以时发也。或从王事,知光大也。" + }, + "4": { + "name": "六四", + "text": "括囊;无咎,无誉。", + "image": "括囊无咎,慎不害也。无誉,未受命也。" + }, + "5": { + "name": "六五", + "text": "黄裳,元吉。", + "image": "黄裳元吉,文在中也。" + }, + "6": { + "name": "上六", + "text": "龙战于野,其血玄黄。", + "image": "龙战于野,其道穷也。" + } + }, + "use": { + "name": "用六", + "text": "利永贞。", + "image": "用六永贞,以大终也。" + } + }, + "(1, 0, 0, 0, 1, 0)": { + "name": "屯", + "text": "元亨,利贞,勿用有攸往,利建侯。", + "image": "云雷屯;君子以经纶。", + "lines": { + "1": { + "name": "初九", + "text": "磐桓,利居贞,利建侯。", + "image": "虽磐桓,志行正也。以贵下贱,大得民也。" + }, + "2": { + "name": "六二", + "text": "屯如邅如,乘马班如。匪寇婚媾,女子贞不字,十年乃字。", + "image": "六二之难,乘刚也。十年乃字,反常也。" + }, + "3": { + "name": "六三", + "text": "即鹿无虞,惟入于林中,君子几不如舍,往吝。", + "image": "即鹿无虞,以纵禽也。君子舍之,往吝穷也。" + }, + "4": { + "name": "九四", + "text": "乘马班如,求婚媾,往吉,无不利。", + "image": "求而往,明也。" + }, + "5": { + "name": "九五", + "text": "屯其膏,小贞吉,大贞凶。", + "image": "屯其膏,施未光也。" + }, + "6": { + "name": "上六", + "text": "乘马班如,泣血涟如。", + "image": "泣血涟如,何可长也。" + } + } + }, + "(0, 1, 0, 0, 0, 1)": { + "name": "蒙", + "text": "亨。匪我求童蒙,童蒙求我。初筮告,再三渎,渎则不告。利贞。", + "image": "山下出泉,蒙;君子以果行育德。", + "lines": { + "1": { + "name": "初六", + "text": "发蒙,利用刑人,用说桎梏,以往吝。", + "image": "利用刑人,以正法也。" + }, + "2": { + "name": "九二", + "text": "包蒙,吉。纳妇,吉。子克家。", + "image": "子克家,刚柔接也。" + }, + "3": { + "name": "六三", + "text": "勿用取女,见金夫,不有躬,无攸利。", + "image": "勿用取女,行不顺也。" + }, + "4": { + "name": "六四", + "text": "困蒙,吝。", + "image": "困蒙之吝,独远实也。" + }, + "5": { + "name": "六五", + "text": "童蒙,吉。", + "image": "童蒙之吉,顺以巽也。" + }, + "6": { + "name": "上九", + "text": "击蒙,不利为寇,利御寇。", + "image": "利用御寇,上下顺也。" + } + } + }, + "(1, 1, 1, 0, 1, 0)": { + "name": "需", + "text": "有孚,光亨,贞吉。利涉大川。", + "image": "云上于天,需;君子以饮食宴乐。", + "lines": { + "1": { + "name": "初九", + "text": "需于郊,利用恒,无咎。", + "image": "需于郊,不犯难行也。利用恒,无咎;未失常也。" + }, + "2": { + "name": "九二", + "text": "需于沙,小有言,终吉。", + "image": "需于沙,衍在中也。虽小有言,以终吉也。" + }, + "3": { + "name": "九三", + "text": "需于泥,致寇至。", + "image": "需于泥,灾在外也。自我致寇,敬慎不败也。" + }, + "4": { + "name": "六四", + "text": "需于血,出自穴。", + "image": "需于血,顺以听也。" + }, + "5": { + "name": "九五", + "text": "需于酒食,贞吉。", + "image": "酒食贞吉,以中正也。" + }, + "6": { + "name": "上六", + "text": "入于穴,有不速之客三人来,敬之终吉。", + "image": "不速之客来,敬之终吉;虽不当位,未大失也。" + } + } + }, + "(0, 1, 0, 1, 1, 1)": { + "name": "讼", + "text": "有孚,窒。惕中吉。终凶。利见大人,不利涉大川。", + "image": "天与水违行,讼;君子以作事谋始。", + "lines": { + "1": { + "name": "初六", + "text": "不永所事,小有言,终吉。", + "image": "不永所事,讼不可长也。虽有小言,其辩明也。" + }, + "2": { + "name": "九二", + "text": "不克讼,归而逋,其邑人三百户,无眚。", + "image": "不克讼,归而逋也。自下讼上,患至掇也。" + }, + "3": { + "name": "六三", + "text": "食旧德,贞厉,终吉,或从王事,无成。", + "image": "食旧德,从上吉也。" + }, + "4": { + "name": "九四", + "text": "不克讼,复即命渝,安贞吉。", + "image": "复即命,渝安贞;不失也。" + }, + "5": { + "name": "九五", + "text": "讼,元吉。", + "image": "讼元吉,以中正也。" + }, + "6": { + "name": "上九", + "text": "或锡之鞶带,终朝三褫之。", + "image": "以讼受服,亦不足敬也。" + } + } + }, + "(0, 1, 0, 0, 0, 0)": { + "name": "师", + "text": "贞,丈人吉,无咎。", + "image": "地中有水,师;君子以容民畜众。", + "lines": { + "1": { + "name": "初六", + "text": "师出以律,否臧凶。", + "image": "师出以律,失律凶也。" + }, + "2": { + "name": "九二", + "text": "在师中吉,无咎,王三锡命。", + "image": "在师中吉,承天宠也。王三锡命,怀万邦也。" + }, + "3": { + "name": "六三", + "text": "师或舆尸,凶。", + "image": "师或舆尸,大无功也。" + }, + "4": { + "name": "六四", + "text": "师左次,无咎。", + "image": "左次无咎,未失常也。" + }, + "5": { + "name": "六五", + "text": "田有禽,利执言,无咎。长子帅师,弟子舆尸,贞凶。", + "image": "长子帅师,以中行也。弟子舆尸,使不当也。" + }, + "6": { + "name": "上六", + "text": "大君有命,开国承家,小人勿用。", + "image": "大君有命,以正功也。小人勿用,必乱邦也。" + } + } + }, + "(0, 0, 0, 0, 1, 0)": { + "name": "比", + "text": "吉。原筮元永贞,无咎。不宁方来,后夫凶。", + "image": "地上有水,比;先王以建万国,亲诸侯。", + "lines": { + "1": { + "name": "初六", + "text": "有孚,比之,无咎。有孚盈缶,终来有他,吉。", + "image": "比之初六,有他吉也。" + }, + "2": { + "name": "六二", + "text": "比之自内,贞吉。", + "image": "比之自内,不自失也。" + }, + "3": { + "name": "六三", + "text": "比之匪人。", + "image": "比之匪人,不亦伤乎!" + }, + "4": { + "name": "六四", + "text": "外比之,贞吉。", + "image": "外比於贤,以从上也。" + }, + "5": { + "name": "九五", + "text": "显比,王用三驱,失前禽。邑人不诫,吉。", + "image": "显比之吉,位正中也。舍逆取顺,失前禽也。邑人不诫,上使中也。" + }, + "6": { + "name": "上六", + "text": "比之无首,凶。", + "image": "比之无首,无所终也。" + } + } + }, + "(1, 1, 1, 0, 1, 1)": { + "name": "小畜", + "text": "亨。密云不雨,自我西郊。", + "image": "风行天上,小畜;君子以懿文德。", + "lines": { + "1": { + "name": "初九", + "text": "复自道,何其咎?吉。", + "image": "复自道,其义吉也。" + }, + "2": { + "name": "九二", + "text": "牵复,吉。", + "image": "牵复在中,亦不自失也。" + }, + "3": { + "name": "九三", + "text": "舆说辐,夫妻反目。", + "image": "夫妻反目,不能正室也。" + }, + "4": { + "name": "六四", + "text": "有孚,血去惕出,无咎。", + "image": "有孚惕出,上合志也。" + }, + "5": { + "name": "九五", + "text": "有孚挛如,富以其邻。", + "image": "有孚挛如,不独富也。" + }, + "6": { + "name": "上九", + "text": "既雨既处,尚德载,妇贞厉。月几望,君子征凶。", + "image": "既雨既处,德积载也。君子征凶,有所疑也。" + } + } + }, + "(1, 1, 0, 1, 1, 1)": { + "name": "履", + "text": "履虎尾,不咥人,亨。", + "image": "上天下泽,履;君子以辨上下,安民志。", + "lines": { + "1": { + "name": "初九", + "text": "素履,往,无咎。", + "image": "素履之往,独行愿也。" + }, + "2": { + "name": "九二", + "text": "履道坦坦,幽人贞吉。", + "image": "幽人贞吉,中不自乱也。" + }, + "3": { + "name": "六三", + "text": "眇能视,跛能履,履虎尾,咥人,凶。武人为于大君。", + "image": "眇能视,不足以有明也。跛能履,不足以与行也。咥人之凶,位不当也。武人为于大君,志刚也。" + }, + "4": { + "name": "九四", + "text": "履虎尾,愬愬,终吉。", + "image": "诉诉终吉,志行也。" + }, + "5": { + "name": "九五", + "text": "夬履,贞厉。", + "image": "夬履贞厉,位正当也。" + }, + "6": { + "name": "上九", + "text": "视履考祥,其旋元吉。", + "image": "元吉在上,大有庆也。" + } + } + }, + "(1, 1, 1, 0, 0, 0)": { + "name": "泰", + "text": "小往大来,吉亨。", + "image": "天地交,泰;后以财成天地之道,辅相天地之宜,以左右民。", + "lines": { + "1": { + "name": "初九", + "text": "拔茅茹,以其汇,征吉。", + "image": "拔茅征吉,志在外也。" + }, + "2": { + "name": "九二", + "text": "包荒,用冯河,不遐遗,朋亡,得尚于中行。", + "image": "包荒,得尚于中行,以光大也。" + }, + "3": { + "name": "九三", + "text": "无平不陂,无往不复,艰贞无咎。勿恤其孚,于食有福。", + "image": "无往不复,天地际也。" + }, + "4": { + "name": "六四", + "text": "翩翩,不富,以其邻,不戒以孚。", + "image": "翩翩不富,皆失实也。不戒以孚,中心愿也。" + }, + "5": { + "name": "六五", + "text": "帝乙归妹,以祉元吉。", + "image": "以祉元吉,中以行愿也。" + }, + "6": { + "name": "上六", + "text": "城复于隍,勿用师。自邑告命,贞吝。", + "image": "城复于隍,其命乱也。" + } + } + }, + "(0, 0, 0, 1, 1, 1)": { + "name": "否", + "text": "否之匪人,不利君子贞,大往小来。", + "image": "天地不交,否;君子以俭德辟难,不可荣以禄。", + "lines": { + "1": { + "name": "初六", + "text": "拔茅茹,以其汇,贞吉亨。", + "image": "拔茅贞吉,志在君也。" + }, + "2": { + "name": "六二", + "text": "包承,小人吉,大人否,亨。", + "image": "大人否亨,不乱群也。" + }, + "3": { + "name": "六三", + "text": "包羞。", + "image": "包羞,位不当也。" + }, + "4": { + "name": "九四", + "text": "有命,无咎,畴离祉。", + "image": "有命无咎,志行也。" + }, + "5": { + "name": "九五", + "text": "休否,大人吉。其亡其亡,系于苞桑。", + "image": "大人之吉,位正当也。" + }, + "6": { + "name": "上九", + "text": "倾否,先否后喜。", + "image": "否终则倾,何可长也。" + } + } + }, + "(1, 0, 1, 1, 1, 1)": { + "name": "同人", + "text": "同人于野,亨。利涉大川。利君子贞。", + "image": "天与火,同人;君子以类族辨物。", + "lines": { + "1": { + "name": "初九", + "text": "同人于门,无咎。", + "image": "出门同人,又谁咎也。" + }, + "2": { + "name": "六二", + "text": "同人于宗,吝。", + "image": "同人于宗,吝道也。" + }, + "3": { + "name": "九三", + "text": "伏戎于莽,升其高陵,三岁不兴。", + "image": "伏戎于莽,敌刚也。三岁不兴,安行也。" + }, + "4": { + "name": "九四", + "text": "乘其墉,弗克攻,吉。", + "image": "乘其墉,义弗克也,其吉,则困而反则也。" + }, + "5": { + "name": "九五", + "text": "同人,先号啕而后笑。大师克相遇。", + "image": "同人之先,以中直也。大师相遇,言相克也。" + }, + "6": { + "name": "上九", + "text": "同人于郊,无悔。", + "image": "同人于郊,志未得也。" + } + } + }, + "(1, 1, 1, 1, 0, 1)": { + "name": "大有", + "text": "元亨。", + "image": "火在天上,大有;君子以竭恶扬善,顺天休命。", + "lines": { + "1": { + "name": "初九", + "text": "无交害,匪咎,艰则无咎。", + "image": "大有初九,无交害也。" + }, + "2": { + "name": "九二", + "text": "大车以载,有攸往,无咎。", + "image": "大车以载,积中不败也。" + }, + "3": { + "name": "九三", + "text": "公用亨于天子,小人弗克。", + "image": "公用亨于天子,小人害也。" + }, + "4": { + "name": "九四", + "text": "匪其彭,无咎。", + "image": "匪其彭,无咎;明辨晰也。" + }, + "5": { + "name": "六五", + "text": "厥孚交如,威如,吉。", + "image": "厥孚交如,信以发志也。威如之吉,易而无备也。" + }, + "6": { + "name": "上九", + "text": "自天佑之,吉无不利。", + "image": "大有上吉,自天佑也。" + } + } + }, + "(0, 0, 1, 0, 0, 0)": { + "name": "谦", + "text": "亨。君子有终。", + "image": "地中有山,谦;君子以裒多益寡,称物平施。", + "lines": { + "1": { + "name": "初六", + "text": "谦谦君子,用涉大川,吉。", + "image": "谦谦君子,卑以自牧也。" + }, + "2": { + "name": "六二", + "text": "鸣谦,贞吉。", + "image": "鸣谦贞吉,中心得也。" + }, + "3": { + "name": "九三", + "text": "劳谦君子,有终吉。", + "image": "劳谦君子,万民服也。" + }, + "4": { + "name": "六四", + "text": "无不利,撝谦。", + "image": "无不利,撝谦;不违则也。" + }, + "5": { + "name": "六五", + "text": "不富,以其邻,利用侵伐,无不利。", + "image": "利用侵伐,征不服也。" + }, + "6": { + "name": "上六", + "text": "鸣谦,利用行师,征邑国。", + "image": "鸣谦,志未得也。可用行师,征邑国也。" + } + } + }, + "(0, 0, 0, 1, 0, 0)": { + "name": "豫", + "text": "利建侯行师。", + "image": "雷出地奋,豫。先王以作乐崇德,殷荐之上帝,以配祖考。", + "lines": { + "1": { + "name": "初六", + "text": "鸣豫,凶。", + "image": "初六鸣豫,志穷凶也。" + }, + "2": { + "name": "六二", + "text": "介于石,不终日,贞吉。", + "image": "不终日,贞吉;以中正也。" + }, + "3": { + "name": "六三", + "text": "盱豫,悔。迟有悔。", + "image": "盱豫有悔,位不当也。" + }, + "4": { + "name": "九四", + "text": "由豫,大有得。勿疑。朋盍簪。", + "image": "由豫,大有得;志大行也。" + }, + "5": { + "name": "六五", + "text": "贞疾,恒不死。", + "image": "六五贞疾,乘刚也。恒不死,中未亡也。" + }, + "6": { + "name": "上六", + "text": "冥豫,成有渝,无咎。", + "image": "冥豫在上,何可长也。" + } + } + }, + "(1, 0, 0, 1, 1, 0)": { + "name": "随", + "text": "元亨利贞,无咎。", + "image": "泽中有雷,随;君子以向晦入宴息。", + "lines": { + "1": { + "name": "初六", + "text": "官有渝,贞吉。出门交有功。", + "image": "官有渝,从正吉也。出门交有功,不失也。" + }, + "2": { + "name": "六二", + "text": "系小子,失丈夫。", + "image": "系小子,弗兼与也。" + }, + "3": { + "name": "六三", + "text": "系丈夫,失小子。随有求得,利居贞。", + "image": "系丈夫,志舍下也。" + }, + "4": { + "name": "九四", + "text": "随有获,贞凶。有孚在道,以明,何咎。", + "image": "随有获,其义凶也。有孚在道,明功也。" + }, + "5": { + "name": "九五", + "text": "孚于嘉,吉。", + "image": "孚于嘉,吉;位正中也。" + }, + "6": { + "name": "上六", + "text": "拘系之,乃从维之,王用亨于西山。", + "image": "拘系之,上穷也。" + } + } + }, + "(0, 1, 1, 0, 0, 1)": { + "name": "蛊", + "text": "元亨,利涉大川。先甲三日,后甲三日。", + "image": "山下有风,蛊;君子以振民育德。", + "lines": { + "1": { + "name": "初六", + "text": "干父之蛊,有子,考无咎,厉终吉。", + "image": "干父之蛊,意承考也。" + }, + "2": { + "name": "九二", + "text": "干母之蛊,不可贞。", + "image": "干母之蛊,得中道也。" + }, + "3": { + "name": "九三", + "text": "干父之蛊,小有悔,无大咎。", + "image": "干父之蛊,终无咎也。" + }, + "4": { + "name": "六四", + "text": "裕父之蛊,往见吝。", + "image": "裕父之蛊,往未得也。" + }, + "5": { + "name": "六五", + "text": "干父之蛊,用誉。", + "image": "干父之蛊;承以德也。" + }, + "6": { + "name": "上九", + "text": "不事王侯,高尚其事。", + "image": "不事王侯,志可则也。" + } + } + }, + "(1, 1, 0, 0, 0, 0)": { + "name": "临", + "text": "元,亨,利,贞。至于八月有凶。", + "image": "泽上有地,临;君子以教思无穷,容保民无疆。", + "lines": { + "1": { + "name": "初六", + "text": "咸临,贞吉。", + "image": "咸临贞吉,志行正也。" + }, + "2": { + "name": "九二", + "text": "咸临,吉无不利。", + "image": "咸临,吉无不利;未顺命也。" + }, + "3": { + "name": "六三", + "text": "甘临,无攸利。既忧之,无咎。", + "image": "甘临,位不当也。既忧之,咎不长也。" + }, + "4": { + "name": "六四", + "text": "至临,无咎。", + "image": "至临无咎,位当也。" + }, + "5": { + "name": "六五", + "text": "知临,大君之宜,吉。", + "image": "大君之宜,行中之谓也。" + }, + "6": { + "name": "上六", + "text": "敦临,吉无咎。", + "image": "敦临之吉,志在内也。" + } + } + }, + "(0, 0, 0, 0, 1, 1)": { + "name": "观", + "text": "盥而不荐,有孚顒若。", + "image": "风行地上,观;先王以省方,观民设教。", + "lines": { + "1": { + "name": "初六", + "text": "童观,小人无咎,君子吝。", + "image": "初六童观,小人道也。" + }, + "2": { + "name": "六二", + "text": "窥观,利女贞。", + "image": "窥观女贞,亦可丑也。" + }, + "3": { + "name": "六三", + "text": "观我生,进退。", + "image": "观我生,进退;未失道也。" + }, + "4": { + "name": "六四", + "text": "观国之光,利用宾于王。", + "image": "观国之光,尚宾也。" + }, + "5": { + "name": "九五", + "text": "观我生,君子无咎。", + "image": "观我生,观民也。" + }, + "6": { + "name": "上九", + "text": "观其生,君子无咎。", + "image": "观其生,志未平也。" + } + } + }, + "(1, 0, 0, 1, 0, 1)": { + "name": "噬嗑", + "text": "亨。利用狱。", + "image": "雷电噬嗑;先王以明罚敕法。", + "lines": { + "1": { + "name": "初九", + "text": "屦校灭趾,无咎。", + "image": "屦校灭趾,不行也。" + }, + "2": { + "name": "六二", + "text": "噬肤灭鼻,无咎。", + "image": "噬肤灭鼻,乘刚也。" + }, + "3": { + "name": "六三", + "text": "噬腊肉,遇毒;小吝,无咎。", + "image": "遇毒,位不当也。" + }, + "4": { + "name": "九四", + "text": "噬乾胏,得金矢,利艰贞,吉。", + "image": "利艰贞吉,未光也。" + }, + "5": { + "name": "六五", + "text": "噬乾肉,得黄金,贞厉,无咎。", + "image": "贞厉无咎,得当也。" + }, + "6": { + "name": "上九", + "text": "何校灭耳,凶。", + "image": "何校灭耳,聪不明也。" + } + } + }, + "(1, 0, 1, 0, 0, 1)": { + "name": "贲", + "text": "亨。小利有攸往。", + "image": "山下有火,贲;君子以明庶政,无敢折狱。", + "lines": { + "1": { + "name": "初九", + "text": "贲其趾,舍车而徒。", + "image": "舍车而徒,义弗乘也。" + }, + "2": { + "name": "六二", + "text": "贲其须。", + "image": "贲其须,与上兴也。" + }, + "3": { + "name": "九三", + "text": "贲如濡如,永贞吉。", + "image": "永贞之吉,终莫之陵也。" + }, + "4": { + "name": "六四", + "text": "贲如皤如,白马翰如,匪寇婚媾。", + "image": "当位疑也。匪寇婚媾,终无尤也。" + }, + "5": { + "name": "六五", + "text": "贲于丘园,束帛戋戋,吝,终吉。", + "image": "六五之吉,有喜也。" + }, + "6": { + "name": "上九", + "text": "白贲,无咎。", + "image": "白贲无咎,上得志也。" + } + } + }, + "(0, 0, 0, 0, 0, 1)": { + "name": "剥", + "text": "不利有攸往。", + "image": "山附地上,剥;上以厚下,安宅。", + "lines": { + "1": { + "name": "初六", + "text": "剥床以足,蔑贞凶。", + "image": "剥床以足,以灭下也。" + }, + "2": { + "name": "六二", + "text": "剥床以辨,蔑贞凶。", + "image": "剥床以辨,未有与也。" + }, + "3": { + "name": "六三", + "text": "剥之,无咎。", + "image": "剥之无咎,失上下也。" + }, + "4": { + "name": "六四", + "text": "剥床以肤,凶。", + "image": "剥床以肤,切近灾也。" + }, + "5": { + "name": "六五", + "text": "贯鱼,以宫人宠,无不利。", + "image": "以宫人宠,终无尤也。" + }, + "6": { + "name": "上九", + "text": "硕果不食,君子得舆,小人剥庐。", + "image": "君子得舆,民所载也。小人剥庐,终不可用也。" + } + } + }, + "(1, 0, 0, 0, 0, 0)": { + "name": "复", + "text": "亨。出入无疾,朋来无咎。反复其道,七日来复,利有攸往。", + "image": "雷在地中,复;先王以至日闭关,商旅不行,后不省方。", + "lines": { + "1": { + "name": "初九", + "text": "不远复,无祇悔,元吉。", + "image": "不远之复,以修身也。" + }, + "2": { + "name": "六二", + "text": "休复,吉。", + "image": "休复之吉,以下仁也。" + }, + "3": { + "name": "六三", + "text": "频复,厉无咎。", + "image": "频复之厉,义无咎也。" + }, + "4": { + "name": "六四", + "text": "中行独复。", + "image": "中行独复,以从道也。" + }, + "5": { + "name": "六五", + "text": "敦复,无悔。", + "image": "敦复无悔,中以自考也。" + }, + "6": { + "name": "上六", + "text": "迷复,凶,有灾眚。用行师,终有大败,以其国君,凶;至于十年,不克征。", + "image": "迷复之凶,反君道也。" + } + } + }, + "(1, 0, 0, 1, 1, 1)": { + "name": "无妄", + "text": "元亨,利贞。其匪正有眚,不利有攸往。", + "image": "天下雷行,物与无妄;先王以茂对时,育万物。", + "lines": { + "1": { + "name": "初九", + "text": "无妄,往吉。", + "image": "无妄之往,得志也。" + }, + "2": { + "name": "六二", + "text": "不耕获,不灾畲,则利有攸往。", + "image": "不耕获,未富也。" + }, + "3": { + "name": "六三", + "text": "无妄之灾,或系之牛,行人之得,邑人之灾。", + "image": "行人得牛,邑人灾也。" + }, + "4": { + "name": "九四", + "text": "可贞,无咎。", + "image": "可贞无咎,固有之也。" + }, + "5": { + "name": "九五", + "text": "无妄之疾,勿药有喜。", + "image": "无妄之药,不可试也。" + }, + "6": { + "name": "上九", + "text": "无妄,行有眚,无攸利。", + "image": "无妄之行,穷之灾也。" + } + } + }, + "(1, 1, 1, 0, 0, 1)": { + "name": "大畜", + "text": "利贞,不家食吉,利涉大川。", + "image": "天在山中,大畜;君子以多识前言往行,以畜其德。", + "lines": { + "1": { + "name": "初九", + "text": "有厉利已。", + "image": "有厉利已,不犯灾也。" + }, + "2": { + "name": "九二", + "text": "舆说輹。", + "image": "舆说輹,中无尤也。" + }, + "3": { + "name": "九三", + "text": "良马逐,利艰贞,曰闲舆卫,利有攸往。", + "image": "利有攸往,上合志也。" + }, + "4": { + "name": "六四", + "text": "童牛之牿,元吉。", + "image": "六四元吉,有喜也。" + }, + "5": { + "name": "六五", + "text": "豮豕之牙,吉。", + "image": "六五之吉,有庆也。" + }, + "6": { + "name": "上九", + "text": "何天之衢,亨。", + "image": "何天之衢,道大行也。" + } + } + }, + "(1, 0, 0, 0, 0, 1)": { + "name": "颐", + "text": "贞吉。观颐,自求口实。", + "image": "山下有雷,颐;君子以慎言语,节饮食。", + "lines": { + "1": { + "name": "初九", + "text": "舍尔灵龟,观我朵颐,凶。", + "image": "观我朵颐,亦不足贵也。" + }, + "2": { + "name": "六二", + "text": "颠颐,拂经,于丘颐,征凶。", + "image": "六二征凶,行失类也。" + }, + "3": { + "name": "六三", + "text": "拂颐,贞凶,十年勿用,无攸利。", + "image": "十年勿用,道大悖也。" + }, + "4": { + "name": "六四", + "text": "颠颐,吉;虎视眈眈,其欲逐逐,无咎。", + "image": "颠颐之吉,上施光也。" + }, + "5": { + "name": "六五", + "text": "拂经,居贞吉,不可涉大川。", + "image": "居贞之吉,顺以从上也。" + }, + "6": { + "name": "上九", + "text": "由颐,厉吉,利涉大川。", + "image": "由颐厉吉,大有庆也。" + } + } + }, + "(0, 1, 1, 1, 1, 0)": { + "name": "大过", + "text": "栋挠,利有攸往,亨。", + "image": "泽灭木,大过;君子以独立不惧,遁世无闷。", + "lines": { + "1": { + "name": "初六", + "text": "藉用白茅,无咎。", + "image": "藉用白茅,柔在下也。" + }, + "2": { + "name": "九二", + "text": "枯杨生稊,老夫得其女妻,无不利。", + "image": "老夫女妻,过以相与也。" + }, + "3": { + "name": "九三", + "text": "栋桡,凶。", + "image": "栋桡之凶,不可以有辅也。" + }, + "4": { + "name": "九四", + "text": "栋隆,吉;有它吝。", + "image": "栋隆之吉,不桡乎下也。" + }, + "5": { + "name": "九五", + "text": "枯杨生华,老妇得士夫,无咎无誉。", + "image": "枯杨生华,何可久也。老妇士夫,亦可丑也。" + }, + "6": { + "name": "上六", + "text": "过涉灭顶,凶,无咎。", + "image": "过涉之凶,不可咎也。" + } + } + }, + "(0, 1, 0, 0, 1, 0)": { + "name": "坎", + "text": "有孚,维心亨,行有尚。", + "image": "水洊至,习坎;君子以常德行,习教事。", + "lines": { + "1": { + "name": "初六", + "text": "习坎,入于坎窞,凶。", + "image": "习坎入坎,失道凶也。" + }, + "2": { + "name": "九二", + "text": "坎有险,求小得。", + "image": "求小得,未出中也。" + }, + "3": { + "name": "六三", + "text": "来之坎坎,险且枕,入于坎窞,勿用。", + "image": "来之坎坎,终无功也。" + }, + "4": { + "name": "六四", + "text": "樽酒簋贰,用缶,纳约自牖,终无咎。", + "image": "樽酒簋贰,刚柔际也。" + }, + "5": { + "name": "九五", + "text": "坎不盈,只既平,无咎。", + "image": "坎不盈,中未大也。" + }, + "6": { + "name": "上六", + "text": "系用徽纆,置于丛棘,三岁不得,凶。", + "image": "上六失道,凶三岁也。" + } + } + }, + "(1, 0, 1, 1, 0, 1)": { + "name": "离", + "text": "利贞,亨。畜牝牛,吉。", + "image": "明两作离,大人以继明照于四方。", + "lines": { + "1": { + "name": "初九", + "text": "履错然,敬之无咎。", + "image": "履错之敬,以辟咎也。" + }, + "2": { + "name": "六二", + "text": "黄离,元吉。", + "image": "黄离元吉,得中道也。" + }, + "3": { + "name": "九三", + "text": "日昃之离,不鼓缶而歌,则大耋之嗟,凶。", + "image": "日昃之离,何可久也。" + }, + "4": { + "name": "九四", + "text": "突如其来如,焚如,死如,弃如。", + "image": "突如其来如,无所容也。" + }, + "5": { + "name": "六五", + "text": "出涕沱若,戚嗟若,吉。", + "image": "六五之吉,离王公也。" + }, + "6": { + "name": "上九", + "text": "王用出征,有嘉折首,获匪其丑,无咎。", + "image": "王用出征,以正邦也。" + } + } + }, + "(0, 0, 1, 1, 1, 0)": { + "name": "咸", + "text": "亨,利贞,取女吉。", + "image": "山上有泽,咸;君子以虚受人。", + "lines": { + "1": { + "name": "初六", + "text": "咸其拇。", + "image": "咸其拇,志在外也。" + }, + "2": { + "name": "六二", + "text": "咸其腓,凶,居吉。", + "image": "虽凶居吉,顺不害也。" + }, + "3": { + "name": "九三", + "text": "咸其股,执其随,往吝。", + "image": "咸其股,亦不处也。志在随人,所执下也。" + }, + "4": { + "name": "九四", + "text": "贞吉,悔亡,憧憧往来,朋从尔思。", + "image": "贞吉悔亡,未感害也。憧憧往来,未光大也。" + }, + "5": { + "name": "九五", + "text": "咸其脢,无悔。", + "image": "咸其脢,志末也。" + }, + "6": { + "name": "上六", + "text": "咸其辅颊舌。", + "image": "咸其辅颊舌,滕口说也。" + } + } + }, + "(0, 1, 1, 1, 0, 0)": { + "name": "恒", + "text": "亨,无咎,利贞,利有攸往。", + "image": "雷风恒;君子以立不易方。", + "lines": { + "1": { + "name": "初六", + "text": "浚恒,贞凶,无攸利。", + "image": "浚恒之凶,始求深也。" + }, + "2": { + "name": "九二", + "text": "悔亡。", + "image": "九二悔亡,能久中也。" + }, + "3": { + "name": "九三", + "text": "不恒其德,或承之羞,贞吝。", + "image": "不恒其德,无所容也。" + }, + "4": { + "name": "九四", + "text": "田无禽。", + "image": "久非其位,安得禽也。" + }, + "5": { + "name": "六五", + "text": "恒其德,贞;妇人吉,夫子凶。", + "image": "妇人贞吉,从一而终也。夫子制义,从妇凶也。" + }, + "6": { + "name": "上六", + "text": "振恒,凶。", + "image": "振恒在上,大无功也。" + } + } + }, + "(0, 0, 1, 1, 1, 1)": { + "name": "遁", + "text": "亨,小利贞。", + "image": "天下有山,遁;君子以远小人,不恶而严。", + "lines": { + "1": { + "name": "初六", + "text": "遁尾,厉,勿用有攸往。", + "image": "遁尾之厉,不往何灾也。" + }, + "2": { + "name": "六二", + "text": "执之用黄牛之革,莫之胜说。", + "image": "执用黄牛,固志也。" + }, + "3": { + "name": "九三", + "text": "系遁,有疾厉,畜臣妾吉。", + "image": "系遁之厉,有疾惫也。畜臣妾吉,不可大事也。" + }, + "4": { + "name": "九四", + "text": "好遁,君子吉,小人否。", + "image": "君子好遁,小人否也。" + }, + "5": { + "name": "九五", + "text": "嘉遁,贞吉。", + "image": "嘉遁贞吉,以正志也。" + }, + "6": { + "name": "上九", + "text": "肥遁,无不利。", + "image": "肥遁无不利,无所疑也。" + } + } + }, + "(1, 1, 1, 1, 0, 0)": { + "name": "大壮", + "text": "利贞。", + "image": "雷在天上,大壮;君子以非礼勿履。", + "lines": { + "1": { + "name": "初九", + "text": "壮于趾,征凶,有孚。", + "image": "壮于趾,其孚穷也。" + }, + "2": { + "name": "九二", + "text": "贞吉。", + "image": "九二贞吉,以中也。" + }, + "3": { + "name": "九三", + "text": "小人用壮,君子用罔,贞厉,羝羊触藩,羸其角。", + "image": "小人用壮,君子罔也。" + }, + "4": { + "name": "九四", + "text": "贞吉悔亡;藩决不羸,壮于大舆之輹。", + "image": "藩决不羸,尚往也。" + }, + "5": { + "name": "六五", + "text": "丧羊于易,无悔。", + "image": "丧羊于易,位不当也。" + }, + "6": { + "name": "上六", + "text": "羝羊触藩,不能退,不能遂,无攸利,艰则吉。", + "image": "不能退,不能遂,不祥也。艰则吉,咎不长也。" + } + } + }, + "(0, 0, 0, 1, 0, 1)": { + "name": "晋", + "text": "康侯用锡马蕃庶,昼日三接。", + "image": "明出地上,晋;君子以自昭明德。", + "lines": { + "1": { + "name": "初六", + "text": "晋如,摧如,贞吉。罔孚,裕无咎。", + "image": "晋如,摧如;独行正也。裕无咎;未受命也。" + }, + "2": { + "name": "六二", + "text": "晋如,愁如,贞吉;受兹介福,于其王母。", + "image": "受兹介福,以中正也。" + }, + "3": { + "name": "六三", + "text": "众允,悔亡。", + "image": "众允之,志上行也。" + }, + "4": { + "name": "九四", + "text": "晋如硕鼠,贞厉。", + "image": "硕鼠贞厉,位不当也。" + }, + "5": { + "name": "六五", + "text": "悔亡,失得勿恤,往吉,无不利。", + "image": "失得勿恤,往有庆也。" + }, + "6": { + "name": "上九", + "text": "晋其角,维用伐邑,厉吉无咎,贞吝。", + "image": "维用伐邑,道未光也。" + } + } + }, + "(1, 0, 1, 0, 0, 0)": { + "name": "明夷", + "text": "利艰贞。", + "image": "明入地中,明夷;君子以莅众,用晦而明。", + "lines": { + "1": { + "name": "初九", + "text": "明夷于飞,垂其翼;君子于行,三日不食,有攸往,主人有言。", + "image": "君子于行,义不食也。" + }, + "2": { + "name": "六二", + "text": "明夷,夷于左股,用拯马壮,吉。", + "image": "六二之吉,顺以则也。" + }, + "3": { + "name": "九三", + "text": "明夷于南狩,得其大首,不可疾贞。", + "image": "南狩之志,乃大得也。" + }, + "4": { + "name": "六四", + "text": "入于左腹,获明夷之心,出于门庭。", + "image": "入于左腹,获心意也。" + }, + "5": { + "name": "六五", + "text": "箕子之明夷,利贞。", + "image": "箕子之贞,明不可息也。" + }, + "6": { + "name": "上六", + "text": "不明晦,初登于天,后入于地。", + "image": "初登于天,照四国也。后入于地,失则也。" + } + } + }, + "(1, 0, 1, 0, 1, 1)": { + "name": "家人", + "text": "利女贞。", + "image": "风自火出,家人;君子以言有物,而行有恒。", + "lines": { + "1": { + "name": "初九", + "text": "闲有家,悔亡。", + "image": "闲有家,志未变也。" + }, + "2": { + "name": "六二", + "text": "无攸遂,在中馈,贞吉。", + "image": "六二之吉,顺以巽也。" + }, + "3": { + "name": "九三", + "text": "九三:家人嗃嗃,悔厉吉;妇子嘻嘻,终吝。", + "image": "家人嗃嗃,未失也;妇子嘻嘻,失家节也。" + }, + "4": { + "name": "九四", + "text": "富家,大吉。", + "image": "富家大吉,顺在位也。" + }, + "5": { + "name": "九五", + "text": "王假有家,勿恤,往吉。", + "image": "王假有家,交相爱也。" + }, + "6": { + "name": "上九", + "text": "有孚威如,终吉。", + "image": "威如之吉,反身之谓也。" + } + } + }, + "(1, 1, 0, 1, 0, 1)": { + "name": "睽", + "text": "小事吉。", + "image": "上火下泽,睽;君子以同而异。", + "lines": { + "1": { + "name": "初九", + "text": "悔亡,丧马勿逐,自复;见恶人无咎。", + "image": "见恶人,以辟咎也。" + }, + "2": { + "name": "九二", + "text": "遇主于巷,无咎。", + "image": "遇主于巷,未失道也。" + }, + "3": { + "name": "六三", + "text": "见舆曳,其牛掣,其人天且劓,无初有终。", + "image": "见舆曳,位不当也。无初有终,遇刚也。" + }, + "4": { + "name": "九四", + "text": "睽孤,遇元夫,交孚,厉无咎。", + "image": "交孚无咎,志行也。" + }, + "5": { + "name": "六五", + "text": "悔亡,厥宗噬肤,往何咎。", + "image": "厥宗噬肤,往有庆也。" + }, + "6": { + "name": "上九", + "text": "睽孤,见豕负涂,载鬼一车,先张之弧,后说之弧;匪寇婚媾;往遇雨则吉。", + "image": "遇雨之吉,群疑亡也。" + } + } + }, + "(0, 0, 1, 0, 1, 0)": { + "name": "蹇", + "text": "利西南,不利东北;利见大人,贞吉。", + "image": "山上有水,蹇;君子以反身修德。", + "lines": { + "1": { + "name": "初六", + "text": "往蹇,来誉。", + "image": "往蹇来誉,宜待也。" + }, + "2": { + "name": "六二", + "text": "王臣蹇蹇,匪躬之故。", + "image": "王臣蹇蹇,终无尤也。" + }, + "3": { + "name": "九三", + "text": "往蹇来反。", + "image": "往蹇来反,内喜之也。" + }, + "4": { + "name": "六四", + "text": "往蹇来连。", + "image": "往蹇来连,当位实也。" + }, + "5": { + "name": "九五", + "text": "大蹇朋来。", + "image": "大蹇朋来,以中节也。" + }, + "6": { + "name": "上六", + "text": "往蹇来硕,吉;利见大人。", + "image": "往蹇来硕,志在内也。利见大人,以从贵也。" + } + } + }, + "(0, 1, 0, 1, 0, 0)": { + "name": "解", + "text": "利西南,无所往,其来复吉;有攸往,夙吉。", + "image": "雷雨作,解;君子以赦过宥罪。", + "lines": { + "1": { + "name": "初六", + "text": "无咎。", + "image": "刚柔之际,义无咎也。" + }, + "2": { + "name": "九二", + "text": "田获三狐,得黄矢,贞吉。", + "image": "九二贞吉,得中道也。" + }, + "3": { + "name": "六三", + "text": "负且乘,致寇至,贞吝。", + "image": "负且乘,亦可丑也。自我致戎,又谁咎也。" + }, + "4": { + "name": "九四", + "text": "解而拇,朋至斯孚。", + "image": "解而拇,未当位也。" + }, + "5": { + "name": "六五", + "text": "君子维有解,吉;有孚于小人。", + "image": "君子有解,小人退也。" + }, + "6": { + "name": "上六", + "text": "公用射隼于高墉之上,获之,无不利。", + "image": "公用射隼,以解悖也。" + } + } + }, + "(1, 1, 0, 0, 0, 1)": { + "name": "损", + "text": "有孚,元吉,无咎,可贞,利有攸往。曷之用,二簋可用享。", + "image": "山下有泽,损;君子以惩忿窒欲。", + "lines": { + "1": { + "name": "初九", + "text": "己事遄往,无咎,酌损之。", + "image": "己事遄往,尚合志也。" + }, + "2": { + "name": "九二", + "text": "利贞,征凶,弗损,益之。", + "image": "九二利贞,中以为志也。" + }, + "3": { + "name": "六三", + "text": "三人行,则损一人;一人行,则得其友。", + "image": "一人行,三则疑也。" + }, + "4": { + "name": "六四", + "text": "损其疾,使遄有喜,无咎。", + "image": "损其疾,亦可喜也。" + }, + "5": { + "name": "六五", + "text": "或益之十朋之龟,弗克违,元吉。", + "image": "六五元吉,自上佑也。" + }, + "6": { + "name": "上九", + "text": "弗损益之,无咎,贞吉,利有攸往,得臣无家。", + "image": "弗损益之,大得志也。" + } + } + }, + "(1, 0, 0, 0, 1, 1)": { + "name": "益", + "text": "利有攸往,利涉大川。", + "image": "风雷,益;君子以见善则迁,有过则改。", + "lines": { + "1": { + "name": "初九", + "text": "利用为大作,元吉,无咎。", + "image": "元吉无咎,下不厚事也。" + }, + "2": { + "name": "六二", + "text": "或益之十朋之龟,弗克违,永贞吉;王用享于帝,吉。", + "image": "或益之,自外来也。" + }, + "3": { + "name": "六三", + "text": "益之用凶事,无咎。有孚中行,告公用圭。", + "image": "益用凶事,固有之也。" + }, + "4": { + "name": "六四", + "text": "中行告公从,利用为依迁国。", + "image": "告公从,以益志也。" + }, + "5": { + "name": "九五", + "text": "有孚惠心,勿问元吉。有孚惠我德。", + "image": "有孚惠心,勿问之矣。惠我德,大得志也。" + }, + "6": { + "name": "上九", + "text": "莫益之,或击之,立心勿恒,凶。", + "image": "莫益之,偏辞也。或击之,自外来也。" + } + } + }, + "(1, 1, 1, 1, 1, 0)": { + "name": "夬", + "text": "扬于王庭,孚号,有厉,告自邑,不利即戎,利有攸往。", + "image": "泽上于天,夬;君子以施禄及下,居德则忌。", + "lines": { + "1": { + "name": "初九", + "text": "壮于前趾,往不胜为咎。", + "image": "往不胜为咎,得志也。" + }, + "2": { + "name": "九二", + "text": "惕号,莫夜有戎,勿恤。", + "image": "有戎勿恤,得中道也。" + }, + "3": { + "name": "九三", + "text": "壮于頄,有凶。君子夬夬,独行遇雨,若濡有愠,无咎。", + "image": "君子夬夬,终无咎也。" + }, + "4": { + "name": "九四", + "text": "臀无肤,其行次且。牵羊悔亡,闻言不信。", + "image": "其行次且,位不当也。闻言不信,聪不明也。" + }, + "5": { + "name": "九五", + "text": "苋陆夬夬,中行无咎。", + "image": "中行无咎,中未光也。" + }, + "6": { + "name": "上六", + "text": "无号,终有凶。", + "image": "无号之凶,终不可长也。" + } + } + }, + "(0, 1, 1, 1, 1, 1)": { + "name": "姤", + "text": "女壮,勿用取女。", + "image": "天下有风,姤;后以施命诰四方。", + "lines": { + "1": { + "name": "初六", + "text": "系于金柅,贞吉,有攸往,见凶,羸豕孚蹢躅。", + "image": "系于金柅,柔道牵也。" + }, + "2": { + "name": "九二", + "text": "包有鱼,无咎,不利宾。", + "image": "包有鱼,义不及宾也。" + }, + "3": { + "name": "九三", + "text": "臀无肤,其行次且,厉,无大咎。", + "image": "其行次且,行未牵也。" + }, + "4": { + "name": "九四", + "text": "包无鱼,起凶。", + "image": "无鱼之凶,远民也。" + }, + "5": { + "name": "九五", + "text": "以杞包瓜,含章,有陨自天。", + "image": "九五含章,中正也。有陨自天,志不舍命也。" + }, + "6": { + "name": "上九", + "text": "姤其角,吝,无咎。", + "image": "姤其角,上穷吝也。" + } + } + }, + "(0, 0, 0, 1, 1, 0)": { + "name": "萃", + "text": "亨,王假有庙,利见大人,亨,利贞,用大牲吉,利有攸往。", + "image": "泽上于地,萃;君子以除戎器,戒不虞。", + "lines": { + "1": { + "name": "初六", + "text": "有孚不终,乃乱乃萃,若号,一握为笑,勿恤,往无咎。", + "image": "乃乱乃萃,其志乱也。" + }, + "2": { + "name": "六二", + "text": "引吉,无咎,孚乃利用禴。", + "image": "引吉无咎,中未变也。" + }, + "3": { + "name": "六三", + "text": "萃如,嗟如,无攸利,往无咎,小吝。", + "image": "往无咎,上巽也。" + }, + "4": { + "name": "九四", + "text": "大吉,无咎。", + "image": "大吉无咎,位不当也。" + }, + "5": { + "name": "九五", + "text": "萃有位,无咎;匪孚,元永贞,悔亡。", + "image": "萃有位,志未光也。" + }, + "6": { + "name": "上六", + "text": "赍咨涕洟,无咎。", + "image": "赍咨涕洟,未安上也。" + } + } + }, + "(0, 1, 1, 0, 0, 0)": { + "name": "升", + "text": "元亨,用见大人,勿恤,南征吉。", + "image": "地中生木,升;君子以顺德,积小以高大。", + "lines": { + "1": { + "name": "初六", + "text": "允升,大吉。", + "image": "允升大吉,上合志也。" + }, + "2": { + "name": "九二", + "text": "孚乃利用禴,无咎。", + "image": "九二之孚,有喜也。" + }, + "3": { + "name": "九三", + "text": "升虚邑。", + "image": "升虚邑,无所疑也。" + }, + "4": { + "name": "六四", + "text": "王用亨于岐山,吉,无咎。", + "image": "王用亨于岐山,顺事也。" + }, + "5": { + "name": "六五", + "text": "贞吉,升阶。", + "image": "贞吉升阶,大得志也。" + }, + "6": { + "name": "上六", + "text": "冥升,利于不息之贞。", + "image": "冥升在上,消不富也。" + } + } + }, + "(0, 1, 0, 1, 1, 0)": { + "name": "困", + "text": "亨,贞,大人吉,无咎,有言不信。", + "image": "泽无水,困;君子以致命遂志。", + "lines": { + "1": { + "name": "初六", + "text": "臀困于株木,入于幽谷,三岁不见。", + "image": "入于幽谷,幽不明也。" + }, + "2": { + "name": "九二", + "text": "困于酒食,朱绂方来,利用享祀,征凶,无咎。", + "image": "困于酒食,中有庆也。" + }, + "3": { + "name": "六三", + "text": "困于石,据于蒺藜,入于其宫,不见其妻,凶。", + "image": "据于蒺蔾,乘刚也。入于其宫,不见其妻,不祥也。" + }, + "4": { + "name": "九四", + "text": "来徐徐,困于金车,吝,有终。", + "image": "来徐徐,志在下也。虽不当位,有与也。" + }, + "5": { + "name": "九五", + "text": "劓刖,困于赤绂,乃徐有说,利用祭祀。", + "image": "劓刖,志未得也。乃徐有说,以中直也。利用祭祀,受福也。" + }, + "6": { + "name": "上六", + "text": "困于葛藟,于臲卼,曰动悔,有悔,征吉。", + "image": "困于葛藟,未当也。动悔,有悔吉,行也。" + } + } + }, + "(0, 1, 1, 0, 1, 0)": { + "name": "井", + "text": "改邑不改井,无丧无得,往来井井,汔至,,亦未繘井,羸其瓶,凶。", + "image": "木上有水,井;君子以劳民劝相。", + "lines": { + "1": { + "name": "初六", + "text": "井泥不食,旧井无禽。", + "image": "井泥不食,下也。旧井无禽,时舍也。" + }, + "2": { + "name": "九二", + "text": "井谷射鲋,瓮敝漏。", + "image": "井谷射鲋,无与也。" + }, + "3": { + "name": "九三", + "text": "井渫不食,为我心恻,可用汲,王明,并受其福。", + "image": "井渫不食,行恻也。求王明,受福也。" + }, + "4": { + "name": "六四", + "text": "井甃,无咎。", + "image": "井甃无咎,修井也。" + }, + "5": { + "name": "九五", + "text": "井洌,寒泉食。", + "image": "寒泉之食,中正也。" + }, + "6": { + "name": "上六", + "text": "井收勿幕,有孚元吉。", + "image": "元吉在上,大成也。" + } + } + }, + "(1, 0, 1, 1, 1, 0)": { + "name": "革", + "text": "己日乃孚,元亨,利贞,悔亡。", + "image": "泽中有火,革;君子以治历明时。", + "lines": { + "1": { + "name": "初九", + "text": "巩用黄牛之革。", + "image": "巩用黄牛,不可以有为也。" + }, + "2": { + "name": "六二", + "text": "己日乃革之,征吉,无咎。", + "image": "己日革之,行有嘉也。" + }, + "3": { + "name": "九三", + "text": "征凶,贞厉,革言三就,有孚。", + "image": "革言三就,又何之矣。" + }, + "4": { + "name": "九四", + "text": "悔亡,有孚改命,吉。", + "image": "改命之吉,信志也。" + }, + "5": { + "name": "九五", + "text": "大人虎变,未占有孚。", + "image": "大人虎变,其文炳也。" + }, + "6": { + "name": "上九", + "text": "君子豹变,小人革面,征凶,居贞吉。", + "image": "君子豹变,其文蔚也。小人革面,顺以从君也。" + } + } + }, + "(0, 1, 1, 1, 0, 1)": { + "name": "鼎", + "text": "元吉,亨。", + "image": "木上有火,鼎;君子以正位凝命。", + "lines": { + "1": { + "name": "初六", + "text": "鼎颠趾,利出否,得妾以其子,无咎。", + "image": "鼎颠趾,未悖也。利出否,以从贵也。" + }, + "2": { + "name": "九二", + "text": "鼎有实,我仇有疾,不我能即,吉。", + "image": "鼎有实,慎所之也。我仇有疾,终无尤也。" + }, + "3": { + "name": "九三", + "text": "鼎耳革,其行塞,雉膏不食,方雨亏悔,终吉。", + "image": "鼎耳革,失其义也。" + }, + "4": { + "name": "九四", + "text": "鼎折足,覆公餗,其形渥,凶。", + "image": "覆公餗,信如何也。" + }, + "5": { + "name": "六五", + "text": "鼎黄耳金铉,利贞。", + "image": "鼎黄耳,中以为实也。" + }, + "6": { + "name": "上九", + "text": "鼎玉铉,大吉,无不利。", + "image": "玉铉在上,刚柔节也。" + } + } + }, + "(1, 0, 0, 1, 0, 0)": { + "name": "震", + "text": "亨。震来虩虩,笑言哑哑。震惊百里,不丧匕鬯。", + "image": "洊雷,震;君子以恐惧修省。", + "lines": { + "1": { + "name": "初九", + "text": "震来虩虩,后笑言哑哑,吉。", + "image": "震来虩虩,恐致福也。笑言哑哑,后有则也。" + }, + "2": { + "name": "六二", + "text": "震来厉,亿丧贝,跻于九陵,勿逐,七日得。", + "image": "震来厉,乘刚也。" + }, + "3": { + "name": "六三", + "text": "震苏苏,震行无眚。", + "image": "震苏苏,位不当也。" + }, + "4": { + "name": "九四", + "text": "震遂泥。", + "image": "震遂泥,未光也。" + }, + "5": { + "name": "六五", + "text": "震往来厉,亿无丧,有事。", + "image": "震往来厉,危行也。其事在中,大无丧也。" + }, + "6": { + "name": "上六", + "text": "震索索,视矍矍,征凶,震不于其躬,于其邻,无咎,婚媾有言。", + "image": "震索索,中未得也。虽凶无咎,畏邻戒也。" + } + } + }, + "(0, 0, 1, 0, 0, 1)": { + "name": "艮", + "text": "艮其背,不获其身,行其庭,不见其人,无咎。", + "image": "兼山,艮;君子以思不出其位。", + "lines": { + "1": { + "name": "初六", + "text": "艮其趾,无咎,利永贞。", + "image": "艮其趾,未失正也。" + }, + "2": { + "name": "六二", + "text": "艮其腓,不拯其随,其心不快。", + "image": "不拯其随,未退听也。" + }, + "3": { + "name": "九三", + "text": "艮其限,列其夤,厉熏心。", + "image": "艮其限,危熏心也。" + }, + "4": { + "name": "六四", + "text": "艮其身,无咎。", + "image": "艮其身,止诸躬也。" + }, + "5": { + "name": "六五", + "text": "艮其辅,言有序,悔亡。", + "image": "艮其辅,以中正也。" + }, + "6": { + "name": "上九", + "text": "敦艮,吉。", + "image": "敦艮之吉,以厚终也。" + } + } + }, + "(0, 0, 1, 0, 1, 1)": { + "name": "渐", + "text": "女归吉,利贞。", + "image": "山上有木,渐;君子以居贤德,善俗。", + "lines": { + "1": { + "name": "初六", + "text": "鸿渐于干,小子厉,有言,无咎。", + "image": "小子之厉,义无咎也。" + }, + "2": { + "name": "六二", + "text": "鸿渐于磐,饮食衎衎,吉。", + "image": "饮食衎衎,吉,不素饱也。" + }, + "3": { + "name": "九三", + "text": "鸿渐于陆,夫征不复,妇孕不育,凶;利御寇。", + "image": "夫征不复,离群丑也。妇孕不育,失其道也。利用御寇,顺相保也。" + }, + "4": { + "name": "六四", + "text": "鸿渐于木,或得其桷,无咎。", + "image": "或得其桷,顺以巽也。" + }, + "5": { + "name": "九五", + "text": "鸿渐于陵,妇三岁不孕,终莫之胜,吉。", + "image": "终莫之胜,吉;得所愿也。" + }, + "6": { + "name": "上九", + "text": "鸿渐于陆,其羽可用为仪,吉。", + "image": "其羽可用为仪,吉;不可乱也。" + } + } + }, + "(1, 1, 0, 1, 0, 0)": { + "name": "归妹", + "text": "征凶,无攸利。", + "image": "泽上有雷,归妹;君子以永终知敝。", + "tuan": "归妹,天地之大义也。天地不交,而万物不兴,归妹人之终始也。说以动,所归妹也。征凶,位不当也。无攸利,柔乘刚也。", + "lines": { + "1": { + "name": "初九", + "text": "归妹以娣,跛能履,征吉。", + "image": "归妹以娣,以恒也。跛能履吉,相承也。" + }, + "2": { + "name": "九二", + "text": "眇能视,利幽人之贞。", + "image": "利幽人之贞,未变常也。" + }, + "3": { + "name": "六三", + "text": "归妹以须,反归以娣。", + "image": "归妹以须,未当也。" + }, + "4": { + "name": "九四", + "text": "归妹愆期,迟归有时。", + "image": "愆期之志,有待而行也。" + }, + "5": { + "name": "六五", + "text": "帝乙归妹,其君之袂,不如其娣之袂良;月几望,吉。", + "image": "帝乙归妹,不如其娣之袂良也。其位在中,以贵行也。" + }, + "6": { + "name": "上六", + "text": "女承筐无实,士刲羊无血,无攸利。", + "image": "上六无实,承虚筐也。" + } + } + }, + "(1, 0, 1, 1, 0, 0)": { + "name": "丰", + "text": "亨,王假之,勿忧,宜日中。", + "image": "雷电皆至,丰;君子以折狱致刑。", + "tuan": "丰,大也。明以动,故丰。王假之,尚大也。勿忧宜日中,宜照天下也。日中则昃,月盈则食,天地盈虚,与时消息,而况人于人乎?况于鬼神乎?", + "lines": { + "1": { + "name": "初九", + "text": "遇其配主,虽旬无咎,往有尚。", + "image": "虽旬无咎,过旬灾也。" + }, + "2": { + "name": "六二", + "text": "丰其蔀,日中见斗,往得疑疾,有孚发若,吉。", + "image": "有孚发若,信以发志也。" + }, + "3": { + "name": "九三", + "text": "丰其沛,日中见沬,折其右肱,无咎。", + "image": "丰其沛,不可大事也。折其右肱,终不可用也。" + }, + "4": { + "name": "九四", + "text": "丰其蔀,日中见斗,遇其夷主,吉。", + "image": "丰其蔀,位未当也。日中见斗,幽不明也。遇其夷主,吉;行也。" + }, + "5": { + "name": "六五", + "text": "来章,有庆誉,吉。", + "image": "六五之吉,有庆也。" + }, + "6": { + "name": "上六", + "text": "丰其屋,蔀其家,闚其户,阒其无人,三岁不觌,凶。", + "image": "丰其屋,天际翔也。闚其户,阒其无人,自藏也。" + } + } + }, + "(0, 0, 1, 1, 0, 1)": { + "name": "旅", + "text": "小亨,旅贞吉。", + "image": "山上有火,旅;君子以明慎用刑,而不留狱。", + "tuan": "旅,小亨,柔得中乎外,而顺乎刚,止而丽乎明,是以小亨,旅贞吉也。旅之时义大矣哉!", + "lines": { + "1": { + "name": "初六", + "text": "旅琐琐,斯其所取灾。", + "image": "旅琐琐,志穷灾也。" + }, + "2": { + "name": "六二", + "text": "旅即次,怀其资,得童仆贞。", + "image": "得童仆贞,终无尤也。" + }, + "3": { + "name": "九三", + "text": "旅焚其次,丧其童仆,贞厉。", + "image": "旅焚其次,亦以伤矣。以旅与下,其义丧也。" + }, + "4": { + "name": "九四", + "text": "旅于处,得其资斧,我心不快。", + "image": "旅于处,未得位也。得其资斧,心未快也。" + }, + "5": { + "name": "六五", + "text": "射雉一矢亡,终以誉命。", + "image": "终以誉命,上逮也。" + }, + "6": { + "name": "上九", + "text": "鸟焚其巢,旅人先笑后号啕。丧牛于易,凶。", + "image": "以旅在上,其义焚也。丧牛于易,终莫之闻也。" + } + } + }, + "(0, 1, 1, 0, 1, 1)": { + "name": "巽", + "text": "小亨,利有攸往,利见大人。", + "image": "随风,巽;君子以申命行事。", + "tuan": "重巽以申命,刚巽乎中正而志行。柔皆顺乎刚,是以小亨,利有攸往,利见大人。", + "lines": { + "1": { + "name": "初六", + "text": "进退,利武人之贞。", + "image": "进退,志疑也。利武人之贞,志治也。" + }, + "2": { + "name": "九二", + "text": "巽在床下,用史巫纷若,吉,无咎。", + "image": "纷若之吉,得中也。" + }, + "3": { + "name": "九三", + "text": "频巽,吝。", + "image": "频巽之吝,志穷也。" + }, + "4": { + "name": "六四", + "text": "悔亡,田获三品。", + "image": "田获三品,有功也。" + }, + "5": { + "name": "九五", + "text": "贞吉悔亡,无不利,无初有终,先庚三日,后庚三日,吉。", + "image": "九五之吉,位正中也。" + }, + "6": { + "name": "上九", + "text": "巽在床下,丧其资斧,贞凶。", + "image": "巽在床下,上穷也。丧其资斧,正乎凶也。" + } + } + }, + "(1, 1, 0, 1, 1, 0)": { + "name": "兑", + "text": "亨,利贞。", + "image": "丽泽,兑;君子以朋友讲习。", + "tuan": "兑,说也。刚中而柔外,说以利贞,是以顺乎天,而应乎人。说以先民,民忘其劳;说以犯难,民忘其死;说之大,民劝矣哉!", + "lines": { + "1": { + "name": "初九", + "text": "和兑,吉。", + "image": "和兑之吉,行未疑也。" + }, + "2": { + "name": "九二", + "text": "孚兑,吉,悔亡。", + "image": "孚兑之吉,信志也。" + }, + "3": { + "name": "六三", + "text": "来兑,凶。", + "image": "来兑之凶,位不当也。" + }, + "4": { + "name": "九四", + "text": "商兑,未宁,介疾有喜。", + "image": "九四之喜,有庆也。" + }, + "5": { + "name": "九五", + "text": "孚于剥,有厉。", + "image": "孚于剥,位正当也。" + }, + "6": { + "name": "上六", + "text": "引兑。", + "image": "上六引兑,未光也。" + } + } + }, + "(0, 1, 0, 0, 1, 1)": { + "name": "涣", + "text": "亨,王假有庙,利涉大川,利贞。", + "image": "风行水上,涣;先王以享于帝立庙。", + "tuan": "涣,亨。刚来而不穷,柔得位乎外而上同。王假有庙,王乃在中也。利涉大川,乘木有功也。", + "lines": { + "1": { + "name": "初六", + "text": "用拯马壮,吉。", + "image": "初六之吉,顺也。" + }, + "2": { + "name": "九二", + "text": "涣奔其机,悔亡。", + "image": "涣奔其机,得愿也。" + }, + "3": { + "name": "六三", + "text": "涣其躬,无悔。", + "image": "涣其躬,志在外也。" + }, + "4": { + "name": "六四", + "text": "涣其群,元吉。涣有丘,匪夷所思。", + "image": "涣其群,元吉;光大也。" + }, + "5": { + "name": "九五", + "text": "涣汗其大号,涣王居,无咎。", + "image": "王居无咎,正位也。" + }, + "6": { + "name": "上九", + "text": "涣其血,去逖出,无咎。", + "image": "涣其血,远害也。" + } + } + }, + "(1, 1, 0, 0, 1, 0)": { + "name": "节", + "text": "亨,苦节,不可贞。", + "image": "泽上有水,节;君子以制数度,议德行。", + "tuan": "节,亨,刚柔分,而刚得中。苦节不可贞,其道穷也。说以行险,当位以节,中正以通。天地节而四时成,节以制度,不伤财,不害民。", + "lines": { + "1": { + "name": "初九", + "text": "不出户庭,无咎。", + "image": "不出户庭,知通塞也。" + }, + "2": { + "name": "九二", + "text": "不出门庭,凶。", + "image": "不出门庭,失时极也。" + }, + "3": { + "name": "六三", + "text": "不节若,则嗟若,无咎。", + "image": "不节之嗟,又谁咎也。" + }, + "4": { + "name": "六四", + "text": "安节,亨。", + "image": "安节之亨,承上道也。" + }, + "5": { + "name": "九五", + "text": "甘节,吉;往有尚。", + "image": "甘节之吉,居位中也。" + }, + "6": { + "name": "上六", + "text": "苦节,贞凶,悔亡。", + "image": "苦节贞凶,其道穷也。" + } + } + }, + "(1, 1, 0, 0, 1, 1)": { + "name": "中孚", + "text": "豚鱼,吉,利涉大川,利贞。", + "image": "泽上有风,中孚;君子以议狱缓死。", + "tuan": "中孚,柔在内而刚得中。说而巽,孚,乃化邦也。豚鱼吉,信及豚鱼也。利涉大川,乘木舟虚也。中孚以利贞,乃应乎天也。", + "lines": { + "1": { + "name": "初九", + "text": "虞吉,有它不燕。", + "image": "初九虞吉,志未变也。" + }, + "2": { + "name": "九二", + "text": "鸣鹤在阴,其子和之,我有好爵,吾与尔靡之。", + "image": "其子和之,中心愿也。" + }, + "3": { + "name": "六三", + "text": "得敌,或鼓或罢,或泣或歌。", + "image": "可鼓或罢,位不当也。" + }, + "4": { + "name": "六四", + "text": "月几望,马匹亡,无咎。", + "image": "马匹亡,绝类上也。" + }, + "5": { + "name": "九五", + "text": "有孚挛如,无咎。", + "image": "有孚挛如,位正当也。" + }, + "6": { + "name": "上九", + "text": "翰音登于天,贞凶。", + "image": "翰音登于天,何可长也。" + } + } + }, + "(0, 0, 1, 1, 0, 0)": { + "name": "小过", + "text": "亨,利贞,可小事,不可大事。飞鸟遗之音,不宜上,宜下,大吉。", + "image": "山上有雷,小过;君子以行过乎恭,丧过乎哀,用过乎俭。", + "tuan": "小过,小者过而亨也。过以利贞,与时行也。柔得中,是以小事吉也。刚失位而不中,是以不可大事也。有飞鸟之象焉,有飞鸟遗之音,不宜上宜下,大吉;上逆而下顺也。", + "lines": { + "1": { + "name": "初六", + "text": "飞鸟以凶。", + "image": "飞鸟以凶,不可如何也。" + }, + "2": { + "name": "六二", + "text": "过其祖,遇其妣;不及其君,遇其臣,无咎。", + "image": "不及其君,臣不可过也。" + }, + "3": { + "name": "九三", + "text": "弗过防之,从或戕之,凶。", + "image": "从或戕之,凶如何也。" + }, + "4": { + "name": "九四", + "text": "无咎,弗过遇之,往厉必戒,勿用永贞。", + "image": "弗过遇之,位不当也。往厉必戒,终不可长也。" + }, + "5": { + "name": "六五", + "text": "密云不雨,自我西郊,公弋取彼在穴", + "image": "密云不雨,已上也。" + }, + "6": { + "name": "上六", + "text": "弗遇过之,飞鸟离之,凶,是谓灾眚。", + "image": "弗遇过之,已亢也。" + } + } + }, + "(1, 0, 1, 0, 1, 0)": { + "name": "既济", + "text": "亨小,利贞,初吉终乱。", + "image": "水在火上,既济;君子以思患而预防之。", + "tuan": "既济,亨,小者亨也。利贞,刚柔正而位当也。初吉,柔得中也。终止则乱,其道穷也。", + "lines": { + "1": { + "name": "初九", + "text": "曳其轮,濡其尾,无咎。", + "image": "曳其轮,义无咎也。" + }, + "2": { + "name": "六二", + "text": "妇丧其茀,勿逐,七日得。", + "image": "七日得,以中道也。" + }, + "3": { + "name": "九三", + "text": "高宗伐鬼方,三年克之,小人勿用。", + "image": "三年克之,惫也。" + }, + "4": { + "name": "六四", + "text": "繻有衣袽,终日戒。", + "image": "终日戒,有所疑也。" + }, + "5": { + "name": "九五", + "text": "东邻杀牛,不如西邻之禴祭,实受其福。", + "image": "东邻杀牛,不如西邻之时也。实受其福,吉大来也。" + }, + "6": { + "name": "上六", + "text": "濡其首,厉。", + "image": "濡其首厉,何可久也。" + } + } + }, + "(0, 1, 0, 1, 0, 1)": { + "name": "未济", + "text": "亨,小狐汔济,濡其尾,无攸利。", + "image": "火在水上,未济;君子以慎辨物居方。", + "tuan": "亨;柔得中也。小狐汔济,未出中也。濡其尾,无攸利;不续终也。虽不当位,刚柔应也。", + "lines": { + "1": { + "name": "初六", + "text": "濡其尾,吝。", + "image": "濡其尾,亦不知极也。" + }, + "2": { + "name": "九二", + "text": "曳其轮,贞吉。", + "image": "九二贞吉,中以行正也。" + }, + "3": { + "name": "六三", + "text": "未济,征凶,利涉大川。", + "image": "未济征凶,位不当也。" + }, + "4": { + "name": "九四", + "text": "贞吉,悔亡,震用伐鬼方,三年有赏于大国。", + "image": "贞吉悔亡,志行也。" + }, + "5": { + "name": "六五", + "text": "贞吉,无悔,君子之光,有孚,吉。", + "image": "君子之光,其晖吉也。" + }, + "6": { + "name": "上九", + "text": "有孚于饮酒,无咎,濡其首,有孚失是。", + "image": "饮酒濡首,亦不知节也。" + } + } + } + } +} \ No newline at end of file diff --git a/next/docs/evidence/stage-11/acceptance.md b/next/docs/evidence/stage-11/acceptance.md new file mode 100644 index 0000000..05f6004 --- /dev/null +++ b/next/docs/evidence/stage-11/acceptance.md @@ -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生产容器保持不变,最终切换仍需人工确认。 diff --git a/next/docs/evidence/stage-11/heaven-dark-1920x1080.jpg b/next/docs/evidence/stage-11/heaven-dark-1920x1080.jpg new file mode 100644 index 0000000..afe51b9 Binary files /dev/null and b/next/docs/evidence/stage-11/heaven-dark-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-11/heaven-interpret-loading-dark.jpg b/next/docs/evidence/stage-11/heaven-interpret-loading-dark.jpg new file mode 100644 index 0000000..8f9708a Binary files /dev/null and b/next/docs/evidence/stage-11/heaven-interpret-loading-dark.jpg differ diff --git a/next/docs/evidence/stage-11/heaven-light-390x844.jpg b/next/docs/evidence/stage-11/heaven-light-390x844.jpg new file mode 100644 index 0000000..cfbe437 Binary files /dev/null and b/next/docs/evidence/stage-11/heaven-light-390x844.jpg differ diff --git a/next/frontend/index.html b/next/frontend/index.html index c0ef21b..520f9fc 100644 --- a/next/frontend/index.html +++ b/next/frontend/index.html @@ -19,6 +19,7 @@
+