From c6d648cebefbf4958db6c6af9d6641f334d3a122 Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 5 Aug 2026 23:44:17 +0800 Subject: [PATCH] feat: complete heaven readings and screener publication --- ARCHITECTURE.md | 8 +- backend/features/heaven/agent.py | 215 +++++++++- backend/features/heaven/engine.py | 2 + backend/features/heaven/knowledge.py | 387 ++++++++++++++++++ backend/features/heaven/readings.py | 78 ++-- backend/features/heaven/repository.py | 19 + backend/features/heaven/six_yao.py | 432 ++++++++++++++++++++ backend/features/heaven/trend.py | 22 +- backend/features/screener/catalog.py | 4 + backend/features/screener/publication.py | 70 ++++ backend/features/screener/repository.py | 96 +++++ backend/features/screener/service.py | 103 ++++- backend/features/screener/signals.py | 198 +++++++++ config/architecture-inventory.json | 66 +-- data/heaven_knowledge.json | 117 ++++++ docs/product/小白复盘-完整产品规格说明书.md | 26 +- docs/product/问天知识与规则说明.md | 112 +++++ frontend/index.html | 4 +- frontend/pages.config.js | 6 +- frontend/pages/heaven/foundation.css | 200 ++++++--- frontend/pages/heaven/page.html | 15 +- frontend/pages/heaven/page.js | 164 ++++++-- frontend/pages/screener/foundation.css | 173 ++++++++ frontend/pages/screener/page.html | 18 +- frontend/pages/screener/page.js | 237 ++++++++++- frontend/shared/context.js | 4 + frontend/shared/session.js | 10 +- tests/e2e/app-shell.spec.js | 178 +++++++- tests/test_frontend_contract.py | 14 + tests/test_heaven_knowledge.py | 130 ++++++ tests/test_heaven_prompts.py | 197 +++++++++ tests/test_heaven_readings.py | 137 ++++++- tests/test_screener_publication.py | 237 +++++++++++ tests/test_six_yao.py | 84 ++++ 34 files changed, 3538 insertions(+), 225 deletions(-) create mode 100644 backend/features/heaven/knowledge.py create mode 100644 backend/features/heaven/six_yao.py create mode 100644 backend/features/screener/publication.py create mode 100644 backend/features/screener/signals.py create mode 100644 data/heaven_knowledge.json create mode 100644 docs/product/问天知识与规则说明.md create mode 100644 tests/test_heaven_knowledge.py create mode 100644 tests/test_heaven_prompts.py create mode 100644 tests/test_screener_publication.py create mode 100644 tests/test_six_yao.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 438c4e9..b4d9e4f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -95,8 +95,12 @@ background scheduler six-line input validation and safety gates belong to `manual.py`; trend setup, market mode, source disclosure, and quality checks belong to `trend.py`; stock, index, and sector context collection belongs to `market_context.py`; personal fields, hexagrams, saved readings, and - model interpretation belong to `readings.py`. These owners cooperate through the composed - service object and do not duplicate or delegate method bodies through the facade. + interpretation orchestration belong to `readings.py`; deterministic Jing Fang Na Jia, eight + palaces, six relatives, self/response, six spirits, calendar relations, and hidden spirits + belong to `six_yao.py`; source-traceable Wentian knowledge retrieval and the only LLM-bound + context projection belong to `knowledge.py`; prompt construction and answer validation remain + in `agent.py`. These owners cooperate through the composed service object and do not duplicate + or delegate method bodies through the facade. - Application-facing system credentials, data/LLM status, and administrator settings belong to `backend/features/system/service.py`; account-context delegation belongs to `backend/features/accounts/application.py`. They are composed into `DashboardService` and diff --git a/backend/features/heaven/agent.py b/backend/features/heaven/agent.py index 40c1d3b..c724a55 100644 --- a/backend/features/heaven/agent.py +++ b/backend/features/heaven/agent.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re from typing import Any from backend.llm import transport as llm_transport @@ -10,6 +11,13 @@ class HeavenAgentError(RuntimeError): pass +HEAVEN_PROMPT_VERSIONS = { + "trend": "heaven-trend-v4", + "fortune": "heaven-fortune-v9", + "heart": "heaven-heart-v5", +} + + def interpret_heaven( mode: str, context: dict[str, Any], @@ -42,6 +50,34 @@ def interpret_heaven( answer = str(result.content).strip() if not answer: raise KeyError("empty response") + try: + _validate_answer(mode, answer, context) + except HeavenAgentError as validation_error: + repair_messages = [ + *messages, + {"role": "assistant", "content": answer}, + { + "role": "user", + "content": ( + f"上一版未通过本地一致性校验:{validation_error}" + "请依据最初输入完整重写最终答案,只修正违规推断并补齐必答项。" + "不得讨论校验、提示词或重写过程,只输出新的正式解读。" + ), + }, + ] + repaired = llm_transport.chat_completion( + api_key=api_key, + base_url=base_url, + model=model, + messages=repair_messages, + timeout=timeout, + user_agent="XiaobaiReviewWeb/0.7", + ) + answer = str(repaired.content).strip() + if not answer: + raise KeyError("empty repaired response") + _validate_answer(mode, answer, context) + result = repaired except llm_transport.OpenAIHTTPError as exc: raise HeavenAgentError(exc.describe("问天模型调用失败")) from exc except (llm_transport.OpenAITransportError, KeyError) as exc: @@ -55,34 +91,175 @@ def interpret_heaven( def _system_prompt(mode: str) -> str: common = """ -你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。 -问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性。 -使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题。 +你是“小白复盘”的问天解读器。输入由calculation、knowledge和interpretation_contract组成:calculation是确定性程序已经算出的事实;knowledge是本次按条件精确检索到的原典、传统规则和产品边界;interpretation_contract规定本次必须回答与禁止推断的内容。 +只能综合输入中已经提供的事实和知识。不得改卦、改爻、改纳甲、改世应、改干支、重新计算五运六气,也不得凭模型记忆补造缺失字段。知识记录之间若存在张力,应说明条件与分歧,不要强行合成唯一结论。 +问天属于传统文化与自我观察,不是可验证的行情预测模型。必须给出有内容的倾向和依据,但不得把象义宣布为必然发生的股价结果,不输出无条件买卖指令,不用神秘话术制造确定性。 +使用中文和普通用户能够理解的表达。专业术语首次出现时紧接一句白话解释。先给核心判断,再说明证据和变化关系。每个主题必须使用独立一行的简短标题,格式为“## 标题”,标题后另起一段正文;不得把全部内容挤在一个长段落中。可以使用Markdown加粗,不使用Markdown表格。 """.strip() if mode == "trend": return common + """ -当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天。 -行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色。 -先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言。 -重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件。 -全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格。 -不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。 +当前任务是“观势·解势”。行情只负责在进入模型之前生成卦象,本次回答不得引用或反推指数涨跌、成交额、涨跌停、板块强弱或个股表现,也不得说明某一爻原先对应哪类市场指标。 +calculation中有意不提供股票、行业和板块身份。不得猜测或讨论观察对象所属行业、政策、消费环境、基本面、资金面或任何现实市场变量;只解释已经生成的卦象。 +必须明确给出卦义上的当下倾向、主要矛盾、实际动爻所示的转折,以及本卦走向之卦后的变化方向。允许使用偏进、偏守、先难后易、由盛转收、转机有限、内外相违或结论有条件等相对判断;不得只罗列卦辞,也不得用“谨慎、等待、守信、辨伪”一类泛化劝诫代替解势。 +以knowledge中本卦、上下卦、卦辞、彖义、大象、实际动爻和之卦记录为依据。无动爻、一动爻和多动爻分别服从本次检索到的方法规则;多动爻有冲突时必须指出冲突,不得压成单一套话。 +按“## 核心判断、## 卦势依据、## 动爻转折、## 之卦趋向、## 决策映射”组织答案;无动爻时仍保留“动爻转折”,明确说明本次无动爻并解释结构的延续条件。结尾可以把卦势翻译成克制的交易决策语言,但只能表达条件、节奏和需要验证的矛盾,不得预测具体涨跌、价格、日期或给出直接荐股结论。篇幅随动爻数量自然展开,不设置固定字数。 """.strip() if mode == "fortune": return common + """ -当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年。 -严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运与司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量。 -重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局。 -首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作。 -如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期。 -不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌。 -全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价。 +当前任务是“观气·解运”。页面用于直观展示的五行权重、主导元素和预制复合断语已明确排除,不得自行恢复这些结果,也不得按百分比重新生成单一五行结论。 +严格区分中运、司天在泉、当前主气客气、节气定位和日辰触发。先解释中运与司天在泉构成的年纲,再解释客气加临主气的当前关系,最后说明日辰如何触发;不得把同一项拆成多份证据重复计权。相生不直接等于吉,相克不直接等于凶。 +必须使用knowledge中与本日中运、六气和客主关系精确匹配的记录。可形成“湿热交蒸、燥中夹滞”一类复合表达,但要从输入关系逐层说明,不能从页面权重结论倒推。 +如calculation.personal存在,只结合日主、十神和当日派生关系说明用户容易出现的主观感受与判断偏差;不得使用简化强弱、喜用神、出生日期或权重平衡结论。 +personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master.pillars是当日历法,不是用户出生四柱。stem_relations只是当日年、月、日三柱天干相对本命日主的程序结果,只能使用knowledge中本次命中的关系释义,不得自行重算十神或扩展五行生克过程。不得使用藏干、支中藏某干、某支为某库或燥湿属性等输入未提供的信息,也不得把当日日柱写成用户命局,或推断用户命局中某个十神“较重”、身强身弱或喜用神。 +日辰只按calculation.day_trigger.summary与knowledge中的日辰边界解释,不得从干支另行推导藏气、库气或五行生克链。个人合参不得宣称本命日主被当日某气生扶、泄耗或克制,只能说明已给关系标签可能对应的主观注意点。 +day_trigger.summary中的日干运势、地支五行和六气对应是三个并列的确定性事实。不得把日柱整体改写成某一种五行,也不得把地支与六气的“对应”改写成地支自身具有某种六气属性。 +calculation.industry_symbols只提供五行与行业的传统取象归类及其本次出现依据。必须说明这些气机对相关行业可能形成的象征性关注、节奏或约束,但不得引入行业实时行情,不得预测行业涨跌或把取象写成投资推荐;未列入industry_symbols的行业不得自行补造。 +按“## 年纲、## 客主加临、## 日辰触发、## 行业影响、## 个人合参、## 制衡动作”组织答案;没有个人资料时可以省略“个人合参”。不得用行情上涨下跌、行业表现或个股结果证明运气关系。篇幅按实际关系自然展开,不设置固定字数。 """.strip() return common + """ -当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定。 -全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。 -不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。 +当前任务是“观心·解卦”。用户在起卦前确定的问题位于calculation.question,question_preset只说明问题来源。必须针对实际问题作答;无题观心时不得猜测用户没有说出的事项。 +question_scope是本次问题的产品边界。trade预设专指股票交易中的参与条件、机会、阻碍和风险,不是商业合作、融资、借贷或寻找资金方;除非用户问题明确写出这些背景,否则不得擅自补入。 +纳甲、卦宫、世应、六亲、六神、月建日辰、旬空、伏神、动变和冲合关系已经由确定性程序给出。只能解释这些结果,不得自行改排盘、补用神或用模型记忆重算。六神只作辅助,任何单项都不能独立决定结论。 +六亲是关系类别,不是现实人物或资金来源的一一映射。必须使用knowledge中的六亲、旬空、动变和六神边界;不得把妻财直接写成现金或融资,把子孙写成资金提供方,把兄弟写成合作方,也不得由某一六神直接推出紧迫、欺骗或吉凶。 +除非calculation.question明确说明用户已经持仓、买入、卖出或正在管理仓位,否则不得假定用户已经入场,不得使用“持仓、仓位、建仓、入场、持有、买入、卖出、止损、止盈”等措辞描述用户现状。可以只写尚待核对的参与条件、风险边界和决策倾向。 +除非问题明确涉及融资、借贷、合作或资源安排,否则不得制造外围资金、外围资源、资金进入、资源进入,也不得虚构资金或资源的来源、提供、注入、安排和路径。 +按“## 所问之答、## 卦象依据、## 动变与之卦、## 可验证之处”组织答案。先直接回应所问,再用白话解释本卦所示处境、世应与相关六亲、关键动爻和变爻,最后说明之卦趋向及一项可以由用户验证的动作。若证据相互冲突,应明确说明结论成立的条件,不以“吉、凶”二字替代推理。 +交易问题可以判断参与条件、内外阻碍、风险和决策倾向,但不得宣告具体股价、涨跌日期或替用户作无条件买卖决定。不得用旬空、填实、出空或干支日推算“未来几日”或某日应验;可验证动作必须是用户当下能核对的交易条件或自身判断,不能制造现实中不存在的合作方、承诺、资金或资源安排。心境问题聚焦念头、压力和盲点;无题观心只作一般卦象观照。篇幅随问题和动爻复杂度自然展开,不使用固定三句模板,也不得输出使用竖线分栏的Markdown表格。 """.strip() + + +def _validate_answer( + mode: str, answer: str, context: dict[str, Any] | None = None +) -> None: + compact = "".join(answer.split()) + if len(compact) < 60: + raise HeavenAgentError("问天模型返回内容过短,未形成有效解读。") + forbidden = ("必涨", "必跌", "保证上涨", "保证下跌", "无条件买入", "无条件卖出") + if any(term in answer for term in forbidden): + raise HeavenAgentError("问天模型返回了禁止的确定性行情断语。") + if mode == "fortune" and "%" in answer: + raise HeavenAgentError("解运结果错误引用了已排除的权重百分比。") + if mode == "trend": + market_narratives = ( + "行业", "板块", "个股", "指数", "成交额", "涨停", "跌停", + "政策", "消费环境", "基本面", "资金面", + ) + if any(term in answer for term in market_narratives): + raise HeavenAgentError("解势结果错误引入了卦象之外的现实市场叙事。") + if mode == "fortune": + if re.search( + r"(?:命局|个人本身).{0,16}(?:偏重|较重|过旺|过弱|身强|身弱|喜用神)", + answer, + ): + raise HeavenAgentError("解运结果错误推断了输入中不存在的命局强弱。") + if re.search( + r"藏干|[子丑寅卯辰巳午未申酉戌亥](?:中|内)|[子丑寅卯辰巳午未申酉戌亥].{0,4}(?:含|藏)|(?:中|内)藏|余气|[辰戌丑未].{0,4}(?:火库|水库|金库|木库|土库|燥土|湿土)", + answer, + ): + raise HeavenAgentError("解运结果使用了输入中未提供的藏干推断。") + if re.search( + r"木生火|火生土|土生金|金生水|水生木|木克土|土克水|水克火|火克金|金克木", + answer, + ): + raise HeavenAgentError("解运结果自行扩展了输入中未提供的五行生克链。") + if re.search( + r"(?:本命)?日主.{0,32}(?:生扶|泄耗|受克|被克|得生|被生|偏强|偏弱)", + answer, + ): + raise HeavenAgentError("解运结果把当日关系错误扩展成了本命强弱推断。") + if re.search( + r"(?:日柱)?[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]" + r".{0,8}(?:本身|自身)(?:就)?是[木火土金水]", + answer, + ): + raise HeavenAgentError("解运结果错误地把整个日柱归成了单一五行。") + if re.search( + r"[子丑寅卯辰巳午未申酉戌亥](?:的|具有|带有).{0,8}" + r"(?:风木|君火|湿土|相火|燥金|寒水)(?:之)?(?:属性|性质)", + answer, + ): + raise HeavenAgentError("解运结果把六气对应误写成了地支自身属性。") + calculation = (context or {}).get("calculation") or {} + if calculation.get("industry_symbols") and "行业影响" not in answer: + raise HeavenAgentError("解运结果遗漏了本次必答的行业影响。") + if re.search( + r"行业.{0,16}(?:必涨|必跌|必然上涨|必然下跌|确定领涨|确定领跌|投资推荐)", + answer, + ): + raise HeavenAgentError("解运结果把行业取象错误写成了行情预测或投资推荐。") + personal = calculation.get("personal") or {} + personal_today = personal.get("today_relative_to_natal_day_master") or {} + pillar_values = { + str(value) + for group in (calculation.get("pillars") or {}, personal_today.get("pillars") or {}) + for value in group.values() + if value + } + mentioned_pillars = set( + re.findall(r"[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]", answer) + ) + if mentioned_pillars - pillar_values: + raise HeavenAgentError("解运结果补入了确定性输入中不存在的干支。") + month_pillar = str((calculation.get("pillars") or {}).get("month") or "") + month_branch = month_pillar[1:2] + mentioned_month_branches = set( + re.findall(r"([子丑寅卯辰巳午未申酉戌亥])月", answer) + ) + if mentioned_month_branches - ({month_branch} if month_branch else set()): + raise HeavenAgentError("解运结果补入了当前月份之外的地支月。") + if personal: + relations = { + str(value) + for value in (personal_today.get("stem_relations") or {}).values() + if value + } + if "个人合参" not in answer and "本命日主" not in answer: + raise HeavenAgentError("解运结果遗漏了本次必答的个人合参。") + if relations and not any(relation in answer for relation in relations): + raise HeavenAgentError("解运结果未使用程序提供的当日关系标签。") + if mode != "heart": + return + calculation = (context or {}).get("calculation") or {} + question = str(calculation.get("question") or "") + if _contains_markdown_table(answer): + raise HeavenAgentError("解卦结果错误输出了Markdown表格。") + position_terms = ( + "持仓", "仓位", "建仓", "入场", "持有", "买入", "卖出", "止损", "止盈", + ) + if not any(term in question for term in position_terms) and any( + term in answer for term in position_terms + ): + raise HeavenAgentError("解卦结果擅自假定了用户的持仓或买卖状态。") + financing_terms = ( + "融资", "借贷", "合作", "出资", "资金来源", "资金方", "投资人", "投资方", + "外部资金", "外围资金", "外部资源", "外围资源", + ) + invented_scenarios = ( + "融资", "借贷", "合作方", "资金提供方", "资金意向", "资金注入", "自有资金", + "外围资金", "外围资源", "资金进入", "资源进入", + ) + invented_resource_path = re.search( + r"(?:资金|资源).{0,8}(?:来源|提供|注入|安排|路径)", answer + ) + if not any(term in question for term in financing_terms) and ( + any(term in answer for term in invented_scenarios) or invented_resource_path + ): + raise HeavenAgentError("解卦结果擅自补入了用户没有提出的融资或合作场景。") + timing_patterns = ( + r"未来\s*[一二三四五六七八九十\d]+\s*(?:个)?(?:交易)?日", + r"[子丑寅卯辰巳午未申酉戌亥]{1,2}日(?:到来|来临|之前|之后|前后)", + r"(?:等待|等到|待).{0,16}(?:旬空|空亡).{0,16}(?:填实|出空)", + ) + if any(re.search(pattern, answer) for pattern in timing_patterns): + raise HeavenAgentError("解卦结果错误使用旬空或干支推算了具体应期。") + + +def _contains_markdown_table(answer: str) -> bool: + return bool( + re.search(r"(?m)^\s*\|", answer) + or re.search(r"(?m)^\s*:?-{3,}:?\s*\|", answer) + or re.search(r"(?m)\|\s*:?-{3,}:?\s*(?:\||$)", answer) + ) diff --git a/backend/features/heaven/engine.py b/backend/features/heaven/engine.py index b6cd0bf..488bea0 100644 --- a/backend/features/heaven/engine.py +++ b/backend/features/heaven/engine.py @@ -733,6 +733,7 @@ def hexagram_from_lines(values: list[int]) -> dict[str, Any]: return { "name": primary["name"], "text": primary["text"], + "tuan": primary.get("tuan") or "", "image": primary.get("image") or "", "inner_trigram": inner, "outer_trigram": outer, @@ -741,6 +742,7 @@ def hexagram_from_lines(values: list[int]) -> dict[str, Any]: "transformed": { "name": transformed["name"], "text": transformed["text"], + "tuan": transformed.get("tuan") or "", "image": transformed.get("image") or "", "inner_trigram": transformed_inner, "outer_trigram": transformed_outer, diff --git a/backend/features/heaven/knowledge.py b/backend/features/heaven/knowledge.py new file mode 100644 index 0000000..be19dec --- /dev/null +++ b/backend/features/heaven/knowledge.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import json +from functools import lru_cache +from typing import Any + +from backend.bootstrap.config import APP_DIR + + +KNOWLEDGE_FILE = APP_DIR / "data" / "heaven_knowledge.json" + + +def prepare_heaven_context(mode: str, calculation: dict[str, Any]) -> dict[str, Any]: + """Build the only context shape that may cross the LLM boundary.""" + if mode == "trend": + prepared = _prepare_trend(calculation) + elif mode == "fortune": + prepared = _prepare_fortune(calculation) + elif mode == "heart": + prepared = _prepare_heart(calculation) + else: + raise ValueError("不支持的问天知识模式。") + prepared["knowledge"] = retrieve_heaven_knowledge(mode, prepared) + return prepared + + +def retrieve_heaven_knowledge(mode: str, context: dict[str, Any]) -> dict[str, Any]: + catalog = _knowledge_catalog() + source_ids: list[str] + records: list[dict[str, Any]] + if mode == "trend": + source_ids = ["zhouyi"] + records = _trend_records(catalog, context) + elif mode == "fortune": + source_ids = ["neijing"] + records = _fortune_records(catalog, context) + elif mode == "heart": + source_ids = ["zhouyi", "jingfang", "huozhulin", "zengshan"] + records = _heart_records(catalog, context) + else: + raise ValueError("不支持的问天知识模式。") + return { + "version": str(catalog.get("version") or ""), + "retrieval": "deterministic-keyed", + "sources": [ + {"id": source_id, **dict(catalog["sources"][source_id])} + for source_id in source_ids + ], + "records": records, + } + + +def _prepare_trend(context: dict[str, Any]) -> dict[str, Any]: + return { + "mode": "trend", + "calculation": { + "data_trade_date": context.get("data_trade_date") or "", + "hexagram": context.get("hexagram") or {}, + "movement": context.get("movement") or {}, + }, + "interpretation_contract": { + "required": ["明确卦势倾向", "主要矛盾", "实际动爻转折", "本卦到之卦的变化关系"], + "allowed": ["偏进或偏守", "先难后易或由盛转收", "结论有条件或存在分歧"], + "forbidden": ["原始行情旁证", "具体涨跌预测", "时间点预测", "无条件买卖指令", "泛化劝诫代替解卦"], + }, + } + + +def _prepare_fortune(context: dict[str, Any]) -> dict[str, Any]: + field = context.get("five_phase_field") or {} + framework = field.get("framework") or {} + relations = framework.get("relations") or {} + layers = { + str(item.get("id") or ""): item + for item in framework.get("layers") or [] + if isinstance(item, dict) + } + six_qi = field.get("six_qi") or {} + movement = field.get("movement") or {} + pillars = field.get("pillars") or {} + personal = context.get("personal_profile") or {} + sector_catalog = { + str(group.get("element") or ""): [ + str(item.get("name") or "").strip() + for item in group.get("industries") or [] + if str(item.get("name") or "").strip() + ] + for group in field.get("sector_catalog") or [] + if isinstance(group, dict) + } + industry_bases: dict[str, list[str]] = {} + + def add_industry_basis(element: str, basis: str) -> None: + if element not in sector_catalog or not sector_catalog[element]: + return + industry_bases.setdefault(element, []) + if basis not in industry_bases[element]: + industry_bases[element].append(basis) + + add_industry_basis(str(movement.get("phase") or ""), "中运") + for label, qi in ( + ("司天", six_qi.get("sitian")), + ("在泉", six_qi.get("zaiquan")), + ("主气", six_qi.get("host_qi")), + ("客气", six_qi.get("guest_qi")), + ): + qi_text = str(qi or "") + add_industry_basis(qi_text[-1:] if qi_text else "", label) + day_master = personal.get("day_master") or {} + current = personal.get("current") or {} + personal_context = {} + if day_master: + current_ten_gods = current.get("ten_gods") or {} + personal_context = { + "natal_day_master": { + "stem": day_master.get("stem") or "", + "element": day_master.get("element") or "", + }, + "today_relative_to_natal_day_master": { + "pillars": current.get("pillars") or {}, + "stem_relations": { + key: str((current_ten_gods.get(key) or {}).get("stem") or "") + for key in ("year", "month", "day") + }, + }, + } + return { + "mode": "fortune", + "calculation": { + "calendar_date": context.get("calendar_date") or field.get("date") or "", + "lunar_date": field.get("lunar_date") or "", + "pillars": { + "year": pillars.get("year") or "", + "month": pillars.get("month") or "", + "day": pillars.get("day") or "", + }, + "solar_terms": field.get("solar_terms") or {}, + "year_movement": { + "phase": movement.get("phase") or "", + "tendency": movement.get("tendency") or "", + "label": movement.get("label") or "", + }, + "annual_qi": { + "sitian": six_qi.get("sitian") or "", + "zaiquan": six_qi.get("zaiquan") or "", + "ruling": six_qi.get("ruling") or "", + "ruling_qi": six_qi.get("ruling_qi") or "", + "annual_pattern": relations.get("annual_pattern") or {}, + }, + "current_qi": { + "step": six_qi.get("step"), + "step_name": six_qi.get("step_name") or "", + "host_qi": six_qi.get("host_qi") or "", + "guest_qi": six_qi.get("guest_qi") or "", + "guest_host_relation": relations.get("guest_host") or {}, + "alignment": relations.get("alignment") or six_qi.get("alignment") or "", + }, + "day_trigger": { + "day_pillar": pillars.get("day") or "", + "summary": (layers.get("day") or {}).get("summary") or "", + }, + "industry_symbols": [ + { + "element": element, + "basis": bases, + "industries": sector_catalog[element], + } + for element, bases in industry_bases.items() + ], + "personal": personal_context, + }, + "excluded_from_interpretation": [ + "五行权重与百分比", + "主导元素排序", + "权重生成的复合断语", + "预制情绪与交易行为结论", + "行业实时行情旁证", + "简化喜用神与强弱结论", + ], + "interpretation_contract": { + "required": ["年纲", "当前客主加临", "日辰触发", "行业影响", "个人合参(如有)", "制衡动作"], + "forbidden": [ + "重新计算五行权重", + "把相生直接判吉", + "把相克直接判凶", + "用市场涨跌证明气场", + "把行业取象写成行业涨跌预测或投资推荐", + "把当日日柱误称为用户命局", + "推断未提供的命局强弱或喜用神", + ], + }, + } + + +def _prepare_heart(context: dict[str, Any]) -> dict[str, Any]: + preset = str(context.get("question_preset") or "custom") + if preset not in {"trade", "mind", "unthemed", "custom"}: + preset = "custom" + return { + "mode": "heart", + "calculation": { + "question": str(context.get("question") or "").strip(), + "question_preset": preset, + "question_scope": { + "trade": "股票交易中的参与条件、机会、阻碍与风险,不是融资或商业合作问题。", + "mind": "影响股票交易判断的情绪、执念或盲点。", + "unthemed": "不指定事项的一般观照。", + "custom": "只按用户实际写出的事项理解,不补写背景。", + }[preset], + "ritual": context.get("ritual") or {}, + "hexagram": context.get("hexagram") or {}, + "six_yao": context.get("six_yao") or {}, + }, + "interpretation_contract": { + "required": ["回应所问", "本卦处境", "世应与相关六亲", "关键动变", "之卦趋向", "可验证动作"], + "plain_language": "专业术语首次出现时立即用白话解释。", + "forbidden": [ + "修改纳甲排盘", + "猜测未输入的问题", + "把股票交易改写成融资或合作问题", + "把六亲直接等同于现实人物或资金来源", + "单凭六神或空亡断吉凶", + "根据旬空填实或干支日期预测应期", + "具体股价和时间点预测", + "无条件买卖指令", + ], + }, + } + + +def _trend_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]: + hexagram = (context.get("calculation") or {}).get("hexagram") or {} + moving = [line for line in hexagram.get("lines") or [] if line.get("moving")] + method_key = "stable" if not moving else "single" if len(moving) == 1 else "multiple" + rules = catalog["trend"]["rules"] + records = [ + {"id": "trend-method", "source": "product_method", "text": catalog["trend"]["method"]}, + {"id": f"trend-moving-{method_key}", "source": "product_method", "text": rules[method_key]}, + _hexagram_record("primary", hexagram), + ] + records.extend(_line_record(line) for line in moving) + transformed = hexagram.get("transformed") or {} + if transformed: + records.append(_hexagram_record("transformed", transformed)) + return records + + +def _fortune_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]: + calculation = context.get("calculation") or {} + movement = calculation.get("year_movement") or {} + annual_qi = calculation.get("annual_qi") or {} + current_qi = calculation.get("current_qi") or {} + knowledge = catalog["fortune"] + records = [ + {"id": "fortune-principle", "source": "neijing", "text": knowledge["principle"]}, + ] + if calculation.get("industry_symbols"): + records.append( + { + "id": "fortune-industry-boundary", + "source": "product_method", + "text": knowledge["industry_boundary"], + } + ) + personal = calculation.get("personal") or {} + if personal: + records.append( + { + "id": "fortune-personal-boundary", + "source": "product_method", + "text": knowledge["personal_boundary"], + } + ) + today = personal.get("today_relative_to_natal_day_master") or {} + relation_semantics = knowledge.get("personal_relations") or {} + for relation in dict.fromkeys((today.get("stem_relations") or {}).values()): + if relation in relation_semantics: + records.append( + { + "id": f"fortune-personal-{relation}", + "source": "product_method", + "subject": relation, + "text": relation_semantics[relation], + } + ) + tendency = str(movement.get("tendency") or "") + if tendency in knowledge["movement"]: + records.append({"id": f"movement-{tendency}", "source": "neijing", "text": knowledge["movement"][tendency]}) + for key in ("sitian", "zaiquan"): + qi = str(annual_qi.get(key) or "") + if qi in knowledge["qi"]: + records.append({"id": f"annual-{key}", "source": "neijing", "subject": qi, "text": knowledge["qi"][qi]}) + for key in ("host_qi", "guest_qi"): + qi = str(current_qi.get(key) or "") + if qi in knowledge["qi"]: + records.append({"id": f"current-{key}", "source": "neijing", "subject": qi, "text": knowledge["qi"][qi]}) + relation = current_qi.get("guest_host_relation") or {} + relation_type = str(relation.get("type") or "") + if relation_type in knowledge["relations"]: + records.append({"id": f"relation-{relation_type}", "source": "neijing", "subject": relation.get("label") or "", "text": knowledge["relations"][relation_type]}) + records.append({"id": "day-trigger", "source": "neijing", "text": knowledge["day_trigger"]}) + return records + + +def _heart_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]: + calculation = context.get("calculation") or {} + hexagram = calculation.get("hexagram") or {} + six_yao = calculation.get("six_yao") or {} + preset = str(calculation.get("question_preset") or "custom") + heart = catalog["heart"] + records = [ + {"id": "heart-focus", "source": "product_method", "text": heart["focus"].get(preset, heart["focus"]["custom"])}, + {"id": "heart-evidence-order", "source": "product_method", "items": heart["evidence_order"]}, + {"id": "heart-limits", "source": "product_method", "text": heart["limits"]}, + {"id": "heart-self-response", "source": "jingfang", "text": heart["semantics"]["self_response"]}, + {"id": "heart-calendar", "source": "zengshan", "text": heart["semantics"]["calendar"]}, + {"id": "heart-movement", "source": "huozhulin", "text": heart["semantics"]["movement"]}, + {"id": "heart-six-spirits", "source": "zengshan", "text": heart["semantics"]["six_spirits"]}, + {"id": "heart-timing-boundary", "source": "product_method", "text": heart["semantics"]["timing_boundary"]}, + _hexagram_record("primary", hexagram), + ] + relatives = { + str(line.get("relative") or "") + for line in six_yao.get("lines") or [] + if line.get("relative") + } + for relative in sorted(relatives): + text = (heart["semantics"].get("relatives") or {}).get(relative) + if text: + records.append( + { + "id": f"heart-relative-{relative}", + "source": "huozhulin", + "subject": relative, + "text": text, + } + ) + records.extend(_line_record(line) for line in hexagram.get("lines") or [] if line.get("moving")) + transformed = hexagram.get("transformed") or {} + if transformed: + records.append(_hexagram_record("transformed", transformed)) + palace = six_yao.get("palace") or {} + records.append( + { + "id": "heart-palace", + "source": "jingfang", + "text": ( + f"本卦归{palace.get('name') or '--'}、{palace.get('stage') or '--'}," + f"世在{palace.get('self_position') or '--'}爻,应在{palace.get('response_position') or '--'}爻。" + ), + } + ) + return records + + +def _hexagram_record(kind: str, hexagram: dict[str, Any]) -> dict[str, Any]: + return { + "id": f"zhouyi-{kind}", + "source": "zhouyi", + "kind": kind, + "name": hexagram.get("name") or "", + "inner_trigram": hexagram.get("inner_trigram") or "", + "outer_trigram": hexagram.get("outer_trigram") or "", + "text": hexagram.get("text") or "", + "tuan": hexagram.get("tuan") or "", + "image": hexagram.get("image") or "", + } + + +def _line_record(line: dict[str, Any]) -> dict[str, Any]: + return { + "id": f"zhouyi-line-{line.get('position') or ''}", + "source": "zhouyi", + "position": line.get("position"), + "position_name": line.get("position_name") or "", + "line_name": line.get("line_name") or "", + "text": line.get("text") or "", + "image": line.get("image") or "", + } + + +@lru_cache(maxsize=1) +def _knowledge_catalog() -> dict[str, Any]: + payload = json.loads(KNOWLEDGE_FILE.read_text(encoding="utf-8")) + if not payload.get("version") or not isinstance(payload.get("sources"), dict): + raise ValueError("问天知识库格式不完整。") + return payload diff --git a/backend/features/heaven/readings.py b/backend/features/heaven/readings.py index dbf5955..1a94203 100644 --- a/backend/features/heaven/readings.py +++ b/backend/features/heaven/readings.py @@ -6,11 +6,17 @@ from datetime import date from typing import Any from backend.bootstrap.config import normalize_date -from backend.features.heaven.agent import HeavenAgentError, interpret_heaven +from backend.features.heaven.agent import ( + HEAVEN_PROMPT_VERSIONS, + HeavenAgentError, + interpret_heaven, +) from backend.features.heaven.engine import ( build_five_phase_field, hexagram_from_lines, ) +from backend.features.heaven.knowledge import prepare_heaven_context +from backend.features.heaven.six_yao import build_six_yao_chart from backend.features.market import MarketServiceMixin @@ -75,9 +81,11 @@ class HeavenReadingMixin: return subject, detail hexagram = context.get("hexagram") or {} transformed = hexagram.get("transformed") or {} + question = str(context.get("question") or "").strip() + question_detail = f" · {question[:48]}" if question else "" return ( f"{display_date} 观心", - f"{hexagram.get('name') or '--'} → {transformed.get('name') or '--'}", + f"{hexagram.get('name') or '--'} → {transformed.get('name') or '--'}{question_detail}", ) def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]: @@ -85,6 +93,8 @@ class HeavenReadingMixin: if mode not in {"trend", "fortune", "heart"}: raise ValueError("问天解读模式不正确。") trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) + prompt_version = HEAVEN_PROMPT_VERSIONS[mode] + stale_fortune: dict[str, Any] | None = None if mode == "fortune": existing = self.database.latest_heaven_reading( self.current_user_id, "fortune", trade_date @@ -94,7 +104,9 @@ class HeavenReadingMixin: self.current_user_id, int(existing["id"]) ) existing = None - if existing: + if existing and self.database.heaven_reading_interpretation_version( + self.current_user_id, int(existing["id"]) + ) == prompt_version: return { "answer": existing["answer"], "mode": mode, @@ -103,6 +115,7 @@ class HeavenReadingMixin: "reading": existing, "reused": True, } + stale_fortune = existing if mode in {"trend", "fortune"}: setup = self.heaven_setup( trade_date, @@ -141,38 +154,44 @@ class HeavenReadingMixin: setup["field"], public=False, ) - fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False)) - catalog = fortune_field.pop("sector_catalog", []) - dominant_elements = { - item.get("element") for item in fortune_field.get("balance", [])[:2] - } - fortune_field["industry_affinity"] = [ - { - "element": group.get("element"), - "examples": [ - item.get("name") - for item in group.get("industries", [])[:8] - if item.get("name") - ], - } - for group in catalog - if group.get("element") in dominant_elements - ] context = { "calendar_date": setup["calendar_date"], - "five_phase_field": fortune_field, + "five_phase_field": setup["field"], "personal_profile": personal_profile, } context_date = setup["calendar_date"] if mode == "trend": context_date = setup["trade_date"] else: + question = str(payload.get("question") or "").strip() + if len(question) > 300: + raise ValueError("观心问题不能超过300个字符。") + question_preset = str(payload.get("question_preset") or "unthemed").strip() + if question_preset not in {"trade", "mind", "unthemed", "custom"}: + question_preset = "custom" + if not question: + question = "不设具体问题,只观此刻一念。" + question_preset = "unthemed" + raw_lines = payload.get("lines") + hexagram = self.heaven_hexagram(raw_lines) context = { - "hexagram": self.heaven_hexagram(payload.get("lines")), - "ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。", + "question": question, + "question_preset": question_preset, + "hexagram": hexagram, + "six_yao": build_six_yao_chart( + [int(value) for value in raw_lines], + str(payload.get("cast_at") or ""), + ), + "ritual": { + "breathing": "用户已完成1秒准备与五轮吸3秒、顿2秒、呼4秒的静心呼吸。", + "casting": "用户以三枚铜钱自初爻至上爻投掷六次。", + "reflection": "用户已在看见卦象后察看第一念。", + }, } context_date = trade_date - result, compiler = self._call_heaven_agent(mode, context) + agent_context = prepare_heaven_context(mode, context) + agent_context["interpretation_version"] = prompt_version + result, compiler = self._call_heaven_agent(mode, agent_context) subject, subject_detail = self._heaven_reading_identity( mode, context_date, context ) @@ -181,6 +200,10 @@ class HeavenReadingMixin: if mode == "fortune" else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}" ) + if stale_fortune: + self.database.delete_heaven_reading( + self.current_user_id, int(stale_fortune["id"]) + ) reading = self.database.save_heaven_reading( self.current_user_id, mode, @@ -188,14 +211,14 @@ class HeavenReadingMixin: subject, subject_detail, str(result.get("answer") or ""), - context, + agent_context, dedupe_key, ) return { **result, "mode": mode, "compiler": compiler, - "notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "", + "notice": "当前智能服务繁忙,已自动切换备用服务。" if compiler == "fallback" else "", "reading": reading, "reused": False, } @@ -205,9 +228,10 @@ class HeavenReadingMixin: return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……")) def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]: + prompt_version = HEAVEN_PROMPT_VERSIONS[mode] result = self.llm_gateway.call( f"heaven_{mode}", - f"heaven-{mode}-v1", + prompt_version, lambda profile: interpret_heaven( mode, context, diff --git a/backend/features/heaven/repository.py b/backend/features/heaven/repository.py index b8d4162..8821890 100644 --- a/backend/features/heaven/repository.py +++ b/backend/features/heaven/repository.py @@ -131,6 +131,25 @@ class HeavenRepositoryMixin: items = self.list_heaven_readings(user_id, mode, context_date, 1) return items[0] if items else None + def heaven_reading_interpretation_version( + self, user_id: int, reading_id: int + ) -> str: + with self.connect() as connection: + row = connection.execute( + """ + SELECT context_snapshot FROM heaven_readings + WHERE id = ? AND user_id = ? + """, + (int(reading_id), int(user_id)), + ).fetchone() + if not row: + return "" + try: + snapshot = json.loads(str(row["context_snapshot"] or "{}")) + except (TypeError, json.JSONDecodeError): + return "" + return str(snapshot.get("interpretation_version") or "") + def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool: with self.connect() as connection: cursor = connection.execute( diff --git a/backend/features/heaven/six_yao.py b/backend/features/heaven/six_yao.py new file mode 100644 index 0000000..375f75b --- /dev/null +++ b/backend/features/heaven/six_yao.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from functools import lru_cache +from typing import Any + +from backend.features.heaven.engine import ( + BRANCH_ELEMENT, + ELEMENT_CONTROLS, + ELEMENT_GENERATES, + LINE_POSITIONS, + Solar, +) + + +SHANGHAI = timezone(timedelta(hours=8), "Asia/Shanghai") + +TRIGRAM_BITS = { + "乾": (1, 1, 1), + "兑": (1, 1, 0), + "离": (1, 0, 1), + "震": (1, 0, 0), + "巽": (0, 1, 1), + "坎": (0, 1, 0), + "艮": (0, 0, 1), + "坤": (0, 0, 0), +} +BITS_TRIGRAM = {bits: name for name, bits in TRIGRAM_BITS.items()} +PALACE_ELEMENT = { + "乾": "金", + "兑": "金", + "离": "火", + "震": "木", + "巽": "木", + "坎": "水", + "艮": "土", + "坤": "土", +} + +# 京房纳甲通行表。每组均按初爻至三爻、四爻至上爻排列。 +NAJIA = { + "乾": { + "inner": (("甲", "子"), ("甲", "寅"), ("甲", "辰")), + "outer": (("壬", "午"), ("壬", "申"), ("壬", "戌")), + }, + "坤": { + "inner": (("乙", "未"), ("乙", "巳"), ("乙", "卯")), + "outer": (("癸", "丑"), ("癸", "亥"), ("癸", "酉")), + }, + "震": { + "inner": (("庚", "子"), ("庚", "寅"), ("庚", "辰")), + "outer": (("庚", "午"), ("庚", "申"), ("庚", "戌")), + }, + "巽": { + "inner": (("辛", "丑"), ("辛", "亥"), ("辛", "酉")), + "outer": (("辛", "未"), ("辛", "巳"), ("辛", "卯")), + }, + "坎": { + "inner": (("戊", "寅"), ("戊", "辰"), ("戊", "午")), + "outer": (("戊", "申"), ("戊", "戌"), ("戊", "子")), + }, + "离": { + "inner": (("己", "卯"), ("己", "丑"), ("己", "亥")), + "outer": (("己", "酉"), ("己", "未"), ("己", "巳")), + }, + "艮": { + "inner": (("丙", "辰"), ("丙", "午"), ("丙", "申")), + "outer": (("丙", "戌"), ("丙", "子"), ("丙", "寅")), + }, + "兑": { + "inner": (("丁", "巳"), ("丁", "卯"), ("丁", "丑")), + "outer": (("丁", "亥"), ("丁", "酉"), ("丁", "未")), + }, +} + +PALACE_STAGES = ( + ("本宫", (), 6), + ("一世", (0,), 1), + ("二世", (0, 1), 2), + ("三世", (0, 1, 2), 3), + ("四世", (0, 1, 2, 3), 4), + ("五世", (0, 1, 2, 3, 4), 5), + ("游魂", (0, 1, 2, 4), 4), + ("归魂", (4,), 3), +) + +SIX_SPIRITS = ("青龙", "朱雀", "勾陈", "螣蛇", "白虎", "玄武") +SPIRIT_START = { + "甲": 0, + "乙": 0, + "丙": 1, + "丁": 1, + "戊": 2, + "己": 3, + "庚": 4, + "辛": 4, + "壬": 5, + "癸": 5, +} + +BRANCH_CLASH = { + "子": "午", "午": "子", "丑": "未", "未": "丑", + "寅": "申", "申": "寅", "卯": "酉", "酉": "卯", + "辰": "戌", "戌": "辰", "巳": "亥", "亥": "巳", +} +BRANCH_COMBINE = { + "子": "丑", "丑": "子", "寅": "亥", "亥": "寅", + "卯": "戌", "戌": "卯", "辰": "酉", "酉": "辰", + "巳": "申", "申": "巳", "午": "未", "未": "午", +} +BRANCH_HARM = { + "子": "未", "未": "子", "丑": "午", "午": "丑", + "寅": "巳", "巳": "寅", "卯": "辰", "辰": "卯", + "申": "亥", "亥": "申", "酉": "戌", "戌": "酉", +} +THREE_PUNISHMENTS = (frozenset("寅巳申"), frozenset("丑未戌"), frozenset("子卯")) +SELF_PUNISHMENT = set("辰午酉亥") + +ADVANCE_PAIRS = { + ("亥", "子"), ("寅", "卯"), ("巳", "午"), ("申", "酉"), + ("丑", "辰"), ("辰", "未"), ("未", "戌"), ("戌", "丑"), +} +RETREAT_PAIRS = {(target, source) for source, target in ADVANCE_PAIRS} + + +def build_six_yao_chart(values: list[int], cast_at: str = "") -> dict[str, Any]: + """Return a deterministic Jing Fang Na Jia chart for a six-coin result.""" + if len(values) != 6 or any(value not in {6, 7, 8, 9} for value in values): + raise ValueError("六爻必须由六、七、八、九组成,且从初爻到上爻排列。") + observed_at = _parse_cast_at(cast_at) + solar = Solar.fromYmdHms( + observed_at.year, + observed_at.month, + observed_at.day, + observed_at.hour, + observed_at.minute, + observed_at.second, + ) + lunar = solar.getLunar() + month_gz = lunar.getMonthInGanZhiExact() + day_gz = lunar.getDayInGanZhiExact2() + time_gz = lunar.getTimeInGanZhi() + void_branches = tuple(lunar.getDayXunKongExact2()) + month_branch = month_gz[1] + day_stem, day_branch = day_gz[0], day_gz[1] + + bits = tuple(1 if value % 2 else 0 for value in values) + transformed_values = tuple(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) + palace = _palace_map()[bits] + palace_element = PALACE_ELEMENT[palace["trigram"]] + self_position = int(palace["self_position"]) + response_position = self_position + 3 if self_position <= 3 else self_position - 3 + najia = _najia_for_bits(bits) + transformed_najia = _najia_for_bits(transformed_bits) + spirits = tuple( + SIX_SPIRITS[(SPIRIT_START[day_stem] + index) % 6] for index in range(6) + ) + + lines: list[dict[str, Any]] = [] + for index, ((stem, branch), value) in enumerate(zip(najia, values)): + position = index + 1 + element = BRANCH_ELEMENT[branch] + transformed_stem, transformed_branch = transformed_najia[index] + transformed_element = BRANCH_ELEMENT[transformed_branch] + line = { + "position": position, + "position_name": LINE_POSITIONS[index], + "value": value, + "yin_yang": "阳" if value % 2 else "阴", + "moving": value in {6, 9}, + "stem": stem, + "branch": branch, + "element": element, + "relative": _six_relative(palace_element, element), + "spirit": spirits[index], + "role": "世" if position == self_position else "应" if position == response_position else "", + "void": branch in void_branches, + "month": _calendar_relation("月", month_branch, branch), + "day": _calendar_relation("日", day_branch, branch), + } + if line["moving"]: + line["transformation"] = { + "value": transformed_values[index], + "yin_yang": "阳" if transformed_values[index] % 2 else "阴", + "stem": transformed_stem, + "branch": transformed_branch, + "element": transformed_element, + "relative": _six_relative(palace_element, transformed_element), + "relation_to_origin": _transformation_relation( + branch, + element, + transformed_branch, + transformed_element, + ), + } + lines.append(line) + + hidden = _hidden_spirits(palace["trigram"], palace_element, lines) + for item in hidden: + lines[item["position"] - 1].setdefault("hidden_spirits", []).append(item) + + return { + "method": "京房纳甲·八宫世应", + "method_version": "xiaobai-six-yao-v1", + "sources": ["jingfang", "huozhulin", "zengshan"], + "cast_at": observed_at.isoformat(timespec="seconds"), + "timezone": "Asia/Shanghai", + "day_boundary": "晚子时仍按民用当日排日柱", + "calendar": { + "month": month_gz, + "month_branch": month_branch, + "day": day_gz, + "day_branch": day_branch, + "time": time_gz, + "day_void": "".join(void_branches), + }, + "palace": { + "name": f"{palace['trigram']}宫", + "trigram": palace["trigram"], + "element": palace_element, + "stage": palace["stage"], + "self_position": self_position, + "response_position": response_position, + }, + "lines": lines, + "hidden_spirits": hidden, + "branch_pattern": _hexagram_branch_pattern(lines), + "relationships": _significant_line_relationships(lines), + } + + +def _parse_cast_at(raw: str) -> datetime: + value = str(raw or "").strip() + if not value: + return datetime.now(SHANGHAI) + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("起卦时间格式不正确。") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=SHANGHAI) + return parsed.astimezone(SHANGHAI) + + +@lru_cache(maxsize=1) +def _palace_map() -> dict[tuple[int, ...], dict[str, Any]]: + result: dict[tuple[int, ...], dict[str, Any]] = {} + for trigram, trigram_bits in TRIGRAM_BITS.items(): + pure = trigram_bits + trigram_bits + for stage, flipped, self_position in PALACE_STAGES: + bits = list(pure) + for index in flipped: + bits[index] = 1 - bits[index] + key = tuple(bits) + if key in result: + raise RuntimeError("八宫映射出现重复卦象。") + result[key] = { + "trigram": trigram, + "stage": stage, + "self_position": self_position, + } + if len(result) != 64: + raise RuntimeError("八宫映射未覆盖六十四卦。") + return result + + +def _najia_for_bits(bits: tuple[int, ...]) -> tuple[tuple[str, str], ...]: + inner = BITS_TRIGRAM[bits[:3]] + outer = BITS_TRIGRAM[bits[3:]] + return tuple(NAJIA[inner]["inner"] + NAJIA[outer]["outer"]) + + +def _six_relative(palace_element: str, line_element: str) -> str: + if line_element == palace_element: + return "兄弟" + if ELEMENT_GENERATES[line_element] == palace_element: + return "父母" + if ELEMENT_GENERATES[palace_element] == line_element: + return "子孙" + if ELEMENT_CONTROLS[palace_element] == line_element: + return "妻财" + return "官鬼" + + +def _calendar_relation(prefix: str, actor_branch: str, line_branch: str) -> dict[str, Any]: + actor_element = BRANCH_ELEMENT[actor_branch] + line_element = BRANCH_ELEMENT[line_branch] + labels = [] + if actor_branch == line_branch: + labels.append(f"临{prefix}{'建' if prefix == '月' else '辰'}") + if BRANCH_CLASH[actor_branch] == line_branch: + labels.append("月破" if prefix == "月" else "日冲") + if BRANCH_COMBINE[actor_branch] == line_branch: + labels.append(f"{prefix}合") + if BRANCH_HARM[actor_branch] == line_branch: + labels.append(f"{prefix}害") + element_relation = _actor_element_relation(actor_element, line_element, prefix) + return { + "branch": actor_branch, + "element": actor_element, + "branch_relations": labels, + "element_relation": element_relation, + } + + +def _actor_element_relation(actor: str, target: str, prefix: str) -> str: + if actor == target: + return f"{prefix}与爻同气" + if ELEMENT_GENERATES[actor] == target: + return f"{prefix}生爻" + if ELEMENT_CONTROLS[actor] == target: + return f"{prefix}克爻" + if ELEMENT_GENERATES[target] == actor: + return f"爻生{prefix}" + return f"爻克{prefix}" + + +def _transformation_relation( + origin_branch: str, + origin_element: str, + target_branch: str, + target_element: str, +) -> list[str]: + labels = [] + if (origin_branch, target_branch) in ADVANCE_PAIRS: + labels.append("化进神") + elif (origin_branch, target_branch) in RETREAT_PAIRS: + labels.append("化退神") + if BRANCH_COMBINE[origin_branch] == target_branch: + labels.append("化合") + if BRANCH_CLASH[origin_branch] == target_branch: + labels.append("化冲") + if target_element == origin_element: + labels.append("变爻同气") + elif ELEMENT_GENERATES[target_element] == origin_element: + labels.append("回头生") + elif ELEMENT_CONTROLS[target_element] == origin_element: + labels.append("回头克") + elif ELEMENT_GENERATES[origin_element] == target_element: + labels.append("原爻生变") + else: + labels.append("原爻克变") + return labels + + +def _hidden_spirits( + palace_trigram: str, + palace_element: str, + lines: list[dict[str, Any]], +) -> list[dict[str, Any]]: + present = {str(line["relative"]) for line in lines} + missing = {"父母", "兄弟", "子孙", "妻财", "官鬼"} - present + if not missing: + return [] + pure_bits = TRIGRAM_BITS[palace_trigram] + TRIGRAM_BITS[palace_trigram] + result = [] + for index, (stem, branch) in enumerate(_najia_for_bits(pure_bits)): + element = BRANCH_ELEMENT[branch] + relative = _six_relative(palace_element, element) + if relative not in missing: + continue + result.append( + { + "position": index + 1, + "position_name": LINE_POSITIONS[index], + "stem": stem, + "branch": branch, + "element": element, + "relative": relative, + "flying_relative": lines[index]["relative"], + } + ) + return result + + +def _hexagram_branch_pattern(lines: list[dict[str, Any]]) -> str: + pairs = ((0, 3), (1, 4), (2, 5)) + if all(BRANCH_CLASH[lines[left]["branch"]] == lines[right]["branch"] for left, right in pairs): + return "六冲" + if all(BRANCH_COMBINE[lines[left]["branch"]] == lines[right]["branch"] for left, right in pairs): + return "六合" + return "" + + +def _significant_line_relationships(lines: list[dict[str, Any]]) -> list[dict[str, Any]]: + result = [] + for left_index in range(6): + for right_index in range(left_index + 1, 6): + left = lines[left_index] + right = lines[right_index] + if not (left["moving"] or right["moving"] or left["role"] or right["role"]): + continue + labels = _branch_pair_relations(left["branch"], right["branch"]) + element_relation = _pair_element_relation(left["element"], right["element"]) + if not labels and element_relation == "同气": + continue + result.append( + { + "positions": [left["position"], right["position"]], + "lines": [left["position_name"], right["position_name"]], + "branch_relations": labels, + "element_relation": element_relation, + } + ) + return result + + +def _branch_pair_relations(left: str, right: str) -> list[str]: + labels = [] + if BRANCH_COMBINE[left] == right: + labels.append("六合") + if BRANCH_CLASH[left] == right: + labels.append("六冲") + if BRANCH_HARM[left] == right: + labels.append("六害") + pair = frozenset((left, right)) + if pair in THREE_PUNISHMENTS or (left == right and left in SELF_PUNISHMENT): + labels.append("相刑") + return labels + + +def _pair_element_relation(left: str, right: str) -> str: + if left == right: + return "同气" + if ELEMENT_GENERATES[left] == right: + return "前者生后者" + if ELEMENT_GENERATES[right] == left: + return "后者生前者" + if ELEMENT_CONTROLS[left] == right: + return "前者克后者" + return "后者克前者" diff --git a/backend/features/heaven/trend.py b/backend/features/heaven/trend.py index 13aafa7..bfc46fc 100644 --- a/backend/features/heaven/trend.py +++ b/backend/features/heaven/trend.py @@ -5,6 +5,7 @@ from typing import Any from backend.bootstrap.config import normalize_date from backend.data.providers.tushare_client import _sector_coverage_issue +from backend.features.heaven.agent import HEAVEN_PROMPT_VERSIONS from backend.features.heaven.engine import build_five_phase_field, build_market_hexagram @@ -157,11 +158,7 @@ class HeavenTrendMixin: field, public=True, ) - daily_fortune_reading = self.database.latest_heaven_reading( - self.current_user_id, "fortune", normalized_date - ) - if self._legacy_truncated_heaven_reading(daily_fortune_reading): - daily_fortune_reading = None + daily_fortune_reading = self._reusable_daily_fortune_reading(normalized_date) return { "trade_date": data_date, "calendar_date": normalized_date, @@ -182,6 +179,21 @@ class HeavenTrendMixin: }, } + def _reusable_daily_fortune_reading( + self, context_date: str + ) -> dict[str, Any] | None: + reading = self.database.latest_heaven_reading( + self.current_user_id, "fortune", context_date + ) + if not reading or self._legacy_truncated_heaven_reading(reading): + return None + version = self.database.heaven_reading_interpretation_version( + self.current_user_id, int(reading["id"]) + ) + if version != HEAVEN_PROMPT_VERSIONS["fortune"]: + return None + return reading + @staticmethod def _heaven_market_mode( trade_date: str, diff --git a/backend/features/screener/catalog.py b/backend/features/screener/catalog.py index f8e985e..a12bb88 100644 --- a/backend/features/screener/catalog.py +++ b/backend/features/screener/catalog.py @@ -1,6 +1,7 @@ from __future__ import annotations from backend.features.screener.strategies import ADVANCED_CURATED_STRATEGIES +from backend.features.screener.signals import attach_strategy_validity REGIMES = { @@ -705,3 +706,6 @@ for strategy in CURATED_STRATEGIES: ) BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES) + +for strategy in BUILTIN_STRATEGIES: + attach_strategy_validity(strategy) diff --git a/backend/features/screener/publication.py b/backend/features/screener/publication.py new file mode 100644 index 0000000..75f2f5d --- /dev/null +++ b/backend/features/screener/publication.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any + + +def resolve_published_batch( + markers: list[dict[str, Any]], + requested_date: str, + legacy_date: str = "", +) -> tuple[dict[str, Any] | None, dict[str, Any], dict[str, Any] | None]: + requested_marker = next( + ( + item for item in markers + if str(item.get("trade_date") or "") == requested_date + ), + None, + ) + published = next( + (item for item in markers if item.get("status") == "complete"), + None, + ) + if published is None and legacy_date: + published = { + "trade_date": legacy_date, + "status": "complete", + "legacy_inferred": True, + "completed": [], + "skipped": [], + "failed": [], + } + + request_status = dict(requested_marker or {}) + request_status.setdefault("trade_date", requested_date) + request_status.setdefault("status", "pending") + descriptor = _published_descriptor(published, requested_date, request_status) + if descriptor and descriptor["is_fallback"]: + request_status["retaining_trade_date"] = descriptor["trade_date"] + return published, request_status, descriptor + + +def _published_descriptor( + marker: dict[str, Any] | None, + requested_date: str, + request_status: dict[str, Any], +) -> dict[str, Any] | None: + if marker is None: + return None + trade_date = str(marker.get("trade_date") or "") + is_fallback = trade_date != requested_date + status = str(request_status.get("status") or "pending") + notice = "" + if is_fallback: + if status == "running": + notice = "所选日期候选正在生成,当前保留上一成功批次" + elif status in {"failed", "partial"}: + notice = "所选日期候选未完整发布,当前保留上一成功批次" + else: + notice = "所选日期候选尚未发布,当前展示最近成功批次" + return { + "trade_date": trade_date, + "status": "complete", + "started_at": marker.get("started_at") or "", + "finished_at": marker.get("finished_at") or marker.get("updated_at") or "", + "library_version": int(marker.get("library_version") or 0), + "completed_count": len(marker.get("completed") or []), + "skipped_count": len(marker.get("skipped") or []), + "legacy_inferred": bool(marker.get("legacy_inferred")), + "is_fallback": is_fallback, + "notice": notice, + } diff --git a/backend/features/screener/repository.py b/backend/features/screener/repository.py index 9d3f16a..8f15a91 100644 --- a/backend/features/screener/repository.py +++ b/backend/features/screener/repository.py @@ -686,6 +686,102 @@ class ScreenerRepositoryMixin: result.append(payload) return result + def screener_runs_for_dates( + self, user_id: int, trade_dates: list[str], limit: int = 1200, + ) -> list[dict[str, Any]]: + normalized_dates = list(dict.fromkeys(str(item) for item in trade_dates if item)) + if not normalized_dates: + return [] + safe_limit = max(1, min(2400, int(limit))) + owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" + parameters: list[Any] = [] if int(user_id) == 0 else [int(user_id)] + placeholders = ",".join("?" for _ in normalized_dates) + parameters.extend(normalized_dates) + parameters.append(safe_limit) + with self.connect() as connection: + rows = connection.execute( + f""" + WITH ranked AS ( + SELECT id, trade_date, regime, mode, strategy_name, result, created_at, + ROW_NUMBER() OVER ( + PARTITION BY trade_date, mode, regime, strategy_name + ORDER BY id DESC + ) AS context_rank + FROM screener_runs + WHERE {owner_clause} AND trade_date IN ({placeholders}) + ) + SELECT id, trade_date, regime, mode, strategy_name, result, created_at + FROM ranked + WHERE context_rank = 1 + ORDER BY trade_date DESC, id DESC + LIMIT ? + """, + parameters, + ).fetchall() + return [ + payload + for row in rows + if (payload := self._screener_run_payload(row)) is not None + ] + + def recent_screener_runs( + self, user_id: int, trade_date: str, mode: str, limit: int = 40, + ) -> list[dict[str, Any]]: + if int(user_id) == 0 or mode not in {"smart", "curated", "quant"}: + return [] + safe_limit = max(1, min(160, int(limit))) + with self.connect() as connection: + rows = connection.execute( + """ + WITH ranked AS ( + SELECT id, trade_date, regime, mode, strategy_name, result, created_at, + ROW_NUMBER() OVER ( + PARTITION BY trade_date, mode, regime, strategy_name + ORDER BY id DESC + ) AS context_rank + FROM screener_runs + WHERE user_id = ? AND trade_date <= ? AND mode = ? + ) + SELECT id, trade_date, regime, mode, strategy_name, result, created_at + FROM ranked + WHERE context_rank = 1 + ORDER BY trade_date DESC, id DESC + LIMIT ? + """, + (int(user_id), trade_date, mode, safe_limit), + ).fetchall() + return [ + payload + for row in rows + if (payload := self._screener_run_payload(row)) is not None + ] + + def list_screener_batch_markers( + self, end_date: str, limit: int = 30, + ) -> list[dict[str, Any]]: + safe_limit = max(1, min(120, int(limit))) + with self.connect() as connection: + rows = connection.execute( + """ + SELECT cache_key, payload, updated_at + FROM data_snapshots + WHERE kind = 'screener_auto_v1' AND cache_key <= ? + ORDER BY cache_key DESC + LIMIT ? + """, + (end_date, safe_limit), + ).fetchall() + result = [] + for row in rows: + try: + payload = json.loads(row["payload"]) + except json.JSONDecodeError: + continue + payload.setdefault("trade_date", str(row["cache_key"] or "")) + payload.setdefault("updated_at", str(row["updated_at"] or "")) + result.append(payload) + return result + def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None: owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" parameters: tuple[Any, ...] = (int(run_id),) diff --git a/backend/features/screener/service.py b/backend/features/screener/service.py index e74a109..05063d9 100644 --- a/backend/features/screener/service.py +++ b/backend/features/screener/service.py @@ -15,6 +15,11 @@ from backend.features.screener.compiler import ( from backend.features.screener.catalog import FACTOR_FIELDS, FACTOR_GROUPS, REGIMES from backend.features.screener.data_sync import FactorDataService from backend.features.screener.formula import compile_local_strategy +from backend.features.screener.publication import resolve_published_batch +from backend.features.screener.signals import ( + attach_strategy_validity, + build_candidate_archive, +) SCREENER_LIBRARY_VERSION = 8 @@ -91,26 +96,71 @@ class ScreenerServiceMixin: factor_health = self.screener.factor_health(normalized_date) strategies = self.database.list_screener_strategies(self.current_user_id) for strategy in strategies: + attach_strategy_validity(strategy) missing = self._strategy_missing_data(strategy, factor_dates, factor_health) strategy["data_ready"] = not missing strategy["missing_data"] = missing - automatic_results = self.database.screener_runs_for_date(0, normalized_date) - personal_results = self.database.screener_runs_for_date( - self.current_user_id, normalized_date + + batch_markers = self.database.list_screener_batch_markers(normalized_date, 120) + complete_markers = [ + item for item in batch_markers if item.get("status") == "complete" + ][:30] + legacy_results = [] + legacy_date = "" + if not batch_markers: + legacy_results = self.database.screener_runs_for_date(0, normalized_date) + if legacy_results: + legacy_date = normalized_date + published_marker, automatic_status, published_batch = resolve_published_batch( + batch_markers, normalized_date, legacy_date + ) + published_date = str((published_batch or {}).get("trade_date") or "") + automatic_results = ( + legacy_results + if legacy_results and published_date == normalized_date + else self.database.screener_runs_for_date(0, published_date) + if published_date + else [] + ) + personal_results = self.database.recent_screener_runs( + self.current_user_id, normalized_date, "quant", 40 ) recent_results = [ *[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}], - *[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"], + *personal_results, ] latest_results: dict[str, dict[str, Any]] = {} for result in reversed(recent_results): mode = str(result.get("meta", {}).get("mode") or "smart") latest_results[mode] = result - automatic_status = self.database.get_data_snapshot( - "screener_auto_v1", normalized_date - ) or {} + + published_dates = [ + str(item.get("trade_date") or "") for item in complete_markers + if item.get("trade_date") + ] + if legacy_date and legacy_date not in published_dates: + published_dates.append(legacy_date) + archive_runs = self.database.screener_runs_for_dates(0, published_dates, 1800) + archive_runs.extend(personal_results) + archive_as_of_date = published_date or (factor_dates[-1] if factor_dates else "") + marker_regime = (published_marker or {}).get("regime") or {} + archive_regime = str( + (marker_regime.get("id") if isinstance(marker_regime, dict) else marker_regime) + or regime.get("id") or "repair" + ) + active_signals, candidate_history = build_candidate_archive( + archive_runs, + strategies, + factor_dates, + archive_as_of_date, + archive_regime, + ) + self._attach_published_strategy_status( + strategies, automatic_results, published_marker, published_date + ) return { "trade_date": normalized_date, + "requested_trade_date": normalized_date, "regime": regime, "regimes": [{"id": key, "label": value} for key, value in REGIMES.items()], "strategies": strategies, @@ -141,10 +191,49 @@ class ScreenerServiceMixin: "latest_results": latest_results, "recent_results": recent_results, "automatic_status": automatic_status, + "published_batch": published_batch, + "published_status": published_marker or {}, + "active_signals": active_signals, + "candidate_history": candidate_history, # Kept during the client transition for compatibility with older frontends. "latest_result": latest_results.get("smart"), } + @staticmethod + def _attach_published_strategy_status( + strategies: list[dict[str, Any]], + automatic_results: list[dict[str, Any]], + marker: dict[str, Any] | None, + published_date: str, + ) -> None: + results_by_name = { + str((item.get("meta") or {}).get("strategy_name") or ""): item + for item in automatic_results + } + skipped_by_name = { + str(item.get("name") or ""): item + for item in (marker or {}).get("skipped") or [] + } + for strategy in strategies: + name = str(strategy.get("name") or "") + result = results_by_name.get(name) + skipped = skipped_by_name.get(name) + if result is not None: + candidates = result.get("candidates") or [] + status = "ready" if candidates else "no_signal" + detail = f"{len(candidates)} 只候选" if candidates else "数据完整,暂无符合条件个股" + elif skipped is not None: + status = "missing_data" + detail = str(skipped.get("reason") or "缺少策略必需数据") + else: + status = "not_run" + detail = "该成功批次未运行此策略" + strategy["published_run"] = { + "trade_date": published_date, + "status": status, + "detail": detail, + } + def screener_tracking(self, limit: int = 12) -> dict[str, Any]: return self.strategy_tracking.list_tracking(self.current_user_id, limit) diff --git a/backend/features/screener/signals.py b/backend/features/screener/signals.py new file mode 100644 index 0000000..3259d1d --- /dev/null +++ b/backend/features/screener/signals.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from typing import Any + + +FREQUENCY_VALIDITY_DAYS = { + "每日": 1, + "每日9:25": 1, + "每周": 5, + "双周": 10, + "月度": 20, + "事件驱动": 5, +} + + +def signal_validity(mode: str, formula: dict[str, Any] | None) -> dict[str, Any]: + if mode == "smart": + return { + "type": "until_regime_change", + "label": "当前阶段不变时有效", + } + meta = (formula or {}).get("meta") or {} + frequency = str(meta.get("frequency") or "每日") + days = FREQUENCY_VALIDITY_DAYS.get(frequency, 1) + return { + "type": "trading_days", + "days": days, + "label": f"{days}个交易日", + } + + +def attach_strategy_validity(strategy: dict[str, Any]) -> None: + formula = strategy.get("formula") or {} + meta = formula.setdefault("meta", {}) + mode = "curated" if meta.get("library") == "curated" else "smart" + meta["signal_validity"] = signal_validity(mode, formula) + + +def build_candidate_archive( + runs: list[dict[str, Any]], + strategies: list[dict[str, Any]], + trading_dates: list[str], + as_of_date: str, + as_of_regime: str, + history_limit: int = 1200, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + strategy_formulas = { + str(item.get("name") or ""): item.get("formula") or {} + for item in strategies + } + date_positions = {trade_date: index for index, trade_date in enumerate(trading_dates)} + as_of_position = date_positions.get(as_of_date, len(trading_dates) - 1) + history: dict[tuple[str, str, str], dict[str, Any]] = {} + active: dict[tuple[str, str], dict[str, Any]] = {} + + ordered_runs = sorted( + runs, + key=lambda item: ( + str((item.get("meta") or {}).get("trade_date") or ""), + int((item.get("meta") or {}).get("run_id") or 0), + ), + reverse=True, + ) + for result in ordered_runs: + meta = result.get("meta") or {} + mode = str(meta.get("mode") or "smart") + if mode not in {"smart", "curated", "quant"}: + continue + selection_date = str(meta.get("trade_date") or "").replace("-", "") + strategy_name = str(meta.get("strategy_name") or "未命名策略") + regime = str(meta.get("regime") or "") + formula = result.get("formula") or strategy_formulas.get(strategy_name) or {} + validity = signal_validity(mode, formula) + valid, valid_until, remaining = _signal_state( + validity, + selection_date, + regime, + trading_dates, + date_positions, + as_of_position, + as_of_regime, + ) + hit = { + "selection_date": selection_date, + "strategy_name": strategy_name, + "regime": regime, + "run_id": int(meta.get("run_id") or 0), + "validity": validity, + "valid_until": valid_until, + "remaining_trading_days": remaining, + "active": valid, + } + for candidate in result.get("candidates") or []: + code = str(candidate.get("code") or "") + if not code: + continue + history_key = (mode, selection_date, code) + history_row = history.setdefault( + history_key, + _archive_row(candidate, mode, selection_date), + ) + candidate_hit = {**hit, "score_display": candidate.get("score_display")} + _append_hit(history_row, candidate_hit) + if valid: + active_key = (mode, code) + active_row = active.get(active_key) + if active_row is None: + active_row = _archive_row(candidate, mode, selection_date) + active[active_key] = active_row + _append_hit(active_row, candidate_hit) + + history_rows = sorted( + history.values(), + key=lambda item: (item["selection_date"], _numeric_score(item["score_display"])), + reverse=True, + )[: max(1, int(history_limit))] + active_rows = sorted( + active.values(), + key=lambda item: (item["selection_date"], _numeric_score(item["score_display"])), + reverse=True, + ) + for row in [*history_rows, *active_rows]: + _finalize_archive_row(row) + return active_rows, history_rows + + +def _signal_state( + validity: dict[str, Any], + selection_date: str, + regime: str, + trading_dates: list[str], + date_positions: dict[str, int], + as_of_position: int, + as_of_regime: str, +) -> tuple[bool, str, int | None]: + if validity.get("type") == "until_regime_change": + return regime == as_of_regime, "", None + days = max(1, int(validity.get("days") or 1)) + selected_position = date_positions.get(selection_date) + if selected_position is None or as_of_position < selected_position: + return False, "", 0 + elapsed = as_of_position - selected_position + valid = elapsed < days + valid_position = selected_position + days - 1 + valid_until = ( + trading_dates[valid_position] + if 0 <= valid_position < len(trading_dates) + else "" + ) + return valid, valid_until, max(0, days - elapsed) if valid else 0 + + +def _archive_row( + candidate: dict[str, Any], mode: str, selection_date: str +) -> dict[str, Any]: + return { + "mode": mode, + "selection_date": selection_date, + "code": str(candidate.get("code") or ""), + "name": str(candidate.get("name") or ""), + "sector": str(candidate.get("sector") or ""), + "score_display": candidate.get("score_display"), + "pct_chg": candidate.get("pct_chg"), + "return_5d": candidate.get("return_5d"), + "hits": [], + } + + +def _append_hit(row: dict[str, Any], hit: dict[str, Any]) -> None: + identity = (hit["strategy_name"], hit["regime"], hit["run_id"]) + existing = { + (item["strategy_name"], item["regime"], item["run_id"]) + for item in row["hits"] + } + if identity not in existing: + row["hits"].append(dict(hit)) + + +def _finalize_archive_row(row: dict[str, Any]) -> None: + hits = row.get("hits") or [] + active_hits = [item for item in hits if item.get("active")] + row["matched_strategies"] = list( + dict.fromkeys(item["strategy_name"] for item in hits) + ) + row["regimes"] = list(dict.fromkeys(item["regime"] for item in hits if item["regime"])) + row["active"] = bool(active_hits) + row["status"] = "持续有效" if active_hits else "已到期" + labels = list( + dict.fromkeys(item["validity"]["label"] for item in (active_hits or hits)) + ) + row["validity_label"] = " / ".join(labels) + + +def _numeric_score(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return -1.0 diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index dfebedc..1a22163 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -389,9 +389,9 @@ "/pages/themes/foundation.css?v=20260802-5", "/pages/popularity/foundation.css?v=20260802-5", "/pages/dragon-tiger/foundation.css?v=20260802-5", - "/pages/screener/foundation.css?v=20260802-5", + "/pages/screener/foundation.css?v=20260804-1", "/pages/mentor/foundation.css?v=20260802-5", - "/pages/heaven/foundation.css?v=20260802-5", + "/pages/heaven/foundation.css?v=20260804-3", "/pages/review/foundation.css?v=20260802-5" ], "frontend_composition": { @@ -436,23 +436,23 @@ "code_hotspots": [ { "path": "frontend/pages/heaven/foundation.css", - "bytes": 183624, - "lines": 11655 + "bytes": 184959, + "lines": 11685 }, { "path": "frontend/pages/screener/foundation.css", - "bytes": 100200, - "lines": 6433 + "bytes": 103075, + "lines": 6606 }, { "path": "frontend/pages/heaven/page.js", - "bytes": 92669, - "lines": 1965 + "bytes": 96724, + "lines": 2057 }, { "path": "backend/features/heaven/engine.py", - "bytes": 51670, - "lines": 1181 + "bytes": 51764, + "lines": 1183 }, { "path": "frontend/index.html", @@ -466,8 +466,8 @@ }, { "path": "backend/features/screener/catalog.py", - "bytes": 35424, - "lines": 707 + "bytes": 35571, + "lines": 711 }, { "path": "frontend/pages/auction/foundation.css", @@ -501,29 +501,29 @@ }, { "path": "frontend/pages/heaven/page.html", - "bytes": 19103, - "lines": 255 + "bytes": 19747, + "lines": 262 }, { "path": "frontend/pages/screener/page.html", - "bytes": 18513, - "lines": 219 + "bytes": 19663, + "lines": 233 }, { "path": "frontend/pages/market/preview.js", "bytes": 18178, "lines": 446 }, + { + "path": "backend/features/heaven/trend.py", + "bytes": 16772, + "lines": 370 + }, { "path": "backend/features/market/insights_auction_scoring.py", "bytes": 16689, "lines": 355 }, - { - "path": "backend/features/heaven/trend.py", - "bytes": 16310, - "lines": 358 - }, { "path": "frontend/pages/market/charts.js", "bytes": 15311, @@ -551,8 +551,8 @@ }, { "path": "frontend/shared/session.js", - "bytes": 12842, - "lines": 285 + "bytes": 13176, + "lines": 293 }, { "path": "backend/features/market/insights_auction_data.py", @@ -569,6 +569,11 @@ "bytes": 10717, "lines": 221 }, + { + "path": "backend/features/heaven/readings.py", + "bytes": 10539, + "lines": 244 + }, { "path": "backend/data/providers/tushare_sectors.py", "bytes": 9876, @@ -579,11 +584,6 @@ "bytes": 9348, "lines": 222 }, - { - "path": "backend/features/heaven/readings.py", - "bytes": 9313, - "lines": 220 - }, { "path": "frontend/pages/market/entity-detail.js", "bytes": 9119, @@ -624,16 +624,16 @@ "bytes": 6739, "lines": 156 }, + { + "path": "frontend/shared/context.js", + "bytes": 6547, + "lines": 220 + }, { "path": "frontend/pages/sentiment/page.html", "bytes": 6488, "lines": 81 }, - { - "path": "frontend/shared/context.js", - "bytes": 6433, - "lines": 216 - }, { "path": "backend/data/providers/tushare_stocks.py", "bytes": 6244, diff --git a/data/heaven_knowledge.json b/data/heaven_knowledge.json new file mode 100644 index 0000000..402b390 --- /dev/null +++ b/data/heaven_knowledge.json @@ -0,0 +1,117 @@ +{ + "version": "2026.08.05-5", + "sources": { + "zhouyi": { + "title": "周易经文与十翼", + "scope": "卦辞、爻辞、彖传、象传", + "kind": "public_domain_primary", + "note": "观势与观心只引用本项目已校录的卦爻原文,不把现代网络释文当作原典。" + }, + "jingfang": { + "title": "京氏易传", + "scope": "八宫与纳甲体系来源", + "kind": "public_domain_traditional", + "note": "确定性程序采用京房纳甲、八宫世应的通行排法。" + }, + "huozhulin": { + "title": "火珠林", + "scope": "纳甲筮法、六亲与日月关系", + "kind": "public_domain_traditional", + "note": "用于观心规则脉络,不直接复制后世简化断语。" + }, + "zengshan": { + "title": "增删卜易", + "scope": "用神、世应、动变、日月旺衰", + "kind": "public_domain_traditional", + "note": "只采用可明确编码且有一致输入条件的规则;争议规则单独标记。" + }, + "neijing": { + "title": "黄帝内经·素问运气七篇", + "scope": "五运、司天在泉、主客气与运气关系", + "kind": "public_domain_primary", + "note": "观气将原典关系转成当日自我观察语言,不宣称对股价存在因果作用。" + } + }, + "trend": { + "method": "本卦说明当下结构,实际动爻说明变化关节,之卦说明所趋结构;多动爻全部保留,不以固定口诀删去用户实际得到的爻。", + "rules": { + "stable": "无动爻时以本卦整体、上下卦关系和大象为主,说明结构的延续条件,不把静止等同于永远不变。", + "single": "一爻动时以该爻的时位、爻辞和象辞为变化核心,并用之卦检查变化后的结构。", + "multiple": "多爻动时逐一保留相关爻义,先找共同方向与冲突,再结合之卦给出有条件的倾向;不得用固定套话把不同动爻压成同一结论。" + } + }, + "fortune": { + "principle": "先立中运与司天在泉的年纲,再察当前客气加临主气,最后以日辰说明当日触发;不使用产品权重推导传统结论。", + "movement": { + "太过": "太过表示该运之气偏于有余,解释时同时观察其本气表现与对所胜、所生关系的牵动,不直接等同于吉或凶。", + "不及": "不及表示该运之气偏于不足,解释时同时观察其所不胜来乘与所生受累的可能,不直接等同于弱势结论。" + }, + "qi": { + "厥阴风木": "厥阴取风木之动,侧重疏泄、升发、变化与不定;偏盛时可表现为动摇、急变或升散不收。", + "少阴君火": "少阴取君火之明与热,侧重显化、温煦和内在驱动;偏盛时容易躁热,受制时则显而不畅。", + "太阴湿土": "太阴取湿土之濡与承载,侧重黏滞、蓄积和转化;偏盛时容易困重迟缓,得化时则能承接。", + "少阳相火": "少阳取相火之行与枢转,侧重外达、加速和往来;偏盛时容易浮越躁动,受阻时表现为枢机不利。", + "阳明燥金": "阳明取燥金之收与清肃,侧重收敛、裁决和边界;偏盛时容易干急严峻,得润时则清明有序。", + "太阳寒水": "太阳取寒水之藏与凝,侧重潜藏、收引和下行;偏盛时容易凝滞退缩,得温时则蓄势有根。" + }, + "relations": { + "same": "客主同气表示同类气相并,重点看是否相得而彰,还是同气偏盛而亢;不能机械判为有利。", + "guest_generates_host": "客生主表示来气生助时令本气,气机较易衔接;仍需观察生助是否过度及年纲是否承接。", + "host_generates_guest": "主生客表示时令本气向来气流转,有相生也有外泄;不能只取相生而忽略主气受耗。", + "guest_controls_host": "客克主表示来气制约主气,传统称客胜为从;重点解释外来变化居上及原有节律受制。", + "host_controls_guest": "主克客表示主气制约来气,传统称主胜为逆;重点解释时令与来气相持而不把相克直接断凶。" + }, + "day_trigger": "日辰只说明当日关系如何被触发,不与中运、司天在泉或主客气并列重复计权。", + "industry_boundary": "五行对应行业只作传统取象:可以说明本次已经出现的五行之气对相应行业形成的象征性关注、节奏或约束,但不得读取或猜测行业实时行情,不得预测涨跌,也不得把取象写成投资推荐。", + "personal_boundary": "personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master中的pillars是当日历法,stem_relations只是当日年、月、日三柱天干相对本命日主的确定性关系标签。只能使用本次检索到的关系释义,不得自行重算十神、扩展五行生克、使用藏干、库气或支的燥湿属性,也不得把当日日柱称为用户命局,或由这些字段推断命局中某一十神偏重、身强身弱或喜用神。", + "personal_relations": { + "比肩": "比肩作为当日天干关系标签,只提示用户可能更在意自主判断、同类比较或坚持原有立场;不能据此判断命局强弱或现实事件。", + "劫财": "劫财作为当日天干关系标签,只提示用户留意精力、注意力或可支配资源在同类事项间的分流与竞争感;不等同于破财或他人争夺。", + "食神": "食神作为当日天干关系标签,只提示用户留意表达、输出、舒缓与完成感;不等同于收益或确定的轻松结果。", + "伤官": "伤官作为当日天干关系标签,只提示用户留意质疑规则、急于表达或追求自主空间的倾向;不等同于冲突或违规。", + "偏财": "偏财作为当日天干关系标签,只提示用户留意机会分配、灵活取舍与非固定资源的吸引力;不等同于意外获利。", + "正财": "正财作为当日天干关系标签,只提示用户更关注可核对的结果、资源边界和务实落地;不等同于必得收益或现金变化。", + "七杀": "七杀作为当日天干关系标签,只提示用户留意紧迫感、外部压力和快速决断冲动;不等同于危险必然发生。", + "正官": "正官作为当日天干关系标签,只提示用户更在意规则、责任、秩序和可交付标准;不等同于结果必然受控。", + "偏印": "偏印作为当日天干关系标签,只提示用户留意内省、非惯常信息和反复推敲的倾向;不等同于退缩、失眠或方向错误。", + "正印": "正印作为当日天干关系标签,只提示用户更在意依据、支持、学习和安全边界;不等同于必然获得帮助。" + } + }, + "heart": { + "presets": { + "trade": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?", + "mind": "此刻影响我交易判断的情绪、执念或盲点是什么?", + "unthemed": "不设具体问题,只观此刻一念。" + }, + "focus": { + "trade": "以世爻、应爻、妻财爻及实际动变为主要检索对象,同时检查兄弟、官鬼和子孙的生克,不把任何单一六亲固定判吉凶。", + "mind": "以世爻和实际动爻为主,观察官鬼所示压力、子孙所示舒解及内外生克;不把心境问题强行翻译成价格方向。", + "unthemed": "不强选事项用神,以本卦、世爻、实际动爻和之卦作一般观照,不猜测用户没有提出的问题。", + "custom": "先依据用户明确写出的股票交易问题选择相关六亲;无法明确归类时退回世爻、动爻和卦变的一般解释,不擅自补全问题。" + }, + "evidence_order": [ + "用户问题与预设来源", + "本卦及卦宫", + "世应与所问相关六亲", + "月建日辰、旬空及冲合生克", + "实际动爻与变爻", + "之卦与整体卦义", + "六神辅助象义" + ], + "limits": "六神只作辅助象义;空亡、月破、日冲、合冲刑害均需结合用神、世应和动变,不得单项宣布结果。", + "semantics": { + "self_response": "世爻表示求测者当前立场与承受状态,应爻表示所问事项的外部一端或对照面。应爻不是固定的合作方、庄家或资金方;只有用户问题明确给出该角色时,才可作对应解释。", + "calendar": "月建与日辰用于判断爻在起卦时刻的承受、生扶和制约。旬空表示该爻所象征的条件当下可能未落实、难发挥或有名无实,但不能单凭旬空判失败,也不能用填实日期预测何时涨跌或行动。月破、日冲、六合、六冲、六害和相刑同样必须与世应、相关六亲及动变合看。", + "movement": "动爻说明关系正在变化;变爻说明变化后的承接方向。回头生、回头克和原变爻生克只描述力量关系,不自动对应现实中的借贷、融资、合作或某个具体人物。进神退神只说明同类地支变化的进退趋势,不直接宣布价格方向。", + "six_spirits": "六神只补充表达色彩,不单独定成败。青龙不必然有利,白虎不必然紧急或凶险,朱雀不必然等同口舌,玄武不必然等同欺骗,勾陈与螣蛇也不得脱离爻位、六亲和动变独断。", + "timing_boundary": "观心不作应期预测。可以说明某项条件在起卦时刻尚未落实或受制,但不得给出未来若干日、某干支日、出空或填实后必然发生什么。", + "relatives": { + "兄弟": "兄弟是与卦宫五行同类的关系。在股票交易问题中可作为竞争、同类力量或资源分流的候选象义,但不直接等同合作方、亏损或他人拿走资金。", + "子孙": "子孙是卦宫所生的关系,可作为舒缓、产出、执行后的释放或对压力的制衡候选象义,但不直接等同收益、资金提供方或确定的利好。", + "妻财": "妻财是卦宫所克的关系,在股票交易问题中可作为价值、收益预期、持仓利益或可支配资源的候选象义,但不直接等同现金、融资、自有资金或必得之财。", + "官鬼": "官鬼是克制卦宫的关系,可作为压力、风险、规则约束或担忧的候选象义,但不直接等同借贷、坏消息、疾病或必然损失。", + "父母": "父母是生助卦宫的关系,可作为信息、依据、计划、规则、凭据或保护条件的候选象义,但不直接等同政策、合同或某一条消息。" + } + } + } +} diff --git a/docs/product/小白复盘-完整产品规格说明书.md b/docs/product/小白复盘-完整产品规格说明书.md index 853f564..0109613 100644 --- a/docs/product/小白复盘-完整产品规格说明书.md +++ b/docs/product/小白复盘-完整产品规格说明书.md @@ -1102,6 +1102,8 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监 - 解读加载动画循环直到结果出现;动画、标题和底部文字不得重叠。 - 观势/解卦使用六爻推演动画;观气使用五运六气主题动画。 - LLM只解释程序已经算出的结构,不能起卦、改卦、修改干支、行情或五运六气。 +- LLM解读必须经过本地知识检索:按本次卦象、动爻、五运六气关系和纳甲结构精确选择原典与规则,不使用一段固定提示词代替专业知识。 +- 知识记录包含来源、适用条件、采用体系和争议边界;不同传统规则不得静默混用。 - 所有结果明确属于传统文化和娱乐化观察,不构成预测或投资建议。 ### 15.2 观势输入与状态 @@ -1134,6 +1136,8 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监 势值 = 六爻标准化分值平均值×100。显示本卦图形、箭头、之卦图形、势值和三才状态;本卦后不得出现多余圆圈。 +行情和标的身份只用于确定性成卦。进入LLM的解势上下文不得包含股票名称、代码、行业、板块或原始行情证据,避免模型在成卦后补造行业、政策、基本面或资金面故事。 + ### 15.4 观势六爻公式 初爻,个股内核: @@ -1219,13 +1223,21 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监 主页面不展示权重条。五行对应行业是传统取象,不是行情旁证;显示字体必须可读。 -解运结果包含:三层气机、复合断语、情绪与判断偏差、操作惯性、个人影响、今日生克断语和制衡动作。同一账号同一天成功解运一次后保存;再次点击直接显示“已解运”和已保存结果,不重复调用LLM。旧版被截断的结果只允许重新生成一次。 +解运结果按“年纲、客主加临、日辰触发、行业影响、个人合参、制衡动作”分段。行业影响只从本次中运、司天在泉和主客气实际涉及的五行映射到后台行业归类,说明象征性的关注、节奏或约束;不得读取行业实时行情、预测涨跌或形成投资推荐。没有个人资料时省略个人合参。同一账号同一天成功解运一次后保存;再次点击直接显示“已解运”和已保存结果,不重复调用LLM。旧版被截断的结果只允许重新生成一次。提示词或知识边界升级后,只复用版本一致的结果;旧结果在新版成功生成后才替换,调用失败时不得先删除已有完整结果。 + +页面载入时的五行权重与“某气偏显”是小白复盘的结构化概览,用于直观展示,不属于传统五运六气的原生权重结论。点击解运后,LLM上下文不得包含五行权重、主导元素排序、权重生成的复合断语、预制情绪行为结论、行业实时行情或简化喜用神。解运只使用中运太过/不及、司天在泉、主气客气、节气阶段、日辰及其已经由程序判定的关系;行业字段只提供五行传统取象与本次出现依据;个人合参只使用日主、十神和当日派生关系。 + +个人合参必须区分用户本命日主与当日日柱:本命日主是用户资料的派生结果,当日日柱只是本日历法。进入LLM的个人字段只保留本命日主、当日三柱和程序已经计算出的当日天干关系标签;不得自行重算十神、扩展五行生克或使用藏干,也不得把当日日柱写成用户命局,或据此推断用户命局中的十神偏重、身强身弱和喜用神。 + +日辰摘要中的日干运势、地支五行和六气对应是三项并列的确定性事实。解运不得把整个日柱改写为某一种五行,也不得把地支与六气的“对应”改写为地支自身具有该六气属性。 ### 15.7 观心 观心有五阶段:静心、呼吸、起卦、察念、解卦。 -- 用户不输入问题,只在心中默念。 +- 静心入口提供“交易、心境、无题”三个快捷问题;点击后自动填入问题框,用户可以继续编辑或自行输入。 +- 默认不选中任何快捷问题;问题为空时不能开始静心,“无题”会填入“不设具体问题,只观此刻一念。”。 +- 问题在开始静心后锁定,重新观心后恢复编辑;最终问题保存于用户私有历史并按账号隔离。 - 呼吸开始前准备1秒。 - 每息:吸3秒、顿2秒、呼4秒,共9秒;5轮,总45秒。 - 呼吸阶段只显示“吸”“顿”“呼”,不显示轮数或倒计时。 @@ -1242,6 +1254,14 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监 - 第一次投掷时绝不能把六爻全部生成。 - 六次完成后显示本卦、之卦和卦辞;不再显示六个单独爻解释卡。 - 用户先记录或确认“第一念”,之后才允许解卦。 +- 起卦时刻按`Asia/Shanghai`保存。观心确定性引擎采用京房纳甲和八宫世应,计算卦宫、六亲、六神、月建、日辰、旬空、动变、伏神及可明确编码的冲合生克关系;相同六爻、起卦时刻和问题来源必须得到相同排盘。 +- “交易”重点检索世应、妻财及相关动变,“心境”重点检索世爻、动爻及压力/舒解关系,“无题”不强选事项用神。六神、空亡或冲合不得单项决定结论。 +- 六亲只表示五行关系类别,不与现实人物或资金来源一一对应。股票交易问题不得擅自改写成融资、借贷或商业合作,也不得把妻财、子孙或兄弟直接解释为现金、资金提供方或合作方。 +- 问题未明确说明用户已经持仓、买卖或管理仓位时,解卦不得假定用户已经入场,不得使用持仓、仓位、建仓、入场、持有、买入、卖出、止损或止盈等措辞描述用户现状;只能讨论参与条件、风险边界和决策倾向。 +- 问题未明确涉及融资、借贷、合作或资源安排时,解卦不得制造外围资金、外围资源、资金进入、资源进入,或虚构资金/资源的来源、提供、注入、安排与路径。 +- 旬空可以说明条件当下未落实或难发挥,但不得用填实、出空、旬空或干支日推算未来几日或具体日期应验。 +- 解卦必须回应所问,并用白话解释本卦处境、世应与相关六亲、关键动变和之卦趋向;不再使用“一句卦意、一段变化、三句问心”的固定模板。 +- 解卦使用纯文本和简短标题,不输出Markdown表格。 - 支持返回、重新观心、历史记录和静音。 - 夜间模式中提示文字、历史记录、静音和重新观心必须具有足够对比度。 - 用户启用减少动态效果时,提供静态进度和文本反馈,但完整流程仍可完成。 @@ -1467,6 +1487,7 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监 - 超时、模型满载、限流、认证失败和网络失败使用不同的用户安全提示。 - “Selected model is at capacity”表示上游模型容量不足,不是业务数据错误;可在未输出前切辅助模型。 - 同一次业务请求只结算一次额度,主/辅助重试不重复扣次。 +- 问天首稿在进入统一模型回退前先执行本地一致性校验。首稿不合格时,将具体违规原因交给同一模型完整重写一次;重写仍属于同一次业务调用,不重复扣次。只有通过校验的结果才能展示和保存;同一模型重写仍失败后,才按统一模型池规则尝试辅助模型。 ### 18.3 审计与隐私 @@ -1902,6 +1923,7 @@ iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监 | W07 | 观心第一次投币 | 只出现初爻,其余显示未得 | | W08 | 观心投满六次 | 显示本卦/之卦/卦辞,不显示六个爻解释卡 | | W09 | 夜间打开观心 | 提示、按钮、历史和静音文字清晰可读 | +| W10 | 交易预设问题未声明已有持仓 | 解卦不假定持仓/仓位,不虚构资金或资源路径,不输出Markdown表格 | ### 25.8 复盘、主题和响应式 diff --git a/docs/product/问天知识与规则说明.md b/docs/product/问天知识与规则说明.md new file mode 100644 index 0000000..e9bbef1 --- /dev/null +++ b/docs/product/问天知识与规则说明.md @@ -0,0 +1,112 @@ +# 问天知识与规则说明 + +## 1. 目的与边界 + +问天采用“确定性计算、精确知识检索、LLM综合解释、结果一致性校验”的单向链路。LLM不参与起卦、纳甲、排世应、历法和五运六气计算,也不能用模型记忆补造输入中不存在的传统结构。 + +观势、观气和观心分别使用独立知识范围: + +- 观势是市场数据生成卦象后的《周易》象义解释,不声称属于传统摇卦。 +- 观气是传统五运六气关系的当日个人观察,不声称五行或气候导致股价变化。 +- 观心是三枚铜钱六次起卦后的纳甲六爻解释,问题必须在起卦前确定。 + +## 2. 来源等级 + +| 等级 | 来源 | 用途 | +|---|---|---| +| A | 《周易》经文、彖传、象传 | 卦辞、爻辞、上下卦、大象与变卦 | +| A | 《黄帝内经·素问》运气七篇 | 中运、司天在泉、主客气及运气关系 | +| B | 《京氏易传》 | 京房八宫和纳甲体系脉络 | +| B | 《火珠林》 | 纳甲筮法、六亲和日月关系脉络 | +| B | 《增删卜易》 | 用神、世应、动变与日月旺衰规则参考 | +| C | 现代历法程序与公开校验样本 | 节气、干支、旬空及边界交叉验证 | + +原典属于公共领域。现代解释材料只能帮助核对和白话转译,不复制现代作者的成段释文,也不能覆盖原典。知识清单位于`data/heaven_knowledge.json`,每次模型调用保存知识版本和本次命中的记录。 + +## 3. 检索方式 + +第一版不使用向量数据库。知识条件可以由程序精确确定,因此按字段键值检索: + +- 观势:本卦、实际动爻位置、之卦、动爻数量。 +- 观气:中运太过/不及、司天、在泉、主气、客气、客主关系、日辰。 +- 观心:本卦、实际动爻、之卦、卦宫、世应、六亲、问题预设及日月动变。 + +精确检索避免把字面相似但适用条件不同的传统规则送给模型。 + +## 4. 观势规则 + +本卦说明当下结构,实际动爻说明变化关节,之卦说明所趋结构。无动爻以整体和上下卦为主;一爻动以该爻为变化核心;多爻动保留全部实际动爻,先找共同方向和冲突,不采用固定口诀删除部分动爻。 + +输出必须给出明确但非绝对的倾向,可使用偏进、偏守、先难后易、由盛转收、转机有限、内外相违或结论有条件等表达。禁止用固定的谨慎等待套话代替解读,也禁止将卦义直接宣告为股价、价格或日期预测。 + +市场数据只在确定性引擎中用于生成卦象。进入LLM的观势上下文只保留数据日期、本卦、实际动爻和之卦,不包含股票名称、股票代码、行业、板块及原始行情证据。模型不得猜测观察标的,也不得补入政策、行业环境、基本面或资金面叙事。 + +## 5. 观气规则 + +页面权重只用于结构化概览,不进入LLM上下文。解运顺序固定为: + +1. 中运与司天在泉构成年纲。 +2. 当前客气加临主气形成当时关系。 +3. 日辰只说明当天如何触发,不重复计权。 +4. 行业影响只使用本次已出现五行的传统行业取象,不读取行业实时行情。 +5. 个人合参只使用日主、十神和当日派生关系。 + +相生不直接判吉,相克不直接判凶;客主同气也必须区分相得与偏盛。行业取象只允许说明象征性的关注、节奏或约束,不得预测涨跌或形成投资推荐;行业实时行情、简化强弱、喜用神和权重生成的情绪结论均不得作为解运证据。 + +个人合参中,`natal_day_master`才表示用户本命日主;`today_relative_to_natal_day_master.pillars`是当日历法,不是用户出生四柱;`stem_relations`只保存程序已经计算出的当日年、月、日三柱天干关系标签。模型只能解释这些标签,不得自行重算十神、扩展五行生克或使用藏干,也不得据当日日柱推断用户命局中的十神偏重、身强身弱或喜用神。日辰摘要中的日干运势、地支五行和六气对应必须保持为三项并列事实,不能把整个日柱归成单一五行,也不能把六气对应改写成地支自身属性。结果校验会拒绝输入中不存在的干支、藏干和上述错误归并。 + +## 6. 观心确定性排盘 + +### 6.1 采用体系 + +- 京房纳甲通行表。 +- 八宫次序:本宫、一世、二世、三世、四世、五世、游魂、归魂。 +- 世爻位置依次为六、一、二、三、四、五、四、三;应爻与世爻相隔三位。 +- 六亲以卦宫五行为“我”,按生我、同我、我生、我克、克我计算父母、兄弟、子孙、妻财、官鬼。 +- 六神按日干从初爻起排,顺序为青龙、朱雀、勾陈、螣蛇、白虎、玄武。 +- 晚子时仍按民用当日排日柱;这是项目明确选择的日期边界,不与其他流派静默混用。 + +### 6.2 已确定计算 + +每次保存起卦时刻、时区、月柱、日柱、时柱、旬空、卦宫、世应、六亲、六神、月建日辰关系、动爻变爻、回头生克、进退、伏神飞神及关键爻之间的合冲刑害。 + +伏神只在本卦六亲缺失时,从所属卦宫的纯卦同一爻位取出;当前显爻作为飞神。六神只作辅助象义。旬空、月破、日冲、六合、六冲、相刑或六害均不能脱离所问、世应、用神和动变单独宣布结果。 + +六亲表示卦宫与爻五行之间的关系类别,不与现实人物或资金来源一一对应。股票交易问题中,妻财不得直接等同现金或融资,子孙不得直接等同资金提供方,兄弟不得直接等同合作方。除非用户问题明确写出融资、借贷或合作背景,模型不得自行补造这些场景。 + +问题没有明确说明用户已经持仓、买卖或管理仓位时,模型不得擅自使用持仓、仓位、建仓、入场、持有、买入、卖出、止损或止盈等词描述用户现状。此时只能解释参与条件、风险边界和决策倾向。问题没有明确涉及融资、借贷、合作或资源安排时,不得制造外围资金、外围资源、资金进入、资源进入,或虚构资金/资源的来源、提供、注入、安排与路径。 + +旬空只说明相关条件在起卦时可能未落实、难发挥或有名无实,不能单凭旬空判成败,也不得用填实、出空、旬空或干支日推算“未来几日”或具体日期应验。解卦中的可验证动作必须是用户当下能够核对的交易条件或自身判断。 + +解卦结果只输出普通文本和简短标题。不得输出Markdown表格,避免竖线表格作为原始字符出现在页面中。 + +### 6.3 问题预设 + +- 交易:检索世应、妻财及与交易相关的实际动变。 +- 心境:检索世爻、动爻、官鬼所示压力与子孙所示舒解,不翻译为价格预测。 +- 无题:不强选事项用神,只解释卦象、世爻、动爻和变化方向。 +- 自定义:依据用户明确写出的股票交易问题检索;含义不清时退回一般卦义,不猜测事项。 + +## 7. 解读版本与缓存 + +观势、观气、观心分别使用独立提示词版本。当前版本为`heaven-trend-v4`、`heaven-fortune-v9`和`heaven-heart-v5`;保存的解读同时记录提示词版本和知识版本。新结果使用独立短标题和正文段落,历史旧答案由前端识别常见标题后以同样结构展示。 + +模型首稿必须经过本地结果校验。若首稿引用被排除的数据、补入不存在的干支/现实场景、遗漏必答项或输出禁止结论,系统会将具体违规原因反馈给同一模型重写一次;只有校验通过的结果才能展示和保存。重写属于同一次业务调用,不重复计算用户额度;同一模型重写仍失败时才进入统一备用模型策略。 + +观气仍遵守“同一账号同一天成功解运一次”的产品规则,但只复用与当前观气提示词版本一致的缓存。旧版本结果在新版本成功生成后才被替换;如果新模型调用失败,旧结果不先行删除,避免因升级丢失已经保存的历史内容。观势和观心每次独立保存,不使用观气的每日去重缓存。 + +## 8. 争议处理 + +传统体系存在流派差异。当前版本只实现输入明确、可重复计算的通行规则。尚未达成一致或高度依赖师承的规则不得包装成确定性事实;若以后新增,必须记录来源、采用理由、冲突组、版本和回归样本。 + +## 9. 验收原则 + +- 相同输入得到相同排盘。 +- 模型上下文不包含被明确排除的数据。 +- 每次解读可追溯到知识版本与命中规则。 +- 模型不得修改卦象、纳甲、干支或五运六气结果。 +- 回答必须有针对性和依据,不能退化为固定劝诫。 +- 观心专业术语必须附白话解释。 +- 未明确持仓的问题不得被改写成持仓管理,未明确融资/资源背景的问题不得生成资金或资源路径。 +- 解读不得输出Markdown表格。 +- 历史记录和个人资料严格按账号隔离。 diff --git a/frontend/index.html b/frontend/index.html index 3449e83..f8520df 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -36,9 +36,9 @@ - + - + diff --git a/frontend/pages.config.js b/frontend/pages.config.js index 260a64d..3ae7d0e 100644 --- a/frontend/pages.config.js +++ b/frontend/pages.config.js @@ -46,7 +46,7 @@ ["sentiment", "/pages/sentiment/page.html?v=20260803-1", ["sentimentCycleView"]], ["heaven", "/pages/heaven/page.html?v=20260803-1", ["heavenView"]], ["ladder", "/pages/ladder/page.html?v=20260803-1", ["ladderView"]], - ["screener", "/pages/screener/page.html?v=20260803-1", ["screenerView", "screenerTrackingView"]], + ["screener", "/pages/screener/page.html?v=20260804-1", ["screenerView", "screenerTrackingView"]], ["mentor", "/pages/mentor/page.html?v=20260803-1", ["mentorView"]], ["rotation", "/pages/rotation/page.html?v=20260803-1", ["rotationView"]], ["auction", "/pages/auction/page.html?v=20260803-1", ["auctionView"]], @@ -80,7 +80,7 @@ "/pages/themes/page.js?v=20260729-1", "/pages/popularity/page.js?v=20260729-1", "/pages/dragon-tiger/page.js?v=20260729-1", - "/pages/screener/page.js?v=20260729-1", + "/pages/screener/page.js?v=20260804-1", "/pages/mentor/page.js?v=20260729-1", "/pages/heaven/page.js?v=20260729-1", "/pages/review/page.js?v=20260729-1", @@ -89,7 +89,7 @@ "/shared/shell.js?v=20260729-1", "/shared/export.js?v=20260731-1", "/pages/heaven/loading-v2.js?v=20260728-2", - "/shared/context.js?v=20260803-1", + "/shared/context.js?v=20260804-1", "/shared/feedback.js?v=20260803-1", "/shared/application.js?v=20260803-1", "/shared/table.js?v=20260803-1", diff --git a/frontend/pages/heaven/foundation.css b/frontend/pages/heaven/foundation.css index de99a96..a92187f 100644 --- a/frontend/pages/heaven/foundation.css +++ b/frontend/pages/heaven/foundation.css @@ -1341,13 +1341,13 @@ body[data-active-view="heavenView"] .workspace-view { border-right: 1px solid var(--border); } -.personal-day-master > span { +.personal-day-master-label { color: var(--text-secondary); font-size: 10px; } -.personal-day-master > strong { +.personal-day-master-identity { display: flex; align-items: center; @@ -1355,26 +1355,58 @@ body[data-active-view="heavenView"] .workspace-view { gap: 8px; margin-top: 9px; +} + +.personal-day-master-mark { + width: 34px; + + height: 34px; + + display: grid; + + place-items: center; + + flex: 0 0 34px; + + border: 1px solid currentColor; + + border-radius: var(--radius-sm); + + background: var(--wt-soft-gold); +} + +.personal-day-master-mark svg { + width: 18px; + + height: 18px; + + stroke-width: 1.7; +} + +.personal-day-master-copy { + min-width: 0px; + + display: flex; + + flex-direction: column; + + align-items: flex-start; + + gap: 3px; +} + +.personal-day-master-copy > strong { + white-space: nowrap; font-size: 20px; } -.personal-day-master .phase-symbol { - width: 28px; - - height: 28px; - - font-size: 11px; -} - -.personal-day-master > small { +.personal-day-master-copy > small { color: var(--text-secondary); font-size: 10px; - display: block; - - margin-top: 6px; + white-space: nowrap; } #heavenFortunePanel .fortune-metric:nth-child(3n) { @@ -2420,7 +2452,7 @@ body[data-active-view="heavenView"] .workspace-view { border-color: var(--heaven-rule); } -#heavenFortunePanel .personal-day-master > strong { +#heavenFortunePanel .personal-day-master-copy > strong { font-family: var(--heaven-serif); } @@ -2940,35 +2972,13 @@ body[data-active-view="heavenView"] .workspace-view { :where(#heavenView) #heavenFortunePanel .personal-day-master { border-color: var(--heaven-rule); - display: grid; + display: flex; - justify-items: center; -} + flex-direction: column; -.personal-day-master-character { - margin-top: 13px; + align-items: flex-start; - font-family: var(--heaven-serif); - - font-weight: 500; - - line-height: 1; - - font-size: 58px !important; -} - -.personal-day-master-element { - margin-top: 12px; - - font-family: var(--heaven-serif); - - font-size: 15px; - - font-weight: 500; - - letter-spacing: 0.32em; - - text-indent: 0.32em; + justify-content: center; } .phase-text-wood { @@ -5028,6 +5038,78 @@ body[data-active-view="heavenView"] .workspace-view { color: rgb(113, 96, 71); } +#heavenHeartPanel .heart-question-composer { + width: 100%; + max-width: 520px; + min-width: 0; + display: grid; + gap: var(--card-gap); +} + +#heavenHeartPanel #heartIntro .heart-stage-inner { + grid-template-columns: minmax(0, 1fr); +} + +#heavenHeartPanel .heart-question-presets { + display: flex; + justify-content: center; + gap: var(--space-4); +} + +#heavenHeartPanel .heart-question-presets button { + min-width: calc(var(--control-height) * 2); + height: var(--control-height); + border: 1px solid var(--ritual-rule); + border-radius: var(--radius-sm); + background: transparent; + color: var(--ritual-muted); + font-family: var(--heaven-serif); + cursor: pointer; + transition: border-color var(--duration-fast), background var(--duration-fast), color var(--duration-fast); +} + +#heavenHeartPanel .heart-question-presets button:hover:not(:disabled), +#heavenHeartPanel .heart-question-presets button[aria-pressed="true"] { + border-color: var(--heaven-cinnabar); + background: var(--heaven-cinnabar-soft); + color: var(--heaven-cinnabar); +} + +#heavenHeartPanel .heart-question-presets button:disabled { + cursor: default; + opacity: 0.62; +} + +#heavenHeartPanel #heartQuestionInput { + width: 100%; + min-width: 0; + min-height: calc(var(--control-height) * 2); + box-sizing: border-box; + resize: vertical; + border: 1px solid var(--wt-control-border); + border-radius: var(--radius-md); + padding: var(--card-gap); + background: var(--wt-control-bg); + color: var(--wt-text); + font: inherit; + line-height: 1.6; + outline: none; + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); +} + +#heavenHeartPanel #heartQuestionInput::placeholder { + color: var(--wt-faint); +} + +#heavenHeartPanel #heartQuestionInput:focus { + border-color: var(--heaven-cinnabar); + box-shadow: 0 0 0 2px var(--heaven-cinnabar-soft); +} + +#heavenHeartPanel #heartQuestionInput:disabled { + opacity: 0.72; +} + #heavenHeartPanel .button { background: transparent; @@ -9372,7 +9454,7 @@ body[data-active-view="heavenView"] .workspace-view { } #heavenView #heavenFortunePanel .personal-primary-grid { - grid-template-columns: 76px minmax(0px, 1fr); + grid-template-columns: 112px minmax(0px, 1fr); gap: 12px; @@ -9404,7 +9486,7 @@ body[data-active-view="heavenView"] .workspace-view { #heavenView #heavenFortunePanel .personal-day-master { min-height: 118px; - padding: 0px; + padding: 0px 12px 0px 0px; border-right: 1px solid var(--wt-line); } @@ -10183,7 +10265,7 @@ body[data-active-view="heavenView"] .workspace-view { .personal-primary-grid { display: grid; - grid-template-columns: 76px minmax(0px, 1fr); + grid-template-columns: 112px minmax(0px, 1fr); gap: 12px; @@ -10191,42 +10273,26 @@ body[data-active-view="heavenView"] .workspace-view { } .personal-day-master { - display: grid; + display: flex; - place-items: center; + flex-direction: column; - align-content: center; + align-items: flex-start; + + justify-content: center; min-height: 118px; border-right: 1px solid var(--wt-line); } - .personal-day-master > span { + .personal-day-master-label { color: var(--wt-faint); font-size: 10px; } - .personal-day-master-character { - margin-top: 7px; - - font-family: var(--heaven-serif); - - font-size: 35px; - - line-height: 1; - } - - .personal-day-master-element { - margin-top: 5px; - - font-size: 11px; - } - - .personal-day-master small { - margin-top: 5px; - + .personal-day-master-copy small { color: var(--wt-faint); font-size: 9px; diff --git a/frontend/pages/heaven/page.html b/frontend/pages/heaven/page.html index fc39545..6972818 100644 --- a/frontend/pages/heaven/page.html +++ b/frontend/pages/heaven/page.html @@ -171,14 +171,21 @@
观心 · 一 -

把所问之事留在心里

+

把所问之事安放于心

-

只问一事,不必说出来。

-

心里默念它发生的对象与时间。

+

只问一事,在起卦前定下所问。

不求一个喜欢的答案,只看自己真正担心什么。

+
+
+ + + +
+ +
遇事不决可问春风,春风不语即随本心
- +
diff --git a/frontend/pages/heaven/page.js b/frontend/pages/heaven/page.js index d500b90..f3b41ef 100644 --- a/frontend/pages/heaven/page.js +++ b/frontend/pages/heaven/page.js @@ -5,6 +5,11 @@ const HEART_BREATH_PREPARE_MS = 1_000; const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS; const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5; const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS; +const HEART_QUESTION_PRESETS = { + trade: "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?", + mind: "此刻影响我交易判断的情绪、执念或盲点是什么?", + unthemed: "不设具体问题,只观此刻一念。", +}; let qiFieldAnimationFrame = 0; let qiFieldSoloElement = ""; @@ -477,30 +482,41 @@ async function playFortunePerformance(field, token) { const climateText = climateTone?.textContent || ""; if (climateTone) climateTone.textContent = ""; renderQiFieldCanvas(field.balance || [], { intro: true }); - if (!await heavenPerformanceDelay(900, token)) return false; - panel.classList.add("performance-climate-ready"); - if (!await heavenPerformanceDelay(720, token)) return false; - if (!await typeHeavenText(climateTone, climateText, token, 58)) return false; - - const balanceRows = [...panel.querySelectorAll(".phase-balance-row")]; - for (const row of balanceRows) { - row.classList.add("is-ready"); - const percent = number(row.dataset.phasePercent); - if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false; - if (!await heavenPerformanceDelay(90, token)) return false; - } - const layers = [...panel.querySelectorAll(".qi-framework-layer")]; - for (const layer of layers) { - layer.classList.add("is-ready"); + const climateSequence = async () => { + if (!await heavenPerformanceDelay(900, token)) return false; + panel.classList.add("performance-climate-ready"); + if (!await heavenPerformanceDelay(720, token)) return false; + return typeHeavenText(climateTone, climateText, token, 58); + }; + const qiSequence = async () => { + if (!await heavenPerformanceDelay(900, token)) return false; + const balanceRows = [...panel.querySelectorAll(".phase-balance-row")]; + for (const row of balanceRows) { + row.classList.add("is-ready"); + const percent = number(row.dataset.phasePercent); + if (!await countHeavenNumber(row.querySelector(":scope > b"), percent, token, 520, "%")) return false; + if (!await heavenPerformanceDelay(90, token)) return false; + } + const layers = [...panel.querySelectorAll(".qi-framework-layer")]; + for (const layer of layers) { + layer.classList.add("is-ready"); + if (!await heavenPerformanceDelay(250, token)) return false; + } + return true; + }; + const personalSequence = async () => { + if (!await heavenPerformanceDelay(900, token)) return false; + panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => { + setTimeout(() => { + if (token === heavenPerformanceToken) item.classList.add("is-ready"); + }, motionEnabled() ? index * 150 : 0); + }); if (!await heavenPerformanceDelay(250, token)) return false; - } - panel.querySelectorAll(".human-field-grid > div").forEach((item, index) => { - setTimeout(() => { - if (token === heavenPerformanceToken) item.classList.add("is-ready"); - }, motionEnabled() ? index * 150 : 0); - }); - if (!await heavenPerformanceDelay(820, token)) return false; - panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready"); + panel.querySelector(".personal-fortune-panel")?.classList.add("is-ready"); + return true; + }; + const sequences = await Promise.all([climateSequence(), qiSequence(), personalSequence()]); + if (sequences.some((completed) => !completed) || token !== heavenPerformanceToken) return false; panel.classList.add("performance-use-ready"); drawQiUseConnections(true); panel.classList.remove("heaven-performance-pending", "heaven-performance-running"); @@ -1047,19 +1063,26 @@ function renderPersonalFortune() { const tenGods = personal.ten_god_tendency || { favorable: [], caution: [] }; const elementTendency = personal.balance_tendency || { favorable: [], caution: [] }; const preferenceTags = (items) => (items || []).map((item) => `${escapeHtml(item)}`).join("") || "--"; + const dayMasterElement = personal.day_master?.element || ""; + const dayMasterPhase = phaseClass(dayMasterElement); container.innerHTML = `
- 日主 - ${escapeHtml(personal.day_master?.stem || "--")} - ${escapeHtml(personal.day_master?.element || "--")} - ${escapeHtml(personal.day_master?.strength || "")} + 本命日主 +
+ + + ${escapeHtml(personal.day_master?.stem || "--")}${escapeHtml(dayMasterElement)} + 日主${personal.day_master?.strength ? ` · ${escapeHtml(personal.day_master.strength)}` : ""} + +
十神喜恶
偏宜

${preferenceTags(tenGods.favorable)}

偏慎

${preferenceTags(tenGods.caution)}

五行喜忌
偏喜

${preferenceTags(elementTendency.favorable)}

偏忌

${preferenceTags(elementTendency.caution)}

`; + refreshIcons(); } function renderHexagramLines(containerId, lines, includeEvidence = false) { @@ -1091,6 +1114,27 @@ const HEAVEN_READING_META = { heart: { panel: "观心", action: "我已察念,开始解卦", done: "查看解卦", status: "卦已解" }, }; +const HEAVEN_ANSWER_HEADINGS = [ + "核心判断", "卦势依据", "动爻转折", "之卦趋向", "决策映射", + "年纲", "客主加临", "日辰触发", "行业影响", "个人合参", "制衡动作", + "所问之答", "卦象依据", "动变与之卦", "可验证之处", + "主要矛盾", "变化方向", "交易映射", "可验证动作", +]; + +function formatHeavenAnswer(content) { + const labels = HEAVEN_ANSWER_HEADINGS.join("|"); + let normalized = String(content || "").replace(/\r\n?/g, "\n"); + normalized = normalized.replace( + new RegExp(`(^|\\n)\\s*\\*\\*(${labels})[::]?\\*\\*\\s*`, "g"), + (_match, prefix, heading) => `${prefix}## ${heading}\n\n`, + ); + normalized = normalized.replace( + new RegExp(`(^|[。!?;]\\s*|\\n)(${labels})[::]\\s*`, "g"), + (_match, prefix, heading) => `${prefix}\n\n## ${heading}\n\n`, + ); + return formatMentorAnswer(normalized.replace(/\n{3,}/g, "\n\n")); +} + function heavenReadingMeta(mode = state.heavenReadingMode) { return HEAVEN_READING_META[mode] || HEAVEN_READING_META.trend; } @@ -1216,7 +1260,7 @@ function renderHeavenReadingCurrent() { setText("heavenReadingSubject", reading.subject || `${heavenReadingMeta().panel}解读`); setText("heavenReadingSubjectDetail", reading.subject_detail || displayCompactDate(reading.context_date || "")); setText("heavenReadingCreatedAt", reading.created_at ? formatTimestamp(reading.created_at) : "刚刚完成"); - document.querySelector("#heavenReadingAnswer").innerHTML = formatMentorAnswer(reading.answer || ""); + document.querySelector("#heavenReadingAnswer").innerHTML = formatHeavenAnswer(reading.answer || ""); } function renderHeavenReadingHistory() { @@ -1237,7 +1281,7 @@ function renderHeavenReadingHistory() { detail.innerHTML = selected ? `
${escapeHtml(heavenReadingMeta(mode).status)}

${escapeHtml(selected.subject)}

${escapeHtml(selected.subject_detail || displayCompactDate(selected.context_date))}

-
${formatMentorAnswer(selected.answer || "")}
+
${formatHeavenAnswer(selected.answer || "")}
` : emptyStateHtml("选择一条记录查看完整解读"); refreshIcons(); @@ -1292,7 +1336,13 @@ async function interpretHeaven(mode) { stock_code: state.heavenSetup?.chart?.stock?.code || "", }; if (mode === "trend" && state.heavenManualData) payload.manual_data = state.heavenManualData; - if (mode === "heart") payload.lines = state.heartLines; + if (mode === "heart") { + payload.trade_date = todayString(); + payload.lines = state.heartLines; + payload.question = state.heartQuestion; + payload.question_preset = state.heartQuestionPreset; + payload.cast_at = state.heartCastAt; + } const result = await apiRequest("/api/heaven/interpret", "POST", payload); state.heavenInterpretations[mode] = result.reading || { answer: result.answer, @@ -1457,6 +1507,12 @@ function waitForHeartMotion(duration, token = state.heartStageToken) { } async function startHeartBreathing() { + syncHeartQuestion(); + if (!state.heartQuestion) { + showToast("请先写下问题,或选择无题观心"); + return; + } + setHeartQuestionLocked(true); if (state.heartTimer) clearInterval(state.heartTimer); state.heartSeconds = HEART_BREATH_TOTAL_MS / 1000; state.heartBreathingEndsAt = 0; @@ -1534,6 +1590,7 @@ async function beginHeartCasting() { state.heartLines = []; state.heartThrows = []; state.heartHexagram = null; + state.heartCastAt = ""; state.heavenInterpretations.heart = ""; heartCastingBusy = false; resetHeartCoins(); @@ -1598,6 +1655,7 @@ async function tossHeartCoins() { return; } const stageToken = state.heartStageToken; + if (!state.heartCastAt) state.heartCastAt = new Date().toISOString(); const button = document.querySelector("#tossCoinsButton"); heartCastingBusy = true; button.disabled = true; @@ -1872,6 +1930,7 @@ async function resetHeartRitual() { state.heartLines = []; state.heartThrows = []; state.heartHexagram = null; + state.heartCastAt = ""; state.heavenInterpretations.heart = ""; heartIncenseAnimation?.cancel(); heartIncenseAnimation = null; @@ -1881,9 +1940,45 @@ async function resetHeartRitual() { heartCastingBusy = false; resetHeartCoins(); hideHeavenNotice(); + setHeartQuestionLocked(false); + syncHeartQuestion(); await transitionHeartStage("intro"); } +function applyHeartQuestionPreset(preset) { + if (!(preset in HEART_QUESTION_PRESETS) || state.heartStage !== "intro") return; + const input = document.querySelector("#heartQuestionInput"); + state.heartQuestionPreset = preset; + input.value = HEART_QUESTION_PRESETS[preset]; + syncHeartQuestion(); + document.querySelectorAll("[data-heart-question-preset]").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.heartQuestionPreset === preset)); + }); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); +} + +function syncHeartQuestion() { + const input = document.querySelector("#heartQuestionInput"); + if (!input) return; + state.heartQuestion = input.value.trim(); + const matchedPreset = Object.entries(HEART_QUESTION_PRESETS) + .find(([, question]) => question === state.heartQuestion)?.[0]; + state.heartQuestionPreset = matchedPreset || "custom"; + document.querySelectorAll("[data-heart-question-preset]").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.heartQuestionPreset === matchedPreset)); + }); + document.querySelector("#startBreathingButton").disabled = !state.heartQuestion; +} + +function setHeartQuestionLocked(locked) { + const input = document.querySelector("#heartQuestionInput"); + if (input) input.disabled = locked; + document.querySelectorAll("[data-heart-question-preset]").forEach((button) => { + button.disabled = locked; + }); +} + function showHeavenNotice(message) { const notice = document.querySelector("#heavenNotice"); notice.textContent = message; @@ -1898,6 +1993,10 @@ function phaseClass(element) { return { 木: "wood", 火: "fire", 土: "earth", 金: "metal", 水: "water" }[element] || "earth"; } +function phaseIcon(element) { + return { 木: "sprout", 火: "flame", 土: "mountain", 金: "gem", 水: "waves" }[element] || "circle-dot"; +} + function signedScore(value) { const parsed = number(value); return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`; @@ -1944,6 +2043,10 @@ function bindHeavenEvents() { document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile")); document.querySelector("#sectorPhaseForm").addEventListener("submit", saveSectorPhaseOverride); document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing); + document.querySelector("#heartQuestionInput").addEventListener("input", syncHeartQuestion); + document.querySelectorAll("[data-heart-question-preset]").forEach((button) => { + button.addEventListener("click", () => applyHeartQuestionPreset(button.dataset.heartQuestionPreset)); + }); document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting); document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound); document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart")); @@ -1962,4 +2065,5 @@ function bindHeavenEvents() { document.querySelectorAll("[data-heart-return]").forEach((button) => { button.addEventListener("click", resetHeartRitual); }); + syncHeartQuestion(); } diff --git a/frontend/pages/screener/foundation.css b/frontend/pages/screener/foundation.css index b0ba8f0..ec26dfd 100644 --- a/frontend/pages/screener/foundation.css +++ b/frontend/pages/screener/foundation.css @@ -313,6 +313,10 @@ @media (max-width: 720px) { #screenerView.mobile-results [data-screener-panel]:not([hidden]) { + display: block !important; + } + + #screenerView.mobile-results [data-screener-panel]:not([hidden]) > :not([data-screener-results-slot]) { display: none !important; } @@ -6431,3 +6435,172 @@ body[data-active-view="screenerView"] .overview-strip { justify-content: flex-end; } } + +#screenerView .screener-result-tools { + min-width: 0; + + display: flex; + + align-items: center; + + justify-content: flex-end; + + gap: 10px; +} + +#screenerView .screener-archive-tabs { + min-height: 30px; + + height: 30px; + + flex: 0 0 auto; +} + +#screenerView .screener-archive-tabs .segment { + min-height: 26px; + + padding: 0 9px; +} + +#screenerView .screener-batch-notice { + min-height: 38px; + + display: flex; + + align-items: center; + + gap: 12px; + + padding: 8px 14px; + + border-top: 1px solid var(--r2-line-soft); + + background: var(--r2-card); + + color: var(--r2-faint); + + font-size: 11.5px; +} + +#screenerView .screener-batch-notice strong { + flex: 0 0 auto; + + color: var(--r2-ink); + + font-size: 11.5px; +} + +#screenerView .screener-batch-notice.warning { + background: var(--warning-soft); + + color: var(--warning-color); +} + +#screenerView .screener-archive-table { + min-width: 840px; + + table-layout: fixed; +} + +#screenerView .screener-archive-table th:nth-child(1) { + width: 112px; +} + +#screenerView .screener-archive-table th:nth-child(2) { + width: 132px; +} + +#screenerView .screener-archive-table th:nth-child(3) { + width: 110px; +} + +#screenerView .screener-archive-table th:nth-child(4) { + width: 92px; +} + +#screenerView .screener-archive-table th:nth-child(6) { + width: 94px; +} + +#screenerView .screener-archive-table th:nth-child(7) { + width: 190px; +} + +#screenerView .screener-archive-table :is(th, td):nth-child(-n+3), +#screenerView .screener-archive-table :is(th, td):last-child { + position: static; + + box-shadow: none; +} + +#screenerView .screener-archive-strategies { + overflow: hidden; + + color: var(--r2-sub); + + text-overflow: ellipsis; + + white-space: nowrap; +} + +#screenerView .screener-signal-status { + display: inline-flex; + + align-items: center; + + min-height: 22px; + + padding: 0 7px; + + border-radius: 5px; + + background: var(--surface-muted); + + color: var(--text-secondary); + + font-size: 10.5px; + + font-weight: 700; +} + +#screenerView .screener-signal-status.active { + background: var(--market-up-soft); + + color: var(--market-up); +} + +:root[data-theme="dark"] #screenerView :is(.screener-batch-notice, .screener-archive-tabs) { + border-color: var(--border); + + background: var(--surface); +} + +:root[data-theme="dark"] #screenerView .screener-batch-notice.warning { + background: var(--warning-soft); + + color: var(--warning-color); +} + +@media (max-width: 900px) { + #screenerView .result-toolbar { + align-items: flex-start; + + flex-direction: column; + } + + #screenerView .screener-result-tools { + width: 100%; + + align-items: flex-start; + + flex-direction: column; + } + + #screenerView .screener-batch-notice { + align-items: flex-start; + + flex-direction: column; + + gap: 3px; + } +} diff --git a/frontend/pages/screener/page.html b/frontend/pages/screener/page.html index be880f8..d74d6db 100644 --- a/frontend/pages/screener/page.html +++ b/frontend/pages/screener/page.html @@ -149,10 +149,18 @@

候选结果

0 只
- 历史统计不代表未来收益 +
+
+ + + +
+ 历史统计不代表未来收益 +
+
- +
@@ -161,6 +169,12 @@
排名股票板块综合分
+ + + + + +
尚未执行选股
diff --git a/frontend/pages/screener/page.js b/frontend/pages/screener/page.js index ce1cf7c..54464ee 100644 --- a/frontend/pages/screener/page.js +++ b/frontend/pages/screener/page.js @@ -49,7 +49,15 @@ function selectedScreenerResultKey(mode) { function activeScreenerResultEntry(mode = state.screenerMode) { const key = selectedScreenerResultKey(mode); - return key ? state.screenerResultStore[key] || null : null; + const stored = key ? state.screenerResultStore[key] || null : null; + if (stored) return stored; + const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; + const latest = state.screenerResults[normalizedMode]; + const context = state.screenerResultContexts[normalizedMode]; + if (!latest || !context) return null; + if (normalizedMode === "quant") return { result: latest, context }; + const selected = currentScreenerStrategy(normalizedMode); + return selected?.name === context.strategyName ? { result: latest, context } : null; } function activeScreenerResult(mode = state.screenerMode) { @@ -84,11 +92,10 @@ function setScreenerResult(mode, result, options = {}) { function applyScreenerSetup(payload, requestKey) { const dateChanged = Boolean(state.screenerSetupKey && state.screenerSetupKey !== requestKey); - if (dateChanged) { - state.screenerResults = { smart: null, curated: null, quant: null }; - state.screenerResultContexts = { smart: null, curated: null, quant: null }; - state.screenerResultStore = {}; - } + state.screenerResults = { smart: null, curated: null, quant: null }; + state.screenerResultContexts = { smart: null, curated: null, quant: null }; + state.screenerResultStore = {}; + if (dateChanged) state.screenerArchiveView = "latest"; state.screenerSetup = payload; state.screenerSetupKey = requestKey; @@ -182,7 +189,13 @@ async function loadScreenerSetup(force = false) { function renderScreenerSetup() { const setup = state.screenerSetup; if (!setup) return; - setText("screenerDateLabel", `数据日期 ${displayCompactDate(setup.trade_date)}`); + const publishedDate = setup.published_batch?.trade_date; + setText( + "screenerDateLabel", + publishedDate && publishedDate !== setup.trade_date + ? `数据日期 ${displayCompactDate(setup.trade_date)} · 候选批次 ${displayCompactDate(publishedDate)}` + : `数据日期 ${displayCompactDate(setup.trade_date)}`, + ); setText("regimeLabel", setup.regime.label); setText("regimeConfidence", `置信度 ${formatNumber(setup.regime.confidence, 0)}%`); setText("regimeStepStatus", `${setup.regime.label} · 置信度 ${formatNumber(setup.regime.confidence, 0)}%`); @@ -233,6 +246,7 @@ function renderStrategySummary() { function selectScreenerMode(mode) { state.screenerMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart"; + state.screenerArchiveView = "latest"; localStorage.setItem("xiaobaiScreenerMode", state.screenerMode); state.screenerMobileView = "strategy"; renderScreenerMode(); @@ -293,6 +307,17 @@ function curatedSchoolIcon(school) { } function curatedStrategyRunState(strategy, result) { + const publishedRun = strategy?.published_run || {}; + if (publishedRun.status === "missing_data") { + return { label: "数据不足", className: "missing", verifiedEmpty: false }; + } + if (publishedRun.status === "no_signal") { + return { label: "暂无信号", className: "quiet", verifiedEmpty: true }; + } + if (publishedRun.status === "ready") { + const count = (result?.candidates || []).length; + return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false }; + } const missingData = strategy?.missing_data || []; if (!strategy?.data_ready || missingData.length) { return { label: "数据不足", className: "missing", verifiedEmpty: false }; @@ -397,15 +422,24 @@ function renderCuratedStrategyDetail() { } document.querySelector("#curatedHealthMetrics").innerHTML = [ ["运行状态", statusLabel, statusClass], - ["当日信号", result ? `${candidateCount} 只` : "--", ""], + ["批次信号", result ? `${candidateCount} 只` : "--", ""], ["字段覆盖", health.coverage != null ? `${formatNumber(health.coverage, 1)}%` : strategy.data_ready ? "数据已就绪" : "--", ""], ["最近更新", updatedLabel, ""], ].map(([label, value, className]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(""); const status = document.querySelector("#curatedDataStatus"); status.classList.toggle("missing", statusClass === "missing"); - status.innerHTML = strategy.data_ready - ? `${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}` - : `数据尚未完备${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}`; + const publishedRun = strategy.published_run || {}; + if (publishedRun.status === "missing_data") { + status.innerHTML = `缺少必需数据${escapeHtml(publishedRun.detail || "等待后台同步")}`; + } else if (publishedRun.status === "no_signal") { + status.innerHTML = `数据完整,暂无信号必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件`; + } else if (result) { + status.innerHTML = `盘后批次已发布${health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入"}`; + } else if (!strategy.data_ready) { + status.innerHTML = `数据尚未完备${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}`; + } else { + status.innerHTML = "等待成功批次运行中或失败的批次不会覆盖上一成功结果"; + } refreshIcons(); } @@ -842,22 +876,163 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId } function renderScreenerResult() { + renderScreenerArchiveTabs(); + renderScreenerBatchNotice(); + if (state.screenerArchiveView === "latest") { + renderLatestScreenerResult(); + } else { + renderScreenerArchiveResult(); + } +} + +function renderScreenerArchiveTabs() { + document.querySelectorAll("[data-screener-archive-view]").forEach((button) => { + const active = button.dataset.screenerArchiveView === state.screenerArchiveView; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", String(active)); + }); +} + +function renderScreenerBatchNotice() { + const notice = document.querySelector("#screenerBatchNotice"); + const setup = state.screenerSetup || {}; + const batch = setup.published_batch; + const requestStatus = setup.automatic_status || {}; + notice.classList.toggle("warning", Boolean(batch?.is_fallback) || ["failed", "partial"].includes(requestStatus.status)); + if (state.screenerMode === "quant") { + notice.innerHTML = "手动执行自定义选股结果独立保存,不会覆盖阶段选股或策略选股。"; + notice.hidden = false; + return; + } + if (!batch) { + const statusText = { + running: "盘后候选正在生成,完成前不会发布局部结果。", + failed: "最近一次盘后任务失败,尚无可展示的成功批次。", + partial: "最近一次盘后任务未完整完成,尚无可展示的成功批次。", + pending: "盘后候选尚未生成。", + }[requestStatus.status] || "尚无成功发布的盘后候选批次。"; + notice.innerHTML = `尚未发布${escapeHtml(statusText)}`; + notice.hidden = false; + return; + } + const completed = number(batch.completed_count); + const skipped = number(batch.skipped_count); + const detail = batch.notice + || `盘后候选已完整发布${completed ? ` · 完成 ${completed} 套策略` : ""}${skipped ? ` · ${skipped} 套因数据不足跳过` : ""}`; + const time = batch.finished_at ? ` · ${formatTimestamp(batch.finished_at)}` : ""; + notice.innerHTML = `候选基准 ${escapeHtml(displayCompactDate(batch.trade_date))}${time}${escapeHtml(detail)}`; + notice.hidden = false; +} + +function selectedScreenerArchiveRows() { const mode = state.screenerMode || "smart"; + const source = state.screenerArchiveView === "active" + ? state.screenerSetup?.active_signals || [] + : state.screenerSetup?.candidate_history || []; + const strategy = currentScreenerStrategy(mode); + return source.filter((row) => { + if (row.mode !== mode) return false; + if (mode === "quant") return true; + return (row.hits || []).some((hit) => hit.strategy_name === strategy?.name); + }).map((row) => { + let hits = row.hits || []; + if (mode !== "quant") hits = hits.filter((hit) => hit.strategy_name === strategy?.name); + if (state.screenerArchiveView === "active") hits = hits.filter((hit) => hit.active); + const latestHit = [...hits].sort((left, right) => String(right.selection_date).localeCompare(String(left.selection_date)))[0] || {}; + const active = hits.some((hit) => hit.active); + const labels = [...new Set(hits.map((hit) => hit.validity?.label).filter(Boolean))]; + return { + ...row, + selection_date: latestHit.selection_date || row.selection_date, + score_display: latestHit.score_display ?? row.score_display, + matched_strategies: [...new Set(hits.map((hit) => hit.strategy_name).filter(Boolean))], + status: active ? "持续有效" : "已到期", + active, + validity_label: labels.join(" / ") || row.validity_label || "--", + valid_until: latestHit.valid_until || "", + remaining_trading_days: latestHit.remaining_trading_days, + }; + }); +} + +function renderScreenerArchiveResult() { + const mode = state.screenerMode || "smart"; + const rows = selectedScreenerArchiveRows(); + setText("screenerResultTitle", state.screenerArchiveView === "active" ? "持续有效信号" : "历史入选记录"); + const latestTable = document.querySelector("#screenerLatestTable"); + const archiveTable = document.querySelector("#screenerArchiveTable"); + latestTable.hidden = true; + archiveTable.hidden = false; + setText("screenerResultCount", `${rows.length} 只`); + const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" }; + const strategy = currentScreenerStrategy(mode); + const source = document.querySelector("#screenerResultSource"); + source.textContent = [modeLabels[mode], strategy?.name].filter(Boolean).join(" · "); + source.hidden = false; + setText( + "screenerDisclaimer", + state.screenerArchiveView === "active" + ? "持续有效按策略频率与阶段变化计算,不代表未来收益" + : "历史入选仅保留当时策略结果,不使用未来数据回写", + ); + const body = document.querySelector("#screenerArchiveTableBody"); + body.innerHTML = rows.map((row) => { + const validity = row.valid_until + ? `${row.validity_label} · 至 ${displayCompactDate(row.valid_until)}` + : row.validity_label; + const matched = (row.matched_strategies || []).join("、"); + return ` + ${escapeHtml(displayCompactDate(row.selection_date))} + ${escapeHtml(row.name)}${escapeHtml(row.code)} + ${escapeHtml(row.sector)} + ${escapeHtml(row.status)} + ${escapeHtml(matched)} + ${row.score_display == null ? "" : formatNumber(row.score_display, 1)} + ${escapeHtml(validity)} + `; + }).join(""); + bindStockRows(body); + const empty = document.querySelector("#screenerEmpty"); + empty.textContent = state.screenerArchiveView === "active" + ? "当前策略暂无仍在有效期内的信号" + : "当前策略暂无历史入选记录"; + empty.hidden = rows.length > 0; + renderBacktest(null); + updateBacktestTaskStatus(); +} + +function latestScreenerEmptyMessage(mode) { + if (mode === "quant") return "尚未执行自定义选股"; + const strategy = currentScreenerStrategy(mode); + const publishedRun = strategy?.published_run || {}; + if (publishedRun.status === "missing_data") { + return `缺少必需数据:${publishedRun.detail || "等待后台同步"}`; + } + if (publishedRun.status === "no_signal") return "数据完整,暂无符合条件个股"; + const status = state.screenerSetup?.automatic_status?.status; + if (status === "running") return "盘后候选正在生成,成功发布前不会显示局部结果"; + if (status === "failed") return "本次盘后任务失败,当前策略暂无已发布结果"; + if (status === "partial") return "本次盘后任务未完整完成,当前策略暂无已发布结果"; + return "尚无成功发布的盘后候选结果"; +} + +function renderLatestScreenerResult() { + const mode = state.screenerMode || "smart"; + const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" }; + setText("screenerResultTitle", titles[mode]); const result = activeScreenerResult(mode); const context = activeScreenerResultContext(mode); const source = document.querySelector("#screenerResultSource"); - const emptyMessages = { - smart: "当日盘后候选尚未生成", - curated: "所选策略的当日候选尚未生成", - quant: "尚未执行自定义选股", - }; + const emptyMessage = latestScreenerEmptyMessage(mode); + document.querySelector("#screenerLatestTable").hidden = false; + document.querySelector("#screenerArchiveTable").hidden = true; if (!result) { setText("screenerResultCount", "0 只"); source.hidden = true; source.textContent = ""; setText("screenerDisclaimer", "历史统计不代表未来收益"); document.querySelector("#screenerTableBody").innerHTML = ""; - document.querySelector("#screenerEmpty").textContent = emptyMessages[mode]; + document.querySelector("#screenerEmpty").textContent = emptyMessage; document.querySelector("#screenerEmpty").hidden = false; renderBacktest(null); if (mode === "smart") setText("screenerRunStatus", "等待执行"); @@ -881,7 +1056,10 @@ function renderScreenerResult() { : `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`, ); const empty = document.querySelector("#screenerEmpty"); - empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode]; + const verifiedEmpty = mode === "curated" ? "暂无符合条件个股" : "数据完整,暂无符合条件个股"; + empty.textContent = mode === "quant" + ? emptyMessage + : `${verifiedEmpty}${mode === "curated" ? "(必需数据已完整)" : ""}`; empty.hidden = candidates.length > 0; const body = document.querySelector("#screenerTableBody"); const runId = number(meta.run_id); @@ -1053,7 +1231,20 @@ function parseFormulaEditor() { } function exportScreenerResults() { - const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" }; + const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" }; + if (state.screenerArchiveView !== "latest") { + const viewLabel = state.screenerArchiveView === "active" ? "持续有效" : "入选历史"; + const rows = selectedScreenerArchiveRows().map((row) => ({ + ...row, + matched_strategy_names: (row.matched_strategies || []).join("、"), + })); + exportRows(`${modeLabels[state.screenerMode]}-${viewLabel}`, rows, [ + ["入选日", "selection_date"], ["股票代码", "code"], ["股票名称", "name"], + ["板块", "sector"], ["状态", "status"], ["命中策略", "matched_strategy_names"], + ["最新评分", "score_display"], ["有效期", "validity_label"], + ]); + return; + } exportRows(modeLabels[state.screenerMode] || "智能选股", activeScreenerResult()?.candidates || [], [ ["股票代码", "code"], ["股票名称", "name"], ["板块", "sector"], ["综合分", "score_display"], ["历史条件估计%", "historical_probability"], ["当日涨幅%", "pct_chg"], ["5日涨幅%", "return_5d"], @@ -1091,6 +1282,14 @@ function bindScreenerEvents() { document.querySelectorAll("[data-screener-mode]").forEach((button) => { button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode)); }); + document.querySelectorAll("[data-screener-archive-view]").forEach((button) => { + button.addEventListener("click", () => { + state.screenerArchiveView = ["active", "history"].includes(button.dataset.screenerArchiveView) + ? button.dataset.screenerArchiveView + : "latest"; + renderScreenerResult(); + }); + }); document.querySelector("#curatedStrategyList").addEventListener("click", (event) => { if (event.target.closest("button")) return; const card = event.target.closest("[data-curated-strategy]"); diff --git a/frontend/shared/context.js b/frontend/shared/context.js index 3482663..e7ea963 100644 --- a/frontend/shared/context.js +++ b/frontend/shared/context.js @@ -137,6 +137,7 @@ const state = window.XiaobaiState.create({ screenerResults: { smart: null, curated: null, quant: null }, screenerResultContexts: { smart: null, curated: null, quant: null }, screenerResultStore: {}, + screenerArchiveView: "latest", screenerTracking: null, screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode")) ? localStorage.getItem("xiaobaiScreenerMode") @@ -181,6 +182,9 @@ const state = window.XiaobaiState.create({ heartLines: [], heartThrows: [], heartHexagram: null, + heartQuestion: "", + heartQuestionPreset: "custom", + heartCastAt: "", heartCurtainTimer: null, heartStageToken: 0, heartRevealToken: 0, diff --git a/frontend/shared/session.js b/frontend/shared/session.js index 5e63fee..1eaeafa 100644 --- a/frontend/shared/session.js +++ b/frontend/shared/session.js @@ -96,7 +96,15 @@ function applyMembershipAccess() { if (gate) gate.hidden = unlocked; view.querySelectorAll("button, input, textarea, select").forEach((control) => { if (control.closest(".member-gate") || control.hasAttribute("data-member-navigation")) return; - control.disabled = !unlocked; + if (!unlocked) { + if (!("memberDisabled" in control.dataset)) { + control.dataset.memberDisabled = String(control.disabled); + } + control.disabled = true; + } else if ("memberDisabled" in control.dataset) { + control.disabled = control.dataset.memberDisabled === "true"; + delete control.dataset.memberDisabled; + } }); }); const assistantButton = document.querySelector("#assistantButton"); diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index d2902c9..4ad5df8 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -415,6 +415,15 @@ async function mockApplication(page, authSession = session(), options = {}) { if (options.recentScreenerResults) { payload.recent_results = options.recentScreenerResults; } + if (options.screenerPublishedRuns) { + for (const strategy of payload.strategies) { + const publishedRun = options.screenerPublishedRuns[strategy.name]; + if (publishedRun) strategy.published_run = publishedRun; + } + } + if (options.screenerSetupOverride) { + payload = { ...payload, ...options.screenerSetupOverride }; + } } else if (url.pathname === "/api/screener/run") { const body = route.request().postDataJSON(); options.screenerRunBodies = [...(options.screenerRunBodies || []), body]; @@ -1781,6 +1790,7 @@ test("heart breathing prepares once then contracts on each exhale", async ({ pag await page.goto("/index.html"); await page.locator('[data-view="heavenView"]').first().click(); await page.locator('[data-heaven-panel="heart"]').click(); + await page.locator('[data-heart-question-preset="unthemed"]').click(); await page.evaluate(() => startHeartBreathing()); const timing = await page.evaluate(() => ({ @@ -1831,6 +1841,58 @@ test("heart breathing prepares once then contracts on each exhale", async ({ pag await expect(page.locator(".heart-breath-ripple span").first()).toHaveCSS("transition-duration", "4s"); }); +test("heart question presets stay editable and lock only during the ritual", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.locator('[data-view="heavenView"]').first().click(); + await page.locator('[data-heaven-panel="heart"]').click(); + + const input = page.locator("#heartQuestionInput"); + const start = page.locator("#startBreathingButton"); + const trade = page.locator('[data-heart-question-preset="trade"]'); + const mind = page.locator('[data-heart-question-preset="mind"]'); + const unthemed = page.locator('[data-heart-question-preset="unthemed"]'); + await expect(input).toHaveValue(""); + await expect(start).toBeDisabled(); + await expect(trade).toHaveAttribute("aria-pressed", "false"); + await expect(mind).toHaveAttribute("aria-pressed", "false"); + await expect(unthemed).toHaveAttribute("aria-pressed", "false"); + + await trade.click(); + await expect(input).toHaveValue("关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?"); + await expect(trade).toHaveAttribute("aria-pressed", "true"); + await mind.click(); + await expect(input).toHaveValue("此刻影响我交易判断的情绪、执念或盲点是什么?"); + await expect(mind).toHaveAttribute("aria-pressed", "true"); + await unthemed.click(); + await expect(input).toHaveValue("不设具体问题,只观此刻一念。"); + await expect(unthemed).toHaveAttribute("aria-pressed", "true"); + + await input.fill("我是否因为害怕错过而忽略了这笔交易的退出条件?"); + await expect(start).toBeEnabled(); + await expect(trade).toHaveAttribute("aria-pressed", "false"); + await expect(mind).toHaveAttribute("aria-pressed", "false"); + await expect(unthemed).toHaveAttribute("aria-pressed", "false"); + expect(await page.evaluate(() => ({ + question: state.heartQuestion, + preset: state.heartQuestionPreset, + }))).toEqual({ + question: "我是否因为害怕错过而忽略了这笔交易的退出条件?", + preset: "custom", + }); + + await start.click(); + await expect(page.locator("#heartBreathing")).toHaveClass(/active-heart-stage/); + await expect(input).toBeDisabled(); + await expect(trade).toBeDisabled(); + await page.locator("#heartBreathing [data-heart-return]").click(); + await expect(page.locator("#heartIntro")).toHaveClass(/active-heart-stage/); + await expect(input).toBeEnabled(); + await expect(input).toHaveValue("我是否因为害怕错过而忽略了这笔交易的退出条件?"); + await expect(trade).toBeEnabled(); +}); + test("stylesheet layers do not repeat identical rules in the same cascade context", async ({ page }) => { await mockApplication(page, session("admin", true)); await page.goto("/index.html"); @@ -2247,6 +2309,9 @@ test("screener redesign preserves three clear workspaces across desktop and mobi const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(1); await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible(); + await page.locator('[data-screener-mobile-view="results"]').click(); + await expect(page.locator("#screenerView .screener-results-view")).toBeVisible(); + await expect(page.locator("#screenerEmpty")).toBeVisible(); }); test("automatic screener results stay read-only and mode results stay isolated", async ({ page }) => { @@ -2283,7 +2348,7 @@ test("automatic screener results stay read-only and mode results stay isolated", await expect(page.locator("#changeStrategyButton")).toHaveCount(0); await expect(page.locator("#editStrategyButton")).toHaveCount(0); await page.locator('[data-screener-mode="curated"]').click(); - await expect(page.locator("#screenerEmpty")).toContainText("所选策略"); + await expect(page.locator("#screenerEmpty")).toContainText("尚无成功发布"); await page.evaluate(() => { setScreenerResult("curated", { meta: { run_id: 52, trade_date: "20260722", regime: "repair", strategy_name: "连续分红质量" }, @@ -2313,6 +2378,117 @@ test("automatic screener results stay read-only and mode results stay isolated", await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果"); }); +test("screener publishes atomically and exposes latest, active, and historical candidates", async ({ page }) => { + const candidate = { + code: "600001", ts_code: "600001.SH", name: "已发布候选", sector: "电力设备", price: 12, + score_display: 82, historical_probability: 45, probability_samples: 30, + pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78, + reason: "板块强度、相对强度", risk_flags: [], + }; + const smartResult = { + meta: { + run_id: 201, trade_date: "20260803", regime: "repair", + strategy_name: "修复确认", mode: "smart", + }, + candidates: [candidate], + disclaimer: "历史统计不代表未来收益", + backtest: null, + }; + const activeRow = { + mode: "smart", code: candidate.code, name: candidate.name, sector: candidate.sector, + selection_date: "20260803", score_display: candidate.score_display, + hits: [{ + strategy_name: "修复确认", selection_date: "20260803", active: true, + validity: { label: "阶段内有效" }, valid_until: "", remaining_trading_days: null, + score_display: candidate.score_display, + }], + }; + const expiredRow = { + mode: "smart", code: "600002", name: "历史候选", sector: "银行", + selection_date: "20260727", score_display: 76, + hits: [{ + strategy_name: "修复确认", selection_date: "20260727", active: false, + validity: { label: "阶段内有效" }, valid_until: "20260801", remaining_trading_days: 0, + score_display: 76, + }], + }; + const options = { + latestScreenerResults: { smart: smartResult }, + recentScreenerResults: [smartResult], + screenerPublishedRuns: { + 修复确认: { trade_date: "20260803", status: "ready", detail: "1 只候选" }, + 连续分红质量: { trade_date: "20260803", status: "no_signal", detail: "数据完整,暂无符合条件个股" }, + 数据缺失策略: { trade_date: "20260803", status: "missing_data", detail: "缺少近5日资金流" }, + }, + additionalScreenerStrategies: [{ + id: 4, name: "数据缺失策略", description: "验证数据不足状态", regimes: ["repair"], + builtin: true, data_ready: false, missing_data: ["近5日资金流"], + formula: { + meta: { library: "curated", category: "资金动量", quality: "A", frequency: "每日", risk: "中" }, + universe: { exclude_st: true }, filters: [], score: [], limit: 20, min_score: 0.5, + }, + }], + screenerSetupOverride: { + trade_date: "20260804", + requested_trade_date: "20260804", + published_batch: { + trade_date: "20260803", status: "complete", is_fallback: true, + completed_count: 28, skipped_count: 1, finished_at: "2026-08-03T15:30:00+08:00", + notice: "所选日期候选正在生成,当前保留上一成功批次", + }, + automatic_status: { + trade_date: "20260804", status: "running", retaining_trade_date: "20260803", + }, + active_signals: [activeRow], + candidate_history: [activeRow, expiredRow], + }, + }; + + await page.setViewportSize({ width: 1920, height: 1080 }); + await mockApplication(page, session("user", true), options); + await page.goto("/index.html?view=screenerView"); + + await expect(page.locator("#screenerDateLabel")).toContainText("候选批次 2026-08-03"); + await expect(page.locator("#screenerBatchNotice")).toContainText("当前保留上一成功批次"); + await expect(page.locator("#screenerTableBody")).toContainText(candidate.name); + + await page.locator('[data-screener-archive-view="active"]').click(); + await expect(page.locator("#screenerResultTitle")).toHaveText("持续有效信号"); + await expect(page.locator("#screenerArchiveTableBody")).toContainText(candidate.name); + await expect(page.locator("#screenerArchiveTableBody")).not.toContainText("历史候选"); + + await page.locator('[data-screener-archive-view="history"]').click(); + await expect(page.locator("#screenerResultTitle")).toHaveText("历史入选记录"); + await expect(page.locator("#screenerArchiveTableBody")).toContainText(candidate.name); + await expect(page.locator("#screenerArchiveTableBody")).toContainText("历史候选"); + + await page.locator('[data-screener-mode="curated"]').click(); + await expect(page.locator("#screenerEmpty")).toHaveText("数据完整,暂无符合条件个股"); + await expect(page.locator("#screenerTableBody")).not.toContainText(candidate.name); + await page.locator('[data-curated-strategy="4"]').click(); + await expect(page.locator("#screenerEmpty")).toContainText("缺少必需数据"); + await expect(page.locator("#curatedDataStatus")).toContainText("缺少近5日资金流"); + + await page.locator('[data-screener-mode="smart"]').click(); + await page.reload(); + await expect(page.locator("#screenerTableBody")).toContainText(candidate.name); + await expect(page.locator("#screenerBatchNotice")).toContainText("当前保留上一成功批次"); + expect(options.screenerSetupRequests).toBe(2); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('[data-screener-mobile-view="results"]').click(); + await expect(page.locator("#screenerView .screener-results-view")).toBeVisible(); + await expect(page.locator("#screenerTableBody")).toContainText(candidate.name); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(1); + + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.locator("#screenerView .screener-results-view")).toBeVisible(); + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); +}); + test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => { const formula = { meta: { library: "smart" }, universe: {}, filters: [], diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index 444b29e..e14b9a0 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -347,6 +347,20 @@ class FrontendContractTests(unittest.TestCase): self.assertIn('state.heavenInterpretations.fortune = payload.daily_fortune_reading || "";', self.script) self.assertIn('if (existing) {\n openHeavenReading(mode, { loading: false });', self.script) + def test_heart_question_presets_lock_before_casting_and_reach_backend(self): + for preset, label in (("trade", "交易"), ("mind", "心境"), ("unthemed", "无题")): + self.assertIn(f'data-heart-question-preset="{preset}"', self.html) + self.assertIn(f'>{label}', self.html) + self.assertIn('id="heartQuestionInput"', self.html) + self.assertIn("Promise.all([climateSequence(), qiSequence(), personalSequence()])", self.script) + self.assertIn("function formatHeavenAnswer(content)", self.script) + self.assertIn('data-lucide="${phaseIcon(dayMasterElement)}"', self.script) + self.assertIn('function phaseIcon(element)', self.script) + self.assertIn('setHeartQuestionLocked(true);', self.script) + self.assertIn('payload.question = state.heartQuestion;', self.script) + self.assertIn('payload.question_preset = state.heartQuestionPreset;', self.script) + self.assertIn('payload.cast_at = state.heartCastAt;', self.script) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_heaven_knowledge.py b/tests/test_heaven_knowledge.py new file mode 100644 index 0000000..4264086 --- /dev/null +++ b/tests/test_heaven_knowledge.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import unittest + +from backend.features.heaven.engine import build_five_phase_field, hexagram_from_lines +from backend.features.heaven.knowledge import prepare_heaven_context +from backend.features.heaven.six_yao import build_six_yao_chart + + +class HeavenKnowledgeTests(unittest.TestCase): + def test_fortune_context_excludes_weighted_summary_and_adds_bounded_industry_symbols(self): + field = build_five_phase_field("2026-08-04") + prepared = prepare_heaven_context( + "fortune", + { + "calendar_date": "20260804", + "five_phase_field": field, + "personal_profile": {}, + }, + ) + + serialized = str(prepared["calculation"]) + self.assertNotIn("balance", serialized) + self.assertNotIn("weights", serialized) + self.assertNotIn("human_field", serialized) + self.assertNotIn("sector_catalog", serialized) + industry_symbols = prepared["calculation"]["industry_symbols"] + self.assertTrue(industry_symbols) + self.assertTrue(all(item["basis"] and item["industries"] for item in industry_symbols)) + self.assertIn( + "fortune-industry-boundary", + {record["id"] for record in prepared["knowledge"]["records"]}, + ) + self.assertEqual(prepared["knowledge"]["retrieval"], "deterministic-keyed") + self.assertTrue(prepared["knowledge"]["records"]) + + def test_fortune_personal_context_only_exposes_deterministic_stem_relations(self): + field = build_five_phase_field("2026-08-04") + prepared = prepare_heaven_context( + "fortune", + { + "calendar_date": "20260804", + "five_phase_field": field, + "personal_profile": { + "day_master": {"stem": "丁", "element": "火", "strength": "偏弱"}, + "current": { + "pillars": {"year": "丙午", "month": "乙未", "day": "庚戌"}, + "ten_gods": { + "year": {"pillar": "丙午", "stem": "劫财", "branches": ["比肩"]}, + "month": {"pillar": "乙未", "stem": "偏印", "branches": ["食神"]}, + "day": {"pillar": "庚戌", "stem": "正财", "branches": ["伤官"]}, + }, + }, + }, + }, + ) + + personal = prepared["calculation"]["personal"] + self.assertEqual(personal["natal_day_master"], {"stem": "丁", "element": "火"}) + self.assertEqual( + personal["today_relative_to_natal_day_master"]["stem_relations"], + {"year": "劫财", "month": "偏印", "day": "正财"}, + ) + self.assertEqual( + prepared["calculation"]["day_trigger"]["summary"], + next( + item["summary"] + for item in field["framework"]["layers"] + if item["id"] == "day" + ), + ) + record_ids = {record["id"] for record in prepared["knowledge"]["records"]} + self.assertIn("fortune-personal-劫财", record_ids) + self.assertIn("fortune-personal-偏印", record_ids) + self.assertIn("fortune-personal-正财", record_ids) + serialized = str(personal) + self.assertNotIn("strength", serialized) + self.assertNotIn("branches", serialized) + + def test_trend_retrieves_only_actual_moving_lines(self): + hexagram = hexagram_from_lines([6, 7, 8, 9, 7, 8]) + prepared = prepare_heaven_context( + "trend", + { + "data_trade_date": "20260804", + "selected_focus": { + "sector": "银行", + "stock": {"code": "000001", "name": "平安银行", "change": 3.2}, + }, + "hexagram": hexagram, + "movement": {}, + }, + ) + + self.assertNotIn("selected_focus", prepared["calculation"]) + line_records = [ + record for record in prepared["knowledge"]["records"] + if str(record.get("id") or "").startswith("zhouyi-line-") + ] + self.assertEqual([record["position"] for record in line_records], [1, 4]) + + def test_heart_context_contains_question_chart_and_sources(self): + values = [6, 7, 8, 9, 7, 8] + prepared = prepare_heaven_context( + "heart", + { + "question": "这笔交易最需要防范什么?", + "question_preset": "trade", + "hexagram": hexagram_from_lines(values), + "six_yao": build_six_yao_chart(values, "2026-08-04T10:30:00+08:00"), + "ritual": {}, + }, + ) + + self.assertEqual(prepared["calculation"]["question_preset"], "trade") + self.assertEqual(prepared["calculation"]["six_yao"]["method"], "京房纳甲·八宫世应") + self.assertEqual( + {source["id"] for source in prepared["knowledge"]["sources"]}, + {"zhouyi", "jingfang", "huozhulin", "zengshan"}, + ) + record_ids = {record["id"] for record in prepared["knowledge"]["records"]} + self.assertIn("heart-self-response", record_ids) + self.assertIn("heart-calendar", record_ids) + self.assertIn("heart-movement", record_ids) + self.assertIn("heart-relative-妻财", record_ids) + self.assertIn("heart-timing-boundary", record_ids) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_heaven_prompts.py b/tests/test_heaven_prompts.py new file mode 100644 index 0000000..73173d1 --- /dev/null +++ b/tests/test_heaven_prompts.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from backend.features.heaven.agent import ( + HeavenAgentError, + _system_prompt, + _validate_answer, + interpret_heaven, +) + + +class HeavenPromptTests(unittest.TestCase): + def test_trend_prompt_requires_direction_without_fixed_advice_vocabulary(self): + prompt = _system_prompt("trend") + + self.assertIn("必须明确给出卦义上的当下倾向", prompt) + self.assertIn("不得用“谨慎、等待、守信、辨伪”一类泛化劝诫代替解势", prompt) + self.assertNotIn("卦理约占九成", prompt) + self.assertNotIn("全文控制在300至450", prompt) + self.assertIn("## 核心判断", prompt) + self.assertIn("## 决策映射", prompt) + + def test_fortune_prompt_excludes_weighted_conclusion(self): + prompt = _system_prompt("fortune") + + self.assertIn("五行权重、主导元素和预制复合断语已明确排除", prompt) + self.assertIn("相生不直接等于吉,相克不直接等于凶", prompt) + self.assertIn("不得使用藏干", prompt) + self.assertIn("只能使用knowledge中本次命中的关系释义", prompt) + self.assertNotIn("全文控制在420至600", prompt) + self.assertIn("calculation.industry_symbols", prompt) + self.assertIn("## 行业影响", prompt) + self.assertIn("不得预测行业涨跌", prompt) + + def test_heart_prompt_uses_question_and_deterministic_chart(self): + prompt = _system_prompt("heart") + + self.assertIn("用户在起卦前确定的问题位于calculation.question", prompt) + self.assertIn("纳甲、卦宫、世应、六亲、六神", prompt) + self.assertIn("不是商业合作、融资、借贷或寻找资金方", prompt) + self.assertIn("不得假定用户已经入场", prompt) + self.assertIn("不得制造外围资金、外围资源", prompt) + self.assertIn("不得输出使用竖线分栏的Markdown表格", prompt) + self.assertIn("不得用旬空、填实、出空或干支日推算", prompt) + self.assertNotIn("最后三句极短的问心句", prompt) + self.assertIn("## 所问之答", prompt) + self.assertIn("## 可验证之处", prompt) + + def test_fortune_validator_requires_industry_section_when_symbols_are_present(self): + context = { + "calculation": { + "industry_symbols": [ + {"element": "水", "basis": ["中运"], "industries": ["航运"]} + ] + } + } + answer = "年纲与客主关系已经说明,日辰只作当日触发,最后给出个人观察和制衡动作。" * 2 + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer, context) + + _validate_answer("fortune", answer + "行业影响只作航运等水属性行业的传统取象,不预测涨跌。", context) + + def test_trend_validator_rejects_market_story_added_after_casting(self): + answer = "本卦显示当下偏守,动爻揭示由增益转入受阻,之卦说明变化后应先稳住结构。" * 2 + _validate_answer("trend", answer, {}) + with self.assertRaises(HeavenAgentError): + _validate_answer("trend", answer + "白酒行业政策可能带来外部压力。", {}) + + def test_fortune_validator_rejects_invented_natal_strength(self): + context = { + "calculation": { + "pillars": {"year": "丙午", "month": "乙未", "day": "庚戌"}, + "personal": { + "today_relative_to_natal_day_master": { + "pillars": {"year": "丙午", "month": "乙未", "day": "庚戌"} + } + }, + } + } + answer = "丙午年纲、乙未客主和庚戌日辰形成相互制约,个人合参只说明当日关系,不据此判断命局强弱。" * 2 + _validate_answer("fortune", answer, context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "个人本身偏财较重。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "戊辰年进一步增加土气。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "午中藏丁火,所以内在火气被引动。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "戌为火库,内藏火之余气。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "戌属土、含太阳寒水之气。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "木生火、火生土,因此本命日主受到泄耗。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "戌月进一步加重土气。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "日柱庚戌本身就是金。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("fortune", answer + "戌的寒水属性使气机进一步收敛。", context) + + def test_invalid_first_draft_is_rewritten_before_returning(self): + invalid = "本卦解释完整,但白酒行业政策和基本面正在改变外部环境,因此当下应偏守。" * 2 + valid = "本卦显示当下偏守,实际动爻揭示由增益转入受阻,之卦说明变化后要先确认结构能否重新承接。" * 2 + with patch( + "backend.features.heaven.agent.llm_transport.chat_completion", + side_effect=[ + SimpleNamespace(content=invalid, latency_ms=1), + SimpleNamespace(content=valid, latency_ms=1), + ], + ) as completion: + result = interpret_heaven( + "trend", {}, "key", "https://example.invalid", "test-model" + ) + + self.assertEqual(result["answer"], valid) + self.assertEqual(completion.call_count, 2) + repair_messages = completion.call_args_list[1].kwargs["messages"] + self.assertIn("未通过本地一致性校验", repair_messages[-1]["content"]) + + def test_heart_validator_rejects_invented_financing_and_timing(self): + context = { + "calculation": { + "question": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?" + } + } + answer = "本卦说明条件仍有矛盾,世应提示内外预期并不一致,动爻要求先核对退出条件,再看之卦所示的收束方向。" * 2 + _validate_answer("heart", answer, context) + with self.assertRaises(HeavenAgentError): + _validate_answer("heart", answer + "对方资金提供方会转为合作方。", context) + with self.assertRaises(HeavenAgentError): + _validate_answer("heart", answer + "未来三日等到旬空填实后再行动。", context) + + def test_heart_validator_rejects_unstated_position_assumptions(self): + context = { + "calculation": { + "question": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?" + } + } + answer = "本卦说明条件尚未完全衔接,世应提示内外预期有差异,动爻要求先核对参与条件,再看变化后的风险边界。" * 2 + _validate_answer("heart", answer, context) + for term in ("持仓", "仓位", "建仓", "入场", "持有", "买入", "卖出", "止损", "止盈"): + with self.subTest(term=term), self.assertRaises(HeavenAgentError): + _validate_answer("heart", answer + f"用户当前的{term}安排已经明确。", context) + + def test_heart_validator_allows_position_language_when_question_states_it(self): + context = {"calculation": {"question": "我已经持仓,这笔交易的止损条件是否清楚?"}} + answer = "本卦所示的内外条件仍有差异,世应关系提醒先核对持仓依据;动爻显示原判断正在变化,止损条件应以用户已经写下的客观边界为准。" * 2 + + _validate_answer("heart", answer, context) + + def test_heart_validator_rejects_invented_resource_paths(self): + context = {"calculation": {"question": "这笔交易最需要防范什么?"}} + answer = "本卦说明条件尚未完全衔接,世应提示内外预期有差异,动爻要求先核对参与条件,再看变化后的风险边界。" * 2 + for scenario in ( + "外围资金可能进入。", + "外围资源进入路径尚不清楚。", + "资金来源会在之后出现。", + "资源提供的安排已经成形。", + ): + with self.subTest(scenario=scenario), self.assertRaises(HeavenAgentError): + _validate_answer("heart", answer + scenario, context) + + def test_heart_validator_rejects_markdown_tables(self): + context = {"calculation": {"question": "这笔交易最需要防范什么?"}} + answer = "本卦说明条件尚未完全衔接,世应提示内外预期有差异,动爻要求先核对参与条件,再看变化后的风险边界。" * 2 + tables = ( + answer + "\n| 项目 | 解释 |\n| --- | --- |\n| 世爻 | 当前条件 |", + answer + "\n项目 | 解释\n---|---\n世爻 | 当前条件", + ) + for table in tables: + with self.subTest(table=table), self.assertRaises(HeavenAgentError): + _validate_answer("heart", table, context) + + def test_heart_invalid_first_draft_is_rewritten_before_returning(self): + context = {"calculation": {"question": "这笔交易最需要防范什么?"}} + invalid = "本卦提示外围资源进入路径尚未落实,用户应先调整仓位,再等待资金进入。" * 3 + valid = "本卦说明条件尚未完全衔接,世应提示内外预期有差异,动爻要求先核对参与条件,再看变化后的风险边界。" * 2 + with patch( + "backend.features.heaven.agent.llm_transport.chat_completion", + side_effect=[ + SimpleNamespace(content=invalid, latency_ms=1), + SimpleNamespace(content=valid, latency_ms=1), + ], + ) as completion: + result = interpret_heaven( + "heart", context, "key", "https://example.invalid", "test-model" + ) + + self.assertEqual(result["answer"], valid) + self.assertEqual(completion.call_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_heaven_readings.py b/tests/test_heaven_readings.py index 6bfac58..d9d4ea8 100644 --- a/tests/test_heaven_readings.py +++ b/tests/test_heaven_readings.py @@ -7,6 +7,7 @@ from pathlib import Path from unittest.mock import patch from database import ReviewDatabase +from backend.features.heaven.agent import HEAVEN_PROMPT_VERSIONS from server import DashboardService @@ -28,7 +29,10 @@ class HeavenReadingTests(unittest.TestCase): "2026-07-23 观气", "丙午年 · 乙未月 · 己丑日", "当日解运结果", - {"calendar_date": "20260723"}, + { + "calendar_date": "20260723", + "interpretation_version": HEAVEN_PROMPT_VERSIONS["fortune"], + }, dedupe_key, ) @@ -83,6 +87,30 @@ class HeavenReadingTests(unittest.TestCase): self.assertEqual(result["reading"]["id"], existing["id"]) self.assertEqual(result["answer"], "当日解运结果") + def test_setup_only_exposes_current_fortune_prompt_version(self): + current = self.save(self.owner["id"]) + service = DashboardService.__new__(DashboardService) + service.database = self.database + service._request_context = threading.local() + service._request_context.user_id = self.owner["id"] + + self.assertEqual( + service._reusable_daily_fortune_reading("20260723")["id"], + current["id"], + ) + + self.database.save_heaven_reading( + self.owner["id"], + "fortune", + "20260724", + "2026-07-24 观气", + "旧版本", + "旧提示词生成的结果", + {"interpretation_version": "heaven-fortune-v2"}, + "fortune:20260724", + ) + self.assertIsNone(service._reusable_daily_fortune_reading("20260724")) + def test_legacy_truncated_fortune_is_regenerated_once(self): self.database.save_heaven_reading( self.owner["id"], @@ -124,6 +152,54 @@ class HeavenReadingTests(unittest.TestCase): len(self.database.list_heaven_readings(self.owner["id"], "fortune")), 1 ) + def test_stale_prompt_version_is_replaced_after_successful_regeneration(self): + existing = self.database.save_heaven_reading( + self.owner["id"], + "fortune", + "20260723", + "2026-07-23 观气", + "丙午年 · 乙未月 · 己丑日", + "旧提示词生成的完整结果", + {"interpretation_version": "heaven-fortune-v2"}, + "fortune:20260723", + ) + service = DashboardService.__new__(DashboardService) + service.database = self.database + service._request_context = threading.local() + service._request_context.user_id = self.owner["id"] + setup = { + "calendar_date": "20260723", + "field": { + "sector_catalog": [], + "balance": [{"element": "火"}], + "pillars": {"year": "丙午", "month": "乙未", "day": "己丑"}, + }, + } + + with patch.object(service, "heaven_setup", return_value=setup), patch.object( + service, "account_personal_field", return_value={} + ), patch.object( + service, + "_call_heaven_agent", + return_value=({"answer": "新版提示词生成的完整结果", "model": "test", "latency_ms": 1}, "primary"), + ): + result = service.heaven_interpret( + {"mode": "fortune", "trade_date": "2026-07-23"} + ) + + self.assertFalse(result["reused"]) + self.assertEqual(result["reading"]["id"], existing["id"] + 1) + self.assertEqual(result["answer"], "新版提示词生成的完整结果") + self.assertEqual( + self.database.heaven_reading_interpretation_version( + self.owner["id"], result["reading"]["id"] + ), + HEAVEN_PROMPT_VERSIONS["fortune"], + ) + self.assertEqual( + len(self.database.list_heaven_readings(self.owner["id"], "fortune")), 1 + ) + def test_trend_interpretation_saves_successful_agent_result(self): service = DashboardService.__new__(DashboardService) service.database = self.database @@ -168,6 +244,65 @@ class HeavenReadingTests(unittest.TestCase): "完整的解势结果", ) + def test_heart_interpretation_sends_question_and_deterministic_najia(self): + service = DashboardService.__new__(DashboardService) + service.database = self.database + service._request_context = threading.local() + service._request_context.user_id = self.owner["id"] + + with patch.object( + service, + "_call_heaven_agent", + return_value=( + {"answer": "完整的观心解读,包含本卦、世应、动变与针对所问的条件化判断。", "model": "test", "latency_ms": 1}, + "primary", + ), + ) as call_agent: + result = service.heaven_interpret( + { + "mode": "heart", + "trade_date": "2026-08-04", + "lines": [6, 7, 8, 9, 7, 8], + "question": "这笔交易最需要防范什么?", + "question_preset": "trade", + "cast_at": "2026-08-04T10:30:00+08:00", + } + ) + + agent_context = call_agent.call_args.args[1] + calculation = agent_context["calculation"] + self.assertEqual(calculation["question"], "这笔交易最需要防范什么?") + self.assertEqual(calculation["question_preset"], "trade") + self.assertEqual(calculation["six_yao"]["method"], "京房纳甲·八宫世应") + self.assertIn("这笔交易最需要防范什么", result["reading"]["subject_detail"]) + + def test_fallback_notice_describes_the_backup_service(self): + service = DashboardService.__new__(DashboardService) + service.database = self.database + service._request_context = threading.local() + service._request_context.user_id = self.owner["id"] + + with patch.object( + service, + "_call_heaven_agent", + return_value=( + {"answer": "完整的观心解读,说明所问、卦象依据、动变与可验证条件。", "model": "backup", "latency_ms": 1}, + "fallback", + ), + ): + result = service.heaven_interpret( + { + "mode": "heart", + "trade_date": "2026-08-05", + "lines": [6, 7, 8, 9, 7, 8], + "question": "这笔交易最需要防范什么?", + "question_preset": "trade", + "cast_at": "2026-08-05T10:30:00+08:00", + } + ) + + self.assertEqual(result["notice"], "当前智能服务繁忙,已自动切换备用服务。") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_screener_publication.py b/tests/test_screener_publication.py new file mode 100644 index 0000000..a35c01f --- /dev/null +++ b/tests/test_screener_publication.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +from backend.features.screener.catalog import BUILTIN_STRATEGIES +from backend.features.screener.publication import resolve_published_batch +from backend.features.screener.service import ScreenerServiceMixin +from backend.features.screener.signals import build_candidate_archive, signal_validity +from database import ReviewDatabase + + +def _result( + trade_date: str, + mode: str, + strategy_name: str, + code: str = "600000", + regime: str = "repair", + frequency: str = "每日", + run_id: int = 1, +) -> dict: + candidates = [] if not code else [{ + "code": code, + "name": "浦发银行", + "sector": "银行", + "score_display": 72.5, + "pct_chg": 1.2, + "return_5d": 3.4, + }] + return { + "meta": { + "trade_date": trade_date, + "mode": mode, + "strategy_name": strategy_name, + "regime": regime, + "run_id": run_id, + "health": {"status": "normal" if candidates else "no_signal"}, + }, + "formula": {"meta": {"frequency": frequency}}, + "candidates": candidates, + } + + +class ScreenerPublicationTests(unittest.TestCase): + def test_incomplete_requested_batch_retains_previous_complete_batch(self): + for status in ("running", "failed", "partial"): + markers = [ + {"trade_date": "20260804", "status": status}, + {"trade_date": "20260803", "status": "complete", "finished_at": "done"}, + ] + published, requested, descriptor = resolve_published_batch( + markers, "20260804" + ) + self.assertEqual(published["trade_date"], "20260803", status) + self.assertEqual(requested["status"], status) + self.assertTrue(descriptor["is_fallback"]) + + def test_complete_requested_batch_switches_publication(self): + markers = [ + {"trade_date": "20260804", "status": "complete"}, + {"trade_date": "20260803", "status": "complete"}, + ] + published, requested, descriptor = resolve_published_batch( + markers, "20260804" + ) + self.assertEqual(published["trade_date"], "20260804") + self.assertEqual(requested["status"], "complete") + self.assertFalse(descriptor["is_fallback"]) + + def test_legacy_result_is_inferred_only_when_caller_allows_it(self): + published, requested, descriptor = resolve_published_batch( + [], "20260804", "20260804" + ) + self.assertTrue(published["legacy_inferred"]) + self.assertEqual(requested["status"], "pending") + self.assertFalse(descriptor["is_fallback"]) + + def test_all_builtin_strategies_declare_signal_validity(self): + for strategy in BUILTIN_STRATEGIES: + validity = strategy["formula"]["meta"].get("signal_validity") + self.assertIsInstance(validity, dict, strategy["name"]) + self.assertTrue(validity.get("label"), strategy["name"]) + + def test_frequency_validity_uses_trading_days(self): + dates = [f"202607{day:02d}" for day in range(1, 11)] + strategy = { + "name": "周度策略", + "formula": {"meta": {"library": "curated", "frequency": "每周"}}, + } + run = _result(dates[0], "curated", "周度策略", frequency="每周") + active_day_five, _ = build_candidate_archive( + [run], [strategy], dates[:5], dates[4], "repair" + ) + active_day_six, history = build_candidate_archive( + [run], [strategy], dates[:6], dates[5], "repair" + ) + self.assertEqual(len(active_day_five), 1) + self.assertEqual(active_day_six, []) + self.assertEqual(history[0]["status"], "已到期") + self.assertEqual(signal_validity("curated", strategy["formula"])["days"], 5) + + def test_stage_signal_expires_when_regime_changes(self): + run = _result("20260803", "smart", "修复策略", regime="repair") + active, history = build_candidate_archive( + [run], [], ["20260803", "20260804"], "20260804", "retreat" + ) + self.assertEqual(active, []) + self.assertEqual(history[0]["status"], "已到期") + + def test_same_stock_from_multiple_strategies_is_aggregated(self): + runs = [ + _result("20260803", "curated", "策略甲", run_id=1, frequency="每周"), + _result("20260803", "curated", "策略乙", run_id=2, frequency="月度"), + ] + active, history = build_candidate_archive( + runs, [], ["20260803"], "20260803", "repair" + ) + self.assertEqual(len(active), 1) + self.assertEqual(len(history), 1) + self.assertEqual(set(active[0]["matched_strategies"]), {"策略甲", "策略乙"}) + + +class ScreenerRepositoryPublicationTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.database = ReviewDatabase(Path(self.temp.name) / "review.db") + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_marker_history_and_run_dates_are_read_in_publication_order(self): + self.database.save_data_snapshot( + "screener_auto_v1", "20260803", "system", {"status": "complete"} + ) + self.database.save_data_snapshot( + "screener_auto_v1", "20260804", "system", {"status": "running"} + ) + self.database.save_screener_run( + 0, "20260803", "repair", "阶段策略", {"meta": {}}, + _result("20260803", "smart", "阶段策略"), "smart", + ) + markers = self.database.list_screener_batch_markers("20260804") + runs = self.database.screener_runs_for_dates(0, ["20260803"]) + self.assertEqual([item["trade_date"] for item in markers], ["20260804", "20260803"]) + self.assertEqual(runs[0]["meta"]["trade_date"], "20260803") + + +class ScreenerSetupFallbackTests(unittest.TestCase): + def test_setup_uses_previous_successful_automatic_batch(self): + smart = { + "id": 1, + "name": "修复策略", + "description": "修复阶段策略", + "regimes": ["repair"], + "builtin": True, + "formula": {"meta": {"library": "smart", "frequency": "每日"}}, + } + curated = { + "id": 2, + "name": "周度策略", + "description": "周度策略", + "regimes": ["repair"], + "builtin": True, + "formula": {"meta": {"library": "curated", "frequency": "每周"}}, + } + smart_result = _result("20260803", "smart", "修复策略", run_id=11) + curated_result = _result( + "20260803", "curated", "周度策略", code="", run_id=12, frequency="每周" + ) + dates = [f"202607{day:02d}" for day in range(1, 31)] + ["20260803"] + markers = [ + {"trade_date": "20260804", "status": "running"}, + { + "trade_date": "20260803", + "status": "complete", + "regime": {"id": "repair"}, + "completed": [ + {"mode": "smart", "name": "修复策略"}, + {"mode": "curated", "name": "周度策略"}, + ], + "skipped": [], + }, + ] + database = SimpleNamespace( + factor_dates=lambda *_: dates, + auction_factor_dates=lambda *_: dates, + list_screener_strategies=lambda *_: [smart, curated], + list_screener_batch_markers=lambda *_: markers, + screener_runs_for_date=lambda _user, value: ( + [smart_result, curated_result] if value == "20260803" else [] + ), + recent_screener_runs=lambda *_: [], + screener_runs_for_dates=lambda *_: [smart_result, curated_result], + ) + health = { + "market": True, + "auction": True, + "benchmark": True, + "valuation": True, + "fundamental": True, + "dividend_history": True, + "moneyflow_history": True, + "earnings_events": True, + "popularity": True, + "institutions": True, + } + screener = SimpleNamespace( + detect_regime=lambda *_: { + "id": "repair", "label": "修复", "confidence": 80, + "reason": "", "evidence": [], "history": [], + }, + factor_health=lambda *_: health, + ) + service = ScreenerServiceMixin() + service.database = database + service.screener = screener + service.current_user_id = 7 + service._request_context = SimpleNamespace(user_id=7) + service.llm_configured = False + service.llm_primary_model = "" + service.llm_fallback_configured = False + service.llm_fallback_model = "" + + payload = service.screener_setup("20260804") + + self.assertEqual(payload["published_batch"]["trade_date"], "20260803") + self.assertTrue(payload["published_batch"]["is_fallback"]) + self.assertEqual(payload["automatic_status"]["status"], "running") + self.assertEqual(payload["latest_results"]["smart"]["meta"]["run_id"], 11) + self.assertEqual(curated["published_run"]["status"], "no_signal") + self.assertEqual(len(payload["active_signals"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_six_yao.py b/tests/test_six_yao.py new file mode 100644 index 0000000..73b8ca2 --- /dev/null +++ b/tests/test_six_yao.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import unittest + +from backend.features.heaven.six_yao import ( + _palace_map, + build_six_yao_chart, +) + + +class SixYaoEngineTests(unittest.TestCase): + def test_eight_palaces_cover_every_hexagram_once(self): + palaces = _palace_map() + + self.assertEqual(len(palaces), 64) + self.assertEqual( + {item["trigram"] for item in palaces.values()}, + {"乾", "兑", "离", "震", "巽", "坎", "艮", "坤"}, + ) + self.assertEqual( + {item["stage"] for item in palaces.values()}, + {"本宫", "一世", "二世", "三世", "四世", "五世", "游魂", "归魂"}, + ) + + def test_pure_qian_uses_standard_najia_and_self_response(self): + chart = build_six_yao_chart( + [7, 7, 7, 7, 7, 7], + "2026-08-04T10:30:00+08:00", + ) + + self.assertEqual(chart["palace"]["name"], "乾宫") + self.assertEqual(chart["palace"]["stage"], "本宫") + self.assertEqual(chart["palace"]["self_position"], 6) + self.assertEqual(chart["palace"]["response_position"], 3) + self.assertEqual( + [(line["stem"], line["branch"]) for line in chart["lines"]], + [("甲", "子"), ("甲", "寅"), ("甲", "辰"), ("壬", "午"), ("壬", "申"), ("壬", "戌")], + ) + self.assertEqual( + [line["relative"] for line in chart["lines"]], + ["子孙", "妻财", "父母", "官鬼", "兄弟", "父母"], + ) + self.assertEqual(chart["branch_pattern"], "六冲") + + def test_pure_kun_uses_standard_najia(self): + chart = build_six_yao_chart( + [8, 8, 8, 8, 8, 8], + "2026-08-04T10:30:00+08:00", + ) + + self.assertEqual(chart["palace"]["name"], "坤宫") + self.assertEqual( + [(line["stem"], line["branch"]) for line in chart["lines"]], + [("乙", "未"), ("乙", "巳"), ("乙", "卯"), ("癸", "丑"), ("癸", "亥"), ("癸", "酉")], + ) + + def test_cast_time_drives_day_void_and_six_spirits(self): + chart = build_six_yao_chart( + [7, 7, 7, 7, 7, 7], + "2026-08-04T02:30:00Z", + ) + + self.assertEqual(chart["cast_at"], "2026-08-04T10:30:00+08:00") + self.assertEqual(chart["calendar"]["day"], "庚戌") + self.assertEqual(chart["calendar"]["day_void"], "寅卯") + self.assertEqual( + [line["spirit"] for line in chart["lines"]], + ["白虎", "玄武", "青龙", "朱雀", "勾陈", "螣蛇"], + ) + + def test_moving_line_has_deterministic_transformation(self): + chart = build_six_yao_chart( + [6, 7, 8, 9, 7, 8], + "2026-08-04T10:30:00+08:00", + ) + + moving = [line for line in chart["lines"] if line["moving"]] + self.assertEqual([line["position"] for line in moving], [1, 4]) + self.assertTrue(all(line.get("transformation") for line in moving)) + self.assertFalse(any(line.get("transformation") for line in chart["lines"] if not line["moving"])) + + +if __name__ == "__main__": + unittest.main()