rebuild(stage-11): deliver deterministic heaven workflows
This commit is contained in:
@@ -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 "势衰宜守"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user