feat: expand screeners and stabilize interactive feedback
This commit is contained in:
+53
-77
@@ -1433,7 +1433,8 @@ test("regular account cannot see admin controls and member features are gated",
|
||||
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
||||
await page.locator('[data-view="screenerView"]').first().click();
|
||||
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
||||
await expect(page.locator("#screenerRunButton")).toBeDisabled();
|
||||
await page.locator('[data-screener-mode="quant"]').click();
|
||||
await expect(page.locator("#quantRunButton")).toBeDisabled();
|
||||
await page.locator("#assistantButton").click();
|
||||
await expect(page.locator("#settingsDialog")).toBeHidden();
|
||||
await expect(page.locator("#assistantDialog")).toBeVisible();
|
||||
@@ -1817,7 +1818,8 @@ test("curated strategies and quant builder form independent screener workspaces"
|
||||
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(1);
|
||||
await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量");
|
||||
await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1);
|
||||
await expect(page.locator("#curatedRunButton")).toBeEnabled();
|
||||
await expect(page.locator("#curatedRunButton")).toHaveCount(0);
|
||||
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
|
||||
|
||||
await page.locator('[data-screener-mode="quant"]').click();
|
||||
await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden();
|
||||
@@ -1958,32 +1960,35 @@ test("screener redesign preserves three clear workspaces across desktop and mobi
|
||||
});
|
||||
await page.locator('[data-screener-mode="curated"]').click();
|
||||
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2);
|
||||
await expect(page.locator("#curatedDetailDialog")).toBeHidden();
|
||||
await expect(page.locator(".curated-detail-pane")).toBeVisible();
|
||||
const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]');
|
||||
await secondStrategy.locator(".curated-card-description").click();
|
||||
await secondStrategy.click();
|
||||
await expect(secondStrategy).toHaveClass(/active/);
|
||||
await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/);
|
||||
await secondStrategy.locator('[data-curated-inspect="4"]').click();
|
||||
await expect(page.locator("#curatedDetailDialog")).toBeVisible();
|
||||
const dialogBox = await page.locator("#curatedDetailDialog").boundingBox();
|
||||
expect(Math.abs(dialogBox.x + dialogBox.width / 2 - 720)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(dialogBox.y + dialogBox.height / 2 - 450)).toBeLessThanOrEqual(2);
|
||||
await page.locator("#closeCuratedDetailButton").click();
|
||||
await expect(page.locator("#curatedStrategyName")).toHaveText("低波质量");
|
||||
const [libraryBox, detailBox] = await Promise.all([
|
||||
page.locator(".curated-library-pane").boundingBox(),
|
||||
page.locator(".curated-detail-pane").boundingBox(),
|
||||
]);
|
||||
expect(detailBox.x).toBeGreaterThan(libraryBox.x + libraryBox.width - 2);
|
||||
expect(Math.abs(detailBox.y - libraryBox.y)).toBeLessThanOrEqual(1);
|
||||
expect(libraryBox.width).toBeLessThan(detailBox.width);
|
||||
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
|
||||
await page.screenshot({ path: "test-results/screener-stage15-strategy-1440.png", fullPage: true });
|
||||
|
||||
await page.locator('[data-screener-mode="quant"]').click();
|
||||
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
|
||||
await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0);
|
||||
await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator("#screenerResultTitle")).toHaveText("打分结果");
|
||||
await expect(page.locator("#screenerResultTitle")).toHaveText("自定义选股结果");
|
||||
const [builderBox, summaryBox] = await Promise.all([
|
||||
page.locator(".quant-builder-pane").boundingBox(),
|
||||
page.locator(".quant-summary-pane").boundingBox(),
|
||||
]);
|
||||
expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1);
|
||||
expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2);
|
||||
expect(builderBox.width).toBeGreaterThanOrEqual(395);
|
||||
expect(builderBox.width).toBeLessThanOrEqual(405);
|
||||
expect(builderBox.width).toBeGreaterThanOrEqual(490);
|
||||
expect(builderBox.width).toBeLessThanOrEqual(540);
|
||||
const quantRunBox = await page.locator("#quantRunButton").boundingBox();
|
||||
expect(quantRunBox.width).toBeLessThan(180);
|
||||
expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225);
|
||||
@@ -1996,7 +2001,7 @@ test("screener redesign preserves three clear workspaces across desktop and mobi
|
||||
await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible();
|
||||
});
|
||||
|
||||
test("screener stage completion follows its execution context and mode results stay isolated", async ({ page }) => {
|
||||
test("automatic screener results stay read-only and mode results stay isolated", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockApplication(page, session("user", true));
|
||||
await page.goto("/index.html");
|
||||
@@ -2024,15 +2029,11 @@ test("screener stage completion follows its execution context and mode results s
|
||||
|
||||
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3);
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
||||
await page.locator('[data-regime="retreat"]').click();
|
||||
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(2);
|
||||
await expect(page.locator("#screenerRunStatus")).toHaveText("等待执行");
|
||||
await expect(page.locator("#backtestTaskStatus")).toHaveText("随选股执行");
|
||||
await expect(page.locator("#screenerEmpty")).toContainText("当前阶段与策略");
|
||||
await expect(page.locator("#screenerResultSource")).toBeHidden();
|
||||
|
||||
await page.locator('[data-regime="repair"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
||||
await expect(page.locator('[data-regime]')).toHaveCount(0);
|
||||
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
|
||||
await expect(page.locator("#syncScreenerButton")).toHaveCount(0);
|
||||
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 page.evaluate(() => {
|
||||
@@ -2044,10 +2045,10 @@ test("screener stage completion follows its execution context and mode results s
|
||||
});
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("策略结果");
|
||||
await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果");
|
||||
await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 修复 · 连续分红质量");
|
||||
await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 连续分红质量");
|
||||
|
||||
await page.locator('[data-screener-mode="quant"]').click();
|
||||
await expect(page.locator("#screenerEmpty")).toContainText("量化选股");
|
||||
await expect(page.locator("#screenerEmpty")).toContainText("自定义选股");
|
||||
await page.evaluate(() => {
|
||||
setScreenerResult("quant", {
|
||||
meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" },
|
||||
@@ -2056,7 +2057,7 @@ test("screener stage completion follows its execution context and mode results s
|
||||
renderScreenerResult();
|
||||
});
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("量化结果");
|
||||
await expect(page.locator("#screenerResultSource")).toHaveText("量化选股 · 自定义因子权重");
|
||||
await expect(page.locator("#screenerResultSource")).toHaveText("自定义选股 · 自定义因子权重");
|
||||
|
||||
await page.locator('[data-screener-mode="smart"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
||||
@@ -2064,20 +2065,34 @@ test("screener stage completion follows its execution context and mode results s
|
||||
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
|
||||
});
|
||||
|
||||
test("screener keeps results for each stage and curated strategy across switching and reload", async ({ page }) => {
|
||||
test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => {
|
||||
const formula = {
|
||||
meta: { library: "smart" }, universe: {}, filters: [],
|
||||
score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
|
||||
limit: 10, min_score: 0.5,
|
||||
};
|
||||
const candidate = (code, name) => ({
|
||||
code, name, sector: "Test Sector", score_display: 80,
|
||||
historical_probability: 50, probability_samples: 20, pct_chg: 1,
|
||||
return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70,
|
||||
reason: "Context result", risk_flags: [],
|
||||
});
|
||||
const result = (mode, runId, strategyName, row) => ({
|
||||
meta: {
|
||||
run_id: runId, trade_date: "20260722", regime: "repair",
|
||||
strategy_name: strategyName, mode,
|
||||
},
|
||||
candidates: [row],
|
||||
disclaimer: "Historical statistics do not predict future returns.",
|
||||
backtest: null,
|
||||
});
|
||||
const smartResult = result("smart", 101, "修复确认", candidate("600001", "Smart Repair"));
|
||||
const curatedA = result("curated", 102, "连续分红质量", candidate("600002", "Curated A"));
|
||||
const curatedB = result("curated", 103, "Quality B", candidate("600003", "Curated B"));
|
||||
const options = {
|
||||
recentScreenerResults: [],
|
||||
additionalScreenerRegimes: [{ id: "retreat", label: "Retreat" }],
|
||||
latestScreenerResults: { smart: smartResult, curated: curatedA },
|
||||
recentScreenerResults: [smartResult, curatedA, curatedB],
|
||||
additionalScreenerStrategies: [
|
||||
{
|
||||
id: 3, name: "Retreat Defense", description: "Retreat-stage strategy",
|
||||
regimes: ["retreat"], builtin: true, data_ready: true, missing_data: [], formula,
|
||||
},
|
||||
{
|
||||
id: 4, name: "Quality B", description: "Second curated strategy",
|
||||
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [],
|
||||
@@ -2088,66 +2103,27 @@ test("screener keeps results for each stage and curated strategy across switchin
|
||||
},
|
||||
],
|
||||
};
|
||||
options.screenerRunResult = (body) => {
|
||||
const candidateName = body.mode === "smart"
|
||||
? body.regime === "retreat" ? "Smart Retreat" : "Smart Repair"
|
||||
: body.strategy_name === "Quality B" ? "Curated B" : "Curated A";
|
||||
return {
|
||||
meta: {
|
||||
run_id: 100 + options.recentScreenerResults.length,
|
||||
trade_date: "20260722",
|
||||
regime: body.regime,
|
||||
strategy_name: body.strategy_name,
|
||||
mode: body.mode,
|
||||
},
|
||||
candidates: [{
|
||||
code: `60000${options.recentScreenerResults.length + 1}`,
|
||||
name: candidateName,
|
||||
sector: "Test Sector",
|
||||
score_display: 80,
|
||||
historical_probability: 50,
|
||||
probability_samples: 20,
|
||||
pct_chg: 1,
|
||||
return_5d: 2,
|
||||
volume_ratio_5d: 1.2,
|
||||
sector_strength: 70,
|
||||
reason: "Context result",
|
||||
risk_flags: [],
|
||||
}],
|
||||
disclaimer: "Historical statistics do not predict future returns.",
|
||||
backtest: null,
|
||||
};
|
||||
};
|
||||
|
||||
await mockApplication(page, session("user", true), options);
|
||||
await page.goto("/index.html?view=screenerView");
|
||||
|
||||
await page.locator("#screenerRunButton").click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
||||
await page.locator('[data-regime="retreat"]').click();
|
||||
await page.locator("#screenerRunButton").click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
|
||||
await page.locator('[data-regime="repair"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
||||
await page.locator('[data-regime="retreat"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
|
||||
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-screener-mode="curated"]').click();
|
||||
await page.locator('[data-curated-run="2"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||||
await page.locator('[data-curated-run="4"]').click();
|
||||
await page.locator('[data-curated-strategy="4"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
|
||||
await page.locator('[data-curated-strategy="2"] .curated-card-description').click();
|
||||
await page.locator('[data-curated-strategy="2"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||||
expect(options.screenerRunBodies || []).toHaveLength(0);
|
||||
|
||||
await page.reload();
|
||||
await page.locator('[data-screener-mode="smart"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
||||
await page.locator('[data-regime="retreat"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
|
||||
await page.locator('[data-screener-mode="curated"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||||
await page.locator('[data-curated-strategy="4"] .curated-card-description').click();
|
||||
await page.locator('[data-curated-strategy="4"]').click();
|
||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,78 @@
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from database import ReviewDatabase
|
||||
from screener import (
|
||||
ADVANCED_CURATED_STRATEGIES,
|
||||
CURATED_STRATEGIES,
|
||||
FACTOR_FIELDS,
|
||||
FACTOR_GROUPS,
|
||||
ScreenerEngine,
|
||||
_broken_reversal_metrics,
|
||||
_risk_flags,
|
||||
_rsi,
|
||||
_quarter_periods,
|
||||
)
|
||||
from server import automatic_screener_jobs
|
||||
|
||||
|
||||
class CuratedScreenerTests(unittest.TestCase):
|
||||
def test_first_batch_contains_ten_distinct_curated_strategies(self):
|
||||
self.assertEqual(10, len(CURATED_STRATEGIES))
|
||||
self.assertEqual(10, len({item["name"] for item in CURATED_STRATEGIES}))
|
||||
def test_curated_library_contains_original_and_advanced_strategies(self):
|
||||
self.assertEqual(13, len(ADVANCED_CURATED_STRATEGIES))
|
||||
self.assertEqual(23, len(CURATED_STRATEGIES))
|
||||
self.assertEqual(23, len({item["name"] for item in CURATED_STRATEGIES}))
|
||||
self.assertTrue(
|
||||
{"行业动量轮动", "主力资金行业流入"}.issubset(
|
||||
{item["name"] for item in CURATED_STRATEGIES}
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES)
|
||||
)
|
||||
|
||||
def test_every_curated_strategy_explains_environment_and_failure_risk(self):
|
||||
for strategy in CURATED_STRATEGIES:
|
||||
meta = strategy["formula"]["meta"]
|
||||
self.assertTrue(meta.get("suitable_environment"), strategy["name"])
|
||||
self.assertTrue(meta.get("failure_risk"), strategy["name"])
|
||||
self.assertNotIn("emotion_gate", meta, strategy["name"])
|
||||
|
||||
def test_automatic_curated_jobs_are_not_filtered_by_market_regime(self):
|
||||
strategies = [
|
||||
{
|
||||
"name": "阶段策略",
|
||||
"regimes": ["retreat"],
|
||||
"formula": {"meta": {"library": "stage"}},
|
||||
},
|
||||
*CURATED_STRATEGIES,
|
||||
]
|
||||
for regime in ("ice", "repair", "fermentation", "climax", "divergence", "retreat"):
|
||||
jobs = automatic_screener_jobs(strategies, regime)
|
||||
curated_names = {
|
||||
job["strategy"]["name"] for job in jobs if job["mode"] == "curated"
|
||||
}
|
||||
self.assertEqual(
|
||||
{strategy["name"] for strategy in CURATED_STRATEGIES},
|
||||
curated_names,
|
||||
regime,
|
||||
)
|
||||
|
||||
def test_curated_risk_flags_do_not_reintroduce_regime_gating(self):
|
||||
row = {
|
||||
"pct_chg": 0,
|
||||
"return_10d": 0,
|
||||
"volatility_10d": 0,
|
||||
"amount_billion": 5,
|
||||
}
|
||||
self.assertIn("市场处于退潮阶段,策略可能选择空仓", _risk_flags(row, "retreat"))
|
||||
self.assertNotIn(
|
||||
"市场处于退潮阶段,策略可能选择空仓",
|
||||
_risk_flags(row, "retreat", include_regime_risk=False),
|
||||
)
|
||||
|
||||
def test_every_curated_formula_uses_supported_factors(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
database = ReviewDatabase(Path(root) / "review.db")
|
||||
@@ -56,6 +108,15 @@ class CuratedScreenerTests(unittest.TestCase):
|
||||
connection.close()
|
||||
self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns))
|
||||
self.assertIn("fundamental_indicators", tables)
|
||||
self.assertIn("benchmark_bars", tables)
|
||||
|
||||
def test_advanced_strategies_declare_history_and_backtest_contracts(self):
|
||||
for strategy in ADVANCED_CURATED_STRATEGIES:
|
||||
meta = strategy["formula"]["meta"]
|
||||
self.assertGreaterEqual(meta["history_days"], 80, strategy["name"])
|
||||
self.assertGreaterEqual(meta["backtest_days"], 1, strategy["name"])
|
||||
self.assertGreater(meta["take_profit"], 0, strategy["name"])
|
||||
self.assertLess(meta["stop_loss"], 0, strategy["name"])
|
||||
|
||||
def test_quarter_periods_stop_at_selected_date(self):
|
||||
periods = _quarter_periods("20260722", 5)
|
||||
@@ -98,6 +159,10 @@ class CuratedScreenerTests(unittest.TestCase):
|
||||
for index in range(100)
|
||||
],
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO benchmark_bars (trade_date, ts_code, close) VALUES (?, ?, ?)",
|
||||
[(f"2026{index + 1:04d}", "000300.SH", 4000 + index) for index in range(60)],
|
||||
)
|
||||
|
||||
health = database.factor_health_summary("20260722")
|
||||
self.assertTrue(health["market"])
|
||||
@@ -105,10 +170,190 @@ class CuratedScreenerTests(unittest.TestCase):
|
||||
self.assertTrue(health["valuation"])
|
||||
self.assertTrue(health["fundamental"])
|
||||
self.assertTrue(health["dividend_history"])
|
||||
self.assertTrue(health["benchmark"])
|
||||
self.assertEqual(health["valuation_rows"], 1)
|
||||
self.assertEqual(health["fundamental_rows"], 100)
|
||||
self.assertEqual(health["dividend_years"], 5)
|
||||
|
||||
def test_moneyflow_health_requires_the_latest_five_market_dates(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
database = ReviewDatabase(Path(root) / "review.db")
|
||||
dates = [f"202607{day:02d}" for day in range(20, 25)]
|
||||
database.upsert_daily_bars([
|
||||
{
|
||||
"trade_date": trade_date, "ts_code": "600000.SH",
|
||||
"open": 10, "high": 10.2, "low": 9.8, "close": 10,
|
||||
"pct_chg": 0, "vol": 1000, "amount": 100000,
|
||||
}
|
||||
for trade_date in dates
|
||||
])
|
||||
database.upsert_moneyflow([
|
||||
{"trade_date": "20260105", "ts_code": "600000.SH", "net_mf_amount": 10}
|
||||
] * 5)
|
||||
self.assertFalse(database.factor_health_summary(dates[-1])["moneyflow_history"])
|
||||
database.upsert_moneyflow([
|
||||
{"trade_date": trade_date, "ts_code": "600000.SH", "net_mf_amount": 10}
|
||||
for trade_date in dates
|
||||
])
|
||||
health = database.factor_health_summary(dates[-1])
|
||||
self.assertTrue(health["moneyflow_history"])
|
||||
self.assertEqual(health["moneyflow_dates"], 5)
|
||||
|
||||
def test_technical_helpers_detect_rsi_and_daily_reversal_path(self):
|
||||
self.assertLess(_rsi([10, 9, 8, 7, 6, 5, 4], 6), 1)
|
||||
rows = [
|
||||
{"close": 10, "high": 10, "vol": 100},
|
||||
{"close": 11, "high": 11, "vol": 120},
|
||||
{"close": 12, "high": 12, "vol": 130},
|
||||
{"close": 11.2, "high": 11.8, "vol": 100},
|
||||
{"close": 12.5, "high": 12.5, "vol": 140},
|
||||
]
|
||||
metrics = _broken_reversal_metrics(
|
||||
rows, [False, True, True, False, True], "600000", "示例"
|
||||
)
|
||||
self.assertEqual(metrics["signal"], 1)
|
||||
self.assertEqual(metrics["days"], 1)
|
||||
|
||||
def test_factor_builder_generates_long_window_and_benchmark_factors(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
database = ReviewDatabase(Path(root) / "review.db")
|
||||
database.upsert_stock_master([
|
||||
{
|
||||
"ts_code": "600000.SH", "name": "趋势样本", "industry": "银行",
|
||||
"market": "主板", "list_date": "20000101",
|
||||
}
|
||||
])
|
||||
dates = []
|
||||
cursor = datetime(2025, 6, 1)
|
||||
while len(dates) < 260:
|
||||
if cursor.weekday() < 5:
|
||||
dates.append(cursor.strftime("%Y%m%d"))
|
||||
cursor += timedelta(days=1)
|
||||
bars = []
|
||||
benchmarks = []
|
||||
indicators = []
|
||||
for index, trade_date in enumerate(dates):
|
||||
close = 10 + index * 0.05
|
||||
bars.append({
|
||||
"trade_date": trade_date, "ts_code": "600000.SH",
|
||||
"open": close - 0.02, "high": close + 0.08, "low": close - 0.08,
|
||||
"close": close, "pct_chg": 0.25, "vol": 1000 + index,
|
||||
"amount": 200000,
|
||||
})
|
||||
benchmarks.append({
|
||||
"trade_date": trade_date, "ts_code": "000300.SH",
|
||||
"close": 4000 + index, "pct_chg": 0.02,
|
||||
})
|
||||
if index >= 250:
|
||||
indicators.append({
|
||||
"trade_date": trade_date, "ts_code": "600000.SH",
|
||||
"turnover_rate": 2, "volume_ratio": 1,
|
||||
})
|
||||
database.upsert_daily_bars(bars)
|
||||
database.upsert_benchmark_bars(benchmarks)
|
||||
database.upsert_daily_indicators(indicators)
|
||||
|
||||
factors, actual_date = ScreenerEngine(database).build_factors(
|
||||
dates[-1], history_days=260
|
||||
)
|
||||
|
||||
self.assertEqual(actual_date, dates[-1])
|
||||
self.assertEqual(len(factors), 1)
|
||||
factor = factors[0]
|
||||
self.assertEqual(factor["ma_bull_alignment"], 1)
|
||||
self.assertEqual(factor["rs_high_120"], 1)
|
||||
self.assertGreater(factor["momentum_60_5"], 0)
|
||||
self.assertEqual(factor["momentum_60_5_rank"], 0)
|
||||
|
||||
def test_factor_builder_generates_sector_momentum_and_five_day_flow(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
database = ReviewDatabase(Path(root) / "review.db")
|
||||
stocks = [
|
||||
("600001.SH", "动量样本", "电子", 0.16, 180),
|
||||
("600002.SH", "对照样本", "银行", 0.02, -40),
|
||||
]
|
||||
database.upsert_stock_master([
|
||||
{
|
||||
"ts_code": code, "name": name, "industry": industry,
|
||||
"market": "主板", "list_date": "20000101",
|
||||
}
|
||||
for code, name, industry, _, _ in stocks
|
||||
])
|
||||
dates = []
|
||||
cursor = datetime(2026, 4, 1)
|
||||
while len(dates) < 80:
|
||||
if cursor.weekday() < 5:
|
||||
dates.append(cursor.strftime("%Y%m%d"))
|
||||
cursor += timedelta(days=1)
|
||||
bars = []
|
||||
for index, trade_date in enumerate(dates):
|
||||
for code, _, _, slope, _ in stocks:
|
||||
close = 10 + index * slope
|
||||
bars.append({
|
||||
"trade_date": trade_date, "ts_code": code,
|
||||
"open": close - 0.03, "high": close + 0.08,
|
||||
"low": close - 0.08, "close": close,
|
||||
"pct_chg": slope, "vol": 1000 + index,
|
||||
"amount": 300000,
|
||||
})
|
||||
database.upsert_daily_bars(bars)
|
||||
database.upsert_daily_indicators([
|
||||
{
|
||||
"trade_date": dates[-1], "ts_code": code,
|
||||
"turnover_rate": 2, "volume_ratio": 1,
|
||||
"circ_mv": 1000000, "total_mv": 1500000,
|
||||
}
|
||||
for code, *_ in stocks
|
||||
])
|
||||
database.upsert_moneyflow([
|
||||
{
|
||||
"trade_date": trade_date, "ts_code": code,
|
||||
"net_mf_amount": daily_flow,
|
||||
}
|
||||
for trade_date in dates[-5:]
|
||||
for code, _, _, _, daily_flow in stocks
|
||||
])
|
||||
|
||||
factors, _ = ScreenerEngine(database).build_factors(
|
||||
dates[-1], history_days=80
|
||||
)
|
||||
by_code = {item["ts_code"]: item for item in factors}
|
||||
leader = by_code["600001.SH"]
|
||||
laggard = by_code["600002.SH"]
|
||||
self.assertGreater(leader["return_20d"], laggard["return_20d"])
|
||||
self.assertEqual(leader["sector_momentum_rank"], 1)
|
||||
self.assertEqual(laggard["sector_momentum_rank"], 0)
|
||||
self.assertGreater(leader["net_flow_5d_million"], 0)
|
||||
self.assertLess(laggard["net_flow_5d_million"], 0)
|
||||
self.assertEqual(leader["sector_flow_rank"], 1)
|
||||
|
||||
def test_screen_reports_signal_health(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
database = ReviewDatabase(Path(root) / "review.db")
|
||||
engine = ScreenerEngine(database)
|
||||
formula = {
|
||||
"universe": {"exclude_st": True, "listed_days_min": 0},
|
||||
"filters": [{"field": "pct_chg", "op": ">", "value": 0}],
|
||||
"score": [{"field": "amount_billion", "weight": 1, "direction": "desc"}],
|
||||
"limit": 5,
|
||||
"min_score": 0,
|
||||
}
|
||||
result = engine.screen(
|
||||
0, "20260724", formula, "repair", "健康检查", False,
|
||||
mode="curated",
|
||||
prepared_factors=[{
|
||||
"ts_code": "600000.SH", "code": "600000", "name": "浦发银行",
|
||||
"sector": "银行", "listed_days": 1000, "pct_chg": 1,
|
||||
"amount_billion": 5, "price": 10, "return_5d": 1,
|
||||
"volume_ratio_5d": 1, "sector_strength": 50,
|
||||
}],
|
||||
prepared_date="20260724",
|
||||
)
|
||||
health = result["meta"]["health"]
|
||||
self.assertEqual(health["status"], "normal")
|
||||
self.assertEqual(health["signal_count"], 1)
|
||||
self.assertEqual(health["coverage"], 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -24,6 +24,9 @@ class SnapshotDatabase:
|
||||
def save_data_snapshot(self, kind, cache_key, _source, payload):
|
||||
self.aliases[(kind, cache_key)] = copy.deepcopy(payload)
|
||||
|
||||
def save_snapshot(self, _trade_date, _source, payload):
|
||||
self.snapshot = copy.deepcopy(payload)
|
||||
|
||||
def get_latest_real_snapshot(self, _trade_date, strictly_before=False):
|
||||
return copy.deepcopy(self.latest)
|
||||
|
||||
@@ -43,6 +46,7 @@ class DashboardCacheTests(unittest.TestCase):
|
||||
"sentiment_phase": "retreat",
|
||||
"sentiment_direction": "cooling",
|
||||
"sentiment_components": {},
|
||||
"sentiment_engine_version": 2,
|
||||
},
|
||||
}
|
||||
service = self.service(snapshot)
|
||||
@@ -71,6 +75,7 @@ class DashboardCacheTests(unittest.TestCase):
|
||||
"sentiment_phase": "ice",
|
||||
"sentiment_direction": "cooling",
|
||||
"sentiment_components": {},
|
||||
"sentiment_engine_version": 2,
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
@@ -114,15 +114,56 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn(f'data-screener-mode="{mode}"', self.html)
|
||||
self.assertIn(f'data-screener-panel="{mode}"', self.html)
|
||||
for element_id in (
|
||||
"curatedStrategyList", "curatedRunButton", "quantFilterRows",
|
||||
"curatedStrategyList", "quantFilterRows",
|
||||
"quantScoreRows", "quantRunButton", "quantSaveButton",
|
||||
):
|
||||
self.assertIn(f'id="{element_id}"', self.html)
|
||||
for removed_id in (
|
||||
"curatedRunButton", "factorSyncButton", "screenerRunButton",
|
||||
"changeStrategyButton",
|
||||
):
|
||||
self.assertNotIn(f'id="{removed_id}"', self.html)
|
||||
self.assertIn("盘后自动候选池", self.html)
|
||||
self.assertIn("自定义选股", self.html)
|
||||
self.assertIn('id="strategyDrawer" class="strategy-drawer"', self.html)
|
||||
self.assertIn('id="openStrategyDrawerButton"', self.html)
|
||||
self.assertIn('id="closeStrategyDrawerButton"', self.html)
|
||||
self.assertIn('id="activeStrategyDescription"', self.html)
|
||||
self.assertIn('openStrategyDrawer("editor")', self.script)
|
||||
for element_id in ("curatedSuitableEnvironment", "curatedFailureRisk"):
|
||||
self.assertIn(f'id="{element_id}"', self.html)
|
||||
self.assertIn("meta.suitable_environment", self.script)
|
||||
self.assertIn("meta.failure_risk", self.script)
|
||||
self.assertIn('mode === "curated" ? "暂无符合条件个股"', self.script)
|
||||
|
||||
def test_curated_library_explains_empty_signals_and_supports_school_views(self):
|
||||
for element_id in ("curatedSchoolFilters", "curatedStrategyList"):
|
||||
self.assertIn(f'id="{element_id}"', self.html)
|
||||
for view in ("list", "grid"):
|
||||
self.assertIn(f'data-curated-view="{view}"', self.html)
|
||||
for school in ("基本面", "趋势", "短线", "动量"):
|
||||
self.assertIn(school, self.script)
|
||||
self.assertIn("curatedStrategyRunState", self.script)
|
||||
self.assertIn("必需数据已完整,本日没有股票同时满足", self.script)
|
||||
|
||||
def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self):
|
||||
redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8")
|
||||
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign)
|
||||
self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign)
|
||||
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme)
|
||||
self.assertIn('#reviewWorkspaceView .data-table tbody td', self.theme)
|
||||
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme)
|
||||
|
||||
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
|
||||
styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8")
|
||||
self.assertIn("#toast.toast {", styles)
|
||||
self.assertIn("top: auto;", styles)
|
||||
self.assertIn("left: auto;", styles)
|
||||
self.assertIn("height: auto;", styles)
|
||||
self.assertIn("#toast.toast[hidden] { display: none; }", styles)
|
||||
self.assertNotIn(".toast{position:fixed", self.design_system)
|
||||
self.assertNotRegex(wentian, r"(?m)^\.toast\s*\{")
|
||||
|
||||
def test_public_knowledge_editors_are_hidden_for_non_admins(self):
|
||||
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
|
||||
|
||||
@@ -106,6 +106,20 @@ class MarketInsightsTests(unittest.TestCase):
|
||||
self.assertEqual(payload["amount_history"][-1]["stock_count"], 2)
|
||||
self.assertEqual(payload["focus_rows"][0]["code"], "000001")
|
||||
|
||||
def test_auction_amount_history_uses_the_same_a_share_universe_as_summary(self):
|
||||
self.database.upsert_stock_master([
|
||||
{"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", "market": "主板", "list_date": "19910403"},
|
||||
{"ts_code": "688001.SH", "name": "首日上市", "industry": "半导体", "market": "科创板", "list_date": "20260723"},
|
||||
])
|
||||
self.database.upsert_auction_factors([
|
||||
{"ts_code": "000001.SZ", "trade_date": "20260723", "price": 10.5, "pre_close": 10, "amount": 5_000_000, "vol": 20_000},
|
||||
{"ts_code": "688001.SH", "trade_date": "20260723", "price": 50, "pre_close": 10, "amount": 150_000_000, "vol": 3_000_000},
|
||||
{"ts_code": "159001.SZ", "trade_date": "20260723", "price": 1.1, "pre_close": 1, "amount": 90_000_000, "vol": 90_000_000},
|
||||
])
|
||||
history = self.service._auction_amount_history("20260723")
|
||||
self.assertEqual(history[-1]["stock_count"], 1)
|
||||
self.assertEqual(history[-1]["amount_billion"], 0.05)
|
||||
|
||||
def test_real_limit_price_is_isolated_from_scored_candidates(self):
|
||||
class OnePriceClient(FakeMarketClient):
|
||||
def query(self, api_name, params=None, fields=""):
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sentiment_engine import _adaptive_score, _confirmed_phase
|
||||
|
||||
|
||||
class SentimentEngineTests(unittest.TestCase):
|
||||
def test_adaptive_score_uses_latest_250_observations(self):
|
||||
history = [0.0] * 50 + [100.0] * 250
|
||||
self.assertEqual(_adaptive_score(50.0, 50.0, history), 12.5)
|
||||
|
||||
def test_ice_must_repair_before_fermentation(self):
|
||||
phase, reason = _confirmed_phase(
|
||||
{"phase": "冰点"},
|
||||
score=70,
|
||||
day_change=45,
|
||||
systemic_health=70,
|
||||
profit_score=70,
|
||||
ecology_score=75,
|
||||
phase_signal="发酵",
|
||||
extreme_ice=False,
|
||||
fermentation_signal_count=2,
|
||||
)
|
||||
self.assertEqual(phase, "修复")
|
||||
self.assertIn("冰点后", reason)
|
||||
|
||||
def test_repair_requires_continuous_fermentation_confirmation(self):
|
||||
phase, _ = _confirmed_phase(
|
||||
{"phase": "修复"},
|
||||
score=58,
|
||||
day_change=5,
|
||||
systemic_health=55,
|
||||
profit_score=60,
|
||||
ecology_score=65,
|
||||
phase_signal="发酵",
|
||||
extreme_ice=False,
|
||||
fermentation_signal_count=1,
|
||||
)
|
||||
self.assertEqual(phase, "修复")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -130,6 +130,29 @@ class StrategyTrackingTests(unittest.TestCase):
|
||||
self.assertTrue(removed["deleted"])
|
||||
self.assertEqual(removed["tracking"]["batches"], [])
|
||||
|
||||
def test_shared_automatic_run_can_be_added_to_private_tracking(self):
|
||||
run_id = self.database.save_screener_run(
|
||||
0,
|
||||
"20260711",
|
||||
"repair",
|
||||
"系统盘后策略",
|
||||
{},
|
||||
{
|
||||
"meta": {},
|
||||
"candidates": [{
|
||||
"ts_code": "600000.SH",
|
||||
"code": "600000",
|
||||
"name": "浦发银行",
|
||||
"sector": "银行",
|
||||
"price": 12.5,
|
||||
}],
|
||||
},
|
||||
)
|
||||
result = self.service.add_candidate(self.other["id"], run_id, "600000")
|
||||
self.assertEqual(result["added"], 1)
|
||||
self.assertEqual(len(self.database.list_strategy_tracks(self.other["id"])), 1)
|
||||
self.assertEqual(self.database.list_strategy_tracks(self.owner["id"]), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user