rebuild(stage-11): deliver deterministic heaven workflows

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