feat: complete heaven readings and screener publication
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user