rebuild(stage-9): deliver deterministic intelligent screening
This commit is contained in:
@@ -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="小白复盘",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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), ())
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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] = []
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Application-owned background jobs."""
|
||||
@@ -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
|
||||
@@ -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
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>=</option><option>></option><option><=</option><option><</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>
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user