feat: complete heaven readings and screener publication

This commit is contained in:
leefer
2026-08-05 23:44:17 +08:00
parent 6a058c2929
commit b3a21d05b7
34 changed files with 3538 additions and 225 deletions
+177 -1
View File
@@ -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: [],
+14
View File
@@ -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}</button>', 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()
+130
View File
@@ -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()
+197
View File
@@ -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()
+136 -1
View File
@@ -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()
+237
View File
@@ -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()
+84
View File
@@ -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()