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