rebuild(stage-9): deliver deterministic intelligent screening

This commit is contained in:
leefer
2026-07-30 05:15:17 +08:00
parent 6cb52e864a
commit 158257ebb8
46 changed files with 7322 additions and 34 deletions
+12 -1
View File
@@ -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="小白复盘",
+10 -5
View File
@@ -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),
)
+17 -3
View File
@@ -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:
+1
View File
@@ -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), ())
+2
View File
@@ -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]: ...
+3
View File
@@ -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:
+8 -7
View File
@@ -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):
+161 -12
View File
@@ -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 "")
+272
View File
@@ -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
@@ -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,
)
@@ -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,
)
+12
View File
@@ -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)]
@@ -0,0 +1 @@
__all__: list[str] = []
+134
View File
@@ -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
@@ -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)
+260
View File
@@ -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
@@ -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
+42
View File
@@ -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,
}
@@ -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")
+112
View File
@@ -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
+51
View File
@@ -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
+328
View File
@@ -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
+434
View File
@@ -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
@@ -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),
}
+2
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Application-owned background jobs."""
+33
View File
@@ -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
+239
View File
@@ -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"
]
}
}
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -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" },
],
+4 -1
View File
@@ -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 ?? "小白复盘";
});
</script>
<template>
+1 -2
View File
@@ -18,8 +18,7 @@ const menuRoot = ref<HTMLElement | null>(null);
const refreshing = ref(false);
const title = computed(() => {
if (route.name === "system") return "系统管理";
if (route.name === "entity-detail") return "行情详情";
if (typeof route.meta.title === "string") return route.meta.title;
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
});
@@ -4,6 +4,7 @@ import { useRoute } from "vue-router";
import EmptyState from "../../shared/components/EmptyState.vue";
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -29,6 +30,7 @@ const implementedMarket = computed(() =>
<template>
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
<ScreenerPage v-else-if="workspace.key === 'screener'" />
<main v-else class="page-frame">
<header class="page-header">
<h1>{{ workspace.title }}</h1>
+1
View File
@@ -13,6 +13,7 @@ import "./shared/styles/account.css";
import "./shared/styles/market.css";
import "./shared/styles/market-workspace.css";
import "./shared/styles/market-insights.css";
import "./shared/styles/screener.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { Candidate, ScreenerRun } from "../../shared/api/screener";
defineProps<{ run?: ScreenerRun; locked?: boolean }>();
const emit = defineEmits<{ track: [candidate: Candidate] }>();
function number(value: number | null, digits = 2): string {
return value === null ? "" : value.toFixed(digits);
}
</script>
<template>
<section class="card screener-results" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<header class="card-header">
<div>
<h2>候选结果</h2>
<p v-if="run" class="faint">{{ run.strategy_name }} · {{ run.selection_date }}</p>
</div>
<span v-if="run" class="tag">{{ run.items.length }} </span>
</header>
<div v-if="run?.status === 'data_incomplete'" class="notice notice-warning">
数据尚不完整{{ run.missing_fields.join("") }}
</div>
<div v-else-if="run?.status === 'failed'" class="notice notice-warning">
本次计算失败已隔离该策略不影响其他策略
</div>
<div v-else-if="!run || run.status === 'no_signal' || !run.items.length" class="screener-empty">
<strong>暂无符合条件个股</strong>
<span>完整数据下无信号会保留为空不补造候选</span>
</div>
<div v-else class="data-table-wrap">
<table class="data-table">
<thead><tr><th>代码</th><th>股票</th><th>行业</th><th class="numeric">收盘价</th><th class="numeric">涨跌幅%</th><th class="numeric">综合分</th><th>主要依据</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="item in run.items" :key="item.identifier">
<td class="code-column">{{ item.code }}</td><td>{{ item.name }}</td><td>{{ item.sector }}</td>
<td class="numeric">{{ number(item.close) }}</td>
<td class="numeric" :class="{ up: Number(item.pct_chg) > 0, down: Number(item.pct_chg) < 0 }">{{ number(item.pct_chg) }}</td>
<td class="numeric"><strong>{{ number(item.score_display, 1) }}</strong></td>
<td class="wide-column">{{ item.reason }}</td>
<td><button class="btn btn-small" type="button" :disabled="locked" @click="emit('track', item)">加入跟踪</button></td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type {
Candidate,
CustomStrategy,
FormulaCondition,
FormulaScore,
ScreenerFormula,
ScreenerRun,
} from "../../shared/api/screener";
import CandidateTable from "./CandidateTable.vue";
const props = defineProps<{
factors: Record<string, string>;
factorGroups: Record<string, string[]>;
strategies: CustomStrategy[];
runs: ScreenerRun[];
locked: boolean;
busy: boolean;
}>();
const emit = defineEmits<{
save: [name: string, formula: ScreenerFormula];
run: [strategy: CustomStrategy];
remove: [strategy: CustomStrategy];
track: [run: ScreenerRun, candidate: Candidate];
}>();
const name = ref("我的选股策略");
const selectedFactor = ref("return_20d");
const scores = ref<FormulaScore[]>([
{ field: "return_20d", weight: 60, direction: "desc" },
{ field: "sector_strength", weight: 40, direction: "desc" },
]);
const filters = ref<FormulaCondition[]>([
{ field: "amount_billion", op: ">=", value: 1 },
]);
const listedDays = ref(120);
const outputLimit = ref(30);
const minimumScore = ref(50);
const excludeSt = ref(true);
const selected = ref<number | null>(props.strategies[0]?.id ?? null);
const current = computed(() => props.strategies.find((item) => item.id === selected.value));
const run = computed(() =>
props.runs.find((item) => item.strategy_id === `custom-${selected.value}`),
);
const total = computed(() =>
scores.value.reduce((sum, item) => sum + Number(item.weight), 0),
);
watch(
() => props.strategies,
(items) => {
if (!items.some((item) => item.id === selected.value)) selected.value = items[0]?.id ?? null;
},
);
function addFactor(): void {
if (!selectedFactor.value || scores.value.some((item) => item.field === selectedFactor.value)) {
return;
}
scores.value.push({ field: selectedFactor.value, weight: 0, direction: "desc" });
}
function addFilter(): void {
filters.value.push({ field: "amount_billion", op: ">=", value: 1 });
}
function edit(strategy: CustomStrategy): void {
selected.value = strategy.id;
name.value = strategy.name;
scores.value = strategy.formula.score.map((item) => ({
...item,
weight: Math.round(item.weight * 100),
}));
filters.value = strategy.formula.filters.map((item) => ({ ...item }));
listedDays.value = strategy.formula.universe.listed_days_min;
excludeSt.value = strategy.formula.universe.exclude_st;
outputLimit.value = strategy.formula.limit;
minimumScore.value = Math.round(strategy.formula.min_score * 100);
}
function save(): void {
emit("save", name.value, {
universe: {
exclude_st: excludeSt.value,
listed_days_min: Number(listedDays.value),
},
filters: filters.value.map((item) => ({ ...item, value: Number(item.value) })),
score: scores.value.map((item) => ({
...item,
weight: Number(item.weight) / 100,
})),
limit: Number(outputLimit.value),
min_score: Number(minimumScore.value) / 100,
});
}
</script>
<template>
<section class="custom-builder card" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<div class="custom-factors">
<header class="card-header">
<h2>因子与权重</h2>
<span :class="['tag', { warning: total !== 100 }]">合计 {{ total }}%</span>
</header>
<div class="factor-add">
<select v-model="selectedFactor" class="select">
<optgroup v-for="(fields, group) in factorGroups" :key="group" :label="group">
<option v-for="field in fields" :key="field" :value="field">
{{ factors[field] }}
</option>
</optgroup>
</select>
<button class="btn" type="button" @click="addFactor">添加因子</button>
</div>
<div class="factor-list">
<div v-for="(item, index) in scores" :key="item.field">
<label>{{ factors[item.field] }}</label>
<select v-model="item.direction" class="select factor-direction">
<option value="desc">高优</option><option value="asc">低优</option>
</select>
<input v-model.number="item.weight" type="range" min="0" max="100">
<input v-model.number="item.weight" class="input numeric" type="number" min="0" max="100">
<button class="icon-button" type="button" title="移除因子" @click="scores.splice(index, 1)">×</button>
</div>
</div>
</div>
<div class="custom-filters">
<header class="card-header"><h2>过滤与输出</h2></header>
<div class="filter-list">
<div v-for="(item, index) in filters" :key="index">
<select v-model="item.field" class="select">
<option v-for="(label, field) in factors" :key="field" :value="field">{{ label }}</option>
</select>
<select v-model="item.op" class="select"><option>&gt;=</option><option>&gt;</option><option>&lt;=</option><option>&lt;</option><option>==</option><option>!=</option></select>
<input v-model.number="item.value" class="input numeric" type="number">
<button class="icon-button" type="button" title="移除条件" @click="filters.splice(index, 1)">×</button>
</div>
<button class="btn btn-small" type="button" @click="addFilter">添加条件</button>
</div>
<div class="custom-options">
<label class="field"><span>上市天数</span><input v-model.number="listedDays" class="input numeric" type="number" min="0" max="5000"></label>
<label class="field"><span>输出数量</span><input v-model.number="outputLimit" class="input numeric" type="number" min="1" max="50"></label>
<label class="field"><span>最低综合分%</span><input v-model.number="minimumScore" class="input numeric" type="number" min="0" max="100"></label>
<label class="switch-field"><input v-model="excludeSt" type="checkbox"><span>剔除ST与退市风险</span></label>
</div>
<div class="custom-save">
<label class="field"><span>策略名称</span><input v-model="name" class="input" maxlength="30"></label>
<button class="btn btn-primary" type="button" :disabled="busy || total !== 100 || !scores.length" @click="save">保存策略</button>
</div>
</div>
</section>
<section class="card custom-library" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<header class="card-header"><h2>我的策略</h2><span class="tag">手动执行</span></header>
<div class="custom-strategy-list">
<button v-for="item in strategies" :key="item.id" type="button" :class="{ active: selected === item.id }" @click="selected = item.id">
<strong>{{ item.name }}</strong><span> {{ item.version }} </span>
</button>
<div v-if="!strategies.length" class="screener-empty"><span>保存后的自定义策略会出现在这里</span></div>
</div>
<div v-if="current" class="custom-actions">
<button class="btn btn-primary" type="button" :disabled="busy" @click="emit('run', current)">执行选股</button>
<button class="btn" type="button" :disabled="busy" @click="edit(current)">编辑</button>
<button class="btn" type="button" :disabled="busy" @click="emit('remove', current)">删除</button>
</div>
</section>
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { screenerApi, type Candidate, type CustomStrategy, type ScreenerCatalog, type ScreenerFormula, type ScreenerRun, type ScreenerWorkspace } from "../../shared/api/screener";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
import CustomPanel from "./CustomPanel.vue";
import StagePanel from "./StagePanel.vue";
import StrategyPanel from "./StrategyPanel.vue";
const session = useSessionStore();
const market = useMarketStore();
const ui = useUiStore();
const router = useRouter();
const mode = ref<"stage" | "curated" | "custom">("stage");
const catalog = ref<ScreenerCatalog | null>(null);
const workspace = ref<ScreenerWorkspace | null>(null);
const loading = ref(false);
const busy = ref(false);
const error = ref("");
const locked = () => !session.account?.smart_access;
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
catalog.value = await screenerApi.catalog();
workspace.value = locked() ? null : await screenerApi.workspace(market.selectedDate);
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "智能选股数据读取失败";
} finally {
loading.value = false;
}
}
async function saveCustom(name: string, formula: ScreenerFormula): Promise<void> {
busy.value = true;
try { await screenerApi.saveCustom(name, formula); ui.showToast("自定义策略已保存"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
finally { busy.value = false; }
}
async function runCustom(strategy: CustomStrategy): Promise<void> {
busy.value = true;
try { await screenerApi.runCustom(strategy.id, market.selectedDate); ui.showToast("选股计算已完成"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "执行失败"); }
finally { busy.value = false; }
}
async function removeCustom(strategy: CustomStrategy): Promise<void> {
busy.value = true;
try { await screenerApi.deleteCustom(strategy.id); ui.showToast("自定义策略已删除"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "删除失败"); }
finally { busy.value = false; }
}
async function track(run: ScreenerRun, candidate: Candidate): Promise<void> {
try { await screenerApi.addTrack(run.id, candidate.identifier); ui.showToast(`${candidate.name} 已加入策略跟踪`); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "加入跟踪失败"); }
}
onMounted(load);
watch(() => market.selectedDate, load);
</script>
<template>
<main class="page-frame screener-page">
<header class="page-header market-page-header">
<div><h1>智能选股</h1><p class="page-subtitle">数据日期 {{ workspace?.trade_date ?? market.selectedDate }} · 确定性条件与盘后归档</p></div>
<div class="page-actions"><button class="tracking-entry" type="button" @click="router.push('/workspace/screener/tracking')"><span>持续</span><strong>策略跟踪</strong></button><div class="seg-control"><button type="button" :class="{ active: mode === 'stage' }" @click="mode = 'stage'">阶段选股</button><button type="button" :class="{ active: mode === 'curated' }" @click="mode = 'curated'">策略选股</button><button type="button" :class="{ active: mode === 'custom' }" @click="mode = 'custom'">自定义选股</button></div></div>
</header>
<div v-if="locked()" class="notice notice-warning membership-lock"><span><strong>智能选股仅对会员开放</strong>,开通会员后可查看盘后候选并使用自定义选股。</span><button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button></div>
<div v-if="loading" class="card workspace-state">正在读取本地选股归档</div>
<EmptyState v-else-if="error" class="card" title="智能选股暂不可用" :description="error" />
<template v-else-if="catalog">
<StagePanel v-if="mode === 'stage'" :strategies="catalog.stage" :runs="workspace?.stage_runs ?? []" :locked="locked()" @track="track" />
<StrategyPanel v-else-if="mode === 'curated'" :strategies="catalog.curated" :runs="workspace?.curated_runs ?? []" :labels="catalog.factors" :locked="locked()" @track="track" />
<CustomPanel v-else :factors="catalog.factors" :factor-groups="catalog.factor_groups" :strategies="workspace?.custom_strategies ?? []" :runs="workspace?.custom_runs ?? []" :locked="locked()" :busy="busy" @save="saveCustom" @run="runCustom" @remove="removeCustom" @track="track" />
</template>
</main>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Candidate, ScreenerRun, ScreenerStrategy } from "../../shared/api/screener";
import CandidateTable from "./CandidateTable.vue";
const props = defineProps<{ strategies: ScreenerStrategy[]; runs: ScreenerRun[]; locked: boolean }>();
const emit = defineEmits<{ track: [run: ScreenerRun, candidate: Candidate] }>();
const selected = ref(props.runs[0]?.strategy_id ?? props.strategies[0]?.id ?? "");
const run = computed(() => props.runs.find((item) => item.strategy_id === selected.value) ?? props.runs[0]);
const strategy = computed(() => props.strategies.find((item) => item.id === (run.value?.strategy_id ?? selected.value)));
</script>
<template>
<section class="stage-overview card" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<header class="stage-heading">
<div><span>当前阶段自动候选</span><strong>{{ strategy?.display_name ?? "等待盘后判定" }}</strong></div>
<p>{{ strategy?.description ?? "每日收盘数据定稿后自动生成,结果允许为空。" }}</p>
<span class="tag">盘后自动</span>
</header>
<div class="stage-flow" aria-label="自动选股流程">
<span class="done">阶段识别</span><i></i><span class="done">策略匹配</span><i></i><span class="done">自动计算</span><i></i><span>结果归档</span>
</div>
<nav v-if="runs.length > 1" class="stage-run-tabs" aria-label="当日阶段策略">
<button v-for="item in runs" :key="item.id" type="button" :class="{ active: selected === item.strategy_id }" @click="selected = item.strategy_id">{{ item.strategy_name }}</button>
</nav>
</section>
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Candidate, ScreenerRun, ScreenerStrategy } from "../../shared/api/screener";
import CandidateTable from "./CandidateTable.vue";
const props = defineProps<{ strategies: ScreenerStrategy[]; runs: ScreenerRun[]; labels: Record<string, string>; locked: boolean }>();
const emit = defineEmits<{ track: [run: ScreenerRun, candidate: Candidate] }>();
const query = ref("");
const category = ref("全部流派");
const view = ref<"list" | "grid">("list");
const categories = computed(() => ["全部流派", ...new Set(props.strategies.map((item) => item.formula.meta?.category ?? "其他"))]);
const filtered = computed(() => props.strategies.filter((item) => {
const matchCategory = category.value === "全部流派" || item.formula.meta?.category === category.value;
return matchCategory && (!query.value.trim() || item.name.includes(query.value.trim()));
}));
const selected = ref(props.strategies[0]?.id ?? "");
const strategy = computed(() => props.strategies.find((item) => item.id === selected.value) ?? filtered.value[0]);
const run = computed(() => props.runs.find((item) => item.strategy_id === strategy.value?.id));
const regimeLabels: Record<string, string> = { ice: "冰点", repair: "修复", fermentation: "发酵", climax: "高潮", divergence: "分化", retreat: "退潮" };
</script>
<template>
<section class="strategy-layout" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<aside class="card strategy-library">
<header class="card-header"><h2>策略库</h2><span class="tag">{{ filtered.length }} </span></header>
<div class="strategy-tools">
<input v-model="query" class="input" type="search" placeholder="搜索策略">
<select v-model="category" class="select"><option v-for="item in categories" :key="item">{{ item }}</option></select>
<div class="seg-control" aria-label="排列方式"><button type="button" :class="{ active: view === 'list' }" title="列表排列" @click="view = 'list'"></button><button type="button" :class="{ active: view === 'grid' }" title="图标排列" @click="view = 'grid'"></button></div>
</div>
<div class="strategy-items" :class="`is-${view}`">
<button v-for="item in filtered" :key="item.id" type="button" :class="{ active: strategy?.id === item.id }" @click="selected = item.id">
<strong>{{ item.display_name }}</strong><small>{{ item.formula.meta?.category }}</small>
<span :class="['strategy-status', runs.find((run) => run.strategy_id === item.id)?.status]">{{ runs.find((run) => run.strategy_id === item.id)?.status === 'completed' ? '有候选' : runs.find((run) => run.strategy_id === item.id)?.status === 'data_incomplete' ? '数据不足' : '暂无信号' }}</span>
</button>
</div>
</aside>
<article class="card strategy-detail">
<header><div><span>{{ strategy?.formula.meta?.category }}</span><h2>{{ strategy?.display_name }}</h2></div><span class="tag">每日盘后</span></header>
<p>{{ strategy?.description }}</p>
<dl><div><dt>适用环境</dt><dd>{{ strategy?.formula.meta?.suitable_environment }}</dd></div><div><dt>主要失效风险</dt><dd>{{ strategy?.formula.meta?.failure_risk }}</dd></div><div><dt>适用阶段</dt><dd>{{ strategy?.regimes.map((item) => regimeLabels[item]).join(" · ") }}</dd></div></dl>
<div class="strategy-conditions"><h3>选股条件</h3><span v-for="condition in strategy?.formula.filters" :key="`${condition.field}-${condition.op}`">{{ labels[condition.field] }} {{ condition.op }} {{ Array.isArray(condition.value) ? condition.value.join(' ') : condition.value }}</span></div>
</article>
</section>
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { screenerApi, type StrategyTrack } from "../../shared/api/screener";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useUiStore } from "../../shared/stores/ui";
const router = useRouter();
const ui = useUiStore();
const rows = ref<StrategyTrack[]>([]);
const loading = ref(true);
const error = ref("");
async function load(): Promise<void> {
loading.value = true;
try { rows.value = await screenerApi.tracks(); }
catch (reason) { error.value = reason instanceof Error ? reason.message : "策略跟踪读取失败"; }
finally { loading.value = false; }
}
async function remove(row: StrategyTrack): Promise<void> {
try { await screenerApi.removeTrack(row.id); ui.showToast(`${row.name} 已停止跟踪`); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "操作失败"); }
}
function number(value: number | null): string { return value === null ? "" : value.toFixed(2); }
onMounted(load);
</script>
<template>
<main class="page-frame tracking-page">
<header class="page-header"><div><button class="btn btn-ghost" type="button" @click="router.push('/workspace/screener')"> 返回智能选股</button><h1>策略持续跟踪</h1><p class="page-subtitle">仅跟踪手动加入的候选按账户独立保存</p></div></header>
<div v-if="loading" class="card workspace-state">正在读取跟踪记录</div>
<EmptyState v-else-if="error" class="card" title="跟踪记录暂不可用" :description="error" />
<EmptyState v-else-if="!rows.length" class="card" title="暂无跟踪记录" description="从任一候选结果中点击“加入跟踪”后,记录会出现在这里。" />
<section v-else class="card tracking-table"><div class="data-table-wrap"><table class="data-table"><thead><tr><th>代码</th><th>股票</th><th>策略</th><th>入选日期</th><th class="numeric">入选价</th><th class="numeric">T+1开盘%</th><th class="numeric">T+1收盘%</th><th class="numeric">T+3收盘%</th><th class="numeric">T+5收盘%</th><th class="numeric">最大涨幅%</th><th class="numeric">最大回撤%</th><th>操作</th></tr></thead><tbody><tr v-for="row in rows" :key="row.id"><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.strategy_name }}</td><td>{{ row.selection_date }}</td><td class="numeric">{{ number(row.entry_price) }}</td><td class="numeric">{{ number(row.t1_open_return) }}</td><td class="numeric">{{ number(row.t1_return) }}</td><td class="numeric">{{ number(row.t3_return) }}</td><td class="numeric">{{ number(row.t5_return) }}</td><td class="numeric up">{{ number(row.max_gain) }}</td><td class="numeric down">{{ number(row.max_drawdown) }}</td><td><button class="btn btn-small" type="button" @click="remove(row)">停止</button></td></tr></tbody></table></div></section>
</main>
</template>
+110
View File
@@ -0,0 +1,110 @@
import { api } from "./client";
export type FormulaCondition = { field: string; op: string; value: unknown };
export type FormulaScore = { field: string; weight: number; direction: "asc" | "desc" };
export type ScreenerFormula = {
universe: { exclude_st: boolean; listed_days_min: number };
filters: FormulaCondition[];
score: FormulaScore[];
limit: number;
min_score: number;
meta?: Record<string, string>;
};
export type ScreenerStrategy = {
id: string;
version: number;
kind: "stage" | "curated";
name: string;
display_name: string;
description: string;
regimes: string[];
formula: ScreenerFormula;
};
export type Candidate = {
identifier: string;
code: string;
name: string;
sector: string;
close: number | null;
pct_chg: number | null;
amount_billion: number | null;
score_display: number;
reason: string;
risk_flags: string[];
};
export type ScreenerRun = {
id: number;
mode: "stage" | "curated" | "custom";
strategy_id: string;
strategy_name: string;
selection_date: string;
status: "pending" | "running" | "completed" | "no_signal" | "data_incomplete" | "failed";
coverage: number;
missing_fields: string[];
items: Candidate[];
error_message: string;
};
export type CustomStrategy = {
id: number;
name: string;
version: number;
formula: ScreenerFormula;
};
export type ScreenerCatalog = {
factor_groups: Record<string, string[]>;
factors: Record<string, string>;
stage: ScreenerStrategy[];
curated: ScreenerStrategy[];
};
export type ScreenerWorkspace = {
trade_date: string | null;
message: string;
catalog: ScreenerCatalog;
stage_runs: ScreenerRun[];
curated_runs: ScreenerRun[];
custom_strategies: CustomStrategy[];
custom_runs: ScreenerRun[];
};
export type StrategyTrack = {
id: number;
code: string;
name: string;
sector: string;
selection_date: string;
strategy_name: string;
entry_price: number;
t1_open_return: number | null;
t1_return: number | null;
t3_return: number | null;
t5_return: number | null;
max_gain: number | null;
max_drawdown: number | null;
observed_days: number;
};
export const screenerApi = {
catalog(): Promise<ScreenerCatalog> {
return api.get("/screener/catalog");
},
workspace(date: string): Promise<ScreenerWorkspace> {
return api.get(`/screener?date=${encodeURIComponent(date)}`);
},
saveCustom(name: string, formula: ScreenerFormula): Promise<CustomStrategy> {
return api.put("/screener/custom", { name, formula });
},
deleteCustom(id: number): Promise<{ message: string }> {
return api.delete(`/screener/custom/${id}`);
},
runCustom(id: number, date: string): Promise<ScreenerRun> {
return api.post(`/screener/custom/${id}/run?date=${encodeURIComponent(date)}`);
},
tracks(): Promise<StrategyTrack[]> {
return api.get("/screener/tracks");
},
addTrack(runId: number, identifier: string): Promise<{ id: number }> {
return api.post("/screener/tracks", { run_id: runId, identifier });
},
removeTrack(id: number): Promise<{ message: string }> {
return api.delete(`/screener/tracks/${id}`);
},
};
@@ -0,0 +1,102 @@
.screener-page,
.tracking-page { display: grid; align-content: start; gap: var(--layout-gap); overflow-y: auto; }
.screener-page .page-header { margin-bottom: 0; }
.screener-page .page-actions { display: flex; align-items: center; gap: var(--s-10); }
.tracking-entry { min-height: var(--s-36); display: flex; align-items: center; gap: var(--s-8); padding: var(--s-4) var(--s-10); border: var(--s-1) solid var(--color-warning); border-radius: var(--control-radius); color: var(--color-warning); background: var(--color-warning-soft); }
.tracking-entry span { display: grid; place-items: center; width: var(--s-24); height: var(--s-24); border-radius: var(--radius-round); color: var(--color-surface); background: var(--color-warning); font-size: var(--font-10-5); }
.tracking-entry strong { font-size: var(--font-12); }
.stage-overview { overflow: hidden; }
.stage-heading { display: grid; grid-template-columns: var(--s-260) minmax(0, 1fr) auto; align-items: center; gap: var(--s-20); padding: var(--s-16) var(--s-20); }
.stage-heading > div { display: grid; gap: var(--s-4); }
.stage-heading > div span { color: var(--color-text-secondary); font-size: var(--font-11); }
.stage-heading > div strong { font-size: var(--font-18); }
.stage-heading p { color: var(--color-text-secondary); line-height: var(--s-20); }
.stage-flow { display: flex; align-items: center; justify-content: center; gap: var(--s-8); padding: var(--s-10) var(--s-20); border-top: var(--s-1) solid var(--color-divider); background: var(--color-surface-muted); }
.stage-flow span { color: var(--color-text-secondary); font-size: var(--font-11); }
.stage-flow span.done { color: var(--color-down); }
.stage-flow i { width: var(--s-44); height: var(--s-1); background: var(--color-border); }
.stage-run-tabs { display: flex; gap: var(--s-6); overflow-x: auto; padding: var(--s-8) var(--s-12); border-top: var(--s-1) solid var(--color-divider); }
.stage-run-tabs button { padding: var(--s-6) var(--s-10); border-radius: var(--control-radius); color: var(--color-text-secondary); background: var(--color-surface); }
.stage-run-tabs button.active { color: var(--color-primary); background: var(--color-primary-soft); }
.strategy-layout { display: grid; grid-template-columns: var(--s-360) minmax(0, 1fr); gap: var(--layout-gap); min-height: var(--s-360); }
.strategy-library { min-width: 0; overflow: hidden; }
.strategy-library .card-header .tag { margin-left: auto; }
.strategy-tools { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-200) auto; gap: var(--s-6); padding: var(--s-8); border-bottom: var(--s-1) solid var(--color-divider); }
.strategy-tools .input,
.strategy-tools .select { min-height: var(--s-32); padding: var(--s-4) var(--s-8); }
.strategy-items { max-height: var(--s-320); overflow-y: auto; }
.strategy-items.is-list { display: grid; }
.strategy-items.is-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-6); padding: var(--s-6); }
.strategy-items > button { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-2) var(--s-8); padding: var(--s-8) var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; }
.strategy-items.is-grid > button { border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); }
.strategy-items > button:hover,
.strategy-items > button.active { background: var(--color-primary-soft); }
.strategy-items strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.strategy-items small { grid-column: 1; color: var(--color-text-faint); }
.strategy-status { grid-column: 2; grid-row: 1 / span 2; align-self: center; color: var(--color-text-faint); font-size: var(--font-10-5); }
.strategy-status.completed { color: var(--color-up); }
.strategy-status.data_incomplete { color: var(--color-warning); }
.strategy-detail { min-width: 0; padding: var(--s-20); }
.strategy-detail > header { display: flex; justify-content: space-between; gap: var(--s-12); }
.strategy-detail > header div { display: grid; gap: var(--s-6); }
.strategy-detail > header span { color: var(--color-text-faint); font-size: var(--font-11); }
.strategy-detail h2 { font-size: var(--font-18); }
.strategy-detail > p { max-width: var(--s-dialog-wide); margin-top: var(--s-16); color: var(--color-text-secondary); line-height: var(--s-20); }
.strategy-detail dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-10); margin: var(--s-20) 0; }
.strategy-detail dl div { display: grid; gap: var(--s-6); padding: var(--s-10); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface-muted); }
.strategy-detail dt { color: var(--color-text-faint); font-size: var(--font-11); }
.strategy-detail dd { margin: 0; }
.strategy-conditions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-8); }
.strategy-conditions h3 { width: 100%; font-size: var(--font-12); }
.strategy-conditions span { padding: var(--s-6) var(--s-8); border-radius: var(--tag-radius); color: var(--color-text-secondary); background: var(--color-surface-muted); font-size: var(--font-11); }
.screener-results { min-width: 0; overflow: hidden; }
.screener-results .card-header > div { display: flex; align-items: baseline; gap: var(--s-10); }
.screener-results .card-header .tag { margin-left: auto; }
.screener-results .notice { margin: var(--s-12); }
.screener-empty { min-height: var(--s-64); display: grid; place-content: center; gap: var(--s-4); padding: var(--s-20); color: var(--color-text-secondary); text-align: center; }
.screener-empty strong { color: var(--color-text); }
.custom-builder { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-320); overflow: hidden; }
.custom-factors { min-width: 0; border-right: var(--s-1) solid var(--color-divider); }
.custom-factors .card-header .tag { margin-left: auto; }
.factor-add { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-8); padding: var(--s-10); }
.factor-list { display: grid; gap: var(--s-1); background: var(--color-divider); }
.factor-list > div { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-200) var(--s-64) var(--s-32); align-items: center; gap: var(--s-10); padding: var(--s-8) var(--s-10); background: var(--color-surface); }
.factor-list .input { min-height: var(--s-32); padding: var(--s-4) var(--s-6); }
.factor-direction { min-height: var(--s-32); padding: var(--s-4); }
.custom-filters { min-width: 0; }
.filter-list { display: grid; gap: var(--s-6); padding: var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); }
.filter-list > div { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64) var(--s-32); gap: var(--s-6); }
.filter-list .input,
.filter-list .select { min-height: var(--s-32); padding: var(--s-4) var(--s-6); }
.filter-list > .btn { width: fit-content; }
.custom-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-8); padding: var(--s-10); }
.switch-field { grid-column: 1 / -1; display: flex; align-items: center; gap: var(--s-8); color: var(--color-text-secondary); font-size: var(--font-12); }
.custom-save { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--s-8); padding: 0 var(--s-10) var(--s-10); }
.custom-save .btn-primary { width: fit-content; }
.custom-library { overflow: hidden; }
.custom-strategy-list { display: flex; gap: var(--s-8); overflow-x: auto; padding: var(--s-10); }
.custom-strategy-list > button { min-width: var(--s-200); display: flex; justify-content: space-between; gap: var(--s-8); padding: var(--s-10); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface); }
.custom-strategy-list > button.active { border-color: var(--color-primary); background: var(--color-primary-soft); }
.custom-strategy-list span { color: var(--color-text-faint); font-size: var(--font-11); }
.custom-actions { display: flex; gap: var(--s-8); padding: 0 var(--s-10) var(--s-10); }
.tracking-page .page-header > div { display: grid; gap: var(--s-6); }
.tracking-page .page-header .btn { width: fit-content; padding-left: 0; }
.tracking-table { overflow: hidden; }
@media (max-width: 1023px) {
.screener-page .page-actions { width: 100%; align-items: stretch; flex-direction: column; }
.screener-page .seg-control { width: 100%; overflow-x: auto; }
.screener-page .seg-control button { flex: 1; }
.stage-heading,
.strategy-layout,
.custom-builder { grid-template-columns: minmax(0, 1fr); }
.stage-heading { gap: var(--s-8); }
.stage-flow { justify-content: flex-start; overflow-x: auto; }
.strategy-library { max-height: var(--s-400); }
.strategy-detail dl { grid-template-columns: minmax(0, 1fr); }
.custom-factors { border-right: 0; border-bottom: var(--s-1) solid var(--color-divider); }
.factor-list > div { grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64) var(--s-44); }
.factor-list > div input[type="range"] { grid-column: 1 / -1; grid-row: 2; }
.custom-options { grid-template-columns: minmax(0, 1fr); }
.custom-save { grid-template-columns: minmax(0, 1fr); }
}
+172
View File
@@ -0,0 +1,172 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-9");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page, username, password) {
await page.goto("/");
await page.getByLabel("账号名").fill(username);
await page.getByLabel("密码").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
const formula = {
universe: { exclude_st: true, listed_days_min: 120 },
filters: [{ field: "amount_billion", op: ">=", value: 1 }],
score: [
{ field: "return_20d", weight: 0.6, direction: "desc" },
{ field: "sector_strength", weight: 0.4, direction: "desc" },
],
limit: 30,
min_score: 0.5,
meta: {
category: "趋势追踪",
suitable_environment: "趋势明确且成交活跃的市场",
failure_risk: "震荡轮动过快时信号容易反复",
},
};
const stageStrategy = {
id: "stage-ice",
version: 1,
kind: "stage",
name: "冰点抗跌先手",
display_name: "冰点抗跌先手",
description: "寻找冰点中保持相对强度、低波动且有板块承接的个股,允许无结果。",
regimes: ["ice"],
formula,
};
const curated = [
{ ...stageStrategy, id: "curated-01", kind: "curated", name: "连续分红质量", display_name: "连续分红质量" },
{ ...stageStrategy, id: "curated-02", kind: "curated", name: "动态多因子(基础版)", display_name: "动态多因子(基础版)", formula: { ...formula, meta: { ...formula.meta, category: "多因子" } } },
];
const candidate = {
identifier: "000001.SZ",
code: "000001",
name: "平安银行",
sector: "银行",
close: 12.35,
pct_chg: 2.4,
amount_billion: 18.6,
score_display: 86.5,
reason: "中期动量与行业强度居前",
risk_flags: [],
};
function run(id, mode, name, status = "completed") {
return {
id,
mode,
strategy_id: mode === "custom" ? "custom-7" : mode === "stage" ? "stage-ice" : `curated-0${id - 1}`,
strategy_name: name,
selection_date: "2026-07-30",
status,
coverage: 1,
missing_fields: [],
items: status === "completed" ? [candidate] : [],
error_message: "",
};
}
const catalog = {
factor_groups: { "行情与动量": ["return_20d", "amount_billion"], "板块与行业": ["sector_strength"] },
factors: { return_20d: "20日涨幅", amount_billion: "成交额", sector_strength: "板块强度" },
stage: [stageStrategy],
curated,
};
const workspace = {
trade_date: "2026-07-30",
message: "",
catalog,
stage_runs: [run(1, "stage", "冰点抗跌先手")],
curated_runs: [run(2, "curated", "连续分红质量"), run(3, "curated", "动态多因子(基础版)", "no_signal")],
custom_strategies: [{ id: 7, name: "我的选股策略", version: 2, formula }],
custom_runs: [run(4, "custom", "我的选股策略")],
};
const track = {
id: 9,
code: "000001",
name: "平安银行",
sector: "银行",
selection_date: "2026-07-30",
strategy_name: "连续分红质量",
entry_price: 12.35,
t1_open_return: 1.2,
t1_return: 2.4,
t3_return: 4.1,
t5_return: 6.8,
max_gain: 8.2,
max_drawdown: -1.6,
observed_days: 5,
};
async function mockScreener(page) {
await page.route("**/api/screener/catalog", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(catalog) }));
await page.route("**/api/screener?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(workspace) }));
await page.route("**/api/screener/tracks", (route) => {
const body = route.request().method() === "GET" ? [track] : { id: 9 };
return route.fulfill({ contentType: "application/json", body: JSON.stringify(body) });
});
}
test("stage 9 screening preserves deterministic modes, explicit tracking and responsive layout", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => {
if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text());
});
await mockScreener(page);
await authenticate(page, "stage4admin", "Stage4-pass-123!");
await page.goto("/workspace/screener");
await expect(page.getByText("当前阶段自动候选")).toBeVisible();
await expect(page.getByText("中期动量与行业强度居前")).toBeVisible();
await page.getByRole("button", { name: "加入跟踪" }).click();
await expect(page.getByRole("status")).toContainText("已加入策略跟踪");
await page.screenshot({ path: path.join(evidence, "stage-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("button", { name: "策略选股" }).click();
await expect(page.getByText("策略库")).toBeVisible();
await expect(page.getByText("动态多因子(基础版)", { exact: true })).toBeVisible();
await page.getByTitle("图标排列").click();
await expect(page.locator(".strategy-items")).toHaveClass(/is-grid/);
await page.getByRole("button", { name: "自定义选股" }).click();
await expect(page.getByText("合计 100%")).toBeVisible();
await expect(page.getByRole("button", { name: "我的选股策略 第 2 版" })).toBeVisible();
await page.getByRole("button", { name: "夜间" }).click();
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await page.screenshot({ path: path.join(evidence, "custom-dark-390x844.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("button", { name: "持续 策略跟踪" }).click();
await expect(page.getByRole("heading", { name: "策略持续跟踪" })).toBeVisible();
await expect(page.locator(".topbar-title")).toHaveText("智能选股");
await expect(page.getByText("6.80", { exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await page.screenshot({ path: path.join(evidence, "tracking-dark-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
test("nonmembers see the same screening structure in a disabled state", async ({ page }) => {
await page.route("**/api/screener/catalog", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(catalog) }));
await authenticate(page, "stage4user", "Stage4-user-123!");
await page.goto("/workspace/screener");
await expect(page.getByText("智能选股仅对会员开放")).toBeVisible();
await expect(page.getByText("当前阶段自动候选")).toBeVisible();
await expect(page.locator(".stage-overview")).toHaveAttribute("aria-disabled", "true");
await page.getByRole("button", { name: "自定义选股" }).click();
await expect(page.getByText("因子与权重")).toBeVisible();
await expect(page.locator(".custom-builder")).toHaveAttribute("aria-disabled", "true");
});
+9 -2
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7)
assert {
"users",
"memberships",
@@ -128,8 +128,15 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"market_insight_snapshots",
"seat_aliases",
"watchlist_entries",
"screener_factor_snapshots",
"screener_factor_values",
"screener_runs",
"custom_screener_strategies",
"strategy_tracks",
"strategy_track_bars",
"strategy_track_events",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (6, 5, 4, 3, 2, 1)
assert runner.downgrade(MIGRATIONS, target_version=0) == (7, 6, 5, 4, 3, 2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)
+333
View File
@@ -0,0 +1,333 @@
from __future__ import annotations
import json
import pytest
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
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.repository import ScreenerRepository, decode_track
from backend.features.screener.service import automatic_strategies
def _formula(field: str = "close", *, minimum: float = 0) -> dict:
return {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [{"field": field, "op": ">", "value": minimum}],
"score": [{"field": field, "weight": 1.0, "direction": "desc"}],
"limit": 10,
"min_score": 0,
}
def _row(identifier: str, close: float | None) -> dict:
return {
"identifier": identifier,
"code": identifier.split(".")[0],
"name": identifier,
"sector": "测试行业",
"listed_days": 500,
"is_st": False,
"close": close,
"pct_chg": 1,
"amount_billion": 2,
}
def _users(database: Database) -> None:
with database.transaction() as connection:
connection.executemany(
"""
INSERT INTO users (
id, username, username_key, password_hash, is_admin,
status, created_at, updated_at
) VALUES (?, ?, ?, 'hash', 0, 'active', '2026-07-30', '2026-07-30')
""",
((1, "account-a", "account-a"), (2, "account-b", "account-b")),
)
def _snapshot(database: Database, repository: ScreenerRepository) -> int:
with database.transaction() as connection:
return repository.save_factor_snapshot(
connection,
trade_date="2026-07-30",
version="fixture-v1",
observed_at="2026-07-30T15:10:00+08:00",
state="final",
sources=["fixture"],
coverage={"market": 1},
rows=[_row("000001.SZ", 10)],
)
def test_catalog_has_the_exact_reviewed_scope() -> None:
factors = factor_catalog()
strategies = strategy_catalog()
assert len(factors["factors"]) == 109
assert len(strategies) == 36
assert sum(item["kind"] == "stage" for item in strategies) == 7
assert sum(item["kind"] == "curated" for item in strategies) == 29
assert {item["id"] for item in strategies if item["kind"] == "curated"} == {
f"curated-{index:02d}" for index in range(1, 30)
}
def test_curated_strategies_run_independently_of_emotion_phase() -> None:
for regime in ("ice", "repair", "fermentation", "climax", "divergence", "retreat"):
_stage, curated = automatic_strategies(regime)
assert len(curated) == 29
assert {item["id"] for item in curated} == {
f"curated-{index:02d}" for index in range(1, 30)
}
def test_formula_is_deterministic_and_best_value_scores_first() -> None:
rows = [_row("000002.SZ", 20), _row("000001.SZ", 20), _row("000003.SZ", 10)]
first = execute_formula(rows, _formula(), {"market": 1})
second = execute_formula(list(reversed(rows)), _formula(), {"market": 1})
assert first == second
assert [item["identifier"] for item in first["items"]] == [
"000001.SZ",
"000002.SZ",
"000003.SZ",
]
assert first["items"][0]["score"] == 1
assert first["items"][-1]["score"] == 0
def test_missing_required_factor_and_complete_no_match_are_distinct() -> None:
incomplete = execute_formula(
[{**_row("000001.SZ", 10), "roic": None}],
_formula("roic"),
{"financial": 1},
)
no_signal = execute_formula(
[_row("000001.SZ", 10)],
_formula(minimum=100),
{"market": 1},
)
assert incomplete["status"] == "data_incomplete"
assert incomplete["missing_fields"] == ["roic"]
assert no_signal["status"] == "no_signal"
assert no_signal["missing_fields"] == []
def test_formula_weights_must_total_one_hundred_percent() -> None:
formula = _formula()
formula["score"][0]["weight"] = 0.9
with pytest.raises(CatalogError, match="100%"):
validate_formula(formula)
def test_formula_rejects_invalid_comparisons_and_duplicate_scores() -> None:
malformed = _formula()
malformed["filters"][0] = {"field": "close", "op": "between", "value": [20]}
with pytest.raises(CatalogError, match="两个边界"):
validate_formula(malformed)
duplicate = _formula()
duplicate["score"].append({"field": "close", "weight": 0.5, "direction": "desc"})
duplicate["score"][0]["weight"] = 0.5
with pytest.raises(CatalogError, match="不能重复"):
validate_formula(duplicate)
def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
database = Database(tmp_path / "screener.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = ScreenerRepository()
_users(database)
snapshot_id = _snapshot(database, repository)
with database.transaction() as connection:
first = repository.save_custom_strategy(connection, 1, "我的策略", _formula())
repository.save_custom_strategy(connection, 2, "我的策略", _formula())
run = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
repository.finish_run(
connection,
int(run["id"]),
status="completed",
coverage=1,
missing_fields=[],
result=[{**_row("000001.SZ", 10), "score": 1}],
)
repository.add_track(
connection,
user_id=1,
run=run,
candidate={**_row("000001.SZ", 10), "score": 1},
)
with database.read() as connection:
assert [row["id"] for row in repository.custom_strategies(connection, 1)] == [first["id"]]
assert len(repository.custom_strategies(connection, 2)) == 1
assert len(repository.tracks(connection, 1)) == 1
assert repository.tracks(connection, 2) == ()
def test_running_a_strategy_never_creates_tracking_rows(tmp_path) -> None:
database = Database(tmp_path / "runs.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = ScreenerRepository()
_users(database)
snapshot_id = _snapshot(database, repository)
with database.transaction() as connection:
run = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
repository.finish_run(
connection,
int(run["id"]),
status="completed",
coverage=1,
missing_fields=[],
result=[{**_row("000001.SZ", 10), "score": 1}],
)
with database.read() as connection:
assert repository.tracks(connection, 1) == ()
stored = connection.execute(
"SELECT result_json FROM screener_runs WHERE id = ?", (run["id"],)
).fetchone()
assert len(json.loads(stored["result_json"])) == 1
def test_tracking_statistics_and_milestone_events_are_persistent_and_idempotent(tmp_path) -> None:
database = Database(tmp_path / "tracking.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = ScreenerRepository()
_users(database)
snapshot_id = _snapshot(database, repository)
with database.transaction() as connection:
run = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
repository.finish_run(
connection,
int(run["id"]),
status="completed",
coverage=1,
missing_fields=[],
result=[{**_row("000001.SZ", 10), "score": 1}],
)
track_id = repository.add_track(
connection,
user_id=1,
run=run,
candidate={**_row("000001.SZ", 10), "score": 1},
)
bars = (
("2026-07-31", 10.5, 11.2, 9.5, 11.0),
("2026-08-03", 11.0, 11.5, 10.0, 10.5),
("2026-08-04", 10.5, 12.5, 10.2, 12.0),
("2026-08-05", 12.0, 12.2, 9.0, 11.5),
("2026-08-06", 11.5, 14.0, 11.0, 13.0),
)
for trade_date, open_price, high, low, close in bars:
repository.save_track_bar(
connection,
track_id,
trade_date,
{"open": open_price, "high": high, "low": low, "close": close},
)
assert repository.record_track_event(connection, track_id, "t1") is True
assert repository.record_track_event(connection, track_id, "t1") is False
assert repository.record_track_event(connection, track_id, "t5") is True
assert repository.record_track_event(connection, track_id, "t5") is False
with database.read() as connection:
track = repository.tracks(connection, 1)[0]
decoded = decode_track(track, repository.track_bars(connection, track_id))
events = connection.execute(
"SELECT milestone FROM strategy_track_events WHERE track_id = ? ORDER BY milestone",
(track_id,),
).fetchall()
assert decoded["t1_open_return"] == 5
assert decoded["t1_return"] == 10
assert decoded["t3_return"] == 20
assert decoded["t5_return"] == 30
assert decoded["max_gain"] == 40
assert decoded["max_drawdown"] == -10
assert decoded["observed_days"] == 5
assert [row["milestone"] for row in events] == ["t1", "t5"]
def test_repeated_after_close_run_uses_one_persistent_run(tmp_path) -> None:
database = Database(tmp_path / "idempotent.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = ScreenerRepository()
_users(database)
snapshot_id = _snapshot(database, repository)
with database.transaction() as connection:
first = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
repository.finish_run(
connection,
int(first["id"]),
status="no_signal",
coverage=1,
missing_fields=[],
result=[],
)
repeated = repository.begin_run(
connection,
owner_user_id=None,
mode="curated",
strategy_id="curated-01",
strategy_name="测试策略",
strategy_version=1,
selection_date="2026-07-30",
factor_snapshot_id=snapshot_id,
)
count = connection.execute("SELECT COUNT(*) AS total FROM screener_runs").fetchone()
assert repeated["id"] == first["id"]
assert count["total"] == 1
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import httpx
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from tests.support import run_scenario
from tests.test_accounts import (
ADMIN_PASSWORD,
USER_PASSWORD,
csrf_headers,
register,
use_session,
)
def _formula() -> dict:
return {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [{"field": "close", "op": ">", "value": 1}],
"score": [{"field": "close", "weight": 1, "direction": "desc"}],
"limit": 10,
"min_score": 0.5,
}
def test_screener_catalog_lock_and_custom_strategy_boundary(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin = await register(client, "admin-screener", ADMIN_PASSWORD)
client.cookies.clear()
_, regular = await register(client, "regular-screener", USER_PASSWORD)
catalog = await client.get("/api/screener/catalog")
assert catalog.status_code == 200
assert len(catalog.json()["factors"]) == 109
locked = await client.get("/api/screener?date=2026-07-30")
assert locked.status_code == 403
assert locked.json()["error"]["code"] == "membership_required"
rejected = await client.put(
"/api/screener/custom",
headers=csrf_headers(regular),
json={"name": "越权策略", "formula": _formula()},
)
assert rejected.status_code == 403
use_session(client, admin)
workspace = await client.get("/api/screener?date=2026-07-30")
assert workspace.status_code == 200
assert workspace.json()["trade_date"] is None
saved = await client.put(
"/api/screener/custom",
headers=csrf_headers(admin),
json={"name": "我的策略", "formula": _formula()},
)
assert saved.status_code == 200
assert saved.json()["name"] == "我的策略"
refreshed = await client.get("/api/screener?date=2026-07-30")
assert [item["name"] for item in refreshed.json()["custom_strategies"]] == ["我的策略"]
run_scenario(application, scenario)