feat: redesign workspaces and stabilize screeners

This commit is contained in:
leefer
2026-07-26 13:31:45 +08:00
parent 149c468240
commit 6adeb54458
21 changed files with 18786 additions and 917 deletions
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -120,10 +120,13 @@ class AccountAccessTests(unittest.TestCase):
first = self.database.create_user("note_owner", "salt", "hash")
second = self.database.create_user("other_reader", "salt", "hash")
note_id = self.database.save_note(
first["id"], "002141", "贤丰控股", "20260721", "只属于甲", "明日观察"
first["id"], "002141", "贤丰控股", "20260721", "只属于甲", "明日观察",
summary="市场缩量修复",
)
self.assertEqual(len(self.database.list_notes(first["id"], code="002141")), 1)
first_notes = self.database.list_notes(first["id"], code="002141")
self.assertEqual(len(first_notes), 1)
self.assertEqual(first_notes[0]["summary"], "市场缩量修复")
self.assertEqual(self.database.list_notes(second["id"], code="002141"), [])
with self.assertRaises(ValueError):
self.database.save_note(
@@ -141,10 +144,13 @@ class AccountAccessTests(unittest.TestCase):
def test_watchlist_is_scoped_to_its_owner(self):
first = self.database.create_user("watch_owner", "salt", "hash")
second = self.database.create_user("other_watcher", "salt", "hash")
self.database.save_watchlist(first["id"], "002141", "贤丰控股", "元件", "red")
self.database.save_watchlist(
first["id"], "002141", "贤丰控股", "元件", "red", "观察承接"
)
self.database.save_watchlist(second["id"], "002141", "贤丰控股", "元件", "blue")
self.assertEqual(self.database.list_watchlist(first["id"])[0]["color"], "red")
self.assertEqual(self.database.list_watchlist(first["id"])[0]["remark"], "观察承接")
self.assertEqual(self.database.list_watchlist(second["id"])[0]["color"], "blue")
self.assertFalse(self.database.delete_watchlist(second["id"], "000001"))
self.assertTrue(self.database.delete_watchlist(first["id"], "002141"))
+37 -1
View File
@@ -52,6 +52,33 @@ class AccountDataBoundaryTests(unittest.TestCase):
)
self.assertIsNone(self.database.latest_screener_run(self.second["id"], "20260722"))
def test_latest_screener_runs_are_isolated_by_mode_and_user(self):
expected = {
"smart": "600001",
"curated": "600002",
"quant": "600003",
}
for mode, code in expected.items():
self.database.save_screener_run(
self.first["id"], "20260721", "repair", f"{mode}-strategy", FORMULA,
{"candidates": [{"code": code}], "meta": {}}, mode,
)
results = self.database.latest_screener_runs(self.first["id"], "20260722")
self.assertEqual(set(results), set(expected))
for mode, code in expected.items():
self.assertEqual(results[mode]["meta"]["mode"], mode)
self.assertEqual(results[mode]["candidates"][0]["code"], code)
self.assertEqual(
self.database.latest_screener_run(
self.first["id"], "20260722", mode
)["candidates"][0]["code"],
code,
)
self.assertEqual(
self.database.latest_screener_runs(self.second["id"], "20260722"), {}
)
def test_mentor_messages_are_scoped_by_user_mentor_and_date(self):
self.database.save_mentor_exchange(
self.first["id"], "mentor-a", "20260721", "怎么看?", "先看承接。", "20260721"
@@ -164,7 +191,12 @@ class LegacyStrategyMigrationTests(unittest.TestCase):
created_at TEXT NOT NULL
);
INSERT INTO screener_runs VALUES
(1, '20260721', 'repair', '旧策略', '{}', '{"meta":{}}', '2026-07-21');
(1, '20260721', 'repair', '旧策略', '{}', '{"meta":{}}', '2026-07-21'),
(2, '20260721', 'repair', '旧精选策略',
'{"meta":{"library":"curated"}}', '{"meta":{}}', '2026-07-21'),
(3, '20260721', 'repair', '自定义量化公式',
'{"meta":{"library":"custom","category":"量化公式"}}',
'{"meta":{}}', '2026-07-21');
"""
)
connection.commit()
@@ -177,6 +209,10 @@ class LegacyStrategyMigrationTests(unittest.TestCase):
self.assertEqual(owners["旧策略"], 1)
self.assertIsNone(owners["旧内置"])
self.assertIsNotNone(migrated.latest_screener_run(1, "20260722"))
self.assertEqual(
set(migrated.latest_screener_runs(1, "20260722")),
{"smart", "curated", "quant"},
)
class PublicKnowledgePermissionTests(unittest.TestCase):
+2
View File
@@ -15,12 +15,14 @@ class ApiAccessPolicyTests(unittest.TestCase):
("GET", "/api/heaven/readings"): "member",
("GET", "/api/assistant/messages"): "member",
("POST", "/api/screener/run"): "member",
("POST", "/api/screener/tracking"): "member",
("POST", "/api/screener/tracking/refresh"): "member",
("POST", "/api/mentors/chat"): "member",
("POST", "/api/mentors/preferences"): "member",
("POST", "/api/heaven/interpret"): "member",
("POST", "/api/assistant/chat"): "member",
("DELETE", "/api/screener/strategies/42"): "member",
("DELETE", "/api/screener/tracking/42"): "member",
("DELETE", "/api/mentors/messages"): "member",
("DELETE", "/api/assistant/messages"): "member",
("DELETE", "/api/heaven/readings/42"): "member",
+114
View File
@@ -0,0 +1,114 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from database import ReviewDatabase
from screener import (
CURATED_STRATEGIES,
FACTOR_FIELDS,
FACTOR_GROUPS,
ScreenerEngine,
_quarter_periods,
)
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}))
self.assertTrue(
all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES)
)
def test_every_curated_formula_uses_supported_factors(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
engine = ScreenerEngine(database)
for strategy in CURATED_STRATEGIES:
formula = engine.validate_formula(strategy["formula"])
fields = {
item["field"]
for item in formula["filters"] + formula["score"]
}
self.assertTrue(fields.issubset(FACTOR_FIELDS), strategy["name"])
def test_factor_groups_cover_every_quant_factor(self):
grouped = [field for fields in FACTOR_GROUPS.values() for field in fields]
self.assertEqual(set(FACTOR_FIELDS), set(grouped))
self.assertEqual(len(grouped), len(set(grouped)))
def test_database_migrates_valuation_and_fundamental_columns(self):
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "review.db"
ReviewDatabase(path)
connection = sqlite3.connect(path)
try:
indicator_columns = {
row[1] for row in connection.execute("PRAGMA table_info(daily_indicators)")
}
tables = {
row[0] for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
finally:
connection.close()
self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns))
self.assertIn("fundamental_indicators", tables)
def test_quarter_periods_stop_at_selected_date(self):
periods = _quarter_periods("20260722", 5)
self.assertEqual(
["20250630", "20250930", "20251231", "20260331", "20260630"],
periods,
)
def test_factor_health_summary_uses_availability_counts(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
with database.connect() as connection:
connection.execute(
"INSERT INTO daily_bars (trade_date, ts_code) VALUES (?, ?)",
("20260722", "600000.SH"),
)
connection.execute(
"""
INSERT INTO daily_indicators
(trade_date, ts_code, pe_ttm)
VALUES (?, ?, ?)
""",
("20260722", "600000.SH", 8.5),
)
connection.executemany(
"INSERT INTO daily_indicators (trade_date, ts_code) VALUES (?, ?)",
[(f"{year}1231", f"{year % 100:02d}0000.SZ") for year in range(2022, 2026)],
)
connection.execute(
"INSERT INTO auction_factors (trade_date, ts_code) VALUES (?, ?)",
("20260722", "600000.SH"),
)
connection.executemany(
"""
INSERT INTO fundamental_indicators (end_date, ann_date, ts_code, roe)
VALUES (?, ?, ?, ?)
""",
[
("20251231", "20260430", f"{index:06d}.SZ", 10.0)
for index in range(100)
],
)
health = database.factor_health_summary("20260722")
self.assertTrue(health["market"])
self.assertTrue(health["auction"])
self.assertTrue(health["valuation"])
self.assertTrue(health["fundamental"])
self.assertTrue(health["dividend_history"])
self.assertEqual(health["valuation_rows"], 1)
self.assertEqual(health["fundamental_rows"], 100)
self.assertEqual(health["dividend_years"], 5)
if __name__ == "__main__":
unittest.main()
+59 -6
View File
@@ -41,9 +41,14 @@ class FrontendContractTests(unittest.TestCase):
def test_all_primary_views_have_navigation_entries(self):
views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html))
internal_views = set(re.findall(
r'<section id="([A-Za-z][A-Za-z0-9_-]*View)" class="workspace-view[^"]*"[^>]*\bdata-internal-view\b',
self.html,
))
navigation = set(re.findall(r'data-view="([A-Za-z][A-Za-z0-9_-]*)"', self.html))
self.assertEqual(views, navigation)
self.assertEqual(len(views), 16)
self.assertEqual(views - internal_views, navigation)
self.assertEqual(len(views - internal_views), 16)
self.assertEqual(internal_views, {"screenerTrackingView"})
def test_market_discovery_views_are_wired_end_to_end(self):
for view_id in ("auctionView", "themeLibraryView", "popularityView"):
@@ -63,19 +68,52 @@ class FrontendContractTests(unittest.TestCase):
themes = self.html.index('data-view="themeLibraryView"')
self.assertLess(rotation, auction)
self.assertLess(auction, themes)
for dataset in ("focus", "onePrice", "watchlist", "all"):
for dataset in ("focus", "watchlist", "all", "onePrice"):
self.assertIn(f'data-auction-dataset="{dataset}"', self.html)
dataset_positions = [self.html.index(f'data-auction-dataset="{dataset}"') for dataset in ("focus", "watchlist", "all", "onePrice")]
self.assertEqual(dataset_positions, sorted(dataset_positions))
for filter_name in ("all", "above", "matched", "below"):
self.assertIn(f'data-auction-filter="{filter_name}"', self.html)
self.assertNotIn('data-auction-filter="strong"', self.html)
self.assertNotIn('data-auction-filter="limit"', self.html)
self.assertIn('id="auctionThemeCarry"', self.html)
self.assertIn('id="auctionAmountTrend"', self.html)
self.assertIn('id="auctionNewsTitle"', self.html)
self.assertNotIn('id="auctionNewsTitle"', self.html)
self.assertIn('id="auctionWorkspaceTitle"', self.html)
self.assertIn('id="auctionExpectationFilterbar"', self.html)
self.assertIn('class="auction-news-entry"', self.html)
self.assertIn('class="disabled-status">暂不可用', self.html)
self.assertIn('id="auctionExpectationControls"', self.html)
self.assertNotIn('id="auctionAboveCount"', self.html)
self.assertNotIn('id="auctionMatchedCount"', self.html)
self.assertNotIn('id="auctionBelowCount"', self.html)
self.assertNotIn('class="auction-news-entry"', self.html)
def test_visual_renovation_keeps_required_product_controls(self):
for order in ("oldest", "latest"):
self.assertIn(f'data-rotation-order="{order}"', self.html)
self.assertIn('id="dragonProfilesButton"', self.html)
self.assertIn('id="sentimentHistoryBody"', self.html)
self.assertIn('id="sentimentPreviousPositive"', self.html)
self.assertIn('id="accountDropdown"', self.html)
self.assertIn('id="settingsButton"', self.html)
def test_screener_uses_progressive_strategy_editor(self):
for step in ("regime", "strategy", "run", "result"):
self.assertIn(f'data-screener-step="{step}"', self.html)
def test_screener_exposes_curated_and_quant_workspaces(self):
for mode in ("smart", "curated", "quant"):
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",
"quantScoreRows", "quantRunButton", "quantSaveButton",
):
self.assertIn(f'id="{element_id}"', 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)
def test_public_knowledge_editors_are_hidden_for_non_admins(self):
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
@@ -177,6 +215,21 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn('elements.tradeLogDialog.showModal()', self.script)
self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script)
def test_review_workspace_exposes_complete_watchlist_and_three_part_journal(self):
for label in (
"今日涨幅", "5日涨幅", "竞价关注(分)", "跟踪备注", "添加自选",
"今日盘面一句话", "今日做对了什么 / 做错了什么", "明日策略",
):
self.assertIn(label, self.html)
for element_id in (
"watchlistDialog", "watchlistSearchInput", "watchlistRemark",
"journalSummary", "journalContent", "journalPlan",
):
self.assertIn(f'id="{element_id}"', self.html)
self.assertIn('summary: document.querySelector("#journalSummary").value', self.script)
self.assertIn('return_5d', self.script)
self.assertIn('attention_score', self.script)
def test_heaven_interpretations_use_one_dialog_and_history_tabs(self):
self.assertIn('id="heavenReadingDialog"', self.html)
self.assertIn('data-heaven-reading-tab="current"', self.html)
+41
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from database import ReviewDatabase
from market_insights import MarketInsightsService
from screener import FACTOR_FIELDS, ScreenerEngine
from tushare_client import TushareError
class FakeMarketClient:
@@ -166,6 +167,46 @@ class MarketInsightsTests(unittest.TestCase):
self.assertEqual(hot["summary"]["dual_count"], 1)
self.assertEqual(hot["combined"][0]["name"], "平安银行")
def test_feature_pages_use_local_data_when_trade_context_is_offline(self):
class OfflineClient:
def resolve_trade_context(self, requested: str):
raise TushareError("offline")
def query(self, api_name, params=None, fields=""):
raise TushareError("offline")
self.database.save_snapshot(
"20260723", "test", {"meta": {"trade_date": "2026-07-23"}}
)
self.database.save_snapshot(
"20260724", "test", {"meta": {"trade_date": "2026-07-24"}}
)
self.database.upsert_stock_master([
{"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", "market": "主板", "list_date": "19910403"}
])
self.database.upsert_auction_factors([
{"ts_code": "000001.SZ", "trade_date": "20260724", "price": 10.5, "pre_close": 10, "amount": 5_000_000, "vol": 20_000, "turnover_rate": 0.12, "volume_ratio": 1.8}
])
self.database.save_data_snapshot(
"theme_library_v1", "20260724", "market",
{"meta": {"trade_date": "2026-07-24"}, "summary": {"theme_count": 1}, "items": [{"code": "885001.TI", "name": "人工智能", "member_count": 2}]},
)
self.database.save_data_snapshot(
"popularity_v1", "20260724", "market",
{"meta": {"trade_date": "2026-07-24"}, "summary": {"ths_count": 1, "dc_count": 0, "dual_count": 0}, "combined": [{"name": "平安银行"}], "ths": [], "dc": []},
)
service = MarketInsightsService(self.database, OfflineClient(), self.service._now_provider)
auction = service.auction_center("20260725")
self.assertEqual(auction["meta"]["trade_date"], "2026-07-24")
self.assertEqual(auction["summary"]["stock_count"], 1)
themes = service.theme_library("20260725")
self.assertTrue(themes["meta"]["cached"])
self.assertEqual(themes["items"][0]["name"], "人工智能")
popularity = service.popularity("20260725")
self.assertTrue(popularity["meta"]["cached"])
self.assertEqual(popularity["combined"][0]["name"], "平安银行")
class AuctionScreenerFactorTests(unittest.TestCase):
def test_auction_fields_are_available_to_formula_and_factor_rows(self):
+31
View File
@@ -99,6 +99,37 @@ class StrategyTrackingTests(unittest.TestCase):
self.assertEqual(metrics["max_gain"], 10.0)
self.assertEqual(metrics["max_drawdown"], -5.0)
def test_candidate_is_added_manually_and_can_be_removed_by_owner(self):
run_id = self.database.save_screener_run(
self.owner["id"],
"20260711",
"repair",
"手动跟踪策略",
{},
{
"meta": {},
"candidates": [{
"ts_code": "600000.SH",
"code": "600000",
"name": "浦发银行",
"sector": "银行",
"price": 12.5,
}],
},
)
result = self.service.add_candidate(self.owner["id"], run_id, "600000")
self.assertEqual(result["added"], 1)
tracks = self.database.list_strategy_tracks(self.owner["id"])
self.assertEqual(len(tracks), 1)
self.assertEqual(tracks[0]["code"], "600000")
self.assertEqual(self.database.list_strategy_tracks(self.other["id"]), [])
with self.assertRaises(ValueError):
self.service.add_candidate(self.other["id"], run_id, "600000")
removed = self.service.remove_candidate(self.owner["id"], tracks[0]["id"])
self.assertTrue(removed["deleted"])
self.assertEqual(removed["tracking"]["batches"], [])
if __name__ == "__main__":
unittest.main()