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
+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 "势衰宜守"
-270
View File
@@ -1,270 +0,0 @@
from __future__ import annotations
from statistics import mean, median
from typing import Any
WEIGHTS = {
"breadth": 20,
"limit_ecology": 25,
"profit_effect": 30,
"ladder_structure": 15,
"liquidity": 10,
}
def calculate_sentiment(snapshot: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
stats = _stats(snapshot)
historical = [_stats(item) for item in history[-250:]]
breadth = _clamp(stats["breadth_ratio"])
limit_strength = _adaptive(
stats["limit_up"], _linear(stats["limit_up"], 10, 100), _series(historical, "limit_up")
)
down_pressure = _adaptive(
stats["limit_down"], _linear(stats["limit_down"], 0, 50), _series(historical, "limit_down")
)
down_relief = 100 - down_pressure
seal_quality = _linear(stats["seal_rate"], 35, 90)
ecology = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
systemic_health = breadth * 0.60 + down_relief * 0.40
gate = 1 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
profit = _profit(stats)
max_height = _adaptive(
stats["max_height"],
_linear(stats["max_height"], 1, 7),
_series(historical, "max_height"),
)
continuation = (stats["second_board"] + stats["three_plus"]) / max(stats["limit_up"], 1) * 100
three_density = stats["three_plus"] / max(stats["limit_up"], 1) * 100
three_score = _adaptive(
stats["three_plus"], _clamp(three_density * 5), _series(historical, "three_plus")
)
ladder = (
max_height * 0.30
+ _clamp(continuation * 3) * 0.25
+ three_score * 0.25
+ stats["ladder_completeness"] * 0.20
)
prior_amounts = [item["amount"] for item in historical[-20:] if item["amount"] > 0]
baseline = mean(prior_amounts) if prior_amounts else stats["amount"] or 1
amount_ratio = stats["amount"] / max(baseline, 1)
amount_score = _clamp(50 + (amount_ratio - 1) * 100)
limit_share = stats["limit_amount"] / max(stats["amount"], 1) * 100
liquidity = amount_score * 0.70 + _clamp(limit_share * 20) * 0.30
components = {
"breadth": breadth,
"limit_ecology": ecology,
"profit_effect": profit,
"ladder_structure": ladder,
"liquidity": liquidity,
}
score = round(sum(components[key] * weight / 100 for key, weight in WEIGHTS.items()) * gate)
extreme = stats["breadth_ratio"] <= 15 and stats["limit_down"] >= 100
if extreme:
score = min(score, 15)
elif stats["breadth_ratio"] <= 25 and stats["limit_down"] >= 50:
score = min(score, 24)
prior_sentiments = [item.get("sentiment") or {} for item in history[-3:]]
prior_scores = [
float(item["score"]) for item in prior_sentiments if item.get("score") is not None
]
momentum = score - mean(prior_scores) if prior_scores else 0
direction = "升温" if momentum > 3 else "降温" if momentum < -3 else "持平"
previous = prior_sentiments[-1] if prior_sentiments else None
day_change = (
score - float(previous["score"]) if previous and previous.get("score") is not None else 0
)
signal = _phase_signal(score, momentum, profit)
phase, reason = _phase(
previous, score, day_change, systemic_health, profit, ecology, signal, extreme
)
fermentation_ready = signal == "发酵" and score >= 45 and profit >= 45 and systemic_health >= 35
previous_count = int(previous.get("fermentation_signal_count") or 0) if previous else 0
fermentation_count = previous_count + 1 if fermentation_ready else 0
history_days = len(history)
confidence = min(95, round(55 + min(history_days, 20) * 1.25 + (15 if phase == signal else 7)))
labels = {
"breadth": "市场宽度",
"limit_ecology": "涨停生态",
"profit_effect": "赚钱效应",
"ladder_structure": "连板结构",
"liquidity": "成交活跃度",
}
return {
"score": score,
"label": _label(score),
"direction": direction,
"momentum": round(momentum, 1),
"day_change": round(day_change, 1),
"phase": phase,
"phase_signal": signal,
"transition_reason": reason,
"fermentation_signal_count": fermentation_count,
"confidence": confidence,
"history_days": history_days,
"systemic_health": round(systemic_health, 1),
"components": [
{
"key": key,
"label": labels[key],
"score": round(value, 1),
"weight": WEIGHTS[key],
}
for key, value in components.items()
],
"stats": stats,
}
def _stats(snapshot: dict[str, Any]) -> dict[str, float]:
overview = snapshot.get("overview") or {}
limits = snapshot.get("limits") or []
yesterday = snapshot.get("yesterday_limits") or []
streaks = [max(1, int(_number(row.get("streak"), 1))) for row in limits]
levels = set(streaks)
max_height = max(streaks, default=0)
active = _number(overview.get("up_count")) + _number(overview.get("down_count"))
changes = [_number(row.get("current_change")) for row in yesterday]
previous_count = len(yesterday)
return {
"breadth_ratio": _number(overview.get("up_count")) / max(active, 1) * 100,
"limit_up": _number(overview.get("limit_up")),
"limit_down": _number(overview.get("limit_down")),
"broken": _number(overview.get("broken")),
"seal_rate": _number(overview.get("seal_rate")),
"amount": _number(overview.get("amount")),
"limit_amount": sum(_number(row.get("amount")) for row in limits),
"second_board": sum(streak == 2 for streak in streaks),
"three_plus": sum(streak >= 3 for streak in streaks),
"max_height": max_height,
"ladder_completeness": (
sum(level in levels for level in range(1, max_height + 1)) / max_height * 100
if max_height
else 0
),
"previous_count": previous_count,
"positive_rate": sum(change > 0 for change in changes) / max(previous_count, 1) * 100,
"advance_rate": sum(row.get("outcome") == "晋级" for row in yesterday)
/ max(previous_count, 1)
* 100,
"average_change": mean(changes) if changes else 0,
"median_change": median(changes) if changes else 0,
"severe_loss_rate": sum(change <= -5 for change in changes) / max(previous_count, 1) * 100,
"previous_down_rate": sum(row.get("outcome") == "跌停" for row in yesterday)
/ max(previous_count, 1)
* 100,
}
def _profit(stats: dict[str, float]) -> float:
if not stats["previous_count"]:
return 50
median_score = _clamp(50 + stats["median_change"] * 7)
average_score = _clamp(50 + stats["average_change"] * 6)
advance_score = _clamp(stats["advance_rate"] * 2.5)
loss_safety = _clamp(100 - stats["severe_loss_rate"] * 3)
down_safety = _clamp(100 - stats["previous_down_rate"] * 7)
tail = loss_safety * 0.70 + down_safety * 0.30
return (
stats["positive_rate"] * 0.30
+ median_score * 0.25
+ average_score * 0.10
+ advance_score * 0.20
+ tail * 0.15
)
def _phase_signal(score: float, momentum: float, profit: float) -> str:
if score < 25:
return "修复" if momentum > 3 else "冰点"
if score < 45:
return "修复" if momentum > 3 else "退潮"
if score >= 80:
return "高潮" if momentum >= -2 and profit >= 60 else "分化"
if score >= 65:
return "分化" if momentum < -3 or profit < 50 else "发酵"
if momentum < -5:
return "退潮"
return "发酵" if momentum >= 0 and profit >= 45 else "分化"
def _phase(previous, score, change, health, profit, ecology, signal, extreme):
if not previous:
return signal, "首个连续交易日,采用原始阶段信号"
prior = str(previous.get("phase") or signal)
if extreme:
return "冰点", "市场宽度与跌停数量触发极端冰点"
recovery = change >= 6 and score >= 25 and health >= 24
climax = score >= 80 and profit >= 60 and health >= 60 and ecology >= 70
if prior in {"冰点", "退潮"}:
if score < 25:
return "冰点", "市场仍处于冰点区间"
return ("修复", "出现有效回升") if recovery else (prior, "尚未形成有效修复")
if prior == "修复":
if score < 25:
return "冰点", "修复失败并跌入冰点"
if change <= -6 and score < 45:
return "退潮", "修复失败且显著降温"
prior_signal = int(previous.get("fermentation_signal_count") or 0)
if signal == "发酵" and prior_signal >= 1:
return "发酵", "发酵条件连续两个交易日成立"
return "修复", "修复延续,等待发酵确认"
if prior == "发酵":
if score < 45 and (change < 0 or health < 35):
return "退潮", "温度与系统健康度转弱"
if climax:
return "高潮", "温度、赚钱效应与涨停生态达到高潮条件"
return ("分化", "发酵阶段出现降温") if signal in {"分化", "退潮"} else ("发酵", "发酵延续")
if prior == "高潮":
if climax:
return "高潮", "高潮条件继续成立"
return ("退潮", "风险快速释放") if score < 45 or health < 30 else ("分化", "高潮条件消退")
if score < 25:
return "冰点", "分化继续恶化至冰点"
if score < 45 or health < 30:
return "退潮", "分化后继续转弱"
return "分化", "分化延续,等待方向确认"
def _label(score: float) -> str:
if score >= 80:
return "情绪高涨"
if score >= 60:
return "情绪偏强"
if score >= 40:
return "情绪中性"
if score >= 20:
return "情绪偏弱"
return "情绪冰点"
def _series(rows: list[dict[str, float]], key: str) -> list[float]:
return [row[key] for row in rows]
def _adaptive(value: float, fixed: float, history: list[float]) -> float:
if len(history) < 20:
return fixed
below = sum(item < value for item in history[-250:])
equal = sum(item == value for item in history[-250:])
percentile = (below + equal * 0.5) / len(history[-250:]) * 100
return fixed * 0.25 + percentile * 0.75
def _linear(value: float, low: float, high: float) -> float:
return _clamp((value - low) / (high - low) * 100) if high > low else 50
def _clamp(value: float) -> float:
return min(100, max(0, value))
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default
+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")