diff --git a/next/backend/bootstrap/application.py b/next/backend/bootstrap/application.py index d16e6e7..7d36f29 100644 --- a/next/backend/bootstrap/application.py +++ b/next/backend/bootstrap/application.py @@ -1,3 +1,4 @@ +import asyncio import logging from contextlib import asynccontextmanager @@ -10,6 +11,7 @@ from backend.database.migrations import MIGRATIONS, MigrationRunner from backend.http.errors import install_error_handlers from backend.http.request_context import install_request_context from backend.http.router import api_router +from backend.jobs.screener import run_screener_scheduler logger = logging.getLogger(__name__) @@ -34,7 +36,16 @@ def create_application(settings: Settings | None = None) -> FastAPI: }, }, ) - yield + stop = asyncio.Event() + task = None + if runtime.environment != "test": + task = asyncio.create_task(run_screener_scheduler(container.screener, stop)) + try: + yield + finally: + if task: + stop.set() + await task application = FastAPI( title="小白复盘", diff --git a/next/backend/bootstrap/container.py b/next/backend/bootstrap/container.py index 898ea95..c73df03 100644 --- a/next/backend/bootstrap/container.py +++ b/next/backend/bootstrap/container.py @@ -19,6 +19,8 @@ from backend.features.accounts.service import ( from backend.features.market import MarketService from backend.features.market.insights import MarketInsightService from backend.features.market.sync import MarketSnapshotService +from backend.features.screener.repository import ScreenerRepository +from backend.features.screener.service import ScreenerService from backend.security import PasswordHasher, load_or_create_cipher @@ -32,6 +34,7 @@ class ApplicationContainer: system_credentials: SystemCredentialService model_pool: ModelPoolService market: MarketService + screener: ScreenerService def build_container(settings: Settings) -> ApplicationContainer: @@ -56,6 +59,11 @@ def build_container(settings: Settings) -> ApplicationContainer: ), DataSourcePolicy(), ) + market = MarketService( + gateway, + MarketSnapshotService(database, market_repository, gateway), + MarketInsightService(database, market_repository, gateway), + ) return ApplicationContainer( settings=settings, database=database, @@ -64,9 +72,6 @@ def build_container(settings: Settings) -> ApplicationContainer: memberships=MembershipService(database, account_repository), system_credentials=credentials, model_pool=ModelPoolService(database, model_pool_repository, cipher), - market=MarketService( - gateway, - MarketSnapshotService(database, market_repository, gateway), - MarketInsightService(database, market_repository, gateway), - ), + market=market, + screener=ScreenerService(database, ScreenerRepository(), market_repository, gateway), ) diff --git a/next/backend/data/gateway.py b/next/backend/data/gateway.py index ad1e848..faa310b 100644 --- a/next/backend/data/gateway.py +++ b/next/backend/data/gateway.py @@ -20,6 +20,7 @@ from backend.data.policy import DataSourcePolicy from backend.data.providers.base import MarketDataProvider, ProviderError from backend.data.quality import DataQualityError, require_quality from backend.data.repository import MarketRepository +from backend.data.screener_gateway import assemble_screener_inputs from backend.database.connection import Database SHANGHAI = ZoneInfo("Asia/Shanghai") @@ -108,9 +109,7 @@ class DataGateway: row = self._repository.latest_summary(connection, context.actual_date) return {"context": context, "values": json.loads(str(row["payload_json"])) if row else None} - def snapshot_inputs( - self, trade_date: str, previous_trade_date: str - ) -> dict[str, Any]: + def snapshot_inputs(self, trade_date: str, previous_trade_date: str) -> dict[str, Any]: provider = self._provider(DataSource.TUSHARE) self._policy.assert_allowed(provider.source, DataUsage.CALCULATION) return provider.snapshot_inputs(trade_date, previous_trade_date) @@ -136,6 +135,21 @@ class DataGateway: self._policy.assert_allowed(provider.source, DataUsage.CALCULATION) return provider.market_insight(kind, trade_date, previous_trade_date, identifier) + def screener_inputs( + self, trade_date: str, history_days: int = 260 + ) -> tuple[dict[str, Any], dict[str, float], list[str]]: + dates = self.trading_dates(trade_date, history_days) + if len(dates) < 21: + raise MarketDataUnavailable("历史交易日不足21日,无法生成选股因子") + chronological = tuple(reversed(dates)) + provider = self._provider(DataSource.TUSHARE) + self._policy.assert_allowed(provider.source, DataUsage.CALCULATION) + raw = provider.screener_inputs(chronological) + inputs, coverage = assemble_screener_inputs( + self._database, self._repository, trade_date, chronological, raw + ) + return inputs, coverage, [provider.source.value, DataSource.LOCAL.value] + def dynamic_auction( self, identifiers: tuple[str, ...], start_time: str, end_time: str ) -> ProviderResult: diff --git a/next/backend/data/policy.py b/next/backend/data/policy.py index 23f4587..8924e4b 100644 --- a/next/backend/data/policy.py +++ b/next/backend/data/policy.py @@ -32,5 +32,6 @@ class DataSourcePolicy: ("realtime_quote", DataUsage.CALCULATION): (DataSource.IFIND, DataSource.TUSHARE), ("market_insight", DataUsage.CALCULATION): (DataSource.TUSHARE,), ("dynamic_auction", DataUsage.CALCULATION): (DataSource.IFIND,), + ("screener_factors", DataUsage.CALCULATION): (DataSource.TUSHARE,), } return routes.get((dataset, usage), ()) diff --git a/next/backend/data/providers/base.py b/next/backend/data/providers/base.py index 2aff656..d7c9c98 100644 --- a/next/backend/data/providers/base.py +++ b/next/backend/data/providers/base.py @@ -40,3 +40,5 @@ class MarketDataProvider(Protocol): def realtime_snapshots( self, identifiers: tuple[str, ...], start_time: str, end_time: str ) -> ProviderResult: ... + + def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]: ... diff --git a/next/backend/data/providers/eastmoney.py b/next/backend/data/providers/eastmoney.py index 3de80fd..0533638 100644 --- a/next/backend/data/providers/eastmoney.py +++ b/next/backend/data/providers/eastmoney.py @@ -116,6 +116,9 @@ class EastmoneyProvider: ) -> ProviderResult: raise ProviderError("The display provider cannot supply calculation snapshots") + def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]: + raise ProviderError("The display provider cannot supply screener factors") + @staticmethod def _secid(entity_type: str, identifier: str) -> str: if entity_type == "index" and identifier in INDEX_CODES: diff --git a/next/backend/data/providers/ifind.py b/next/backend/data/providers/ifind.py index 068bf9a..f72fef6 100644 --- a/next/backend/data/providers/ifind.py +++ b/next/backend/data/providers/ifind.py @@ -120,9 +120,7 @@ class IfindProvider: }, ) rows.extend(_result(payload, "mixed", "not_applicable", SnapshotState.REALTIME).rows) - covered = { - str(row.get("thscode") or "") for row in rows if row.get("thscode") - } + covered = {str(row.get("thscode") or "") for row in rows if row.get("thscode")} return ProviderResult( tuple(rows), ObservationMetadata( @@ -137,6 +135,9 @@ class IfindProvider: ), ) + def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]: + raise ProviderError("iFinD尚未批准用于盘后因子批量计算") + def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: if not self.configured: raise ProviderError("实时行情服务尚未配置") @@ -183,9 +184,7 @@ class IfindProvider: def _auth_error(self, payload: dict[str, Any]) -> bool: message = str(payload.get("errmsg") or payload.get("message") or "").casefold() return ( - _error_code(payload) in self.auth_error_codes - or "token" in message - or "鉴权" in message + _error_code(payload) in self.auth_error_codes or "token" in message or "鉴权" in message ) def _refresh(self) -> str: @@ -246,7 +245,9 @@ def _result( row = { key: values[index] if isinstance(values, list) and index < len(values) - else values if index == 0 else None + else values + if index == 0 + else None for key, values in columns.items() } if isinstance(times, list) and index < len(times): diff --git a/next/backend/data/providers/tushare.py b/next/backend/data/providers/tushare.py index 0dbceef..40c915d 100644 --- a/next/backend/data/providers/tushare.py +++ b/next/backend/data/providers/tushare.py @@ -157,8 +157,7 @@ class TushareProvider: code = str(row.get("ts_code") or "") current = deduplicated.get(code) if code and ( - current is None - or str(row.get("in_date") or "") > str(current.get("in_date") or "") + current is None or str(row.get("in_date") or "") > str(current.get("in_date") or "") ): deduplicated[code] = row if not deduplicated: @@ -222,8 +221,7 @@ class TushareProvider: "daily": self._optional_query( "ths_daily", {"trade_date": current}, - "ts_code,trade_date,open,high,low,close,pre_close,pct_change," - "vol,turnover_rate", + "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate", ), "hot": self._optional_query("ths_hot", {"trade_date": current}, ""), } @@ -244,12 +242,8 @@ class TushareProvider: return { "ths": self._optional_query("ths_hot", {"trade_date": current}, ""), "dc": self._optional_query("dc_hot", {"trade_date": current}, ""), - "previous_ths": self._optional_query( - "ths_hot", {"trade_date": previous}, "" - ), - "previous_dc": self._optional_query( - "dc_hot", {"trade_date": previous}, "" - ), + "previous_ths": self._optional_query("ths_hot", {"trade_date": previous}, ""), + "previous_dc": self._optional_query("dc_hot", {"trade_date": previous}, ""), } if kind == "dragon-list": return { @@ -278,6 +272,147 @@ class TushareProvider: ) -> ProviderResult: raise ProviderError("Tushare不提供动态竞价快照") + def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]: + if len(trade_dates) < 21: + raise ProviderError("选股因子至少需要21个交易日") + compact_dates = tuple(_compact(value) for value in trade_dates) + current = compact_dates[-1] + quarters = _quarter_periods(current, 5) + years = tuple(f"{int(current[:4]) - offset}1231" for offset in range(1, 6)) + return { + "directory": self._optional_query( + "stock_basic", + {"exchange": "", "list_status": "L"}, + "ts_code,symbol,name,industry,market,list_date,list_status", + ), + "industry": self._optional_query( + "index_member_all", + {"is_new": "Y"}, + "l1_code,l1_name,l2_code,l2_name,ts_code,name,in_date,out_date,is_new", + ), + "daily": self._series_query( + "daily", + compact_dates, + "ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount", + ), + "daily_basic": self._series_query( + "daily_basic", + compact_dates[-5:], + "ts_code,trade_date,turnover_rate,volume_ratio,pe_ttm,pb,ps_ttm,dv_ttm," + "total_mv,circ_mv", + ), + "moneyflow": self._series_query( + "moneyflow", + compact_dates[-5:], + "ts_code,trade_date,buy_lg_amount,sell_lg_amount,buy_elg_amount," + "sell_elg_amount,net_mf_amount", + ), + "benchmark": self._optional_query( + "index_daily", + { + "ts_code": "000300.SH", + "start_date": compact_dates[0], + "end_date": current, + }, + "ts_code,trade_date,close,pct_chg", + ), + "fundamentals": self._period_query( + "fina_indicator", + quarters, + "ts_code,ann_date,end_date,roe,roa,roic,grossprofit_margin," + "netprofit_yoy,or_yoy,ocf_to_or", + ), + "dividends": self._period_query( + "dividend", + years, + "ts_code,end_date,ann_date,div_proc,cash_div_tax,ex_date", + parameter="end_date", + ), + "auction": self._optional_query( + "stk_auction", + {"trade_date": current}, + "ts_code,trade_date,price,pre_close,amount,turnover_rate,volume_ratio", + ), + "limit_events": self._series_query( + "limit_list_d", + compact_dates[-80:], + "trade_date,ts_code,name,limit_type,limit_times", + empty_is_complete=True, + ), + "forecast": self._period_query( + "forecast", + quarters, + "ts_code,ann_date,end_date,type,p_change_min,p_change_max," + "net_profit_min,net_profit_max,last_parent_net", + ), + "express": self._period_query( + "express", + quarters, + "ts_code,ann_date,end_date,revenue,operate_profit,total_profit,n_income," + "total_assets,diluted_roe,yoy_net_profit", + ), + } + + def _series_query( + self, + api_name: str, + dates: tuple[str, ...], + fields: str, + *, + empty_is_complete: bool = False, + ) -> ProviderResult | None: + rows: list[dict[str, Any]] = [] + completed = 0 + for trade_date in dates: + try: + result = self._query( + api_name, + {"trade_date": trade_date}, + fields, + unit="mixed", + empty_is_complete=empty_is_complete, + ) + except ProviderError: + continue + rows.extend(result.rows) + completed += 1 + if completed == 0: + return None + return ProviderResult( + tuple(rows), + _metadata(self.source, "mixed", completed / len(dates)), + ) + + def _period_query( + self, + api_name: str, + periods: tuple[str, ...], + fields: str, + *, + parameter: str = "period", + ) -> ProviderResult | None: + rows: list[dict[str, Any]] = [] + completed = 0 + for period in periods: + try: + result = self._query( + api_name, + {parameter: period}, + fields, + unit="mixed", + empty_is_complete=True, + ) + except ProviderError: + continue + rows.extend(result.rows) + completed += 1 + if completed == 0: + return None + return ProviderResult( + tuple(rows), + _metadata(self.source, "mixed", completed / len(periods)), + ) + def _optional_query( self, api_name: str, params: dict[str, Any], fields: str ) -> ProviderResult | None: @@ -295,8 +430,7 @@ class TushareProvider: def _membership_rows(self, params: dict[str, str]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] fields = ( - "l1_code,l1_name,l2_code,l2_name,l3_code,l3_name," - "ts_code,name,in_date,out_date,is_new" + "l1_code,l1_name,l2_code,l2_name,l3_code,l3_name,ts_code,name,in_date,out_date,is_new" ) for is_new in ("Y", "N"): result = self._query( @@ -378,6 +512,21 @@ def _display(value: str) -> str: return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}" +def _quarter_periods(through: str, count: int) -> tuple[str, ...]: + year = int(through[:4]) + quarter = (int(through[4:6]) - 1) // 3 + periods = [] + for offset in range(count + 4): + index = year * 4 + quarter - offset + period_year, period_quarter = divmod(index, 4) + period = f"{period_year}{('0331', '0630', '0930', '1231')[period_quarter]}" + if period <= through: + periods.append(period) + if len(periods) == count: + break + return tuple(reversed(periods)) + + def _active_on(row: dict[str, Any], trade_date: str) -> bool: start = str(row.get("in_date") or "") end = str(row.get("out_date") or "") diff --git a/next/backend/data/screener_gateway.py b/next/backend/data/screener_gateway.py new file mode 100644 index 0000000..733a8b6 --- /dev/null +++ b/next/backend/data/screener_gateway.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import json +from typing import Any + +from backend.data.contracts import ProviderResult +from backend.data.repository import MarketRepository +from backend.database.connection import Database + + +def assemble_screener_inputs( + database: Database, + repository: MarketRepository, + trade_date: str, + trade_dates: tuple[str, ...], + raw: dict[str, ProviderResult | None], +) -> tuple[dict[str, Any], dict[str, float]]: + inputs = { + key: [_normalize_row(row) for row in value.rows] + for key, value in raw.items() + if isinstance(value, ProviderResult) + } + inputs["earnings"] = _earnings_events( + inputs.pop("forecast", []), inputs.pop("express", []), trade_date + ) + popularity, popularity_coverage = _popularity(database, repository, trade_date) + institutions, institution_coverage = _institutions(database, repository, trade_date) + local_limits, local_limit_coverage = _local_limit_events( + database, repository, trade_dates[-80:] + ) + inputs["popularity"] = popularity + inputs["institutions"] = institutions + inputs["limit_events"] = _merge_events(inputs.get("limit_events", []), local_limits) + + directory_count = len(inputs.get("directory", [])) + daily_current = _on_date(inputs.get("daily", []), trade_date) + current_count = len({str(row.get("ts_code") or "") for row in daily_current}) + expected = max(directory_count, current_count, 1) + basic_current = _on_date(inputs.get("daily_basic", []), trade_date) + fundamentals = _latest_announced(inputs.get("fundamentals", []), trade_date) + moneyflow_counts = _code_date_counts(inputs.get("moneyflow", [])) + industry_codes = { + str(row.get("ts_code") or "") + for row in inputs.get("industry", []) + if _active_member(row, trade_date) + } + auction_count = len({str(row.get("ts_code") or "") for row in inputs.get("auction", [])}) + limit_result = raw.get("limit_events") + provider_limit_coverage = ( + limit_result.metadata.coverage if isinstance(limit_result, ProviderResult) else 0 + ) + forecast = raw.get("forecast") + express = raw.get("express") + earnings_coverage = min( + forecast.metadata.coverage if isinstance(forecast, ProviderResult) else 0, + express.metadata.coverage if isinstance(express, ProviderResult) else 0, + ) + coverage = { + "market": min(current_count / expected, 1), + "valuation": min( + len({str(row.get("ts_code") or "") for row in basic_current}) / expected, 1 + ), + "financial": min(len(fundamentals) / expected, 1), + "moneyflow": min( + sum(count >= min(5, len(trade_dates)) for count in moneyflow_counts.values()) + / expected, + 1, + ), + "industry": min(len(industry_codes) / expected, 1), + "auction": min(auction_count / expected, 1), + "popularity": popularity_coverage, + "institutions": institution_coverage, + "earnings": earnings_coverage, + "limit_events": max(provider_limit_coverage, local_limit_coverage), + } + return inputs, coverage + + +def _popularity( + database: Database, repository: MarketRepository, trade_date: str +) -> tuple[list[dict[str, Any]], float]: + with database.read() as connection: + row = repository.insight_snapshot(connection, "popularity", trade_date) + if row is None: + return [], 0.0 + payload = json.loads(str(row["payload_json"])) + return [ + { + "ts_code": str(item.get("identifier") or ""), + "combined_score": item.get("score"), + "rank_change": item.get("rank_change"), + "dual_source": bool(item.get("dual_source")), + } + for item in payload.get("combined") or [] + if item.get("identifier") + ], float(row["coverage"]) + + +def _institutions( + database: Database, repository: MarketRepository, trade_date: str +) -> tuple[list[dict[str, Any]], float]: + with database.read() as connection: + row = repository.insight_snapshot(connection, "dragon-list", trade_date) + if row is None: + return [], 0.0 + payload = json.loads(str(row["payload_json"])) + seats = payload.get("seats") + if seats is None: + return [], 0.0 + grouped: dict[str, dict[str, float]] = {} + for item in seats: + identifier = str(item.get("ts_code") or "") + if not identifier or "机构" not in str(item.get("exalter") or ""): + continue + target = grouped.setdefault(identifier, {"net": 0.0, "count": 0}) + target["net"] += float(item.get("net_buy") or 0) / 1_000_000 + target["count"] += 1 + return [ + { + "ts_code": identifier, + "net_buy_million": values["net"], + "seat_count": int(values["count"]), + } + for identifier, values in grouped.items() + ], float(row["coverage"]) + + +def _local_limit_events( + database: Database, + repository: MarketRepository, + trade_dates: tuple[str, ...], +) -> tuple[list[dict[str, Any]], float]: + if not trade_dates: + return [], 0.0 + with database.read() as connection: + rows = repository.summaries(connection, trade_dates[-1], len(trade_dates)) + expected = set(trade_dates) + covered: set[str] = set() + events = [] + for row in rows: + trade_date = str(row["trade_date"]) + if trade_date not in expected: + continue + covered.add(trade_date) + payload = json.loads(str(row["payload_json"])) + for key, event in (("limits", "U"), ("broken", "Z"), ("down_limits", "D")): + events.extend( + { + "trade_date": trade_date, + "ts_code": str(item["identifier"]), + "limit_type": event, + } + for item in payload.get(key) or [] + if item.get("identifier") + ) + return events, len(covered) / len(expected) + + +def _normalize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for field in ("trade_date", "ann_date", "end_date", "ex_date", "in_date", "out_date"): + value = str(result.get(field) or "") + if len(value) == 8 and value.isdigit(): + result[field] = f"{value[:4]}-{value[4:6]}-{value[6:]}" + if result.get("price") is not None and result.get("pre_close") is not None: + price = _number(result.get("price")) + previous = _number(result.get("pre_close")) + result["change"] = (price / previous - 1) * 100 if price is not None and previous else None + amount = _number(result.get("amount")) + result["amount_million"] = amount / 1_000_000 if amount is not None else None + return result + + +def _earnings_events( + forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], through: str +) -> list[dict[str, Any]]: + forecast_map: dict[tuple[str, str], dict[str, Any]] = {} + for row in forecasts: + key = (str(row.get("ts_code") or ""), str(row.get("end_date") or "")) + announced = str(row.get("ann_date") or "") + current = forecast_map.get(key) + if ( + all(key) + and announced + and announced <= through + and (current is None or announced > str(current.get("ann_date") or "")) + ): + forecast_map[key] = row + result = [] + for row in expresses: + key = (str(row.get("ts_code") or ""), str(row.get("end_date") or "")) + announced = str(row.get("ann_date") or "") + forecast = forecast_map.get(key) + if forecast is None or not announced or announced > through: + continue + values = [ + value + for value in ( + _number(forecast.get("net_profit_min")), + _number(forecast.get("net_profit_max")), + ) + if value is not None + ] + expected = sum(values) / len(values) if values else None + actual = _number(row.get("n_income")) + if expected in (None, 0) or actual is None: + continue + if abs(actual) > max(abs(expected), 1) * 100: + actual /= 10000 + result.append( + { + "ts_code": key[0], + "end_date": key[1], + "ann_date": announced, + "surprise_pct": (actual / expected - 1) * 100, + } + ) + return result + + +def _on_date(rows: list[dict[str, Any]], trade_date: str) -> list[dict[str, Any]]: + return [row for row in rows if str(row.get("trade_date") or "") == trade_date] + + +def _latest_announced(rows: list[dict[str, Any]], through: str) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for row in rows: + identifier = str(row.get("ts_code") or "") + announced = str(row.get("ann_date") or "") + current = result.get(identifier) + if ( + identifier + and announced + and announced <= through + and (current is None or announced > str(current.get("ann_date") or "")) + ): + result[identifier] = row + return result + + +def _code_date_counts(rows: list[dict[str, Any]]) -> dict[str, int]: + values: dict[str, set[str]] = {} + for row in rows: + identifier = str(row.get("ts_code") or "") + if identifier: + values.setdefault(identifier, set()).add(str(row.get("trade_date") or "")) + return {identifier: len(dates) for identifier, dates in values.items()} + + +def _active_member(row: dict[str, Any], trade_date: str) -> bool: + start = str(row.get("in_date") or "") + end = str(row.get("out_date") or "") + return (not start or start <= trade_date) and (not end or end > trade_date) + + +def _merge_events( + provider: list[dict[str, Any]], local: list[dict[str, Any]] +) -> list[dict[str, Any]]: + merged = { + (str(row.get("trade_date") or ""), str(row.get("ts_code") or "")): row for row in local + } + for row in provider: + merged[(str(row.get("trade_date") or ""), str(row.get("ts_code") or ""))] = row + return list(merged.values()) + + +def _number(value: Any) -> float | None: + try: + result = float(value) + return result if result == result else None + except (TypeError, ValueError): + return None diff --git a/next/backend/database/migrations/m0007_screener.py b/next/backend/database/migrations/m0007_screener.py new file mode 100644 index 0000000..f393228 --- /dev/null +++ b/next/backend/database/migrations/m0007_screener.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def upgrade(connection: sqlite3.Connection) -> None: + statements = ( + """ + CREATE TABLE screener_factor_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_date TEXT NOT NULL, + version TEXT NOT NULL, + observed_at TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('final', 'archive')), + source_set_json TEXT NOT NULL, + coverage_json TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (trade_date, version) + ) + """, + """ + CREATE TABLE screener_factor_values ( + snapshot_id INTEGER NOT NULL + REFERENCES screener_factor_snapshots(id) ON DELETE CASCADE, + identifier TEXT NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + sector TEXT, + listed_days INTEGER NOT NULL, + is_st INTEGER NOT NULL CHECK (is_st IN (0, 1)), + payload_json TEXT NOT NULL, + PRIMARY KEY (snapshot_id, identifier) + ) + """, + """ + CREATE TABLE screener_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + mode TEXT NOT NULL CHECK (mode IN ('stage', 'curated', 'custom')), + strategy_id TEXT NOT NULL, + strategy_name TEXT NOT NULL, + strategy_version INTEGER NOT NULL, + selection_date TEXT NOT NULL, + factor_snapshot_id INTEGER NOT NULL + REFERENCES screener_factor_snapshots(id) ON DELETE RESTRICT, + status TEXT NOT NULL CHECK ( + status IN ( + 'pending', 'running', 'completed', 'no_signal', + 'data_incomplete', 'failed' + ) + ), + started_at TEXT, + completed_at TEXT, + coverage REAL NOT NULL DEFAULT 0 CHECK (coverage >= 0 AND coverage <= 1), + missing_fields_json TEXT NOT NULL DEFAULT '[]', + result_json TEXT NOT NULL DEFAULT '[]', + error_message TEXT NOT NULL DEFAULT '' + ) + """, + """ + CREATE UNIQUE INDEX screener_runs_idempotency_idx ON screener_runs ( + mode, strategy_id, selection_date, strategy_version, + factor_snapshot_id, COALESCE(owner_user_id, 0) + ) + """, + """ + CREATE TABLE custom_screener_strategies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + formula_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (user_id, name) + ) + """, + """ + CREATE TABLE strategy_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + run_id INTEGER NOT NULL REFERENCES screener_runs(id) ON DELETE CASCADE, + identifier TEXT NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + sector TEXT, + selection_date TEXT NOT NULL, + strategy_name TEXT NOT NULL, + entry_price REAL NOT NULL CHECK (entry_price > 0), + added_at TEXT NOT NULL, + UNIQUE (user_id, run_id, identifier) + ) + """, + """ + CREATE TABLE strategy_track_bars ( + track_id INTEGER NOT NULL REFERENCES strategy_tracks(id) ON DELETE CASCADE, + trade_date TEXT NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + PRIMARY KEY (track_id, trade_date) + ) + """, + """ + CREATE TABLE strategy_track_events ( + track_id INTEGER NOT NULL REFERENCES strategy_tracks(id) ON DELETE CASCADE, + milestone TEXT NOT NULL CHECK (milestone IN ('t1', 't5')), + created_at TEXT NOT NULL, + PRIMARY KEY (track_id, milestone) + ) + """, + ) + for statement in statements: + connection.execute(statement) + + +def downgrade(connection: sqlite3.Connection) -> None: + for table in ( + "strategy_track_events", + "strategy_track_bars", + "strategy_tracks", + "custom_screener_strategies", + "screener_runs", + "screener_factor_values", + "screener_factor_snapshots", + ): + connection.execute(f"DROP TABLE {table}") + + +MIGRATION = Migration( + version=7, + name="create_deterministic_screener", + signature="screener:v1:factors-runs-custom-tracking", + upgrade=upgrade, + downgrade=downgrade, +) diff --git a/next/backend/database/migrations/registry.py b/next/backend/database/migrations/registry.py index 923c338..4c0915b 100644 --- a/next/backend/database/migrations/registry.py +++ b/next/backend/database/migrations/registry.py @@ -4,6 +4,7 @@ from backend.database.migrations.m0003_market_foundation import MIGRATION as MAR from backend.database.migrations.m0004_sector_members import MIGRATION as SECTOR_MEMBERS from backend.database.migrations.m0005_market_insights import MIGRATION as MARKET_INSIGHTS from backend.database.migrations.m0006_watchlists import MIGRATION as WATCHLISTS +from backend.database.migrations.m0007_screener import MIGRATION as SCREENER from backend.database.migrations.runner import Migration MIGRATIONS: tuple[Migration, ...] = ( @@ -13,4 +14,5 @@ MIGRATIONS: tuple[Migration, ...] = ( SECTOR_MEMBERS, MARKET_INSIGHTS, WATCHLISTS, + SCREENER, ) diff --git a/next/backend/features/accounts/auth.py b/next/backend/features/accounts/auth.py index f9bc4b8..1c88663 100644 --- a/next/backend/features/accounts/auth.py +++ b/next/backend/features/accounts/auth.py @@ -65,3 +65,15 @@ def require_smart_access( SmartAccessPrincipal = Annotated[Principal, Depends(require_smart_access)] + + +def require_smart_write( + request: Request, + principal: CsrfPrincipal, +) -> Principal: + if not request.app.state.container.memberships.can_use_smart_features(principal): + raise AppError("membership_required", "该功能仅对会员开放。", 403) + return principal + + +SmartWritePrincipal = Annotated[Principal, Depends(require_smart_write)] diff --git a/next/backend/features/screener/__init__.py b/next/backend/features/screener/__init__.py new file mode 100644 index 0000000..c9c2ef6 --- /dev/null +++ b/next/backend/features/screener/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/next/backend/features/screener/catalog.py b/next/backend/features/screener/catalog.py new file mode 100644 index 0000000..8702cda --- /dev/null +++ b/next/backend/features/screener/catalog.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +import math +from functools import lru_cache +from pathlib import Path +from typing import Any + +CONFIG_ROOT = Path(__file__).resolve().parents[3] / "config" +ALLOWED_OPERATORS = frozenset({">", ">=", "<", "<=", "==", "!=", "between", "in"}) + + +class CatalogError(RuntimeError): + pass + + +@lru_cache(maxsize=1) +def factor_catalog() -> dict[str, Any]: + payload = _read("screener-factors.json") + factors = payload.get("factors") + groups = payload.get("groups") + if payload.get("schema_version") != 1 or not isinstance(factors, dict): + raise CatalogError("选股因子目录无效") + grouped = [field for values in (groups or {}).values() for field in values] + if len(grouped) != len(set(grouped)) or set(grouped) != set(factors): + raise CatalogError("选股因子分组与目录不一致") + return payload + + +@lru_cache(maxsize=1) +def strategy_catalog() -> tuple[dict[str, Any], ...]: + payload = _read("screener-strategies.json") + items = payload.get("strategies") + if payload.get("schema_version") != 1 or not isinstance(items, list): + raise CatalogError("选股策略目录无效") + identifiers: set[str] = set() + names: set[str] = set() + factors = set(factor_catalog()["factors"]) + for item in items: + identifier = str(item.get("id") or "") + name = str(item.get("name") or "") + if not identifier or identifier in identifiers or not name or name in names: + raise CatalogError("选股策略标识或名称重复") + identifiers.add(identifier) + names.add(name) + validate_formula(item.get("formula"), factors) + if sum(item.get("kind") == "stage" for item in items) != 7: + raise CatalogError("阶段策略必须为7套") + if sum(item.get("kind") == "curated" for item in items) != 29: + raise CatalogError("精选策略必须为29套") + return tuple(items) + + +def strategy_by_id(identifier: str) -> dict[str, Any] | None: + return next((item for item in strategy_catalog() if item["id"] == identifier), None) + + +def validate_formula(formula: Any, factors: set[str] | None = None) -> dict[str, Any]: + if not isinstance(formula, dict): + raise CatalogError("选股公式必须是对象") + known = factors or set(factor_catalog()["factors"]) + universe = formula.get("universe") or {} + listed_days = universe.get("listed_days_min", 120) + if not isinstance(listed_days, int) or not 0 <= listed_days <= 5000: + raise CatalogError("上市天数范围无效") + filters = formula.get("filters") + scores = formula.get("score") + if not isinstance(filters, list) or len(filters) > 20: + raise CatalogError("筛选条件必须为不超过20项的列表") + if not isinstance(scores, list) or not 1 <= len(scores) <= 12: + raise CatalogError("评分因子必须为1至12项") + for condition in filters: + if condition.get("field") not in known: + raise CatalogError(f"未知筛选因子:{condition.get('field')}") + if condition.get("op") not in ALLOWED_OPERATORS or "value" not in condition: + raise CatalogError("筛选运算符或比较值无效") + _validate_comparison(condition["op"], condition["value"]) + total = 0.0 + score_fields: set[str] = set() + for score in scores: + if score.get("field") not in known: + raise CatalogError(f"未知评分因子:{score.get('field')}") + field = str(score["field"]) + if field in score_fields: + raise CatalogError("评分因子不能重复") + score_fields.add(field) + raw_weight = score.get("weight") + if not isinstance(raw_weight, (int, float)) or isinstance(raw_weight, bool): + raise CatalogError("评分权重必须是数值") + weight = float(raw_weight) + if weight <= 0 or score.get("direction", "desc") not in {"asc", "desc"}: + raise CatalogError("评分权重或方向无效") + total += weight + if abs(total - 1) > 0.000001: + raise CatalogError("评分权重总和必须为100%") + limit = formula.get("limit") + minimum = formula.get("min_score") + if not isinstance(limit, int) or not 1 <= limit <= 50: + raise CatalogError("输出数量必须为1至50") + if not isinstance(minimum, (int, float)) or not 0 <= minimum <= 1: + raise CatalogError("最低综合分必须在0至1之间") + return formula + + +def _validate_comparison(operator: str, value: Any) -> None: + if operator == "between": + if not isinstance(value, list) or len(value) != 2: + raise CatalogError("区间条件必须包含两个边界") + if not all(_comparable(item) for item in value) or value[0] > value[1]: + raise CatalogError("区间条件边界无效") + return + if operator == "in": + if not isinstance(value, list) or not 1 <= len(value) <= 20: + raise CatalogError("集合条件必须包含1至20个值") + if not all(_comparable(item) for item in value): + raise CatalogError("集合条件包含无效值") + return + if not _comparable(value): + raise CatalogError("比较值必须是有限数值或布尔值") + + +def _comparable(value: Any) -> bool: + return isinstance(value, bool) or ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ) + + +def _read(filename: str) -> dict[str, Any]: + try: + return json.loads((CONFIG_ROOT / filename).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise CatalogError(f"无法读取{filename}") from exc diff --git a/next/backend/features/screener/cross_section.py b/next/backend/features/screener/cross_section.py new file mode 100644 index 0000000..873a908 --- /dev/null +++ b/next/backend/features/screener/cross_section.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from collections import defaultdict +from statistics import fmean, median +from typing import Any + +from backend.features.screener.catalog import factor_catalog +from backend.features.screener.factor_math import mean, number, pearson, percentile_map, rounded + + +def finalize_factor_rows( + rows: list[dict[str, Any]], dataset_ready: dict[str, bool] +) -> list[dict[str, Any]]: + market_returns = [number(row.get("return_5d")) for row in rows] + valid_market = [value for value in market_returns if value is not None] + market_mean = fmean(valid_market) if valid_market else None + for row in rows: + stock_return = number(row.get("return_5d")) + row["relative_strength"] = ( + rounded(stock_return - market_mean, 2) + if stock_return is not None and market_mean is not None + else None + ) + _rank(rows, "return_5d", "return_5d_rank", "desc") + _rank(rows, "momentum_60_5", "momentum_60_5_rank", "desc") + _sector_factors(rows, dataset_ready) + _composite_factors(rows) + _style_factors(rows) + _market_height(rows) + known = factor_catalog()["factors"] + for row in rows: + for field in known: + row.setdefault(field, None) + return rows + + +def _sector_factors(rows: list[dict[str, Any]], dataset_ready: dict[str, bool]) -> None: + fields = ( + "sector_strength", + "sector_return_5d", + "sector_return_20d", + "sector_momentum_rank", + "sector_stock_momentum_rank", + "sector_net_flow_5d_million", + "sector_flow_rank", + "sector_prosperity_rank", + "sector_trend_rank", + "sector_crowding_rank", + "sector_composite_score", + "sector_limit_count", + "sector_up_count", + "sector_breadth_ma20", + ) + if not dataset_ready.get("industry"): + for row in rows: + row.update(dict.fromkeys(fields)) + return + sectors: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + if row.get("sector"): + sectors[str(row["sector"])].append(row) + market_amount = sum(float(number(row.get("amount_billion")) or 0) for row in rows) + metrics = [] + for name, members in sectors.items(): + returns_5 = [number(row.get("return_5d")) for row in members] + returns_20 = [number(row.get("return_20d")) for row in members] + average_5 = mean(returns_5) + average_20 = mean(returns_20) + flows = [number(row.get("net_flow_5d_million")) for row in members] + sector_flow = ( + sum(float(value) for value in flows) + if all(value is not None for value in flows) + else None + ) + limit_values = [row.get("is_limit_up_today") for row in members] + limit_count = ( + sum(bool(value) for value in limit_values) + if all(value is not None for value in limit_values) + else None + ) + changes = [number(row.get("pct_chg")) for row in members] + up_count = ( + sum(float(value) >= 5 for value in changes if value is not None) + if all(value is not None for value in changes) + else None + ) + above = [row.get("above_ma20") for row in members] + breadth = ( + sum(bool(value) for value in above) / len(above) * 100 + if above and all(value is not None for value in above) + else None + ) + growth = [ + mean([number(row.get("revenue_yoy")), number(row.get("netprofit_yoy"))]) + for row in members + ] + valid_growth = [value for value in growth if value is not None] + prosperity = median(valid_growth) if valid_growth else None + turnovers = [number(row.get("turnover_rate")) for row in members] + average_turnover = mean(turnovers) + amount_share = ( + sum(float(number(row.get("amount_billion")) or 0) for row in members) + / market_amount + * 100 + if market_amount + else None + ) + crowding = ( + average_turnover + amount_share + if average_turnover is not None and amount_share is not None + else None + ) + trend = ( + average_20 + breadth / 10 if average_20 is not None and breadth is not None else None + ) + strength = ( + min( + 100, + max( + 0, + 50 + average_5 * 4 + (limit_count or 0) * 3 + (up_count or 0) * 0.6, + ), + ) + if average_5 is not None + else None + ) + metrics.append( + { + "identifier": name, + "sector": name, + "return_20": average_20, + "flow": sector_flow, + "prosperity": prosperity, + "trend": trend, + "crowding": crowding, + } + ) + stock_ranks = percentile_map(members, "return_20d", "desc") + for row in members: + row.update( + { + "sector_strength": rounded(strength, 1), + "sector_return_5d": rounded(average_5, 2), + "sector_return_20d": rounded(average_20, 2), + "sector_stock_momentum_rank": rounded( + stock_ranks.get(str(row["identifier"])), 4 + ), + "sector_net_flow_5d_million": rounded(sector_flow, 2), + "sector_limit_count": limit_count, + "sector_up_count": up_count, + "sector_breadth_ma20": rounded(breadth, 1), + } + ) + rank_specs = { + "sector_momentum_rank": ("return_20", "desc"), + "sector_flow_rank": ("flow", "desc"), + "sector_prosperity_rank": ("prosperity", "desc"), + "sector_trend_rank": ("trend", "desc"), + "sector_crowding_rank": ("crowding", "desc"), + } + maps = { + output: percentile_map(metrics, source, direction) + for output, (source, direction) in rank_specs.items() + } + for name, members in sectors.items(): + values = {field: mapping.get(name) for field, mapping in maps.items()} + composite = ( + values["sector_prosperity_rank"] * 0.4 + + values["sector_trend_rank"] * 0.3 + + (1 - values["sector_crowding_rank"]) * 0.3 + if all(value is not None for value in values.values()) + else None + ) + for row in members: + row.update({field: rounded(value, 4) for field, value in values.items()}) + row["sector_composite_score"] = rounded(composite, 4) + for row in rows: + if not row.get("sector"): + row.update(dict.fromkeys(fields)) + + +def _composite_factors(rows: list[dict[str, Any]]) -> None: + specs = { + "factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")), + "factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")), + "factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")), + "factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")), + "factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")), + } + for output, factor_specs in specs.items(): + maps = [percentile_map(rows, field, direction) for field, direction in factor_specs] + for row in rows: + values = [mapping.get(str(row["identifier"])) for mapping in maps] + row[output] = rounded(mean(values), 4) + future_rank = percentile_map(rows, "return_20d", "desc") + weights = {} + for output in specs: + pairs = [(number(row.get(output)), future_rank.get(str(row["identifier"]))) for row in rows] + valid = [(left, right) for left, right in pairs if left is not None and right is not None] + correlation = pearson( + [float(left) for left, _ in valid], + [float(right) for _, right in valid], + ) + weights[output] = max(0.05, correlation) + for row in rows: + available = [ + (number(row.get(field)), weight) + for field, weight in weights.items() + if number(row.get(field)) is not None + ] + row["multi_factor_composite"] = ( + rounded( + sum(float(value) * weight for value, weight in available) + / sum(weight for _, weight in available), + 4, + ) + if available + else None + ) + + +def _style_factors(rows: list[dict[str, Any]]) -> None: + size = percentile_map(rows, "total_mv_billion", "desc") + large = [row for row in rows if (size.get(str(row["identifier"])) or 0) >= 0.7] + small = [row for row in rows if (size.get(str(row["identifier"])) or 1) <= 0.3] + large_return = mean([number(row.get("return_20d")) for row in large]) + small_return = mean([number(row.get("return_20d")) for row in small]) + prefer_large = ( + large_return >= small_return + if large_return is not None and small_return is not None + else None + ) + growth = [row for row in rows if (number(row.get("factor_growth_score")) or 0) >= 0.7] + value = [row for row in rows if (number(row.get("factor_value_score")) or 0) >= 0.7] + growth_return = mean([number(row.get("return_20d")) for row in growth]) + value_return = mean([number(row.get("return_20d")) for row in value]) + prefer_growth = ( + growth_return >= value_return + if growth_return is not None and value_return is not None + else None + ) + for row in rows: + size_rank = size.get(str(row["identifier"])) + row["style_size_fit"] = ( + rounded(size_rank if prefer_large else 1 - size_rank, 4) + if size_rank is not None and prefer_large is not None + else None + ) + row["style_growth_fit"] = ( + row.get("factor_growth_score") + if prefer_growth + else row.get("factor_value_score") + if prefer_growth is not None + else None + ) + row["style_fit_score"] = rounded( + mean([number(row.get("style_size_fit")), number(row.get("style_growth_fit"))]), + 4, + ) + + +def _market_height(rows: list[dict[str, Any]]) -> None: + current = [int(row["limit_streak"]) for row in rows if row.get("limit_streak") is not None] + previous = [ + int(row["previous_limit_streak"]) + for row in rows + if row.get("previous_limit_streak") is not None + ] + current_height = max(current, default=0) + previous_height = max(previous, default=0) + for row in rows: + streak = row.get("limit_streak") + prior = row.get("previous_limit_streak") + if streak is None or prior is None: + row["is_market_height"] = None + row["new_space_board"] = None + continue + is_height = current_height >= 2 and int(streak) == current_height + row["is_market_height"] = is_height + row["new_space_board"] = is_height and not ( + previous_height >= 2 and int(prior) == previous_height + ) + + +def _rank(rows: list[dict[str, Any]], source: str, target: str, direction: str) -> None: + mapping = percentile_map(rows, source, direction) + for row in rows: + row[target] = rounded(mapping.get(str(row["identifier"])), 4) diff --git a/next/backend/features/screener/engine.py b/next/backend/features/screener/engine.py new file mode 100644 index 0000000..a97d1a8 --- /dev/null +++ b/next/backend/features/screener/engine.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from typing import Any + +from backend.features.screener.catalog import factor_catalog, validate_formula + +FACTOR_MINIMUM_COVERAGE = { + "valuation": 0.95, + "financial": 0.90, + "moneyflow": 0.90, + "industry": 0.95, + "auction": 0.90, + "popularity": 0.95, + "institutions": 0.95, + "earnings": 0.95, + "market": 0.98, +} + +FACTOR_DATASET = { + **dict.fromkeys( + { + "pe_ttm", + "pb", + "ps_ttm", + "dividend_yield_ttm", + "circ_mv_billion", + "total_mv_billion", + "turnover_rate", + "turnover_5d", + }, + "valuation", + ), + **dict.fromkeys( + { + "dividend_years", + "roe", + "roa", + "roic", + "gross_margin", + "netprofit_yoy", + "revenue_yoy", + "ocf_to_opincome", + "financial_risk", + "factor_value_score", + "factor_growth_score", + "factor_quality_score", + "multi_factor_composite", + }, + "financial", + ), + **dict.fromkeys( + { + "earnings_surprise_pct", + "earnings_days_since_announce", + "earnings_event_quality", + }, + "earnings", + ), + **dict.fromkeys( + { + "net_flow_million", + "large_flow_million", + "net_flow_5d_million", + "flow_to_circ_mv_5d", + "sector_net_flow_5d_million", + "sector_flow_rank", + }, + "moneyflow", + ), + **dict.fromkeys( + { + "sector_strength", + "sector_return_5d", + "sector_return_20d", + "sector_momentum_rank", + "sector_stock_momentum_rank", + "sector_prosperity_rank", + "sector_trend_rank", + "sector_crowding_rank", + "sector_composite_score", + "sector_limit_count", + "sector_up_count", + "sector_breadth_ma20", + }, + "industry", + ), + **dict.fromkeys( + { + "auction_change", + "auction_amount_million", + "auction_turnover_rate", + "auction_volume_ratio", + }, + "auction", + ), + **dict.fromkeys( + {"popularity_score", "popularity_rank_change", "popularity_dual_source"}, + "popularity", + ), + **dict.fromkeys( + {"institution_net_buy_million", "institution_seat_count"}, + "institutions", + ), +} + + +def execute_formula( + rows: list[dict[str, Any]], + formula: dict[str, Any], + coverage: dict[str, float], +) -> dict[str, Any]: + validate_formula(formula) + required = sorted({str(item["field"]) for item in [*formula["filters"], *formula["score"]]}) + universe = formula.get("universe") or {} + universe_rows = [ + row + for row in rows + if not (universe.get("exclude_st", True) and bool(row.get("is_st"))) + and int(row.get("listed_days") or 0) >= int(universe.get("listed_days_min") or 0) + ] + missing_datasets = sorted( + { + dataset + for field in required + if (dataset := FACTOR_DATASET.get(field, "market")) + and coverage.get(dataset, 0) < FACTOR_MINIMUM_COVERAGE[dataset] + } + ) + field_coverage = { + field: ( + sum(row.get(field) is not None for row in universe_rows) / len(universe_rows) + if universe_rows + else 0.0 + ) + for field in required + } + missing_fields = sorted( + field + for field in required + if field_coverage[field] < FACTOR_MINIMUM_COVERAGE[FACTOR_DATASET.get(field, "market")] + ) + if missing_datasets or missing_fields: + return { + "status": "data_incomplete", + "items": [], + "missing_datasets": missing_datasets, + "missing_fields": missing_fields, + "field_coverage": field_coverage, + "eligible_count": 0, + } + + eligible = [] + for row in universe_rows: + if any(row.get(field) is None for field in required): + continue + if all( + _matches(row[item["field"]], item["op"], item["value"]) for item in formula["filters"] + ): + eligible.append(row) + if not eligible: + return { + "status": "no_signal", + "items": [], + "missing_datasets": [], + "missing_fields": [], + "field_coverage": field_coverage, + "eligible_count": 0, + } + + percentiles = { + item["field"]: _percentiles(eligible, item["field"], item["direction"]) + for item in formula["score"] + } + labels = factor_catalog()["factors"] + results = [] + for row in eligible: + contributions = [] + total = 0.0 + identifier = str(row["identifier"]) + for item in formula["score"]: + points = percentiles[item["field"]][identifier] * float(item["weight"]) + total += points + contributions.append( + { + "field": item["field"], + "label": labels[item["field"]], + "value": row[item["field"]], + "points": round(points * 100, 1), + } + ) + if total < float(formula["min_score"]): + continue + contributions.sort(key=lambda item: (-item["points"], item["field"])) + results.append( + { + "identifier": identifier, + "code": row["code"], + "name": row["name"], + "sector": row.get("sector") or "", + "close": row.get("close"), + "pct_chg": row.get("pct_chg"), + "amount_billion": row.get("amount_billion"), + "score": round(total, 6), + "score_display": round(total * 100, 1), + "contributions": contributions, + "reason": "、".join(item["label"] for item in contributions[:3]), + "risk_flags": _risk_flags(row), + } + ) + results.sort(key=lambda item: (-item["score"], item["identifier"])) + results = results[: int(formula["limit"])] + return { + "status": "completed" if results else "no_signal", + "items": results, + "missing_datasets": [], + "missing_fields": [], + "field_coverage": field_coverage, + "eligible_count": len(eligible), + } + + +def _percentiles(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]: + ordered = sorted( + rows, + key=lambda row: ( + float(row[field]) if direction == "asc" else -float(row[field]), + str(row["identifier"]), + ), + ) + if len(ordered) == 1: + return {str(ordered[0]["identifier"]): 1.0} + return { + str(row["identifier"]): 1 - index / (len(ordered) - 1) for index, row in enumerate(ordered) + } + + +def _matches(value: Any, operator: str, expected: Any) -> bool: + if operator == "between": + return expected[0] <= value <= expected[1] + if operator == "in": + return value in expected + return { + ">": value > expected, + ">=": value >= expected, + "<": value < expected, + "<=": value <= expected, + "==": value == expected, + "!=": value != expected, + }[operator] + + +def _risk_flags(row: dict[str, Any]) -> list[str]: + flags = [] + if row.get("financial_risk"): + flags.append("存在已确认财务风险") + if row.get("volatility_10d") is not None and float(row["volatility_10d"]) >= 6: + flags.append("近期波动偏高") + if row.get("pct_chg") is not None and float(row["pct_chg"]) >= 9: + flags.append("当日涨幅较高") + return flags diff --git a/next/backend/features/screener/factor_math.py b/next/backend/features/screener/factor_math.py new file mode 100644 index 0000000..2247296 --- /dev/null +++ b/next/backend/features/screener/factor_math.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import math +from statistics import fmean +from typing import Any + + +def number(value: Any) -> float | None: + try: + result = float(value) + return result if math.isfinite(result) else None + except (TypeError, ValueError): + return None + + +def change(current: float | None, previous: float | None) -> float | None: + if current is None or previous in (None, 0): + return None + return (current / previous - 1) * 100 + + +def mean(values: list[float | None]) -> float | None: + valid = [value for value in values if value is not None] + return fmean(valid) if valid else None + + +def ratio(numerator: float | None, denominator: float | None) -> float | None: + if numerator is None or denominator in (None, 0): + return None + return numerator / denominator + + +def rsi(closes: list[float], period: int = 6) -> float | None: + if len(closes) <= period: + return None + differences = [closes[index] - closes[index - 1] for index in range(1, len(closes))] + recent = differences[-period:] + gains = sum(max(value, 0) for value in recent) / period + losses = sum(max(-value, 0) for value in recent) / period + if losses == 0: + return 100.0 if gains > 0 else 50.0 + return 100 - 100 / (1 + gains / losses) + + +def ema(values: list[float], period: int) -> list[float]: + if not values: + return [] + alpha = 2 / (period + 1) + result = [values[0]] + for value in values[1:]: + result.append(value * alpha + result[-1] * (1 - alpha)) + return result + + +def macd(values: list[float]) -> tuple[list[float], list[float]]: + fast = ema(values, 12) + slow = ema(values, 26) + difference = [left - right for left, right in zip(fast, slow, strict=True)] + return difference, ema(difference, 9) + + +def weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]: + weeks: dict[str, dict[str, float]] = {} + for row in rows: + date = str(row.get("trade_date") or "") + if len(date) != 10: + continue + from datetime import date as date_type + + parsed = date_type.fromisoformat(date) + key = f"{parsed.isocalendar().year}-{parsed.isocalendar().week:02d}" + weeks.setdefault(key, {"close": 0.0, "amount": 0.0}) + close = number(row.get("close")) + amount = number(row.get("amount")) + if close is not None: + weeks[key]["close"] = close + if amount is not None: + weeks[key]["amount"] += amount + values = list(weeks.values()) + return [item["close"] for item in values], [item["amount"] for item in values] + + +def percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]: + valid = [row for row in rows if number(row.get(field)) is not None] + ordered = sorted( + valid, + key=lambda row: ( + number(row[field]) if direction == "asc" else -float(number(row[field]) or 0), + str(row["identifier"]), + ), + ) + if len(ordered) == 1: + return {str(ordered[0]["identifier"]): 1.0} + return { + str(row["identifier"]): 1 - index / (len(ordered) - 1) for index, row in enumerate(ordered) + } + + +def pearson(left: list[float], right: list[float]) -> float: + if len(left) < 3 or len(left) != len(right): + return 0.0 + left_mean = fmean(left) + right_mean = fmean(right) + numerator = sum( + (first - left_mean) * (second - right_mean) + for first, second in zip(left, right, strict=True) + ) + left_scale = math.sqrt(sum((value - left_mean) ** 2 for value in left)) + right_scale = math.sqrt(sum((value - right_mean) ** 2 for value in right)) + return numerator / (left_scale * right_scale) if left_scale and right_scale else 0.0 + + +def rounded(value: float | None, digits: int = 4) -> float | None: + return round(value, digits) if value is not None else None + + +def calculate_earnings_quality(bars: list[dict[str, Any]], announcement_date: str) -> bool | None: + index = next( + ( + offset + for offset, row in enumerate(bars) + if str(row.get("trade_date") or "") == announcement_date + ), + -1, + ) + if index < 0: + return None + prior = [ + number(row.get("vol")) + for row in bars[max(0, index - 5) : index] + if number(row.get("vol")) is not None + ] + baseline = mean(prior) + current = bars[index] + volume_ratio = ratio(number(current.get("vol")), baseline) + bad = ( + number(current.get("close")) is not None + and number(current.get("open")) is not None + and float(number(current["close"]) or 0) < float(number(current["open"]) or 0) + and float(number(current.get("pct_chg")) or 0) < 0 + and volume_ratio is not None + and volume_ratio >= 1.8 + ) + return not bad diff --git a/next/backend/features/screener/factors.py b/next/backend/features/screener/factors.py new file mode 100644 index 0000000..12551d1 --- /dev/null +++ b/next/backend/features/screener/factors.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +from backend.features.screener.cross_section import finalize_factor_rows +from backend.features.screener.engine import FACTOR_MINIMUM_COVERAGE +from backend.features.screener.technical import build_technical_rows + +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +def build_factor_snapshot( + trade_date: str, + inputs: dict[str, Any], + coverage: dict[str, float], + sources: list[str], +) -> dict[str, Any]: + ready = { + dataset: coverage.get(dataset, 0) >= minimum + for dataset, minimum in FACTOR_MINIMUM_COVERAGE.items() + } + ready["limit_events"] = coverage.get("limit_events", 0) >= 1 + rows = finalize_factor_rows(build_technical_rows(trade_date, inputs, ready), ready) + canonical = json.dumps( + {"trade_date": trade_date, "coverage": coverage, "rows": rows}, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + version = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] + return { + "trade_date": trade_date, + "version": version, + "observed_at": datetime.now(SHANGHAI).isoformat(timespec="seconds"), + "coverage": coverage, + "sources": sorted(set(sources)), + "rows": rows, + } diff --git a/next/backend/features/screener/repository.py b/next/backend/features/screener/repository.py new file mode 100644 index 0000000..6bc10ff --- /dev/null +++ b/next/backend/features/screener/repository.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from typing import Any + + +class ScreenerRepository: + def save_factor_snapshot( + self, + connection: sqlite3.Connection, + *, + trade_date: str, + version: str, + observed_at: str, + state: str, + sources: list[str], + coverage: dict[str, float], + rows: list[dict[str, Any]], + ) -> int: + connection.execute( + """ + INSERT OR IGNORE INTO screener_factor_snapshots ( + trade_date, version, observed_at, state, source_set_json, + coverage_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + trade_date, + version, + observed_at, + state, + _json(sources), + _json(coverage), + _now(), + ), + ) + snapshot = connection.execute( + """ + SELECT id FROM screener_factor_snapshots + WHERE trade_date = ? AND version = ? + """, + (trade_date, version), + ).fetchone() + if snapshot is None: + raise RuntimeError("因子快照写入失败") + snapshot_id = int(snapshot["id"]) + connection.executemany( + """ + INSERT OR REPLACE INTO screener_factor_values ( + snapshot_id, identifier, code, name, sector, + listed_days, is_st, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + ( + snapshot_id, + row["identifier"], + row["code"], + row["name"], + row.get("sector"), + int(row.get("listed_days") or 0), + int(bool(row.get("is_st"))), + _json(row), + ) + for row in rows + ), + ) + return snapshot_id + + def latest_factor_snapshot( + self, connection: sqlite3.Connection, through: str + ) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT * FROM screener_factor_snapshots + WHERE trade_date <= ? ORDER BY trade_date DESC, id DESC LIMIT 1 + """, + (through,), + ).fetchone() + + def factor_snapshot( + self, connection: sqlite3.Connection, snapshot_id: int + ) -> sqlite3.Row | None: + return connection.execute( + "SELECT * FROM screener_factor_snapshots WHERE id = ?", + (snapshot_id,), + ).fetchone() + + def factor_rows(self, connection: sqlite3.Connection, snapshot_id: int) -> list[dict[str, Any]]: + return [ + json.loads(str(row["payload_json"])) + for row in connection.execute( + """ + SELECT payload_json FROM screener_factor_values + WHERE snapshot_id = ? ORDER BY identifier + """, + (snapshot_id,), + ).fetchall() + ] + + def begin_run( + self, + connection: sqlite3.Connection, + *, + owner_user_id: int | None, + mode: str, + strategy_id: str, + strategy_name: str, + strategy_version: int, + selection_date: str, + factor_snapshot_id: int, + ) -> sqlite3.Row: + connection.execute( + """ + INSERT OR IGNORE INTO screener_runs ( + owner_user_id, mode, strategy_id, strategy_name, + strategy_version, selection_date, factor_snapshot_id, + status, started_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?) + """, + ( + owner_user_id, + mode, + strategy_id, + strategy_name, + strategy_version, + selection_date, + factor_snapshot_id, + _now(), + ), + ) + row = connection.execute( + """ + SELECT * FROM screener_runs + WHERE mode = ? AND strategy_id = ? AND selection_date = ? + AND strategy_version = ? AND factor_snapshot_id = ? + AND COALESCE(owner_user_id, 0) = COALESCE(?, 0) + """, + ( + mode, + strategy_id, + selection_date, + strategy_version, + factor_snapshot_id, + owner_user_id, + ), + ).fetchone() + if row is None: + raise RuntimeError("选股任务写入失败") + return row + + def finish_run( + self, + connection: sqlite3.Connection, + run_id: int, + *, + status: str, + coverage: float, + missing_fields: list[str], + result: list[dict[str, Any]], + error_message: str = "", + ) -> None: + connection.execute( + """ + UPDATE screener_runs SET + status = ?, completed_at = ?, coverage = ?, + missing_fields_json = ?, result_json = ?, error_message = ? + WHERE id = ? + """, + ( + status, + _now(), + max(0, min(coverage, 1)), + _json(missing_fields), + _json(result), + error_message, + run_id, + ), + ) + + def latest_runs( + self, + connection: sqlite3.Connection, + mode: str, + through: str, + owner_user_id: int | None = None, + ) -> tuple[sqlite3.Row, ...]: + return tuple( + connection.execute( + """ + SELECT run.* FROM screener_runs run + JOIN ( + SELECT strategy_id, MAX(id) AS latest_id + FROM screener_runs + WHERE mode = ? AND selection_date = ? + AND COALESCE(owner_user_id, 0) = COALESCE(?, 0) + GROUP BY strategy_id + ) latest ON latest.latest_id = run.id + ORDER BY run.strategy_id + """, + (mode, through, owner_user_id), + ).fetchall() + ) + + def run_for_user( + self, connection: sqlite3.Connection, run_id: int, user_id: int + ) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT * FROM screener_runs + WHERE id = ? AND (owner_user_id IS NULL OR owner_user_id = ?) + """, + (run_id, user_id), + ).fetchone() + + def save_custom_strategy( + self, + connection: sqlite3.Connection, + user_id: int, + name: str, + formula: dict[str, Any], + ) -> sqlite3.Row: + now = _now() + connection.execute( + """ + INSERT INTO custom_screener_strategies ( + user_id, name, version, formula_json, created_at, updated_at + ) VALUES (?, ?, 1, ?, ?, ?) + ON CONFLICT(user_id, name) DO UPDATE SET + version = version + 1, + formula_json = excluded.formula_json, + updated_at = excluded.updated_at + """, + (user_id, name, _json(formula), now, now), + ) + row = connection.execute( + """ + SELECT * FROM custom_screener_strategies + WHERE user_id = ? AND name = ? + """, + (user_id, name), + ).fetchone() + if row is None: + raise RuntimeError("自定义策略写入失败") + return row + + def custom_strategies( + self, connection: sqlite3.Connection, user_id: int + ) -> tuple[sqlite3.Row, ...]: + return tuple( + connection.execute( + """ + SELECT * FROM custom_screener_strategies + WHERE user_id = ? ORDER BY updated_at DESC, id DESC + """, + (user_id,), + ).fetchall() + ) + + def custom_strategy( + self, connection: sqlite3.Connection, user_id: int, strategy_id: int + ) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT * FROM custom_screener_strategies + WHERE id = ? AND user_id = ? + """, + (strategy_id, user_id), + ).fetchone() + + def delete_custom_strategy( + self, connection: sqlite3.Connection, user_id: int, strategy_id: int + ) -> bool: + cursor = connection.execute( + "DELETE FROM custom_screener_strategies WHERE id = ? AND user_id = ?", + (strategy_id, user_id), + ) + return cursor.rowcount > 0 + + def add_track( + self, + connection: sqlite3.Connection, + *, + user_id: int, + run: sqlite3.Row, + candidate: dict[str, Any], + ) -> int: + connection.execute( + """ + INSERT OR IGNORE INTO strategy_tracks ( + user_id, run_id, identifier, code, name, sector, + selection_date, strategy_name, entry_price, added_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + int(run["id"]), + candidate["identifier"], + candidate["code"], + candidate["name"], + candidate.get("sector"), + str(run["selection_date"]), + str(run["strategy_name"]), + float(candidate["close"]), + _now(), + ), + ) + row = connection.execute( + """ + SELECT id FROM strategy_tracks + WHERE user_id = ? AND run_id = ? AND identifier = ? + """, + (user_id, int(run["id"]), candidate["identifier"]), + ).fetchone() + if row is None: + raise RuntimeError("策略跟踪写入失败") + return int(row["id"]) + + def tracks(self, connection: sqlite3.Connection, user_id: int) -> tuple[sqlite3.Row, ...]: + return tuple( + connection.execute( + """ + SELECT track.*, run.mode, run.strategy_id + FROM strategy_tracks track + JOIN screener_runs run ON run.id = track.run_id + WHERE track.user_id = ? ORDER BY track.added_at DESC, track.id DESC + """, + (user_id,), + ).fetchall() + ) + + def track_bars(self, connection: sqlite3.Connection, track_id: int) -> tuple[sqlite3.Row, ...]: + return tuple( + connection.execute( + """ + SELECT * FROM strategy_track_bars + WHERE track_id = ? ORDER BY trade_date + """, + (track_id,), + ).fetchall() + ) + + def tracked_before( + self, connection: sqlite3.Connection, trade_date: str + ) -> tuple[sqlite3.Row, ...]: + return tuple( + connection.execute( + """ + SELECT * FROM strategy_tracks + WHERE selection_date < ? ORDER BY id + """, + (trade_date,), + ).fetchall() + ) + + def save_track_bar( + self, + connection: sqlite3.Connection, + track_id: int, + trade_date: str, + row: dict[str, Any], + ) -> None: + connection.execute( + """ + INSERT INTO strategy_track_bars ( + track_id, trade_date, open, high, low, close + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(track_id, trade_date) DO UPDATE SET + open = excluded.open, + high = excluded.high, + low = excluded.low, + close = excluded.close + """, + ( + track_id, + trade_date, + float(row["open"]), + float(row["high"]), + float(row["low"]), + float(row["close"]), + ), + ) + + def record_track_event( + self, connection: sqlite3.Connection, track_id: int, milestone: str + ) -> bool: + cursor = connection.execute( + """ + INSERT OR IGNORE INTO strategy_track_events (track_id, milestone, created_at) + VALUES (?, ?, ?) + """, + (track_id, milestone, _now()), + ) + return cursor.rowcount > 0 + + def remove_track(self, connection: sqlite3.Connection, user_id: int, track_id: int) -> bool: + cursor = connection.execute( + "DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?", + (track_id, user_id), + ) + return cursor.rowcount > 0 + + +def decode_run(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + result = dict(row) + result["missing_fields"] = json.loads(str(row["missing_fields_json"])) + result["items"] = json.loads(str(row["result_json"])) + result.pop("missing_fields_json", None) + result.pop("result_json", None) + return result + + +def decode_custom(row: sqlite3.Row) -> dict[str, Any]: + result = dict(row) + result["formula"] = json.loads(str(row["formula_json"])) + result.pop("formula_json", None) + return result + + +def decode_track(row: sqlite3.Row, bars: tuple[sqlite3.Row, ...]) -> dict[str, Any]: + result = dict(row) + entry = float(row["entry_price"]) + closes = [float(item["close"]) for item in bars] + highs = [float(item["high"]) for item in bars] + lows = [float(item["low"]) for item in bars] + result["t1_open_return"] = ( + round((float(bars[0]["open"]) / entry - 1) * 100, 2) if bars else None + ) + for index in (1, 3, 5): + result[f"t{index}_return"] = ( + round((closes[index - 1] / entry - 1) * 100, 2) if len(closes) >= index else None + ) + result["max_gain"] = round((max(highs) / entry - 1) * 100, 2) if highs else None + result["max_drawdown"] = round((min(lows) / entry - 1) * 100, 2) if lows else None + result["observed_days"] = len(bars) + return result + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") diff --git a/next/backend/features/screener/routes.py b/next/backend/features/screener/routes.py new file mode 100644 index 0000000..df6c0a7 --- /dev/null +++ b/next/backend/features/screener/routes.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Path, Query, Request + +from backend.data.gateway import MarketDataUnavailable +from backend.data.providers.base import ProviderError +from backend.data.quality import DataQualityError +from backend.features.accounts.auth import ( + AdminWritePrincipal, + AuthenticatedPrincipal, + SmartAccessPrincipal, + SmartWritePrincipal, +) +from backend.features.screener.schemas import ( + CustomStrategyInput, + IdentifierResponse, + MessageResponse, + ScreenerCatalogResponse, + ScreenerSyncResponse, + ScreenerWorkspaceResponse, + TrackInput, +) +from backend.features.screener.service import ScreenerError +from backend.http.errors import AppError + +router = APIRouter(prefix="/screener", tags=["screener"]) + + +@router.get("/catalog", response_model=ScreenerCatalogResponse) +def catalog(request: Request, _principal: AuthenticatedPrincipal) -> dict: + return request.app.state.container.screener.catalog() + + +@router.get("", response_model=ScreenerWorkspaceResponse) +def workspace( + request: Request, + principal: SmartAccessPrincipal, + requested_date: Annotated[str, Query(alias="date")], +) -> dict: + return _call(request, "workspace", requested_date, principal.user.id) + + +@router.post("/sync", response_model=ScreenerSyncResponse) +def sync( + request: Request, + _principal: AdminWritePrincipal, + requested_date: Annotated[str, Query(alias="date")], +) -> dict: + return _call(request, "sync_and_run", requested_date) + + +@router.put("/custom", response_model=dict) +def save_custom( + payload: CustomStrategyInput, + request: Request, + principal: SmartWritePrincipal, +) -> dict: + return _call(request, "save_custom", principal.user.id, payload.name, payload.formula) + + +@router.delete("/custom/{strategy_id}", response_model=MessageResponse) +def delete_custom( + request: Request, + principal: SmartWritePrincipal, + strategy_id: Annotated[int, Path(gt=0)], +) -> MessageResponse: + _call(request, "delete_custom", principal.user.id, strategy_id) + return MessageResponse(message="自定义策略已删除。") + + +@router.post("/custom/{strategy_id}/run", response_model=dict) +def run_custom( + request: Request, + principal: SmartWritePrincipal, + strategy_id: Annotated[int, Path(gt=0)], + requested_date: Annotated[str, Query(alias="date")], +) -> dict: + return _call(request, "run_custom", principal.user.id, strategy_id, requested_date) + + +@router.get("/tracks", response_model=list[dict]) +def tracks(request: Request, principal: SmartAccessPrincipal) -> list[dict]: + return _call(request, "tracks", principal.user.id) + + +@router.post("/tracks", response_model=IdentifierResponse) +def add_track( + payload: TrackInput, + request: Request, + principal: SmartWritePrincipal, +) -> IdentifierResponse: + identifier = _call(request, "add_track", principal.user.id, payload.run_id, payload.identifier) + return IdentifierResponse(id=identifier) + + +@router.delete("/tracks/{track_id}", response_model=MessageResponse) +def remove_track( + request: Request, + principal: SmartWritePrincipal, + track_id: Annotated[int, Path(gt=0)], +) -> MessageResponse: + _call(request, "remove_track", principal.user.id, track_id) + return MessageResponse(message="已停止跟踪。") + + +def _call(request: Request, method: str, *args): + try: + return getattr(request.app.state.container.screener, method)(*args) + except (ScreenerError, MarketDataUnavailable, ProviderError, DataQualityError) as exc: + raise AppError("screener_unavailable", str(exc), 409) from exc diff --git a/next/backend/features/screener/schemas.py b/next/backend/features/screener/schemas.py new file mode 100644 index 0000000..2ee018f --- /dev/null +++ b/next/backend/features/screener/schemas.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class ScreenerCatalogResponse(BaseModel): + factor_groups: dict[str, list[str]] + factors: dict[str, str] + stage: list[dict[str, Any]] + curated: list[dict[str, Any]] + + +class ScreenerWorkspaceResponse(BaseModel): + trade_date: str | None + message: str + catalog: ScreenerCatalogResponse + stage_runs: list[dict[str, Any]] + curated_runs: list[dict[str, Any]] + custom_strategies: list[dict[str, Any]] + custom_runs: list[dict[str, Any]] = Field(default_factory=list) + + +class ScreenerSyncResponse(BaseModel): + trade_date: str + factor_version: str + factor_count: int + phase: str + stage_runs: int + curated_runs: int + completed: int + failed: int + + +class CustomStrategyInput(BaseModel): + name: str = Field(min_length=1, max_length=30) + formula: dict[str, Any] + + +class TrackInput(BaseModel): + run_id: int = Field(gt=0) + identifier: str = Field(min_length=1, max_length=40) + + +class IdentifierResponse(BaseModel): + id: int + + +class MessageResponse(BaseModel): + message: str diff --git a/next/backend/features/screener/service.py b/next/backend/features/screener/service.py new file mode 100644 index 0000000..eba0d1b --- /dev/null +++ b/next/backend/features/screener/service.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import json +from datetime import datetime, time +from typing import Any +from zoneinfo import ZoneInfo + +from backend.data.contracts import SnapshotState +from backend.data.gateway import DataGateway +from backend.data.repository import MarketRepository +from backend.database.connection import Database +from backend.features.screener.catalog import ( + CatalogError, + factor_catalog, + strategy_catalog, + validate_formula, +) +from backend.features.screener.engine import execute_formula +from backend.features.screener.factors import build_factor_snapshot +from backend.features.screener.repository import ( + ScreenerRepository, + decode_custom, + decode_run, + decode_track, +) + +SHANGHAI = ZoneInfo("Asia/Shanghai") +PHASE_REGIMES = { + "冰点": "ice", + "修复": "repair", + "发酵": "fermentation", + "高潮": "climax", + "分化": "divergence", + "退潮": "retreat", +} +FINISHED = frozenset({"completed", "no_signal", "data_incomplete"}) + + +class ScreenerError(RuntimeError): + pass + + +class ScreenerService: + def __init__( + self, + database: Database, + repository: ScreenerRepository, + market_repository: MarketRepository, + gateway: DataGateway, + ) -> None: + self._database = database + self._repository = repository + self._market_repository = market_repository + self._gateway = gateway + + def catalog(self) -> dict[str, Any]: + factors = factor_catalog() + strategies = strategy_catalog() + return { + "factor_groups": factors["groups"], + "factors": factors["factors"], + "stage": [_public_strategy(item) for item in strategies if item["kind"] == "stage"], + "curated": [_public_strategy(item) for item in strategies if item["kind"] == "curated"], + } + + def workspace(self, requested_date: str, user_id: int) -> dict[str, Any]: + through = self._gateway.trade_context(requested_date).actual_date + with self._database.read() as connection: + custom = [ + decode_custom(row) + for row in self._repository.custom_strategies(connection, user_id) + ] + if through is None: + return { + "trade_date": None, + "message": "等待管理员首次同步真实收盘行情", + "catalog": self.catalog(), + "stage_runs": [], + "curated_runs": [], + "custom_strategies": custom, + "custom_runs": [], + } + with self._database.read() as connection: + stage = [ + decode_run(row) + for row in self._repository.latest_runs(connection, "stage", through) + ] + curated = [ + decode_run(row) + for row in self._repository.latest_runs(connection, "curated", through) + ] + custom_runs = [ + decode_run(row) + for row in self._repository.latest_runs(connection, "custom", through, user_id) + ] + return { + "trade_date": through, + "message": "", + "catalog": self.catalog(), + "stage_runs": stage, + "curated_runs": curated, + "custom_strategies": custom, + "custom_runs": custom_runs, + } + + def sync_and_run(self, trade_date: str) -> dict[str, Any]: + market = self._market_snapshot(trade_date) + inputs, coverage, sources = self._gateway.screener_inputs(trade_date) + snapshot = build_factor_snapshot(trade_date, inputs, coverage, sources) + state = str(market["state"]) + with self._database.transaction() as connection: + snapshot_id = self._repository.save_factor_snapshot( + connection, + trade_date=trade_date, + version=snapshot["version"], + observed_at=snapshot["observed_at"], + state=state, + sources=snapshot["sources"], + coverage=snapshot["coverage"], + rows=snapshot["rows"], + ) + phase = str((market["payload"].get("sentiment") or {}).get("phase") or "") + regime = PHASE_REGIMES.get(phase) + stage_strategies, curated_strategies = automatic_strategies(regime) + runs = [ + self._run(snapshot_id, trade_date, strategy, None) + for strategy in [*stage_strategies, *curated_strategies] + ] + self._update_tracks(trade_date, snapshot["rows"]) + return { + "trade_date": trade_date, + "factor_version": snapshot["version"], + "factor_count": len(snapshot["rows"]), + "phase": phase, + "stage_runs": len(stage_strategies), + "curated_runs": len(curated_strategies), + "completed": sum(run and run["status"] in FINISHED for run in runs), + "failed": sum(run and run["status"] == "failed" for run in runs), + } + + def run_after_close(self, now: datetime | None = None) -> dict[str, Any] | None: + clock = now or datetime.now(SHANGHAI) + if clock.time() < time(15, 10): + return None + trade_date = clock.date().isoformat() + market = self._market_snapshot(trade_date) + if market["trade_date"] != trade_date or market["state"] != SnapshotState.FINAL.value: + return None + return self.sync_and_run(trade_date) + + def save_custom(self, user_id: int, name: str, formula: dict[str, Any]) -> dict[str, Any]: + normalized = " ".join(name.split()) + if not normalized or len(normalized) > 30: + raise ScreenerError("自定义策略名称应为1至30个字符") + try: + validate_formula(formula) + except CatalogError as exc: + raise ScreenerError(str(exc)) from exc + with self._database.transaction() as connection: + return decode_custom( + self._repository.save_custom_strategy(connection, user_id, normalized, formula) + ) + + def delete_custom(self, user_id: int, strategy_id: int) -> None: + with self._database.transaction() as connection: + if not self._repository.delete_custom_strategy(connection, user_id, strategy_id): + raise ScreenerError("未找到该自定义策略") + + def run_custom(self, user_id: int, strategy_id: int, through: str) -> dict[str, Any]: + with self._database.read() as connection: + custom = self._repository.custom_strategy(connection, user_id, strategy_id) + snapshot = self._repository.latest_factor_snapshot(connection, through) + if custom is None: + raise ScreenerError("未找到该自定义策略") + if snapshot is None: + raise ScreenerError("当前日期尚未生成完整因子快照") + strategy = { + "id": f"custom-{custom['id']}", + "name": str(custom["name"]), + "version": int(custom["version"]), + "kind": "custom", + "formula": json.loads(str(custom["formula_json"])), + } + result = self._run(int(snapshot["id"]), str(snapshot["trade_date"]), strategy, user_id) + if result is None: + raise ScreenerError("自定义策略执行失败") + return result + + def tracks(self, user_id: int) -> list[dict[str, Any]]: + with self._database.read() as connection: + return [ + decode_track(row, self._repository.track_bars(connection, int(row["id"]))) + for row in self._repository.tracks(connection, user_id) + ] + + def add_track(self, user_id: int, run_id: int, identifier: str) -> int: + with self._database.transaction() as connection: + run = self._repository.run_for_user(connection, run_id, user_id) + if run is None: + raise ScreenerError("未找到可访问的选股结果") + items = json.loads(str(run["result_json"])) + candidate = next( + (item for item in items if str(item.get("identifier")) == identifier), None + ) + if ( + candidate is None + or not isinstance(candidate.get("close"), (int, float)) + or float(candidate["close"]) <= 0 + ): + raise ScreenerError("该候选无法加入持续跟踪") + return self._repository.add_track( + connection, user_id=user_id, run=run, candidate=candidate + ) + + def remove_track(self, user_id: int, track_id: int) -> None: + with self._database.transaction() as connection: + if not self._repository.remove_track(connection, user_id, track_id): + raise ScreenerError("未找到该跟踪记录") + + def _run( + self, + snapshot_id: int, + trade_date: str, + strategy: dict[str, Any], + owner_user_id: int | None, + ) -> dict[str, Any] | None: + mode = str(strategy["kind"]) + with self._database.transaction() as connection: + row = self._repository.begin_run( + connection, + owner_user_id=owner_user_id, + mode=mode, + strategy_id=str(strategy["id"]), + strategy_name=str(strategy["name"]), + strategy_version=int(strategy["version"]), + selection_date=trade_date, + factor_snapshot_id=snapshot_id, + ) + existing = decode_run(row) + if existing and existing["status"] in FINISHED: + return existing + snapshot = self._repository.factor_snapshot(connection, snapshot_id) + rows = self._repository.factor_rows(connection, snapshot_id) + if snapshot is None: + raise ScreenerError("因子快照不存在") + coverage = json.loads(str(snapshot["coverage_json"])) + try: + outcome = execute_formula(rows, strategy["formula"], coverage) + status = str(outcome["status"]) + missing = [*outcome["missing_datasets"], *outcome["missing_fields"]] + score_coverage = min(outcome["field_coverage"].values(), default=0) + error = "" + except (CatalogError, KeyError, TypeError, ValueError) as exc: + status, missing, score_coverage, outcome, error = ( + "failed", + [], + 0, + {"items": []}, + str(exc), + ) + with self._database.transaction() as connection: + self._repository.finish_run( + connection, + int(row["id"]), + status=status, + coverage=score_coverage, + missing_fields=missing, + result=outcome["items"], + error_message=error, + ) + return decode_run( + connection.execute( + "SELECT * FROM screener_runs WHERE id = ?", (row["id"],) + ).fetchone() + ) + + def _market_snapshot(self, trade_date: str) -> dict[str, Any]: + with self._database.read() as connection: + row = self._market_repository.latest_summary(connection, trade_date) + if row is None or str(row["trade_date"]) != trade_date: + raise ScreenerError("当日收盘行情尚未完成,选股任务未启动") + if str(row["state"]) not in {SnapshotState.FINAL.value, SnapshotState.ARCHIVE.value}: + raise ScreenerError("行情快照尚未收盘定稿") + return { + "trade_date": str(row["trade_date"]), + "state": str(row["state"]), + "payload": json.loads(str(row["payload_json"])), + } + + def _update_tracks(self, trade_date: str, rows: list[dict[str, Any]]) -> None: + current = {str(row["identifier"]): row for row in rows} + with self._database.transaction() as connection: + for track in self._repository.tracked_before(connection, trade_date): + row = current.get(str(track["identifier"])) + if row is None or any( + row.get(field) is None for field in ("open", "high", "low", "close") + ): + continue + self._repository.save_track_bar(connection, int(track["id"]), trade_date, row) + days = len(self._repository.track_bars(connection, int(track["id"]))) + if days in {1, 5}: + self._repository.record_track_event(connection, int(track["id"]), f"t{days}") + + +def _public_strategy(strategy: dict[str, Any]) -> dict[str, Any]: + return { + "id": strategy["id"], + "version": strategy["version"], + "kind": strategy["kind"], + "name": strategy["name"], + "display_name": strategy.get("display_name") or strategy["name"], + "description": strategy["description"], + "regimes": strategy.get("regimes") or [], + "formula": strategy["formula"], + } + + +def automatic_strategies( + regime: str | None, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + strategies = strategy_catalog() + stage = [ + item + for item in strategies + if item["kind"] == "stage" and regime in (item.get("regimes") or []) + ] + curated = [item for item in strategies if item["kind"] == "curated"] + return stage, curated diff --git a/next/backend/features/screener/technical.py b/next/backend/features/screener/technical.py new file mode 100644 index 0000000..9217b2e --- /dev/null +++ b/next/backend/features/screener/technical.py @@ -0,0 +1,434 @@ +from __future__ import annotations + +from statistics import fmean, pstdev +from typing import Any + +from backend.features.screener.factor_math import ( + calculate_earnings_quality, + change, + macd, + mean, + number, + ratio, + rounded, + rsi, + weekly_series, +) +from backend.features.screener.technical_support import ( + broken_metrics, + dividend_years, + ending_streak, + group, + large_flow, + latest_by_code, + max_streak, + point_in_time, +) +from backend.features.screener.technical_support import ( + limit_events as map_limit_events, +) +from backend.features.screener.technical_support import ( + listed_days as calculate_listed_days, +) + + +def build_technical_rows( + trade_date: str, + inputs: dict[str, Any], + dataset_ready: dict[str, bool], +) -> list[dict[str, Any]]: + daily = group(inputs.get("daily") or (), "ts_code") + basics = latest_by_code(inputs.get("daily_basic") or (), trade_date) + basic_history = group(inputs.get("daily_basic") or (), "ts_code") + flows = group(inputs.get("moneyflow") or (), "ts_code") + fundamentals = point_in_time(inputs.get("fundamentals") or (), trade_date) + dividends = group(inputs.get("dividends") or (), "ts_code") + auctions = latest_by_code(inputs.get("auction") or (), trade_date) + earnings = point_in_time(inputs.get("earnings") or (), trade_date) + popularity = {str(row["ts_code"]): row for row in inputs.get("popularity") or ()} + institutions = {str(row["ts_code"]): row for row in inputs.get("institutions") or ()} + directory = {str(row["ts_code"]): row for row in inputs.get("directory") or ()} + industries = { + str(row["ts_code"]): str(row.get("l2_name") or "") + for row in inputs.get("industry") or () + if row.get("ts_code") + } + benchmark = { + str(row["trade_date"]): float(row["close"]) + for row in inputs.get("benchmark") or () + if number(row.get("close")) is not None + } + limit_event_map = map_limit_events(inputs.get("limit_events") or ()) + rows = [] + for identifier, bars in daily.items(): + bars.sort(key=lambda row: str(row.get("trade_date") or "")) + if not bars or str(bars[-1].get("trade_date") or "") != trade_date: + continue + info = directory.get(identifier) + if info is None: + continue + closes = [number(row.get("close")) for row in bars] + if any(value is None for value in closes) or not closes: + continue + close_values = [float(value) for value in closes if value is not None] + row = _stock_row( + trade_date=trade_date, + identifier=identifier, + info=info, + bars=bars, + closes=close_values, + basic=basics.get(identifier, {}), + basic_history=basic_history.get(identifier, []), + flows=flows.get(identifier, []), + fundamental=fundamentals.get(identifier, {}), + dividends=dividends.get(identifier, []), + auction=auctions.get(identifier, {}), + earnings=earnings.get(identifier, {}), + popularity=popularity.get(identifier), + institution=institutions.get(identifier), + sector=industries.get(identifier) if dataset_ready.get("industry") else None, + benchmark=benchmark, + limit_events=limit_event_map, + dataset_ready=dataset_ready, + ) + rows.append(row) + return rows + + +def _stock_row( + *, + trade_date: str, + identifier: str, + info: dict[str, Any], + bars: list[dict[str, Any]], + closes: list[float], + basic: dict[str, Any], + basic_history: list[dict[str, Any]], + flows: list[dict[str, Any]], + fundamental: dict[str, Any], + dividends: list[dict[str, Any]], + auction: dict[str, Any], + earnings: dict[str, Any], + popularity: dict[str, Any] | None, + institution: dict[str, Any] | None, + sector: str | None, + benchmark: dict[str, float], + limit_events: dict[str, dict[str, str]], + dataset_ready: dict[str, bool], +) -> dict[str, Any]: + current = bars[-1] + previous = bars[-2] if len(bars) >= 2 else {} + highs = [number(item.get("high")) for item in bars] + lows = [number(item.get("low")) for item in bars] + volumes = [number(item.get("vol")) for item in bars] + changes = [number(item.get("pct_chg")) for item in bars] + open_price = number(current.get("open")) + close_price = closes[-1] + code = str(info.get("symbol") or info.get("code") or identifier.split(".")[0]) + name = str(info.get("name") or "") + is_st = "ST" in name.upper() or "退" in name + listed_days = calculate_listed_days(info.get("list_date"), trade_date) + ma20 = mean(closes[-20:]) if len(closes) >= 20 else None + ma60 = mean(closes[-60:]) if len(closes) >= 60 else None + prior_ma20 = mean(closes[-25:-5]) if len(closes) >= 25 else None + prior_ma60 = mean(closes[-65:-5]) if len(closes) >= 65 else None + ma_values = [ + mean(closes[-window:]) if len(closes) >= window else None for window in (5, 10, 20, 60) + ] + high_values = [float(value) for value in highs if value is not None] + low_values = [float(value) for value in lows if value is not None] + event_flags = [ + limit_events.get(str(item.get("trade_date") or ""), {}).get(identifier) for item in bars + ] + up_flags = [value == "U" for value in event_flags] + down_flags = [value == "D" for value in event_flags] + event_known = dataset_ready.get("limit_events", False) + benchmark_60 = [benchmark.get(str(item.get("trade_date") or "")) for item in bars[-61:]] + rs_values = [ + float(item["close"]) / benchmark[str(item["trade_date"])] + for item in bars[-120:] + if number(item.get("close")) is not None and benchmark.get(str(item.get("trade_date"))) + ] + weekly_closes, weekly_amounts = weekly_series(bars) + weekly_dif, weekly_dea = macd(weekly_closes) + daily_dif, daily_dea = macd(closes) + daily_cross = ( + len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1] and daily_dif[-2] <= daily_dea[-2] + ) + pullback = ( + ma20 is not None + and open_price is not None + and close_price >= ma20 + and open_price <= ma20 * 1.02 + and close_price > open_price + ) + turnover_history = sorted(basic_history, key=lambda item: str(item.get("trade_date") or "")) + flow_history = sorted(flows, key=lambda item: str(item.get("trade_date") or ""))[-5:] + net_flows = [number(item.get("net_mf_amount")) for item in flow_history] + current_flow = flow_history[-1] if flow_history else {} + circ_mv = number(basic.get("circ_mv")) + net_5d_raw = sum(value for value in net_flows if value is not None) if net_flows else None + broken = broken_metrics(bars, up_flags) + previous_signal = event_flags[-2] if len(event_flags) >= 2 else None + prior_three = event_flags[max(0, len(event_flags) - 4) : -2] + previous_streak = ending_streak(up_flags, len(up_flags) - 2) if event_known else None + current_streak = ending_streak(up_flags) if event_known else None + current_low = number(current.get("low")) + previous_close = number(previous.get("close")) + body = abs(close_price - open_price) if open_price is not None else None + lower_shadow = ( + max(0.0, min(open_price, close_price) - current_low) + if open_price is not None and current_low is not None + else None + ) + lower_shadow_ratio = ( + lower_shadow / body + if lower_shadow is not None and body not in (None, 0) + else 10.0 + if lower_shadow and body == 0 + else None + ) + earnings_date = str(earnings.get("ann_date") or "") + earnings_days = ( + sum(earnings_date < str(item.get("trade_date") or "") <= trade_date for item in bars) + if earnings_date + else None + ) + earnings_ready = dataset_ready.get("earnings", False) + earnings_quality = ( + calculate_earnings_quality(bars, earnings_date) + if earnings_date + else True + if earnings_ready + else None + ) + netprofit = number(fundamental.get("netprofit_yoy")) + financial_risk = ( + True + if is_st or (netprofit is not None and netprofit <= -100) + else False + if dataset_ready.get("financial") + else None + ) + row = { + "identifier": identifier, + "code": code, + "name": name, + "sector": sector, + "listed_days": listed_days, + "is_st": is_st, + "close": rounded(close_price, 2), + "pct_chg": rounded(number(current.get("pct_chg")), 2), + "return_5d": rounded(change(close_price, closes[-6]), 2) if len(closes) >= 6 else None, + "return_10d": rounded(change(close_price, closes[-11]), 2) if len(closes) >= 11 else None, + "return_20d": rounded(change(close_price, closes[-21]), 2) if len(closes) >= 21 else None, + "return_60d": rounded(change(close_price, closes[-61]), 2) if len(closes) >= 61 else None, + "momentum_60_5": rounded(change(closes[-6], closes[-61]), 2) if len(closes) >= 61 else None, + "above_ma20": close_price > ma20 if ma20 is not None else None, + "rsi_6": rounded(rsi(closes, 6), 2), + "ma60_slope": rounded(change(ma60, prior_ma60), 3), + "ma20_slope_5d": rounded(change(ma20, prior_ma20), 3), + "ma_bull_alignment": ( + bool(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]) + if all(value is not None for value in ma_values) + else None + ), + "drawdown_from_high_250": ( + rounded((1 - close_price / max(high_values[-250:])) * 100, 2) + if len(high_values) >= 250 and max(high_values[-250:]) > 0 + else None + ), + "donchian_breakout_pct": ( + rounded(change(close_price, max(high_values[-21:-1])), 2) + if len(high_values) >= 21 + else None + ), + "range_20d": ( + rounded(change(max(high_values[-21:-1]), min(low_values[-21:-1])), 2) + if len(high_values) >= 21 and len(low_values) >= 21 + else None + ), + "rs_high_120": len(rs_values) >= 120 and rs_values[-1] >= max(rs_values) + if benchmark + else None, + "excess_return_60d": ( + rounded( + float(change(close_price, closes[-61]) or 0) + - float(change(benchmark_60[-1], benchmark_60[0]) or 0), + 2, + ) + if len(closes) >= 61 and len(benchmark_60) == 61 and all(benchmark_60) + else None + ), + "weekly_trend_signal": ( + weekly_dif[-1] > 0 and weekly_dea[-1] > 0 if len(weekly_closes) >= 30 else None + ), + "daily_buy_trigger": daily_cross or pullback if len(closes) >= 26 else None, + "weekly_amount_trend": ( + weekly_amounts[-1] >= fmean(weekly_amounts[-5:-1]) if len(weekly_amounts) >= 5 else None + ), + "volume_ratio_5d": ( + rounded(ratio(number(current.get("vol")), mean(volumes[-6:-1])), 2) + if len(volumes) >= 6 + else None + ), + "turnover_5d": ( + rounded( + sum( + float(number(item.get("turnover_rate")) or 0) for item in turnover_history[-5:] + ), + 2, + ) + if dataset_ready.get("valuation") and len(turnover_history) >= 5 + else None + ), + "volatility_10d": ( + rounded(pstdev(float(value) for value in changes[-10:] if value is not None), 2) + if len(changes) >= 10 and all(value is not None for value in changes[-10:]) + else None + ), + "amount_billion": rounded((number(current.get("amount")) or 0) / 100000, 2), + "turnover_rate": rounded(number(basic.get("turnover_rate")), 2), + "circ_mv_billion": rounded(circ_mv / 10000, 2) if circ_mv is not None else None, + "total_mv_billion": rounded((number(basic.get("total_mv")) or 0) / 10000, 2) + if number(basic.get("total_mv")) is not None + else None, + "pe_ttm": rounded(number(basic.get("pe_ttm")), 2), + "pb": rounded(number(basic.get("pb")), 2), + "ps_ttm": rounded(number(basic.get("ps_ttm")), 2), + "dividend_yield_ttm": rounded(number(basic.get("dv_ttm")), 2), + "dividend_years": dividend_years(dividends, trade_date) + if dataset_ready.get("financial") + else None, + "roe": rounded(number(fundamental.get("roe")), 2), + "roa": rounded(number(fundamental.get("roa")), 2), + "roic": rounded(number(fundamental.get("roic")), 2), + "gross_margin": rounded(number(fundamental.get("grossprofit_margin")), 2), + "netprofit_yoy": rounded(netprofit, 2), + "revenue_yoy": rounded(number(fundamental.get("or_yoy")), 2), + "ocf_to_opincome": rounded(number(fundamental.get("ocf_to_or")), 2), + "earnings_surprise_pct": ( + rounded(number(earnings.get("surprise_pct")), 2) + if earnings + else 0.0 + if earnings_ready + else None + ), + "earnings_days_since_announce": ( + earnings_days if earnings_days is not None else 999 if earnings_ready else None + ), + "earnings_event_quality": earnings_quality, + "popularity_score": ( + rounded(number((popularity or {}).get("combined_score")), 2) + if popularity + else 0.0 + if dataset_ready.get("popularity") + else None + ), + "popularity_rank_change": ( + number((popularity or {}).get("rank_change")) + if popularity + else 0.0 + if dataset_ready.get("popularity") + else None + ), + "popularity_dual_source": ( + bool((popularity or {}).get("dual_source")) + if popularity + else False + if dataset_ready.get("popularity") + else None + ), + "institution_net_buy_million": ( + rounded(number((institution or {}).get("net_buy_million")), 2) + if institution + else 0.0 + if dataset_ready.get("institutions") + else None + ), + "institution_seat_count": ( + number((institution or {}).get("seat_count")) + if institution + else 0 + if dataset_ready.get("institutions") + else None + ), + "net_flow_million": rounded((number(current_flow.get("net_mf_amount")) or 0) / 100, 2) + if current_flow + else None, + "large_flow_million": large_flow(current_flow), + "net_flow_5d_million": rounded(net_5d_raw / 100, 2) + if net_5d_raw is not None and len(flow_history) >= 5 + else None, + "flow_to_circ_mv_5d": rounded(net_5d_raw / circ_mv * 100, 4) + if net_5d_raw is not None and circ_mv + else None, + "limit_streak": current_streak, + "previous_limit_streak": previous_streak, + "previous_first_limit": previous_signal == "U" and "U" not in prior_three + if event_known + else None, + "previous_limit_signal": previous_signal in {"U", "Z"} + and not any(value in {"U", "Z"} for value in prior_three) + if event_known + else None, + "is_limit_up_today": up_flags[-1] if event_known else None, + "is_limit_down_today": down_flags[-1] if event_known else None, + "no_limit_30d": not any(up_flags[-30:]) if event_known and len(up_flags) >= 30 else None, + "had_limit_80d": any(up_flags[-80:-30]) if event_known and len(up_flags) >= 80 else None, + "no_limit_down_20d": not any(down_flags[-20:]) + if event_known and len(down_flags) >= 20 + else None, + "financial_risk": financial_risk, + "max_continuous_board_10d": max_streak(up_flags[-10:]) + if event_known and len(up_flags) >= 10 + else None, + "dragon_first_yin": ( + previous_streak is not None + and previous_streak >= 3 + and not up_flags[-1] + and open_price is not None + and close_price < open_price + ) + if event_known + else None, + "yin_day_pct": rounded(number(current.get("pct_chg")), 2) + if event_known and previous_streak and previous_streak >= 3 and not up_flags[-1] + else None, + "broken_reversal": broken["signal"] if event_known else None, + "days_since_broken": broken["days"] if event_known else None, + "close_above_broken_high": broken["recovered"] if event_known else None, + "vol_vs_broken_day": broken["volume_ratio"] if event_known else None, + "recent_limit_up_5d": sum(up_flags[-5:]) if event_known and len(up_flags) >= 5 else None, + "intraday_min_pct": rounded(change(current_low, previous_close), 2), + "lower_shadow_ratio": rounded(lower_shadow_ratio, 2), + "vol_vs_previous": rounded( + ratio(number(current.get("vol")), number(previous.get("vol"))), 3 + ), + "previous_amount_billion": rounded((number(previous.get("amount")) or 0) / 100000, 2) + if previous + else None, + "auction_change": rounded(number(auction.get("change")), 2), + "auction_amount_million": rounded(number(auction.get("amount_million")), 2), + "auction_turnover_rate": rounded(number(auction.get("turnover_rate")), 4), + "auction_volume_ratio": rounded(number(auction.get("volume_ratio")), 2), + "relative_position_60": ( + rounded( + (close_price - min(low_values[-60:])) + / (max(high_values[-60:]) - min(low_values[-60:])), + 4, + ) + if len(high_values) >= 60 and max(high_values[-60:]) > min(low_values[-60:]) + else None + ), + "max_abs_change_15d": max(abs(float(value)) for value in changes[-15:] if value is not None) + if len(changes) >= 15 + else None, + "close_to_high_15d": rounded(ratio(close_price, max(high_values[-15:])), 4) + if len(high_values) >= 15 + else None, + "close_to_high_60d": rounded(ratio(close_price, max(high_values[-60:])), 4) + if len(high_values) >= 60 + else None, + } + return row diff --git a/next/backend/features/screener/technical_support.py b/next/backend/features/screener/technical_support.py new file mode 100644 index 0000000..5764d26 --- /dev/null +++ b/next/backend/features/screener/technical_support.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import date +from typing import Any + +from backend.features.screener.factor_math import number, ratio, rounded + + +def group(rows: Any, field: str) -> dict[str, list[dict[str, Any]]]: + result: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + key = str(row.get(field) or "") + if key: + result[key].append(dict(row)) + return result + + +def latest_by_code(rows: Any, through: str) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for row in rows: + identifier = str(row.get("ts_code") or "") + row_date = str(row.get("trade_date") or "") + if ( + identifier + and row_date <= through + and ( + identifier not in result + or row_date > str(result[identifier].get("trade_date") or "") + ) + ): + result[identifier] = dict(row) + return result + + +def point_in_time(rows: Any, through: str) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for row in rows: + identifier = str(row.get("ts_code") or "") + announced = str(row.get("ann_date") or "") + if ( + identifier + and announced + and announced <= through + and ( + identifier not in result + or announced > str(result[identifier].get("ann_date") or "") + ) + ): + result[identifier] = dict(row) + return result + + +def limit_events(rows: Any) -> dict[str, dict[str, str]]: + result: dict[str, dict[str, str]] = defaultdict(dict) + for row in rows: + date_value = str(row.get("trade_date") or "") + identifier = str(row.get("ts_code") or "") + event = str(row.get("limit_type") or "") + if date_value and identifier and event in {"U", "D", "Z"}: + result[date_value][identifier] = event + return result + + +def listed_days(value: Any, through: str) -> int: + try: + listed = str(value or "").replace("-", "") + start = date(int(listed[:4]), int(listed[4:6]), int(listed[6:])) + return (date.fromisoformat(through) - start).days + except (ValueError, TypeError): + return 0 + + +def dividend_years(rows: list[dict[str, Any]], through: str) -> int: + return len( + { + str(row.get("end_date") or "")[:4] + for row in rows + if str(row.get("ann_date") or row.get("ex_date") or "") <= through + and (number(row.get("cash_div_tax")) or 0) > 0 + } + ) + + +def large_flow(row: dict[str, Any]) -> float | None: + if not row: + return None + direct = number(row.get("large_net_amount")) + if direct is not None: + return rounded(direct / 100, 2) + buys = [number(row.get(field)) for field in ("buy_lg_amount", "buy_elg_amount")] + sells = [number(row.get(field)) for field in ("sell_lg_amount", "sell_elg_amount")] + if any(value is None for value in buys + sells): + return None + return rounded( + (sum(float(value) for value in buys) - sum(float(value) for value in sells)) / 100, + 2, + ) + + +def ending_streak(flags: list[bool], end: int | None = None) -> int: + index = len(flags) - 1 if end is None else end + count = 0 + while index >= 0 and flags[index]: + count += 1 + index -= 1 + return count + + +def max_streak(flags: list[bool]) -> int: + best = current = 0 + for flag in flags: + current = current + 1 if flag else 0 + best = max(best, current) + return best + + +def broken_metrics(bars: list[dict[str, Any]], up_flags: list[bool]) -> dict[str, Any]: + if len(bars) < 3: + return {"signal": None, "days": None, "recovered": None, "volume_ratio": None} + last_limit = next((index for index in range(len(up_flags) - 2, -1, -1) if up_flags[index]), -1) + if last_limit < 0: + return {"signal": False, "days": None, "recovered": False, "volume_ratio": None} + days = len(bars) - 1 - last_limit + broken_high = number(bars[last_limit].get("high")) + recovered = ( + number(bars[-1].get("close")) is not None + and broken_high is not None + and float(number(bars[-1]["close"]) or 0) > broken_high + ) + volume_ratio = ratio(number(bars[-1].get("vol")), number(bars[last_limit].get("vol"))) + return { + "signal": 1 <= days <= 3 and recovered and volume_ratio is not None and volume_ratio >= 1, + "days": days, + "recovered": recovered, + "volume_ratio": rounded(volume_ratio, 3), + } diff --git a/next/backend/http/router.py b/next/backend/http/router.py index b08e747..74264d9 100644 --- a/next/backend/http/router.py +++ b/next/backend/http/router.py @@ -2,9 +2,11 @@ from fastapi import APIRouter from backend.features.accounts.routes import router as accounts_router from backend.features.market.routes import router as market_router +from backend.features.screener.routes import router as screener_router from backend.http.routes.health import router as health_router api_router = APIRouter() api_router.include_router(health_router) api_router.include_router(accounts_router) api_router.include_router(market_router) +api_router.include_router(screener_router) diff --git a/next/backend/jobs/__init__.py b/next/backend/jobs/__init__.py new file mode 100644 index 0000000..78f9a48 --- /dev/null +++ b/next/backend/jobs/__init__.py @@ -0,0 +1 @@ +"""Application-owned background jobs.""" diff --git a/next/backend/jobs/screener.py b/next/backend/jobs/screener.py new file mode 100644 index 0000000..a428146 --- /dev/null +++ b/next/backend/jobs/screener.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import asyncio +import logging + +from backend.features.screener.service import ScreenerError, ScreenerService + +logger = logging.getLogger(__name__) + + +async def run_screener_scheduler(service: ScreenerService, stop: asyncio.Event) -> None: + while not stop.is_set(): + try: + result = await asyncio.to_thread(service.run_after_close) + if result: + logger.info( + "Screener after-close run completed", + extra={"event": "screener.completed", "context": result}, + ) + except ScreenerError as exc: + logger.info( + "Screener is waiting for complete market data", + extra={"event": "screener.waiting", "context": {"reason": str(exc)}}, + ) + except Exception: + logger.exception( + "Screener scheduler failed", + extra={"event": "screener.failed"}, + ) + try: + await asyncio.wait_for(stop.wait(), timeout=300) + except TimeoutError: + pass diff --git a/next/config/screener-factors.json b/next/config/screener-factors.json new file mode 100644 index 0000000..28e7067 --- /dev/null +++ b/next/config/screener-factors.json @@ -0,0 +1,239 @@ +{ + "schema_version": 1, + "factors": { + "close": "收盘价", + "pct_chg": "当日涨幅", + "return_5d": "5日涨幅", + "return_10d": "10日涨幅", + "return_20d": "20日涨幅", + "return_60d": "60日涨幅", + "return_5d_rank": "5日涨幅排名", + "momentum_60_5": "中期动量", + "momentum_60_5_rank": "中期动量排名", + "above_ma20": "站上20日线", + "rsi_6": "RSI(6)", + "ma60_slope": "60日线斜率", + "ma20_slope_5d": "20日线5日斜率", + "ma_bull_alignment": "均线多头排列", + "drawdown_from_high_250": "距250日高点回撤", + "donchian_breakout_pct": "唐奇安突破幅度", + "range_20d": "20日振幅", + "rs_high_120": "RS线120日新高", + "excess_return_60d": "60日超额收益", + "weekly_trend_signal": "周线趋势信号", + "daily_buy_trigger": "日线买点", + "weekly_amount_trend": "周成交趋势", + "volume_ratio_5d": "5日量比", + "turnover_5d": "5日累计换手", + "volatility_10d": "10日波动率", + "amount_billion": "成交额", + "turnover_rate": "换手率", + "circ_mv_billion": "流通市值", + "net_flow_million": "主力净流入", + "large_flow_million": "大单净流入", + "net_flow_5d_million": "5日主力净流入", + "flow_to_circ_mv_5d": "5日净流入占流通市值", + "sector_strength": "板块强度", + "sector_return_5d": "行业5日涨幅", + "sector_return_20d": "行业20日涨幅", + "sector_momentum_rank": "行业20日动量排名", + "sector_stock_momentum_rank": "行业内个股动量排名", + "sector_net_flow_5d_million": "行业5日主力净流入", + "sector_flow_rank": "行业资金流排名", + "sector_prosperity_rank": "行业景气度排名", + "sector_trend_rank": "行业趋势排名", + "sector_crowding_rank": "行业拥挤度排名", + "sector_composite_score": "行业三维综合分", + "sector_limit_count": "板块涨停数", + "sector_up_count": "板块强势股数", + "relative_strength": "相对强度", + "limit_streak": "连板高度", + "auction_change": "竞价涨幅", + "auction_amount_million": "竞价成交额", + "auction_turnover_rate": "竞价换手率", + "auction_volume_ratio": "竞价量比", + "total_mv_billion": "总市值", + "pe_ttm": "市盈率TTM", + "pb": "市净率", + "ps_ttm": "市销率TTM", + "dividend_yield_ttm": "股息率TTM", + "dividend_years": "近年持续分红", + "roe": "净资产收益率", + "roa": "总资产收益率", + "roic": "投入资本回报率", + "gross_margin": "销售毛利率", + "netprofit_yoy": "净利润同比", + "revenue_yoy": "营业收入同比", + "ocf_to_opincome": "经营现金流质量", + "earnings_surprise_pct": "业绩超预期幅度", + "earnings_days_since_announce": "业绩公告后天数", + "earnings_event_quality": "业绩事件质量", + "popularity_score": "人气榜热度", + "popularity_rank_change": "人气排名跃升", + "popularity_dual_source": "双榜共识", + "institution_net_buy_million": "机构席位净买入", + "institution_seat_count": "机构席位数", + "style_size_fit": "大小盘风格匹配", + "style_growth_fit": "成长价值风格匹配", + "style_fit_score": "当前风格匹配度", + "factor_value_score": "价值因子分", + "factor_growth_score": "成长因子分", + "factor_quality_score": "质量因子分", + "factor_momentum_score": "动量因子分", + "factor_sentiment_score": "交易情绪因子分", + "multi_factor_composite": "动态多因子综合分", + "relative_position_60": "60日相对位置", + "max_abs_change_15d": "15日最大波动", + "close_to_high_15d": "距15日高点", + "close_to_high_60d": "距60日高点", + "no_limit_30d": "近30日无涨停", + "had_limit_80d": "近80日曾涨停", + "previous_first_limit": "昨日首板", + "previous_limit_signal": "昨日涨停或触板", + "previous_limit_streak": "昨日连板高度", + "previous_amount_billion": "昨日成交额", + "is_limit_up_today": "当日涨停", + "is_limit_down_today": "当日跌停", + "sector_breadth_ma20": "行业20日线宽度", + "no_limit_down_20d": "近20日无跌停", + "financial_risk": "财务风险标记", + "is_market_height": "当前市场最高板", + "new_space_board": "新晋空间板", + "max_continuous_board_10d": "近10日最高连板", + "dragon_first_yin": "龙头首阴", + "yin_day_pct": "首阴跌幅", + "vol_vs_previous": "较前日量能", + "broken_reversal": "断板反包", + "days_since_broken": "断板后天数", + "close_above_broken_high": "收复断板高点", + "vol_vs_broken_day": "较断板日量能", + "recent_limit_up_5d": "近5日涨停次数", + "intraday_min_pct": "盘中最大跌幅", + "lower_shadow_ratio": "下影线实体比" + }, + "groups": { + "行情动量": [ + "close", + "pct_chg", + "return_5d", + "return_10d", + "return_20d", + "return_60d", + "return_5d_rank", + "momentum_60_5", + "momentum_60_5_rank", + "above_ma20", + "rsi_6", + "ma60_slope", + "ma20_slope_5d", + "ma_bull_alignment", + "drawdown_from_high_250", + "donchian_breakout_pct", + "range_20d", + "rs_high_120", + "excess_return_60d", + "weekly_trend_signal", + "daily_buy_trigger", + "weekly_amount_trend", + "relative_strength", + "relative_position_60", + "close_to_high_15d", + "close_to_high_60d" + ], + "量价交易": [ + "volume_ratio_5d", + "turnover_5d", + "volatility_10d", + "amount_billion", + "turnover_rate", + "net_flow_million", + "large_flow_million", + "net_flow_5d_million", + "flow_to_circ_mv_5d", + "previous_amount_billion", + "intraday_min_pct", + "lower_shadow_ratio", + "vol_vs_previous", + "vol_vs_broken_day" + ], + "板块结构": [ + "sector_strength", + "sector_return_5d", + "sector_return_20d", + "sector_momentum_rank", + "sector_stock_momentum_rank", + "sector_net_flow_5d_million", + "sector_flow_rank", + "sector_prosperity_rank", + "sector_trend_rank", + "sector_crowding_rank", + "sector_composite_score", + "sector_limit_count", + "sector_up_count", + "sector_breadth_ma20", + "limit_streak", + "previous_limit_streak", + "previous_first_limit", + "previous_limit_signal", + "is_limit_up_today", + "is_limit_down_today", + "no_limit_30d", + "had_limit_80d", + "max_abs_change_15d", + "no_limit_down_20d", + "is_market_height", + "new_space_board", + "max_continuous_board_10d", + "dragon_first_yin", + "yin_day_pct", + "broken_reversal", + "days_since_broken", + "close_above_broken_high", + "recent_limit_up_5d" + ], + "竞价因子": [ + "auction_change", + "auction_amount_million", + "auction_turnover_rate", + "auction_volume_ratio" + ], + "估值规模": [ + "circ_mv_billion", + "total_mv_billion", + "pe_ttm", + "pb", + "ps_ttm", + "dividend_yield_ttm", + "dividend_years" + ], + "财务质量": [ + "roe", + "roa", + "roic", + "gross_margin", + "netprofit_yoy", + "revenue_yoy", + "ocf_to_opincome", + "financial_risk", + "earnings_surprise_pct", + "earnings_days_since_announce", + "earnings_event_quality" + ], + "特色数据": [ + "popularity_score", + "popularity_rank_change", + "popularity_dual_source", + "institution_net_buy_million", + "institution_seat_count", + "style_size_fit", + "style_growth_fit", + "style_fit_score", + "factor_value_score", + "factor_growth_score", + "factor_quality_score", + "factor_momentum_score", + "factor_sentiment_score", + "multi_factor_composite" + ] + } +} diff --git a/next/config/screener-strategies.json b/next/config/screener-strategies.json new file mode 100644 index 0000000..2d1216e --- /dev/null +++ b/next/config/screener-strategies.json @@ -0,0 +1,2810 @@ +{ + "schema_version": 1, + "strategies": [ + { + "id": "stage-01", + "version": 1, + "kind": "stage", + "name": "冰点抗跌先手", + "description": "寻找冰点中保持相对强度、低波动且有板块承接的个股,允许无结果。", + "regimes": [ + "ice" + ], + "formula": { + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "pct_chg", + "op": "between", + "value": [ + -3, + 7 + ] + }, + { + "field": "return_5d", + "op": ">=", + "value": -5 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + }, + { + "field": "volatility_10d", + "op": "<=", + "value": 7 + } + ], + "score": [ + { + "field": "relative_strength", + "weight": 0.3, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.25, + "direction": "desc" + }, + { + "field": "volume_ratio_5d", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "volatility_10d", + "weight": 0.15, + "direction": "asc" + }, + { + "field": "amount_billion", + "weight": 0.1, + "direction": "desc" + } + ], + "limit": 12, + "min_score": 0.58, + "meta": { + "library": "smart", + "category": "周期策略", + "quality": "系统", + "frequency": "每日", + "risk": "随市场阶段", + "data_group": "行情因子" + } + } + }, + { + "id": "stage-02", + "version": 1, + "kind": "stage", + "name": "修复先锋", + "description": "筛选率先站回趋势、温和放量并获得板块共振的修复前排。", + "regimes": [ + "repair" + ], + "formula": { + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "pct_chg", + "op": "between", + "value": [ + 1, + 9.7 + ] + }, + { + "field": "return_5d", + "op": ">", + "value": 0 + }, + { + "field": "above_ma20", + "op": "==", + "value": 1 + }, + { + "field": "volume_ratio_5d", + "op": ">=", + "value": 1.05 + } + ], + "score": [ + { + "field": "sector_strength", + "weight": 0.28, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "volume_ratio_5d", + "weight": 0.18, + "direction": "desc" + }, + { + "field": "net_flow_million", + "weight": 0.16, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.14, + "direction": "desc" + } + ], + "limit": 15, + "min_score": 0.54, + "meta": { + "library": "smart", + "category": "周期策略", + "quality": "系统", + "frequency": "每日", + "risk": "随市场阶段", + "data_group": "行情因子" + } + } + }, + { + "id": "stage-03", + "version": 1, + "kind": "stage", + "name": "主线发酵跟随", + "description": "在主线扩散期寻找趋势、成交承载和板块涨停梯队共同增强的个股。", + "regimes": [ + "fermentation" + ], + "formula": { + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "pct_chg", + "op": "between", + "value": [ + 0, + 9.8 + ] + }, + { + "field": "return_5d", + "op": ">=", + "value": 3 + }, + { + "field": "above_ma20", + "op": "==", + "value": 1 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 2 + } + ], + "score": [ + { + "field": "sector_limit_count", + "weight": 0.25, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "return_10d", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.16, + "direction": "desc" + }, + { + "field": "large_flow_million", + "weight": 0.15, + "direction": "desc" + } + ], + "limit": 15, + "min_score": 0.55, + "meta": { + "library": "smart", + "category": "周期策略", + "quality": "系统", + "frequency": "每日", + "risk": "随市场阶段", + "data_group": "行情因子" + } + } + }, + { + "id": "stage-04", + "version": 1, + "kind": "stage", + "name": "高潮核心去后排", + "description": "高潮阶段只保留容量、趋势和辨识度较高的核心,降低后排跟风权重。", + "regimes": [ + "climax" + ], + "formula": { + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "pct_chg", + "op": "between", + "value": [ + -2, + 7 + ] + }, + { + "field": "return_10d", + "op": ">=", + "value": 5 + }, + { + "field": "above_ma20", + "op": "==", + "value": 1 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 5 + } + ], + "score": [ + { + "field": "amount_billion", + "weight": 0.28, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.22, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "volatility_10d", + "weight": 0.15, + "direction": "asc" + }, + { + "field": "limit_streak", + "weight": 0.15, + "direction": "desc" + } + ], + "limit": 10, + "min_score": 0.62, + "meta": { + "library": "smart", + "category": "周期策略", + "quality": "系统", + "frequency": "每日", + "risk": "随市场阶段", + "data_group": "行情因子" + } + } + }, + { + "id": "stage-05", + "version": 1, + "kind": "stage", + "name": "分化承接回流", + "description": "寻找分化中仍有趋势承接、板块强度和资金回流的核心候选。", + "regimes": [ + "divergence" + ], + "formula": { + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "pct_chg", + "op": "between", + "value": [ + -3, + 7 + ] + }, + { + "field": "return_5d", + "op": ">", + "value": 0 + }, + { + "field": "above_ma20", + "op": "==", + "value": 1 + }, + { + "field": "volume_ratio_5d", + "op": "between", + "value": [ + 0.7, + 3.5 + ] + } + ], + "score": [ + { + "field": "relative_strength", + "weight": 0.28, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "net_flow_million", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "volatility_10d", + "weight": 0.16, + "direction": "asc" + }, + { + "field": "amount_billion", + "weight": 0.12, + "direction": "desc" + } + ], + "limit": 12, + "min_score": 0.57, + "meta": { + "library": "smart", + "category": "周期策略", + "quality": "系统", + "frequency": "每日", + "risk": "随市场阶段", + "data_group": "行情因子" + } + } + }, + { + "id": "stage-06", + "version": 1, + "kind": "stage", + "name": "退潮防守观察", + "description": "退潮期采用高门槛防守筛选,结果为空代表当前不宜主动出击。", + "regimes": [ + "retreat" + ], + "formula": { + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "pct_chg", + "op": "between", + "value": [ + -2, + 4 + ] + }, + { + "field": "return_5d", + "op": ">=", + "value": -2 + }, + { + "field": "above_ma20", + "op": "==", + "value": 1 + }, + { + "field": "volatility_10d", + "op": "<=", + "value": 4.5 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 2 + } + ], + "score": [ + { + "field": "volatility_10d", + "weight": 0.3, + "direction": "asc" + }, + { + "field": "relative_strength", + "weight": 0.25, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.15, + "direction": "desc" + }, + { + "field": "net_flow_million", + "weight": 0.1, + "direction": "desc" + } + ], + "limit": 8, + "min_score": 0.68, + "meta": { + "library": "smart", + "category": "周期策略", + "quality": "系统", + "frequency": "每日", + "risk": "随市场阶段", + "data_group": "行情因子" + } + } + }, + { + "id": "stage-07", + "version": 1, + "kind": "stage", + "name": "竞价强势确认", + "description": "用竞价涨幅、成交承载和量比确认修复或发酵阶段的主动进攻标的。", + "regimes": [ + "repair", + "fermentation", + "divergence" + ], + "formula": { + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "auction_change", + "op": "between", + "value": [ + 1, + 7 + ] + }, + { + "field": "auction_amount_million", + "op": ">=", + "value": 3 + }, + { + "field": "auction_volume_ratio", + "op": ">=", + "value": 0.8 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "auction_amount_million", + "weight": 0.26, + "direction": "desc" + }, + { + "field": "auction_volume_ratio", + "weight": 0.22, + "direction": "desc" + }, + { + "field": "auction_change", + "weight": 0.18, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.18, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.16, + "direction": "desc" + } + ], + "limit": 15, + "min_score": 0.56, + "meta": { + "library": "smart", + "category": "周期策略", + "quality": "系统", + "frequency": "每日", + "risk": "随市场阶段", + "data_group": "行情因子" + } + } + }, + { + "id": "curated-01", + "version": 1, + "kind": "curated", + "name": "连续分红质量", + "description": "寻找持续派息、盈利质量稳定且波动可控的长期现金回报型公司。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "红利价值", + "quality": "A", + "frequency": "月度", + "risk": "中低", + "data_group": "估值与财务", + "suitable_environment": "防守市、低利率环境与中长期配置窗口", + "failure_risk": "风险偏好快速上升时,稳健资产的价格弹性通常落后" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 1095 + }, + "filters": [ + { + "field": "dividend_years", + "op": ">=", + "value": 4 + }, + { + "field": "dividend_yield_ttm", + "op": ">=", + "value": 2 + }, + { + "field": "roe", + "op": ">=", + "value": 6 + }, + { + "field": "pb", + "op": "between", + "value": [ + 0.1, + 4 + ] + } + ], + "score": [ + { + "field": "dividend_yield_ttm", + "weight": 0.3, + "direction": "desc" + }, + { + "field": "roe", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "ocf_to_opincome", + "weight": 0.18, + "direction": "desc" + }, + { + "field": "volatility_10d", + "weight": 0.16, + "direction": "asc" + }, + { + "field": "total_mv_billion", + "weight": 0.12, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.52 + } + }, + { + "id": "curated-02", + "version": 1, + "kind": "curated", + "name": "ROIC质量低波", + "description": "以投入资本回报、毛利率和估值为核心,寻找低波动的高质量公司。", + "regimes": [ + "ice", + "repair", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "质量价值", + "quality": "A-", + "frequency": "月度", + "risk": "中低", + "data_group": "估值与财务", + "suitable_environment": "震荡偏弱、重视盈利质量与回撤控制的市场", + "failure_risk": "主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 730 + }, + "filters": [ + { + "field": "roic", + "op": ">=", + "value": 6 + }, + { + "field": "gross_margin", + "op": ">=", + "value": 15 + }, + { + "field": "pe_ttm", + "op": "between", + "value": [ + 1, + 45 + ] + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "roic", + "weight": 0.28, + "direction": "desc" + }, + { + "field": "gross_margin", + "weight": 0.22, + "direction": "desc" + }, + { + "field": "ps_ttm", + "weight": 0.18, + "direction": "asc" + }, + { + "field": "volatility_10d", + "weight": 0.18, + "direction": "asc" + }, + { + "field": "total_mv_billion", + "weight": 0.14, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.54 + } + }, + { + "id": "curated-03", + "version": 1, + "kind": "curated", + "name": "低估值现金流白马", + "description": "筛选估值克制、经营现金流健康、资产回报稳定的大中型公司。", + "regimes": [ + "ice", + "repair", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "现金流价值", + "quality": "A-", + "frequency": "月度", + "risk": "中低", + "data_group": "估值与财务", + "suitable_environment": "估值修复、价值回归及防守配置阶段", + "failure_risk": "低估值可能来自基本面持续走弱,需警惕价值陷阱" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 730 + }, + "filters": [ + { + "field": "pb", + "op": "between", + "value": [ + 0.1, + 1.8 + ] + }, + { + "field": "roa", + "op": ">=", + "value": 3 + }, + { + "field": "ocf_to_opincome", + "op": ">", + "value": 0 + }, + { + "field": "netprofit_yoy", + "op": ">=", + "value": -15 + }, + { + "field": "total_mv_billion", + "op": ">=", + "value": 100 + } + ], + "score": [ + { + "field": "roa", + "weight": 0.26, + "direction": "desc" + }, + { + "field": "ocf_to_opincome", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "pb", + "weight": 0.2, + "direction": "asc" + }, + { + "field": "total_mv_billion", + "weight": 0.16, + "direction": "desc" + }, + { + "field": "volatility_10d", + "weight": 0.14, + "direction": "asc" + } + ], + "limit": 20, + "min_score": 0.53 + } + }, + { + "id": "curated-04", + "version": 1, + "kind": "curated", + "name": "高增长合理估值", + "description": "在收入和利润同步增长的公司中,优先选择估值合理、趋势得到确认的标的。", + "regimes": [ + "repair", + "fermentation", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "成长质量", + "quality": "B+", + "frequency": "月度", + "risk": "中", + "data_group": "估值与财务", + "suitable_environment": "业绩驱动、成长风格占优且趋势获得确认的阶段", + "failure_risk": "增长预期下修或估值快速收缩时,回撤可能明显放大" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 365 + }, + "filters": [ + { + "field": "pe_ttm", + "op": "between", + "value": [ + 1, + 35 + ] + }, + { + "field": "revenue_yoy", + "op": ">=", + "value": 10 + }, + { + "field": "netprofit_yoy", + "op": ">=", + "value": 15 + }, + { + "field": "roe", + "op": ">=", + "value": 5 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "netprofit_yoy", + "weight": 0.27, + "direction": "desc" + }, + { + "field": "revenue_yoy", + "weight": 0.23, + "direction": "desc" + }, + { + "field": "roe", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "pe_ttm", + "weight": 0.16, + "direction": "asc" + }, + { + "field": "relative_strength", + "weight": 0.14, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.55 + } + }, + { + "id": "curated-05", + "version": 1, + "kind": "curated", + "name": "行业宽度主线", + "description": "从行业站上20日线的覆盖率和板块强度出发,筛选主线中的强势个股。", + "regimes": [ + "repair", + "fermentation", + "climax", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "行业轮动", + "quality": "B+", + "frequency": "每周", + "risk": "中", + "data_group": "行情与行业", + "suitable_environment": "主线清晰、行业内部多数个股同步走强的行情", + "failure_risk": "板块快速轮动时,宽度信号容易在确认后迅速衰减" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "sector_breadth_ma20", + "op": ">=", + "value": 55 + }, + { + "field": "sector_strength", + "op": ">=", + "value": 55 + }, + { + "field": "above_ma20", + "op": "==", + "value": 1 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 2 + } + ], + "score": [ + { + "field": "sector_breadth_ma20", + "weight": 0.28, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "sector_limit_count", + "weight": 0.16, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.12, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.56 + } + }, + { + "id": "curated-06", + "version": 1, + "kind": "curated", + "name": "首板低开", + "description": "昨日首板且位置不高,次日竞价温和低开并具备成交承载时进入候选。", + "regimes": [ + "ice", + "repair", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "短线竞价", + "quality": "B+", + "frequency": "每日9:25", + "risk": "高", + "data_group": "行情与竞价", + "suitable_environment": "情绪修复期的分歧转一致与首板次日承接", + "failure_risk": "退潮加速或低开缺少量能承接时,弱势可能继续扩大" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 250 + }, + "filters": [ + { + "field": "previous_first_limit", + "op": "==", + "value": 1 + }, + { + "field": "auction_change", + "op": "between", + "value": [ + -4.5, + -2.5 + ] + }, + { + "field": "relative_position_60", + "op": "<=", + "value": 0.55 + }, + { + "field": "previous_amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "auction_amount_million", + "weight": 0.28, + "direction": "desc" + }, + { + "field": "previous_amount_billion", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "relative_position_60", + "weight": 0.2, + "direction": "asc" + }, + { + "field": "sector_strength", + "weight": 0.16, + "direction": "desc" + }, + { + "field": "auction_volume_ratio", + "weight": 0.12, + "direction": "desc" + } + ], + "limit": 12, + "min_score": 0.5 + } + }, + { + "id": "curated-07", + "version": 1, + "kind": "curated", + "name": "小碎步临界突破", + "description": "寻找近期窄幅爬升、接近阶段高点且具备历史活跃记忆的突破候选。", + "regimes": [ + "repair", + "fermentation", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "形态突破", + "quality": "B+", + "frequency": "每日", + "risk": "中高", + "data_group": "历史行情", + "suitable_environment": "趋势蓄势、波动收敛后临近突破的结构市", + "failure_risk": "无量突破或指数剧烈震荡时,容易形成冲高回落" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 250 + }, + "filters": [ + { + "field": "no_limit_30d", + "op": "==", + "value": 1 + }, + { + "field": "had_limit_80d", + "op": "==", + "value": 1 + }, + { + "field": "max_abs_change_15d", + "op": "<=", + "value": 3 + }, + { + "field": "close_to_high_15d", + "op": ">=", + "value": 0.98 + }, + { + "field": "close_to_high_60d", + "op": ">=", + "value": 0.9 + } + ], + "score": [ + { + "field": "close_to_high_15d", + "weight": 0.26, + "direction": "desc" + }, + { + "field": "volume_ratio_5d", + "weight": 0.22, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "max_abs_change_15d", + "weight": 0.18, + "direction": "asc" + }, + { + "field": "circ_mv_billion", + "weight": 0.14, + "direction": "asc" + } + ], + "limit": 15, + "min_score": 0.54 + } + }, + { + "id": "curated-08", + "version": 1, + "kind": "curated", + "name": "连板龙头", + "description": "从昨日连板梯队中按高度、板块热度和成交承载筛选辨识度前排。", + "regimes": [ + "fermentation", + "climax", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "连板接力", + "quality": "B", + "frequency": "每日", + "risk": "很高", + "data_group": "涨停结构", + "suitable_environment": "高度拓展、题材梯队完整且接力情绪活跃的阶段", + "failure_risk": "亏钱效应扩散或高位股集中退潮时,接力风险很高" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "previous_limit_streak", + "op": ">=", + "value": 2 + }, + { + "field": "previous_amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "previous_limit_streak", + "weight": 0.34, + "direction": "desc" + }, + { + "field": "sector_limit_count", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "previous_amount_billion", + "weight": 0.18, + "direction": "desc" + }, + { + "field": "turnover_rate", + "weight": 0.14, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.1, + "direction": "desc" + } + ], + "limit": 10, + "min_score": 0.5 + } + }, + { + "id": "curated-09", + "version": 1, + "kind": "curated", + "name": "微盘三正", + "description": "以正估值、正盈利和正经营现金流约束微盘暴露,保留明确风险提示。", + "regimes": [ + "repair", + "fermentation" + ], + "formula": { + "meta": { + "library": "curated", + "category": "小盘质量", + "quality": "B", + "frequency": "每周", + "risk": "高", + "data_group": "估值与财务", + "suitable_environment": "小盘风格活跃、流动性宽松且风险偏好较高的行情", + "failure_risk": "风格切向大盘或微盘流动性收缩时,组合波动会显著上升" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 365 + }, + "filters": [ + { + "field": "pb", + "op": ">", + "value": 0 + }, + { + "field": "roe", + "op": ">", + "value": 0 + }, + { + "field": "ocf_to_opincome", + "op": ">", + "value": 0 + }, + { + "field": "circ_mv_billion", + "op": "between", + "value": [ + 5, + 100 + ] + }, + { + "field": "amount_billion", + "op": ">=", + "value": 0.5 + } + ], + "score": [ + { + "field": "circ_mv_billion", + "weight": 0.32, + "direction": "asc" + }, + { + "field": "roe", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "ocf_to_opincome", + "weight": 0.2, + "direction": "desc" + }, + { + "field": "turnover_rate", + "weight": 0.14, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.1, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.52 + } + }, + { + "id": "curated-10", + "version": 1, + "kind": "curated", + "name": "首板高开弱转强", + "description": "昨日涨停或触板后,使用9:25最终竞价涨幅、量比和板块承接确认强度。", + "regimes": [ + "repair", + "fermentation", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "短线竞价", + "quality": "B-", + "frequency": "每日9:25", + "risk": "高", + "data_group": "行情与竞价", + "suitable_environment": "竞价承接明确、短线情绪修复或主线发酵阶段", + "failure_risk": "高开缺乏板块共振时,竞价强势可能转为盘中兑现" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "previous_limit_signal", + "op": "==", + "value": 1 + }, + { + "field": "auction_change", + "op": "between", + "value": [ + 1, + 6 + ] + }, + { + "field": "auction_volume_ratio", + "op": ">=", + "value": 0.8 + }, + { + "field": "previous_amount_billion", + "op": "between", + "value": [ + 3, + 25 + ] + } + ], + "score": [ + { + "field": "auction_amount_million", + "weight": 0.28, + "direction": "desc" + }, + { + "field": "auction_volume_ratio", + "weight": 0.24, + "direction": "desc" + }, + { + "field": "auction_change", + "weight": 0.18, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.17, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.13, + "direction": "desc" + } + ], + "limit": 15, + "min_score": 0.52 + } + }, + { + "id": "curated-11", + "version": 1, + "kind": "curated", + "name": "中期动量·强者恒强", + "description": "用60日至5日前的中期动量识别持续强势,同时剔除当日无法正常成交的涨停标的。", + "regimes": [ + "repair", + "fermentation", + "climax", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "动量反转", + "quality": "A-", + "frequency": "每周", + "risk": "中", + "data_group": "历史行情", + "history_days": 80, + "backtest_days": 10, + "take_profit": 8, + "stop_loss": -5, + "suitable_environment": "趋势延续、主升段及强弱分化清晰的行情", + "failure_risk": "无趋势震荡或快速轮动中,动量信号容易反复失效" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "close", + "op": "between", + "value": [ + 3, + 100 + ] + }, + { + "field": "momentum_60_5_rank", + "op": ">=", + "value": 0.9 + }, + { + "field": "is_limit_up_today", + "op": "==", + "value": 0 + } + ], + "score": [ + { + "field": "momentum_60_5", + "weight": 0.55, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.25, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.2, + "direction": "desc" + } + ], + "limit": 25, + "min_score": 0.5 + } + }, + { + "id": "curated-12", + "version": 1, + "kind": "curated", + "name": "强者回调", + "description": "在中期强势股池中寻找回踩20日线、短期超卖且近20日无跌停的牛回头候选。", + "regimes": [ + "repair", + "fermentation", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "动量反转", + "quality": "A-", + "frequency": "每日", + "risk": "中", + "data_group": "历史行情", + "history_days": 80, + "backtest_days": 10, + "take_profit": 8, + "stop_loss": -5, + "suitable_environment": "主升趋势未破、强势股完成良性回踩的窗口", + "failure_risk": "趋势已反转时,回调信号可能演变为下跌中继" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "momentum_60_5_rank", + "op": ">=", + "value": 0.7 + }, + { + "field": "return_5d_rank", + "op": "<=", + "value": 0.2 + }, + { + "field": "above_ma20", + "op": "==", + "value": 1 + }, + { + "field": "rsi_6", + "op": "<=", + "value": 30 + }, + { + "field": "no_limit_down_20d", + "op": "==", + "value": 1 + } + ], + "score": [ + { + "field": "momentum_60_5", + "weight": 0.42, + "direction": "desc" + }, + { + "field": "return_5d", + "weight": 0.33, + "direction": "asc" + }, + { + "field": "amount_billion", + "weight": 0.25, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.48 + } + }, + { + "id": "curated-13", + "version": 1, + "kind": "curated", + "name": "超跌反转", + "description": "筛选短期极端回撤、充分换手但尚未形成长期单边下跌的修复候选。", + "regimes": [ + "ice", + "repair" + ], + "formula": { + "meta": { + "library": "curated", + "category": "动量反转", + "quality": "B+", + "frequency": "每日", + "risk": "高", + "data_group": "行情与财务", + "history_days": 80, + "backtest_days": 5, + "take_profit": 8, + "stop_loss": -5, + "suitable_environment": "急跌后恐慌释放充分、市场进入修复预期的阶段", + "failure_risk": "单边下跌初段容易过早介入,超跌不等于止跌" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "return_5d_rank", + "op": "<=", + "value": 0.05 + }, + { + "field": "turnover_5d", + "op": ">=", + "value": 30 + }, + { + "field": "return_60d", + "op": ">=", + "value": -40 + }, + { + "field": "financial_risk", + "op": "==", + "value": 0 + }, + { + "field": "is_limit_down_today", + "op": "==", + "value": 0 + } + ], + "score": [ + { + "field": "return_5d", + "weight": 0.45, + "direction": "asc" + }, + { + "field": "turnover_5d", + "weight": 0.3, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.25, + "direction": "desc" + } + ], + "limit": 10, + "min_score": 0.5 + } + }, + { + "id": "curated-14", + "version": 1, + "kind": "curated", + "name": "相对强度新高", + "description": "以个股相对沪深300的强度线识别弱市领涨和结构性抱团标的。", + "regimes": [ + "ice", + "repair", + "fermentation", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "动量反转", + "quality": "A", + "frequency": "每周", + "risk": "中", + "data_group": "行情与指数", + "history_days": 130, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "requires_benchmark": true, + "suitable_environment": "指数偏弱但结构性主线明确,或机构抱团强化的行情", + "failure_risk": "基准快速补涨或强势方向瓦解时,相对优势可能迅速消失" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 250 + }, + "filters": [ + { + "field": "amount_billion", + "op": ">=", + "value": 1 + }, + { + "field": "rs_high_120", + "op": "==", + "value": 1 + }, + { + "field": "excess_return_60d", + "op": ">=", + "value": 10 + }, + { + "field": "ma60_slope", + "op": ">", + "value": 0 + } + ], + "score": [ + { + "field": "excess_return_60d", + "weight": 0.5, + "direction": "desc" + }, + { + "field": "ma60_slope", + "weight": 0.25, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.25, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.52 + } + }, + { + "id": "curated-15", + "version": 1, + "kind": "curated", + "name": "均线多头排列", + "description": "使用5、10、20、60日均线多头结构、20日线斜率和250日位置确认趋势。", + "regimes": [ + "repair", + "fermentation", + "climax", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "趋势追踪", + "quality": "A-", + "frequency": "每周", + "risk": "中低", + "data_group": "历史行情", + "history_days": 260, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "suitable_environment": "中期趋势向上、回撤有序的趋势市与主升段", + "failure_risk": "高位趋势末端或宽幅震荡中,均线信号通常反应滞后" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 365 + }, + "filters": [ + { + "field": "ma_bull_alignment", + "op": "==", + "value": 1 + }, + { + "field": "ma20_slope_5d", + "op": ">", + "value": 0 + }, + { + "field": "drawdown_from_high_250", + "op": "<=", + "value": 20 + } + ], + "score": [ + { + "field": "ma20_slope_5d", + "weight": 0.38, + "direction": "desc" + }, + { + "field": "drawdown_from_high_250", + "weight": 0.32, + "direction": "asc" + }, + { + "field": "relative_strength", + "weight": 0.3, + "direction": "desc" + } + ], + "limit": 30, + "min_score": 0.5 + } + }, + { + "id": "curated-16", + "version": 1, + "kind": "curated", + "name": "唐奇安通道突破", + "description": "收盘突破前20日高点,并以突破幅度、量能和突破前振幅过滤假突破。", + "regimes": [ + "repair", + "fermentation", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "趋势追踪", + "quality": "A-", + "frequency": "每日", + "risk": "中", + "data_group": "历史行情", + "history_days": 80, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "suitable_environment": "整理末端、放量突破并启动新趋势的行情", + "failure_risk": "无量突破和宽幅震荡环境中,假突破出现概率较高" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "donchian_breakout_pct", + "op": ">=", + "value": 2 + }, + { + "field": "volume_ratio_5d", + "op": ">=", + "value": 1.8 + }, + { + "field": "range_20d", + "op": "<=", + "value": 35 + } + ], + "score": [ + { + "field": "volume_ratio_5d", + "weight": 0.4, + "direction": "desc" + }, + { + "field": "donchian_breakout_pct", + "weight": 0.35, + "direction": "desc" + }, + { + "field": "range_20d", + "weight": 0.25, + "direction": "asc" + } + ], + "limit": 15, + "min_score": 0.52 + } + }, + { + "id": "curated-17", + "version": 1, + "kind": "curated", + "name": "周线趋势·日线买点", + "description": "周线MACD位于多头区间,日线金叉或回踩20日线收阳时确认多周期共振。", + "regimes": [ + "repair", + "fermentation", + "divergence" + ], + "formula": { + "meta": { + "library": "curated", + "category": "趋势追踪", + "quality": "A", + "frequency": "每周", + "risk": "中低", + "data_group": "多周期行情", + "history_days": 180, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "suitable_environment": "中期趋势稳定、日线回踩或再启动的多周期共振阶段", + "failure_risk": "周线拐点尚未确认时,日线信号可能只是短暂反抽" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 365 + }, + "filters": [ + { + "field": "weekly_trend_signal", + "op": "==", + "value": 1 + }, + { + "field": "daily_buy_trigger", + "op": "==", + "value": 1 + }, + { + "field": "weekly_amount_trend", + "op": "==", + "value": 1 + } + ], + "score": [ + { + "field": "ma20_slope_5d", + "weight": 0.35, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.35, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.3, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.52 + } + }, + { + "id": "curated-18", + "version": 1, + "kind": "curated", + "name": "空间板", + "description": "识别当日新晋市场最高板,并要求所属方向具备足够的涨停支撑。", + "regimes": [ + "repair", + "fermentation" + ], + "formula": { + "meta": { + "library": "curated", + "category": "连板接力", + "quality": "B+", + "frequency": "每日", + "risk": "很高", + "data_group": "涨停结构", + "history_days": 80, + "backtest_days": 3, + "take_profit": 8, + "stop_loss": -6, + "suitable_environment": "市场高度持续拓展、板块梯队完整的强接力环境", + "failure_risk": "高度压缩或亏钱效应扩散时,最高板的补跌风险极高" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "is_market_height", + "op": "==", + "value": 1 + }, + { + "field": "new_space_board", + "op": "==", + "value": 1 + }, + { + "field": "sector_limit_count", + "op": ">=", + "value": 3 + } + ], + "score": [ + { + "field": "limit_streak", + "weight": 0.5, + "direction": "desc" + }, + { + "field": "sector_limit_count", + "weight": 0.3, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.2, + "direction": "desc" + } + ], + "limit": 5, + "min_score": 0.45 + } + }, + { + "id": "curated-19", + "version": 1, + "kind": "curated", + "name": "龙头首阴", + "description": "筛选三板以上强势股断板后的首次缩量阴线,并结合板块强度观察承接质量。", + "regimes": [ + "fermentation", + "climax" + ], + "formula": { + "meta": { + "library": "curated", + "category": "低吸反核", + "quality": "B", + "frequency": "每日", + "risk": "很高", + "data_group": "涨停结构", + "history_days": 80, + "backtest_days": 5, + "take_profit": 8, + "stop_loss": -6, + "suitable_environment": "主线龙头仍有辨识度、首次分歧后存在回流预期的阶段", + "failure_risk": "题材退潮或龙头地位被替代后,首阴可能只是下跌起点" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "max_continuous_board_10d", + "op": ">=", + "value": 3 + }, + { + "field": "dragon_first_yin", + "op": "==", + "value": 1 + }, + { + "field": "yin_day_pct", + "op": ">=", + "value": -7 + }, + { + "field": "vol_vs_previous", + "op": "<=", + "value": 0.8 + } + ], + "score": [ + { + "field": "max_continuous_board_10d", + "weight": 0.45, + "direction": "desc" + }, + { + "field": "vol_vs_previous", + "weight": 0.3, + "direction": "asc" + }, + { + "field": "sector_strength", + "weight": 0.25, + "direction": "desc" + } + ], + "limit": 5, + "min_score": 0.48 + } + }, + { + "id": "curated-20", + "version": 1, + "kind": "curated", + "name": "断板反包", + "description": "连板断板后1至3日内,以涨停收复断板高点和量能确认N字反包。", + "regimes": [ + "repair", + "fermentation" + ], + "formula": { + "meta": { + "library": "curated", + "category": "低吸反核", + "quality": "B+", + "frequency": "每日", + "risk": "高", + "data_group": "涨停结构", + "history_days": 80, + "backtest_days": 3, + "take_profit": 8, + "stop_loss": -6, + "suitable_environment": "强势题材分歧后快速修复、核心股重新获得资金承接时", + "failure_risk": "板块强度不足或反包缩量时,形态持续性通常较弱" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "broken_reversal", + "op": "==", + "value": 1 + }, + { + "field": "days_since_broken", + "op": "between", + "value": [ + 1, + 3 + ] + }, + { + "field": "close_above_broken_high", + "op": "==", + "value": 1 + }, + { + "field": "vol_vs_broken_day", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "days_since_broken", + "weight": 0.35, + "direction": "asc" + }, + { + "field": "vol_vs_broken_day", + "weight": 0.35, + "direction": "desc" + }, + { + "field": "sector_strength", + "weight": 0.3, + "direction": "desc" + } + ], + "limit": 5, + "min_score": 0.46 + } + }, + { + "id": "curated-21", + "version": 1, + "kind": "curated", + "name": "核按钮反核", + "description": "近5日强势股盘中深水急杀后收回,并以长下影和非放量结构确认承接。", + "regimes": [ + "repair", + "fermentation" + ], + "formula": { + "meta": { + "library": "curated", + "category": "低吸反核", + "quality": "B+", + "frequency": "每日", + "risk": "很高", + "data_group": "历史行情", + "history_days": 80, + "backtest_days": 5, + "take_profit": 8, + "stop_loss": -6, + "suitable_environment": "恐慌释放后出现明确承接、短线情绪转暖的窗口", + "failure_risk": "系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "recent_limit_up_5d", + "op": ">=", + "value": 1 + }, + { + "field": "intraday_min_pct", + "op": "<=", + "value": -7 + }, + { + "field": "pct_chg", + "op": ">=", + "value": -3 + }, + { + "field": "lower_shadow_ratio", + "op": ">=", + "value": 2 + }, + { + "field": "vol_vs_previous", + "op": "<=", + "value": 1.1 + } + ], + "score": [ + { + "field": "lower_shadow_ratio", + "weight": 0.42, + "direction": "desc" + }, + { + "field": "intraday_min_pct", + "weight": 0.3, + "direction": "asc" + }, + { + "field": "sector_strength", + "weight": 0.28, + "direction": "desc" + } + ], + "limit": 5, + "min_score": 0.48 + } + }, + { + "id": "curated-22", + "version": 1, + "kind": "curated", + "name": "景气-趋势-拥挤三维行业打分", + "description": "以行业财务景气、价格趋势和交易拥挤度合成行业得分,再选取行业内动量与成交承载靠前的公司。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "行业轮动", + "quality": "A-", + "frequency": "双周", + "risk": "中", + "data_group": "行业、财务与交易拥挤", + "history_days": 80, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "requires_fundamental": true, + "suitable_environment": "行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市", + "failure_risk": "财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "sector_composite_score", + "op": ">=", + "value": 0.58 + }, + { + "field": "sector_crowding_rank", + "op": "<=", + "value": 0.9 + }, + { + "field": "sector_stock_momentum_rank", + "op": ">=", + "value": 0.5 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "sector_composite_score", + "weight": 0.55, + "direction": "desc" + }, + { + "field": "sector_stock_momentum_rank", + "weight": 0.25, + "direction": "desc" + }, + { + "field": "sector_crowding_rank", + "weight": 0.2, + "direction": "asc" + } + ], + "limit": 12, + "min_score": 0.5 + } + }, + { + "id": "curated-23", + "version": 1, + "kind": "curated", + "name": "大小盘/成长价值风格切换(元策略)", + "description": "比较大小盘与成长价值组合近20日相对表现,动态选择当前占优风格中的匹配标的。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "元策略", + "quality": "A-", + "frequency": "每周", + "risk": "中低", + "data_group": "行情、估值与财务", + "history_days": 80, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "requires_fundamental": true, + "requires_valuation": true, + "suitable_environment": "大小盘或成长价值风格形成持续相对强弱的阶段", + "failure_risk": "风格快速往返切换时,近20日相对表现容易产生滞后信号" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 250 + }, + "filters": [ + { + "field": "style_fit_score", + "op": ">=", + "value": 0.65 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "style_fit_score", + "weight": 0.7, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.3, + "direction": "desc" + } + ], + "limit": 20, + "min_score": 0.52 + } + }, + { + "id": "curated-24", + "version": 1, + "kind": "curated", + "name": "业绩超预期漂移(SUE/PEAD)", + "description": "以业绩预告和业绩快报的同报告期差异识别超预期事件,并限定在公告后的首个交易窗口。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "业绩事件", + "quality": "A-", + "frequency": "事件驱动", + "risk": "中", + "data_group": "业绩预告与快报", + "history_days": 80, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "requires_earnings_events": true, + "suitable_environment": "业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时", + "failure_risk": "预告与快报口径可能不同,公告后高开兑现会削弱漂移效应" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "earnings_surprise_pct", + "op": ">=", + "value": 10 + }, + { + "field": "revenue_yoy", + "op": ">", + "value": 0 + }, + { + "field": "earnings_event_quality", + "op": "==", + "value": 1 + }, + { + "field": "earnings_days_since_announce", + "op": "between", + "value": [ + 1, + 5 + ] + } + ], + "score": [ + { + "field": "earnings_surprise_pct", + "weight": 0.6, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.25, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.15, + "direction": "desc" + } + ], + "limit": 15, + "min_score": 0.5 + } + }, + { + "id": "curated-25", + "version": 1, + "kind": "curated", + "name": "动态多因子(基础版)", + "description": "将价值、成长、质量、动量和交易情绪标准化,并按近期横截面有效性动态合成综合分。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "多因子", + "quality": "A-", + "frequency": "每周", + "risk": "中", + "data_group": "行情、估值与财务", + "history_days": 260, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "requires_fundamental": true, + "requires_valuation": true, + "suitable_environment": "因子表现具备一定延续性、市场并非由单一极端主题主导时", + "failure_risk": "近期有效因子可能快速失效,动态权重不能消除风格突变风险" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 250 + }, + "filters": [ + { + "field": "multi_factor_composite", + "op": ">=", + "value": 0.65 + }, + { + "field": "financial_risk", + "op": "==", + "value": 0 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "multi_factor_composite", + "weight": 0.75, + "direction": "desc" + }, + { + "field": "relative_strength", + "weight": 0.15, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.1, + "direction": "desc" + } + ], + "limit": 30, + "min_score": 0.55 + }, + "display_name": "动态多因子(基础版)" + }, + { + "id": "curated-26", + "version": 1, + "kind": "curated", + "name": "热度突增潜伏(另类数据)", + "description": "从同花顺和东方财富人气榜中寻找排名快速跃升、但价格尚未明显兑现的观察候选。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "热度观察", + "quality": "B+", + "frequency": "每日", + "risk": "高", + "data_group": "人气榜与行情", + "history_days": 80, + "backtest_days": 10, + "take_profit": 10, + "stop_loss": -7, + "requires_popularity": true, + "backtestable": false, + "suitable_environment": "人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期", + "failure_risk": "榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "popularity_score", + "op": ">=", + "value": 15 + }, + { + "field": "return_10d", + "op": "<=", + "value": 5 + }, + { + "field": "recent_limit_up_5d", + "op": "==", + "value": 0 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 0.5 + } + ], + "score": [ + { + "field": "popularity_score", + "weight": 0.5, + "direction": "desc" + }, + { + "field": "popularity_rank_change", + "weight": 0.25, + "direction": "desc" + }, + { + "field": "popularity_dual_source", + "weight": 0.1, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.15, + "direction": "desc" + } + ], + "limit": 10, + "min_score": 0.48 + } + }, + { + "id": "curated-27", + "version": 1, + "kind": "curated", + "name": "机构榜溢价", + "description": "筛选龙虎榜机构专用席位低位净买入的公司,并以席位数量和成交承载确认信号。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "资金席位", + "quality": "B+", + "frequency": "每日", + "risk": "中高", + "data_group": "龙虎榜机构席位", + "history_days": 80, + "backtest_days": 10, + "take_profit": 10, + "stop_loss": -7, + "requires_institutions": true, + "suitable_environment": "机构专用席位在相对低位形成明确净买入、且成交承载正常时", + "failure_risk": "高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "institution_net_buy_million", + "op": ">=", + "value": 30 + }, + { + "field": "institution_seat_count", + "op": ">=", + "value": 1 + }, + { + "field": "return_60d", + "op": "<=", + "value": 30 + }, + { + "field": "previous_limit_streak", + "op": "<=", + "value": 2 + } + ], + "score": [ + { + "field": "institution_net_buy_million", + "weight": 0.55, + "direction": "desc" + }, + { + "field": "institution_seat_count", + "weight": 0.15, + "direction": "desc" + }, + { + "field": "relative_position_60", + "weight": 0.2, + "direction": "asc" + }, + { + "field": "amount_billion", + "weight": 0.1, + "direction": "desc" + } + ], + "limit": 10, + "min_score": 0.48 + } + }, + { + "id": "curated-28", + "version": 1, + "kind": "curated", + "name": "行业动量轮动", + "description": "选择20日涨幅居前的行业,并在行业内部保留趋势与成交承载更强的前排公司。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "行业轮动", + "quality": "A-", + "frequency": "双周", + "risk": "中", + "data_group": "行业与历史行情", + "history_days": 80, + "backtest_days": 20, + "take_profit": 12, + "stop_loss": -7, + "suitable_environment": "主线相对清晰、行业趋势能够延续两周以上的结构市", + "failure_risk": "行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "sector_momentum_rank", + "op": ">=", + "value": 0.9 + }, + { + "field": "sector_stock_momentum_rank", + "op": ">=", + "value": 0.8 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "sector_return_20d", + "weight": 0.38, + "direction": "desc" + }, + { + "field": "return_20d", + "weight": 0.32, + "direction": "desc" + }, + { + "field": "total_mv_billion", + "weight": 0.18, + "direction": "desc" + }, + { + "field": "amount_billion", + "weight": 0.12, + "direction": "desc" + } + ], + "limit": 12, + "min_score": 0.48 + } + }, + { + "id": "curated-29", + "version": 1, + "kind": "curated", + "name": "主力资金行业流入", + "description": "寻找近5日主力资金持续净流入、行业涨幅尚未充分兑现的板块前排。", + "regimes": [ + "ice", + "repair", + "fermentation", + "climax", + "divergence", + "retreat" + ], + "formula": { + "meta": { + "library": "curated", + "category": "行业轮动", + "quality": "B+", + "frequency": "每周", + "risk": "中高", + "data_group": "行业与资金流", + "history_days": 80, + "backtest_days": 10, + "take_profit": 10, + "stop_loss": -7, + "requires_moneyflow_history": true, + "suitable_environment": "板块轮动初期、资金先于价格形成连续净流入的阶段", + "failure_risk": "资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 180 + }, + "filters": [ + { + "field": "sector_flow_rank", + "op": ">=", + "value": 0.85 + }, + { + "field": "sector_net_flow_5d_million", + "op": ">", + "value": 0 + }, + { + "field": "sector_return_5d", + "op": "<=", + "value": 8 + }, + { + "field": "flow_to_circ_mv_5d", + "op": ">", + "value": 0 + }, + { + "field": "amount_billion", + "op": ">=", + "value": 1 + } + ], + "score": [ + { + "field": "flow_to_circ_mv_5d", + "weight": 0.42, + "direction": "desc" + }, + { + "field": "sector_net_flow_5d_million", + "weight": 0.3, + "direction": "desc" + }, + { + "field": "sector_return_5d", + "weight": 0.16, + "direction": "asc" + }, + { + "field": "amount_billion", + "weight": 0.12, + "direction": "desc" + } + ], + "limit": 15, + "min_score": 0.48 + } + } + ] +} diff --git a/next/frontend/src/app/router.ts b/next/frontend/src/app/router.ts index 3482f6b..39e164a 100644 --- a/next/frontend/src/app/router.ts +++ b/next/frontend/src/app/router.ts @@ -3,23 +3,36 @@ import { createRouter, createWebHistory } from "vue-router"; import SystemManagementView from "./views/SystemManagementView.vue"; import WorkspaceView from "./views/WorkspaceView.vue"; import EntityDetailView from "./views/EntityDetailView.vue"; +import TrackingPage from "../pages/screener/TrackingPage.vue"; import { findWorkspace } from "./workspaceRegistry"; export default createRouter({ history: createWebHistory(), routes: [ { path: "/", redirect: "/workspace/emotion" }, + { + path: "/workspace/screener/tracking", + name: "screener-tracking", + component: TrackingPage, + meta: { title: "智能选股" }, + }, { path: "/workspace/:workspace", name: "workspace", component: WorkspaceView, beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"), }, - { path: "/system", name: "system", component: SystemManagementView }, + { + path: "/system", + name: "system", + component: SystemManagementView, + meta: { title: "系统管理" }, + }, { path: "/market/:entityType/:identifier", name: "entity-detail", component: EntityDetailView, + meta: { title: "行情详情" }, }, { path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" }, ], diff --git a/next/frontend/src/app/shell/StatusBar.vue b/next/frontend/src/app/shell/StatusBar.vue index b37c3dd..29d1255 100644 --- a/next/frontend/src/app/shell/StatusBar.vue +++ b/next/frontend/src/app/shell/StatusBar.vue @@ -5,7 +5,10 @@ import { useRoute } from "vue-router"; import { findWorkspace } from "../workspaceRegistry"; const route = useRoute(); -const title = computed(() => findWorkspace(String(route.params.workspace ?? ""))?.title ?? "系统管理"); +const title = computed(() => { + if (typeof route.meta.title === "string") return route.meta.title; + return findWorkspace(String(route.params.workspace ?? ""))?.title ?? "小白复盘"; +});