feat: redesign workspaces and stabilize screeners
This commit is contained in:
+1405
-29
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user