refactor: establish standalone application boundary

This commit is contained in:
leefer
2026-08-03 21:42:25 +08:00
parent 656f28a96d
commit d6def3af15
322 changed files with 73872 additions and 44656 deletions
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
from backend.features.accounts.service import AccountService
class AccountApplicationMixin:
def bind_user(self, user_id: int) -> None:
self._request_context.user_id = int(user_id)
encrypted = self.database.get_user_credentials(int(user_id))
self._request_context.credentials = self.vault.decrypt_json(encrypted) if encrypted else {}
self._request_context.access = self.database.user_access(int(user_id)) or {}
@property
def current_user_id(self) -> int:
user_id = getattr(self._request_context, "user_id", 0)
if not user_id:
raise ValueError("当前请求尚未绑定账号。")
return int(user_id)
def membership(self) -> dict[str, Any]:
return self.accounts.membership()
def admin_users(self) -> list[dict[str, Any]]:
return self.accounts.admin_users(self._platform_usage_today_for_user)
def update_membership(self, payload: dict[str, Any]) -> None:
self.accounts.update_membership(payload)
def register_account(self, username: str, password: str) -> dict[str, Any]:
return self.accounts.register(username, password)
def login_account(self, username: str, password: str) -> dict[str, Any]:
return self.accounts.login(username, password)
def change_password(self, current_password: str, new_password: str) -> None:
self.accounts.change_password(current_password, new_password)
def create_account_session(self, user: dict[str, Any]) -> dict[str, Any]:
return self.accounts.create_session(user)
@staticmethod
def _validate_account_input(username: str, password: str) -> None:
AccountService.validate_input(username, password)
def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.accounts.save_birth_profile(payload)
def stored_birth_profile(self) -> dict[str, str] | None:
return self.accounts.stored_birth_profile()
def account_personal_field(
self,
current_date: str,
current_field: dict[str, Any],
public: bool = False,
) -> dict[str, Any] | None:
return self.accounts.personal_field(current_date, current_field, public)
@staticmethod
def _public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]:
return AccountService.public_personal_profile(personal)
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
class AccountRoutesMixin:
def _handle_accounts_public_get(self, parsed) -> bool:
if parsed.path == "/api/auth/me":
self.auth_me()
return True
return False
def _handle_accounts_get(self, parsed) -> bool:
if parsed.path == "/api/account/status":
self.send_json({"ok": True, **self.application_service.status()})
return True
return False
def _handle_accounts_delete(self, parsed) -> bool:
if parsed.path == "/api/account/birth-profile":
deleted = self.application_service.database.delete_user_birth_profile(self.application_service.current_user_id)
self.send_json({"ok": True, "deleted": deleted})
return True
return False
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class AlertRoutesMixin:
def _handle_alerts_get(self, parsed) -> bool:
if parsed.path == "/api/alerts":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.alert_center(
query.get("status", ["all"])[0],
query.get("as_of", [date.today().isoformat()])[0],
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_alerts_post(self, parsed) -> bool:
alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path)
if alert_read_match:
self.send_json(
{"ok": True, **self.application_service.mark_alert_read(int(alert_read_match.group(1)))}
)
return True
if parsed.path == "/api/alerts/read-all":
body = self.read_json_body(True)
self.send_json(
{"ok": True, **self.application_service.mark_all_alerts_read(str(body.get("as_of") or ""))}
)
return True
return False
def _handle_alerts_delete(self, parsed) -> bool:
alert_match = re.fullmatch(r"/api/alerts/(\d+)", parsed.path)
if alert_match:
self.send_json(
{"ok": True, **self.application_service.delete_alert(int(alert_match.group(1)))}
)
return True
return False
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.data.providers.tushare_client import TushareError
class AuctionRoutesMixin:
def _handle_auction_get(self, parsed) -> bool:
if parsed.path == "/api/auction":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.auction_center(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import json
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.bootstrap.config import validate_text
class DragonTigerRoutesMixin:
def _handle_dragon_tiger_get(self, parsed) -> bool:
if parsed.path == "/api/dragon-tiger":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(self.application_service.get_dragon_tiger(trade_date, force))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/dragon-tiger/profiles":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.get_hot_money_profiles(
query.get("force", ["0"])[0] == "1"
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/seat-aliases":
self.send_json({"items": self.application_service.database.list_seat_aliases()})
return True
return False
def save_seat_alias(self) -> None:
try:
body = self.read_json_body()
seat_name = validate_text(body.get("seat_name"), "席位名称", 200, required=True)
alias = validate_text(body.get("alias"), "席位别名", 50, required=True)
self.application_service.database.save_seat_alias(seat_name, alias)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+412
View File
@@ -0,0 +1,412 @@
from __future__ import annotations
import copy
from typing import Any
from backend.bootstrap.config import validate_text
from backend.data.providers.tushare_client import _sector_coverage_issue
from backend.features.heaven.engine import _market_line_scores, _score_to_line
class HeavenManualMixin:
@staticmethod
def _heaven_manual_schema(market_mode: str) -> dict[str, dict[str, Any]]:
intraday = market_mode == "intraday"
fields = {
"stock_amount_percentile": {"line": 1, "label": "成交额全市场分位", "unit": "%", "min": 0, "max": 100},
"stock_turnover_rate": {"line": 1, "label": "个股换手率", "unit": "%", "min": 0, "max": 100},
"stock_turnover_relative": {"line": 1, "label": "相对市场换手", "unit": "", "min": 0, "max": 20},
"stock_volume_activity_ratio": {"line": 1, "label": "同进度量能", "unit": "", "min": 0, "max": 20},
"stock_seal_amount_million": {"line": 1, "label": "封单金额", "unit": "万元", "min": 0, "max": 100000000},
"stock_open_times": {"line": 1, "label": "开板次数", "unit": "", "min": 0, "max": 100, "integer": True},
"stock_change": {"line": 2, "label": "个股涨跌幅", "unit": "%", "min": -100, "max": 100},
"stock_streak": {"line": 2, "label": "连板高度", "unit": "", "min": 0, "max": 100, "integer": True},
"stock_status": {"line": 2, "label": "个股状态", "type": "select", "options": ["普通", "涨停", "炸板", "跌停"]},
"sector_name": {"line": [3, 4], "label": "申万二级行业", "type": "text", "max_length": 50},
"sector_up_count": {"line": 3, "label": "行业上涨家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"sector_down_count": {"line": 3, "label": "行业下跌家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "", "min": 0, "max": 20},
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
"sector_change": {"line": 4, "label": "申万官方涨跌幅", "unit": "%", "min": -100, "max": 100},
"sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
"market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "", "min": 0, "max": 100},
"market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
"market_amount_billion": {"line": 5, "label": "两市成交额", "unit": "亿元", "min": 0, "max": 10000000},
"market_recent_average_amount_billion": {"line": 5, "label": "近期平均成交额", "unit": "亿元", "min": 0, "max": 10000000},
"market_up_count": {"line": 5, "label": "上涨家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"market_down_count": {"line": 5, "label": "下跌家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"market_limit_up_count": {"line": 5, "label": "涨停家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"market_limit_down_count": {"line": 5, "label": "跌停家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"index_sh_change": {"line": 6, "label": "上证指数涨跌幅", "unit": "%", "min": -20, "max": 20},
"index_sz_change": {"line": 6, "label": "深证成指涨跌幅", "unit": "%", "min": -20, "max": 20},
"index_cy_change": {"line": 6, "label": "创业板指涨跌幅", "unit": "%", "min": -20, "max": 20},
"note": {"line": [], "label": "补录说明", "type": "text", "max_length": 200},
}
if intraday:
for key in ("stock_seal_amount_million", "stock_open_times"):
fields.pop(key)
else:
for key in ("stock_turnover_relative", "stock_volume_activity_ratio", "sector_relative_turnover"):
fields.pop(key)
return fields
@classmethod
def _validate_heaven_manual_data(
cls, raw: Any, market_mode: str
) -> dict[str, Any]:
if raw in (None, ""):
return {}
if not isinstance(raw, dict):
raise ValueError("六爻补录数据格式不正确。")
schema = cls._heaven_manual_schema(market_mode)
unknown = set(raw) - set(schema)
if unknown:
raise ValueError(f"六爻补录包含未知字段:{next(iter(sorted(unknown)))}")
values: dict[str, Any] = {}
for key, value in raw.items():
if value is None or (isinstance(value, str) and not value.strip()):
continue
spec = schema[key]
if spec.get("type") == "text":
values[key] = validate_text(value, spec["label"], int(spec["max_length"]))
continue
if spec.get("type") == "select":
text = str(value).strip()
if text not in spec["options"]:
raise ValueError(f"{spec['label']}不在允许范围内。")
values[key] = text
continue
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{spec['label']}必须是数字。") from exc
if number < float(spec["min"]) or number > float(spec["max"]):
raise ValueError(
f"{spec['label']}应在 {spec['min']}{spec['max']} 之间。"
)
values[key] = int(number) if spec.get("integer") else number
return values
@staticmethod
def _apply_heaven_manual_data(
dashboard: dict[str, Any],
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
manual_data: dict[str, Any],
market_mode: str,
trade_date: str,
stock_code: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
dashboard = copy.deepcopy(dashboard)
index_context = copy.deepcopy(index_context or {})
sector = copy.deepcopy(sector or {})
stock = copy.deepcopy(stock or {})
overview = dashboard.setdefault("overview", {})
stock_map = {
"stock_amount_percentile": "amount_percentile",
"stock_turnover_rate": "turnover_rate",
"stock_turnover_relative": "turnover_relative",
"stock_volume_activity_ratio": "volume_activity_ratio",
"stock_seal_amount_million": "seal_amount_million",
"stock_open_times": "open_times",
"stock_change": "change",
"stock_streak": "streak",
"stock_status": "status",
}
sector_map = {
"sector_name": "name",
"sector_up_count": "up_count",
"sector_down_count": "down_count",
"sector_coverage": "coverage",
"sector_relative_turnover": "relative_turnover",
"sector_member_equal_change": "member_equal_change",
"sector_change": "change",
"sector_leading_pct": "leading_pct",
}
overview_map = {
"market_sentiment_score": "sentiment_score",
"market_seal_rate": "seal_rate",
"market_amount_billion": "amount_billion",
"market_recent_average_amount_billion": "recent_average_amount_billion",
"market_up_count": "up_count",
"market_down_count": "down_count",
"market_limit_up_count": "limit_up_count",
"market_limit_down_count": "limit_down_count",
}
for manual_key, target in stock_map.items():
if manual_key in manual_data:
stock[target] = manual_data[manual_key]
for manual_key, target in sector_map.items():
if manual_key in manual_data:
sector[target] = manual_data[manual_key]
for manual_key, target in overview_map.items():
if manual_key in manual_data:
overview[target] = manual_data[manual_key]
if any(key.startswith("stock_") for key in manual_data):
stock.setdefault("code", stock_code)
stock.setdefault("name", stock_code or "--")
stock["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical"
if market_mode == "intraday" and "stock_volume_activity_ratio" in manual_data:
stock["activity_source"] = "user_supplied"
if any(key.startswith("sector_") for key in manual_data):
sector["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical"
sector.setdefault("taxonomy", "sw_l2")
index_keys = (
("index_sh_change", "000001.SH", "上证指数"),
("index_sz_change", "399001.SZ", "深证成指"),
("index_cy_change", "399006.SZ", "创业板指"),
)
rows = {str(row.get("ts_code") or row.get("code") or ""): dict(row) for row in index_context.get("indices") or []}
for manual_key, code, name in index_keys:
if manual_key not in manual_data:
continue
row = rows.get(code, {"ts_code": code, "name": name})
row.update({"pct_chg": manual_data[manual_key], "trade_date": trade_date})
rows[code] = row
ordered_rows = [rows.get(code) for _, code, _ in index_keys]
if all(ordered_rows):
index_context["indices"] = ordered_rows
changes = [float(row.get("pct_chg") or 0) for row in ordered_rows]
aggregate = dict(index_context.get("aggregate") or {})
aggregate["average_pct_chg"] = sum(changes) / 3
index_context["aggregate"] = aggregate
return dashboard, index_context, sector, stock
@classmethod
def _heaven_line_checks(
cls,
trade_date: str,
dashboard: dict[str, Any],
recent_history: list[dict[str, Any]],
index_context: dict[str, Any],
sector: dict[str, Any],
stock: dict[str, Any],
market_mode: str,
manual_data: dict[str, Any],
) -> list[dict[str, Any]]:
intraday = market_mode == "intraday"
closed = market_mode == "closed"
schema = cls._heaven_manual_schema(market_mode)
required = {
1: (["stock_amount_percentile", "stock_turnover_relative", "stock_volume_activity_ratio"] if intraday else ["stock_amount_percentile", "stock_turnover_rate", "stock_seal_amount_million", "stock_open_times"]),
2: ["stock_change", "stock_streak", "stock_status"],
3: (["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_relative_turnover"] if intraday else ["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_member_equal_change", "sector_leading_pct"]),
4: ["sector_name", "sector_change", "sector_leading_pct"],
5: ["market_sentiment_score", "market_seal_rate", "market_amount_billion", "market_recent_average_amount_billion", "market_up_count", "market_down_count", "market_limit_up_count", "market_limit_down_count"],
6: ["index_sh_change", "index_sz_change", "index_cy_change"],
}
names = {
1: ("初爻", "个股内核", "成交活跃、换手与量能"),
2: ("二爻", "个股外显", "涨跌、连板与状态"),
3: ("三爻", "行业内核", "行业宽度与成交活跃"),
4: ("四爻", "行业外显", "行业涨跌与领涨表现"),
5: ("五爻", "市场内核", "情绪、封板、成交与市场宽度"),
6: ("上爻", "指数外显", "三大指数当日涨跌"),
}
index_date = str(index_context.get("trade_date") or "").replace("-", "")
index_rows = list(index_context.get("indices") or [])
index_dates = {str(row.get("trade_date") or "").replace("-", "") for row in index_rows}
index_issues = []
if len(index_rows) < 3:
index_issues.append(f"三大指数仅取得 {len(index_rows)}/3 条行情")
elif index_date != trade_date or index_dates != {trade_date}:
actual_dates = "".join(sorted(value for value in index_dates if value)) or "未知"
index_issues.append(f"指数实际日期为 {actual_dates},目标交易日为 {trade_date}")
elif not index_context.get("precise"):
index_issues.append("三大指数行情未通过完整性校验")
elif intraday and not index_context.get("realtime"):
index_issues.append("盘中缺少可核验的实时指数行情")
elif not intraday and (index_context.get("realtime") or str(index_context.get("source") or "") != "tushare"):
index_issues.append("收盘或历史行情不是官方指数日线")
sector_date = str(sector.get("trade_date") or "").replace("-", "")
sector_coverage = float(sector.get("coverage") or 0)
sector_explained_count = int(
sector.get("explained_count")
if sector.get("explained_count") is not None
else sector.get("quote_count") or 0
)
sector_explained_coverage = float(
sector.get("explained_coverage")
if sector.get("explained_coverage") is not None
else sector_coverage
)
sector_coverage_issue = _sector_coverage_issue(
int(sector.get("member_count") or 0),
int(sector.get("quote_count") or 0),
sector_explained_coverage,
sector_explained_count,
)
sector_common = []
if not sector:
sector_common.append("未取得申万二级行业归属")
elif sector.get("taxonomy") != "sw_l2":
sector_common.append("行业分类不是申万二级")
elif sector_date != trade_date:
sector_common.append("行业行情日期与目标交易日不一致")
elif intraday and not sector.get("realtime"):
sector_common.append("盘中行业行情不是申万实时行情")
elif market_mode == "historical" and sector.get("realtime"):
sector_common.append("历史行业行情不能使用实时快照")
elif closed and sector.get("realtime") and not sector.get("finalized"):
sector_common.append("收盘行业实时行情尚未形成15:00最终快照")
sector_inner = list(sector_common)
sector_outer = list(sector_common)
if not sector.get("inner_precise", sector.get("precise")):
sector_inner.append(str(sector.get("inner_error") or sector.get("error") or "行业内核数据未通过校验"))
if not sector.get("outer_precise", sector.get("precise")):
sector_outer.append(str(sector.get("outer_error") or sector.get("error") or "行业外显数据未通过校验"))
if sector and sector_coverage_issue and sector_coverage_issue not in sector_inner:
sector_inner.append(sector_coverage_issue)
if sector.get("realtime") and not sector.get("relative_turnover"):
sector_inner.append("缺少行业相对全市场换手活跃度")
stock_date = str(stock.get("trade_date") or "").replace("-", "")
stock_common = []
if not stock.get("code"):
stock_common.append("尚未载入有效个股")
elif stock_date != trade_date:
stock_common.append(f"个股实际日期为 {stock_date or '未知'},目标交易日为 {trade_date}")
elif not stock.get("precise"):
stock_common.append("个股行情未通过完整性校验")
elif intraday and not stock.get("realtime"):
stock_common.append("盘中个股行情不是实时行情")
elif not intraday and (stock.get("realtime") or str(stock.get("data_source") or "") != "tushare"):
stock_common.append("收盘或历史个股行情不是官方日线")
stock_inner = list(stock_common)
if intraday and stock.get("turnover_source") in {None, "", "unavailable"}:
stock_inner.append("缺少可核验的实时换手率")
if intraday and stock.get("activity_source") in {None, "", "unavailable"}:
stock_inner.append("缺少同时间进度量能基准")
overview = dashboard.get("overview") or {}
market_key_map = {
"market_sentiment_score": "sentiment_score", "market_seal_rate": "seal_rate",
"market_amount_billion": "amount_billion", "market_recent_average_amount_billion": "recent_average_amount_billion",
"market_up_count": "up_count", "market_down_count": "down_count",
"market_limit_up_count": "limit_up_count", "market_limit_down_count": "limit_down_count",
}
market_issues = []
for manual_key, source_key in market_key_map.items():
if source_key == "recent_average_amount_billion":
history_values = [item.get("amount_billion") for item in recent_history[:-1] if item.get("amount_billion") is not None]
if source_key not in overview and not history_values:
market_issues.append(f"缺少{schema[manual_key]['label']}")
elif source_key not in overview or overview.get(source_key) is None:
market_issues.append(f"缺少{schema[manual_key]['label']}")
automatic_issues = {
1: stock_inner, 2: stock_common, 3: sector_inner,
4: sector_outer, 5: market_issues, 6: index_issues,
}
limits = list(dashboard.get("limits") or [])
scores = _market_line_scores(dashboard, recent_history, index_context, sector, stock, limits)
value_map: dict[str, Any] = {
"stock_amount_percentile": stock.get("amount_percentile"),
"stock_turnover_rate": stock.get("turnover_rate"),
"stock_turnover_relative": stock.get("turnover_relative"),
"stock_volume_activity_ratio": stock.get("volume_activity_ratio"),
"stock_seal_amount_million": stock.get("seal_amount_million"),
"stock_open_times": stock.get("open_times"),
"stock_change": stock.get("change"), "stock_streak": stock.get("streak"),
"stock_status": stock.get("status"), "sector_name": sector.get("name"),
"sector_up_count": sector.get("up_count"), "sector_down_count": sector.get("down_count"),
"sector_coverage": sector.get("coverage"), "sector_relative_turnover": sector.get("relative_turnover"),
"sector_member_equal_change": sector.get("member_equal_change"),
"sector_change": sector.get("change"), "sector_leading_pct": sector.get("leading_pct"),
"market_sentiment_score": overview.get("sentiment_score"), "market_seal_rate": overview.get("seal_rate"),
"market_amount_billion": overview.get("amount_billion"),
"market_recent_average_amount_billion": overview.get("recent_average_amount_billion"),
"market_up_count": overview.get("up_count"), "market_down_count": overview.get("down_count"),
"market_limit_up_count": overview.get("limit_up_count"), "market_limit_down_count": overview.get("limit_down_count"),
}
history_values = [float(item.get("amount_billion")) for item in recent_history[:-1] if item.get("amount_billion") is not None]
if value_map["market_recent_average_amount_billion"] is None and history_values:
value_map["market_recent_average_amount_billion"] = sum(history_values) / len(history_values)
if value_map["stock_amount_percentile"] is None and not intraday:
amount = float(stock.get("amount_billion") or 0)
amounts = [float(item.get("amount_billion") or 0) for item in limits if item.get("amount_billion") is not None]
value_map["stock_amount_percentile"] = (
sum(item <= amount for item in amounts) / len(amounts) * 100 if amounts else None
)
row_by_code = {str(row.get("ts_code") or row.get("code") or ""): row for row in index_context.get("indices") or []}
value_map.update({
"index_sh_change": (row_by_code.get("000001.SH") or {}).get("pct_chg"),
"index_sz_change": (row_by_code.get("399001.SZ") or {}).get("pct_chg"),
"index_cy_change": (row_by_code.get("399006.SZ") or {}).get("pct_chg"),
})
def missing_value(key: str) -> bool:
value = value_map.get(key)
return value is None or (isinstance(value, str) and not value.strip())
invalid_fields = {
line_number: {key for key in keys if missing_value(key)}
for line_number, keys in required.items()
}
if stock_common:
invalid_fields[1].update(required[1])
invalid_fields[2].update(required[2])
else:
if intraday and stock.get("turnover_source") in {None, "", "unavailable"}:
invalid_fields[1].add("stock_turnover_relative")
if intraday and stock.get("activity_source") in {None, "", "unavailable"}:
invalid_fields[1].add("stock_volume_activity_ratio")
if sector_common:
invalid_fields[3].update(required[3])
invalid_fields[4].update(required[4])
else:
if not sector.get("inner_precise", sector.get("precise")) or sector_coverage_issue:
invalid_fields[3].update(key for key in required[3] if key != "sector_name")
if sector.get("realtime") and not sector.get("relative_turnover"):
invalid_fields[3].add("sector_relative_turnover")
# The official SW index supplies only the sector's external change. A valid
# membership name and member-stock leader remain usable when that quote fails.
if not sector.get("outer_precise", sector.get("precise")):
invalid_fields[4].add("sector_change")
if index_issues:
invalid_fields[6].update(required[6])
checks = []
for line_number in range(1, 7):
manual_keys = [key for key in required[line_number] if key in manual_data]
unresolved_fields = [
key for key in required[line_number]
if key in invalid_fields[line_number] and key not in manual_data
]
hard_missing_identity = line_number in {1, 2} and not stock.get("code")
passed = not hard_missing_identity and not unresolved_fields
status = "manual" if passed and manual_keys else "passed" if passed else "failed"
reasons = [] if passed else [
*( ["请先输入并载入股票代码或名称"] if hard_missing_identity else automatic_issues[line_number] ),
*( ["需补充:" + "".join(schema[key]["label"] for key in unresolved_fields)] if unresolved_fields else [] ),
]
score = float(scores[line_number - 1]["score"])
position, layer, formula = names[line_number]
checks.append({
"line": line_number, "position": position, "layer": layer, "formula": formula,
"status": status, "passed": passed, "reasons": reasons,
"score": round(score, 3) if passed else None,
"line_value": _score_to_line(score) if passed else None,
"evidence": scores[line_number - 1]["evidence"] if passed else [],
"fields": [
{
"key": key, "label": schema[key]["label"], "unit": schema[key].get("unit", ""),
"type": schema[key].get("type", "number"), "options": schema[key].get("options", []),
"value": value_map.get(key), "manual": key in manual_data,
"required": True, "min": schema[key].get("min"), "max": schema[key].get("max"),
"integer": bool(schema[key].get("integer")),
}
for key in required[line_number]
],
})
return checks
+338
View File
@@ -0,0 +1,338 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any
from backend.bootstrap.config import (
normalize_date,
tushare_code,
validate_stock_code,
validate_text,
)
from backend.data.providers.tushare_client import TushareError
class HeavenMarketContextMixin:
def _resolve_heaven_stock_code(self, query: str) -> str:
raw = validate_text(query, "股票代码或名称", 30, required=True)
code_match = re.fullmatch(r"(\d{6})(?:\.(?:SH|SZ|BJ))?", raw.upper())
if code_match:
return validate_stock_code(code_match.group(1))
candidates = self.database.search_stock_master(raw)
exact = [item for item in candidates if str(item.get("name") or "").casefold() == raw.casefold()]
if not exact and self.configured:
try:
rows = self._tushare_client().query(
"stock_basic",
{"name": raw, "list_status": "L"},
"ts_code,symbol,name,industry,market,list_date",
)
except TushareError:
rows = []
if rows:
self.database.upsert_stock_master(rows)
candidates = self.database.search_stock_master(raw)
exact = [
item
for item in candidates
if str(item.get("name") or "").casefold() == raw.casefold()
]
matches = exact or candidates
if len(matches) == 1:
return validate_stock_code(str(matches[0].get("code") or ""))
if len(matches) > 1:
choices = "".join(
f"{item.get('name') or '--'}{item.get('code') or '--'}"
for item in matches[:5]
)
raise ValueError(f"匹配到多只股票:{choices}。请输入六位股票代码。")
raise ValueError(f"未找到股票“{raw}”,请检查名称或输入六位股票代码。")
def _heaven_stock_context(
self,
stock_code: str,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str,
) -> dict[str, Any]:
"""Return the only stock contract accepted by heaven trend."""
pool_row = next(
(
dict(row) for key in ("limits", "broken", "down_limits")
for row in dashboard.get(key) or []
if str(row.get("code") or "") == stock_code
),
{},
)
if market_mode == "intraday":
if self.configured:
try:
quote = self._tushare_client().realtime_stock_quote(
tushare_code(stock_code),
trade_date,
)
return {
**quote,
"status": pool_row.get("status") or "普通",
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"precise": True,
}
except TushareError:
pass
if pool_row:
return {
**pool_row,
"data_source": "dashboard_rt" if dashboard.get("meta", {}).get("realtime") else "dashboard",
"trade_date": trade_date,
"realtime": bool(dashboard.get("meta", {}).get("realtime")),
"precise": False,
}
return {
"code": stock_code,
"name": "--",
"sector": "其他",
"trade_date": trade_date,
"realtime": False,
"precise": False,
}
detail = self.get_stock_detail(stock_code, trade_date, force=True)
detail_meta = detail.get("meta") or {}
stock = detail.get("stock") or {}
resolved_date = normalize_date(str(detail_meta.get("trade_date") or trade_date))
source = str(detail_meta.get("source") or "")
return {
"code": stock_code,
"name": stock.get("name") or pool_row.get("name") or "--",
"sector": stock.get("industry") or pool_row.get("sector") or "其他",
"status": pool_row.get("status") or "普通",
"change": stock.get("change") or 0,
"turnover_rate": stock.get("turnover_rate") or 0,
"amount_billion": stock.get("amount_billion") or 0,
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"data_source": source,
"trade_date": resolved_date,
"realtime": False,
"precise": source == "tushare" and resolved_date == trade_date,
}
def _heaven_index_context(
self,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str = "historical",
) -> dict[str, Any]:
cached = self.database.get_data_snapshot("heaven_indices", trade_date)
cached_valid = False
if cached:
cached_rows = list(cached.get("indices") or [])
cached_dates = {
str(row.get("trade_date") or "").replace("-", "")
for row in cached_rows
}
cached_valid = (
len(cached_rows) == 3
and cached_dates == {trade_date}
and bool(cached.get("precise"))
and not cached.get("realtime")
and str(cached.get("source") or "") == "tushare"
and int(cached.get("schema_version") or 0) >= 3
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
error = "Tushare Token 未配置"
else:
try:
client = self._tushare_client()
if market_mode == "intraday":
payload = self._aggregate_index_context(trade_date)
payload["schema_version"] = 3
return payload
payload = client.market_indices(trade_date)
payload["schema_version"] = 3
if market_mode == "closed":
payload["finalized"] = True
self.database.save_data_snapshot(
"heaven_indices",
trade_date,
str(payload.get("source") or "tushare"),
payload,
)
return payload
except Exception as exc:
error = str(exc)
overview = dashboard.get("overview") or {}
up_count = float(overview.get("up_count") or 0)
down_count = float(overview.get("down_count") or 0)
breadth = (up_count - down_count) / max(up_count + down_count, 1)
return {
"source": "market_breadth_proxy",
"trade_date": trade_date,
"realtime": False,
"precise": False,
"schema_version": 3,
"notice": f"指数数据不可用,当前以市场宽度代理:{error}",
"indices": [],
"aggregate": {
"average_pct_chg": round(breadth * 2.5, 3),
"average_return_5d": 0,
"average_return_20d": 0,
},
}
def _aggregate_index_context(
self,
trade_date: str,
tushare_error: str = "",
) -> dict[str, Any]:
quotes = self.realtime_aggregator.tencent_indices()
epochs = [int(item.get("quote_time_epoch") or 0) for item in quotes]
quote_dates = {
datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
for epoch in epochs if epoch
}
if len(quotes) != 3 or quote_dates != {trade_date}:
raise ValueError("腾讯三大指数日期与目标交易日不一致")
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
if max(epochs) - min(epochs) > max_skew:
raise ValueError(f"腾讯三大指数时间差超过{max_skew}")
code_map = {
"000001": "000001.SH",
"399001": "399001.SZ",
"399006": "399006.SZ",
}
client = self._tushare_client()
indices = []
start_date = (
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
).strftime("%Y%m%d")
for quote in quotes:
ts_code = code_map[str(quote.get("code") or "")]
history = client.query(
"index_daily",
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
history.sort(key=lambda item: str(item.get("trade_date") or ""))
completed_closes = [
float(item.get("close") or 0)
for item in history
if str(item.get("trade_date") or "") < trade_date
and float(item.get("close") or 0) > 0
]
close_5d = (
completed_closes[-5]
if len(completed_closes) >= 5
else completed_closes[0] if completed_closes else 0
)
close = float(quote.get("price") or 0)
indices.append(
{
"ts_code": ts_code,
"name": quote.get("name") or ts_code,
"trade_date": trade_date,
"close": close,
"pct_chg": round(float(quote.get("change") or 0), 3),
"return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0,
"return_20d": 0,
"amount_billion": float(quote.get("amount_billion") or 0),
"quote_time": quote.get("quote_time") or "",
}
)
return {
"trade_date": trade_date,
"source": "+".join(
sorted({str(item.get("source") or "web_quote") for item in quotes})
+ ["tushare_index_daily"]
),
"realtime": True,
"precise": True,
"indices": indices,
"aggregate": {
"average_pct_chg": round(
sum(item["pct_chg"] for item in indices) / len(indices), 3
),
"average_return_5d": round(
sum(item["return_5d"] for item in indices) / len(indices), 3
),
"average_return_20d": 0,
},
"quote_time_skew_seconds": max(epochs) - min(epochs),
"notice": (
"指数实时行情来自腾讯行情,5日趋势来自Tushare历史指数。"
+ (f" Tushare实时指数未使用:{tushare_error}" if tushare_error else "")
),
}
def _heaven_sector_context(
self,
identifier: str,
trade_date: str,
market_mode: str = "historical",
) -> dict[str, Any] | None:
"""Return the Shenwan L2 sector context for heaven trend.
观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在
sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照。
"""
cache_key = f"{trade_date}:{identifier.strip().lower()}"
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
cached_date = str((cached or {}).get("trade_date") or "").replace("-", "")
cached_valid = bool(
cached
and cached_date == trade_date
and cached.get("taxonomy") == "sw_l2"
and cached.get("inner_precise", cached.get("precise"))
and cached.get("outer_precise", cached.get("precise"))
and not cached.get("realtime")
and int(cached.get("schema_version") or 0) >= 6
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
return None
try:
payload = self._tushare_client().sw_sector_snapshot(
tushare_code(identifier),
trade_date,
realtime_expected=market_mode == "intraday",
allow_realtime_close=market_mode == "closed",
)
except TushareError as exc:
if cached_valid:
return cached
return {
"name": "",
"code": "",
"taxonomy": "sw_l2",
"source": "tushare",
"trade_date": trade_date,
"realtime": market_mode == "intraday",
"precise": False,
"inner_precise": False,
"outer_precise": False,
"coverage": 0,
"member_count": 0,
"quote_count": 0,
"error": f"申万二级行业数据获取失败:{exc}",
}
if not payload.get("realtime") and payload.get("precise"):
self.database.save_data_snapshot(
"heaven_sector",
cache_key,
str(payload.get("source") or "tushare"),
payload,
)
return payload
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
import json
import secrets
from datetime import date
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.features.heaven.agent import HeavenAgentError, interpret_heaven
from backend.features.heaven.engine import (
build_five_phase_field,
hexagram_from_lines,
)
from backend.features.market import MarketServiceMixin
class HeavenReadingMixin:
def heaven_personal(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
field = build_five_phase_field(
trade_date,
self.database.list_sector_phase_overrides(),
)
personal = self.account_personal_field(trade_date, field, public=True)
if not personal:
raise ValueError("请先在账号设置中保存个人命理资料。")
return personal
def heaven_hexagram(self, raw_lines: Any) -> dict[str, Any]:
if not isinstance(raw_lines, list):
raise ValueError("六爻起卦结果格式不正确。")
try:
lines = [int(value) for value in raw_lines]
except (TypeError, ValueError) as exc:
raise ValueError("六爻必须由六、七、八、九组成。") from exc
return hexagram_from_lines(lines)
def heaven_readings(
self, mode: str, context_date: str = "", limit: int = 100
) -> dict[str, Any]:
mode = str(mode or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("解读记录类型不正确。")
normalized_date = normalize_date(context_date) if context_date else ""
return {
"mode": mode,
"items": self.database.list_heaven_readings(
self.current_user_id, mode, normalized_date, limit
),
}
@staticmethod
def _heaven_reading_identity(
mode: str, context_date: str, context: dict[str, Any]
) -> tuple[str, str]:
display_date = MarketServiceMixin._display_compact_date(context_date)
if mode == "trend":
stock = (context.get("selected_focus") or {}).get("stock") or {}
code = str(stock.get("code") or "").strip()
name = str(stock.get("name") or "").strip()
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
subject = " ".join(item for item in (code, name) if item) or "观势"
detail = f"{display_date} · {hexagram.get('name') or '--'}{transformed.get('name') or '--'}"
return subject, detail
if mode == "fortune":
field = context.get("five_phase_field") or {}
pillars = field.get("pillars") or {}
dominant = (field.get("balance") or [{}])[0]
subject = f"{display_date} 观气"
detail = (
f"{pillars.get('year') or '--'}年 · {pillars.get('month') or '--'}月 · "
f"{pillars.get('day') or '--'}日 · {dominant.get('element') or '--'}气偏显"
)
return subject, detail
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
return (
f"{display_date} 观心",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}",
)
def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
mode = str(payload.get("mode") or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("问天解读模式不正确。")
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
if mode == "fortune":
existing = self.database.latest_heaven_reading(
self.current_user_id, "fortune", trade_date
)
if self._legacy_truncated_heaven_reading(existing):
self.database.delete_heaven_reading(
self.current_user_id, int(existing["id"])
)
existing = None
if existing:
return {
"answer": existing["answer"],
"mode": mode,
"compiler": "stored",
"notice": "",
"reading": existing,
"reused": True,
}
if mode in {"trend", "fortune"}:
setup = self.heaven_setup(
trade_date,
str(payload.get("sector") or ""),
str(payload.get("stock_code") or ""),
payload.get("manual_data"),
)
if mode == "trend":
chart = setup["chart"]
if not chart.get("available"):
issues = "".join((chart.get("quality") or {}).get("issues") or [])
raise ValueError(f"观势数据未通过六爻校验,暂不解势:{issues}")
hexagram_context = json.loads(json.dumps(chart["hexagram"], ensure_ascii=False))
for line in hexagram_context.get("lines", []):
line.pop("evidence", None)
line.pop("score", None)
line.pop("talent", None)
line.pop("layer", None)
line.pop("role", None)
if not line.get("moving"):
line.pop("text", None)
line.pop("image", None)
line.pop("line_name", None)
context = {
"data_trade_date": setup["trade_date"],
"selected_focus": {
"sector": chart.get("sector") or "",
"stock": chart.get("stock") or {},
},
"hexagram": hexagram_context,
"movement": chart.get("movement") or {},
}
else:
personal_profile = self.account_personal_field(
setup["calendar_date"],
setup["field"],
public=False,
)
fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False))
catalog = fortune_field.pop("sector_catalog", [])
dominant_elements = {
item.get("element") for item in fortune_field.get("balance", [])[:2]
}
fortune_field["industry_affinity"] = [
{
"element": group.get("element"),
"examples": [
item.get("name")
for item in group.get("industries", [])[:8]
if item.get("name")
],
}
for group in catalog
if group.get("element") in dominant_elements
]
context = {
"calendar_date": setup["calendar_date"],
"five_phase_field": fortune_field,
"personal_profile": personal_profile,
}
context_date = setup["calendar_date"]
if mode == "trend":
context_date = setup["trade_date"]
else:
context = {
"hexagram": self.heaven_hexagram(payload.get("lines")),
"ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。",
}
context_date = trade_date
result, compiler = self._call_heaven_agent(mode, context)
subject, subject_detail = self._heaven_reading_identity(
mode, context_date, context
)
dedupe_key = (
f"fortune:{context_date}"
if mode == "fortune"
else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}"
)
reading = self.database.save_heaven_reading(
self.current_user_id,
mode,
context_date,
subject,
subject_detail,
str(result.get("answer") or ""),
context,
dedupe_key,
)
return {
**result,
"mode": mode,
"compiler": compiler,
"notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "",
"reading": reading,
"reused": False,
}
@staticmethod
def _legacy_truncated_heaven_reading(reading: dict[str, Any] | None) -> bool:
return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……"))
def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
result = self.llm_gateway.call(
f"heaven_{mode}",
f"heaven-{mode}-v1",
lambda profile: interpret_heaven(
mode,
context,
profile.api_key,
profile.base_url,
profile.model,
),
(HeavenAgentError,),
)
return result.value, result.role
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs, unquote
from backend.bootstrap.config import validate_text
class HeavenRoutesMixin:
def _handle_heaven_get(self, parsed) -> bool:
if parsed.path == "/api/heaven/readings":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.heaven_readings(
query.get("mode", [""])[0],
query.get("context_date", [""])[0],
int(query.get("limit", ["100"])[0]),
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/heaven/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
sector_name = query.get("sector", [""])[0]
stock_code = query.get("stock_code", [""])[0]
manual_data = None
manual_text = query.get("manual_data", [""])[0]
if manual_text:
try:
manual_data = json.loads(manual_text)
except json.JSONDecodeError:
self.send_json({"error": "六爻补录数据格式不正确。"}, HTTPStatus.BAD_REQUEST)
return True
try:
self.send_json(
self.application_service.heaven_setup(
trade_date,
sector_name,
stock_code,
manual_data,
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_heaven_delete(self, parsed) -> bool:
heaven_reading_match = re.fullmatch(r"/api/heaven/readings/(\d+)", parsed.path)
if heaven_reading_match:
deleted = self.application_service.database.delete_heaven_reading(
self.application_service.current_user_id, int(heaven_reading_match.group(1))
)
self.send_json({"ok": True, "deleted": deleted})
return True
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
if sector_phase_match:
name = unquote(sector_phase_match.group(1)).strip()
deleted = self.application_service.database.delete_sector_phase_override(name)
self.send_json({"ok": True, "deleted": deleted})
return True
return False
def save_sector_phase_override(self) -> None:
try:
body = self.read_json_body()
name = validate_text(body.get("name"), "行业或题材名称", 50, required=True)
element = str(body.get("element") or "").strip()
if element not in {"", "", "", "", ""}:
raise ValueError("五行归类必须是木、火、土、金或水。")
self.application_service.database.save_sector_phase_override(name, element)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
File diff suppressed because it is too large Load Diff
+358
View File
@@ -0,0 +1,358 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.data.providers.tushare_client import _sector_coverage_issue
from backend.features.heaven.engine import build_five_phase_field, build_market_hexagram
class HeavenTrendMixin:
def heaven_setup(
self,
trade_date: str,
sector_name: str = "",
stock_code: str = "",
manual_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
dashboard = self.get_dashboard(normalized_date)
data_date = normalize_date(str(dashboard.get("meta", {}).get("trade_date") or normalized_date))
recent_history = self.database.snapshot_summaries(data_date, 10)
market_mode = self._heaven_market_mode(data_date, dashboard)
manual_data = self._validate_heaven_manual_data(manual_data, market_mode)
index_context = self._heaven_index_context(data_date, dashboard, market_mode)
external_stock = None
normalized_stock_code = ""
if stock_code.strip():
normalized_stock_code = self._resolve_heaven_stock_code(stock_code)
external_stock = self._heaven_stock_context(
normalized_stock_code,
data_date,
dashboard,
market_mode,
)
external_sector = None
if normalized_stock_code and self.configured:
external_sector = self._heaven_sector_context(
normalized_stock_code,
data_date,
market_mode,
)
if external_sector and external_stock:
external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
dashboard, index_context, external_sector, external_stock = self._apply_heaven_manual_data(
dashboard,
index_context,
external_sector,
external_stock,
manual_data,
market_mode,
data_date,
normalized_stock_code,
)
if external_sector and external_stock:
external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
sector_input = str((external_sector or {}).get("name") or sector_name.strip())
if not normalized_stock_code:
data_checks = []
chart = {
"available": False,
"selection_required": True,
"data_trade_date": data_date,
"sector": "",
"sector_code": "",
"sector_taxonomy": "",
"stock": {"code": "", "name": "", "status": ""},
"quality": {
"status": "awaiting_selection",
"issues": [],
"principle": "",
"sources": [],
},
"index_context": index_context,
}
else:
data_checks = self._heaven_line_checks(
data_date,
dashboard,
recent_history,
index_context,
external_sector or {},
external_stock or {},
market_mode,
manual_data,
)
quality_issues = [
f"{check['position']}·{check['layer']}{''.join(check['reasons'])}"
for check in data_checks
if not check["passed"]
]
if quality_issues:
chart = {
"available": False,
"selection_required": False,
"data_trade_date": data_date,
"sector": str((external_sector or {}).get("name") or sector_input or "--"),
"sector_code": str((external_sector or {}).get("code") or ""),
"sector_taxonomy": str((external_sector or {}).get("taxonomy") or ""),
"stock": {
"code": normalized_stock_code,
"name": str((external_stock or {}).get("name") or "--"),
"status": str((external_stock or {}).get("status") or ""),
},
"quality": {
"status": "blocked",
"issues": quality_issues,
"principle": "六爻任一层缺少同日、同口径的有效数据,本系统不成卦。",
"sources": self._heaven_trend_sources(
data_date, index_context, external_sector, external_stock
),
},
"index_context": index_context,
}
else:
chart = build_market_hexagram(
dashboard,
recent_history,
index_context,
sector_input,
normalized_stock_code,
external_stock,
external_sector,
)
chart["available"] = True
chart["selection_required"] = False
manual_active = any(check["status"] == "manual" for check in data_checks)
chart["quality"] = {
"status": "manual" if manual_active else "verified",
"issues": [],
"principle": (
"自动行情与用户补充数据均已通过同一套量化公式校验。"
if manual_active
else "指数、板块、个股均已通过同日同口径校验。"
),
"sources": [
*self._heaven_trend_sources(
data_date, index_context, external_sector, external_stock
),
*([{
"lines": "补录爻位",
"layer": "用户补充",
"realtime": market_mode == "intraday",
"detail": str(manual_data.get("note") or "量化数据经原公式重新计算"),
}] if manual_active else []),
],
}
chart["data_checks"] = data_checks
chart["manual_data"] = manual_data
sector_phase_overrides = self.database.list_sector_phase_overrides()
field = build_five_phase_field(
normalized_date,
sector_phase_overrides,
)
personal_profile = self.account_personal_field(
normalized_date,
field,
public=True,
)
daily_fortune_reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", normalized_date
)
if self._legacy_truncated_heaven_reading(daily_fortune_reading):
daily_fortune_reading = None
return {
"trade_date": data_date,
"calendar_date": normalized_date,
"market_mode": market_mode,
"chart": chart,
"field": field,
"personal_profile": personal_profile,
"daily_fortune_reading": daily_fortune_reading,
"sector_phase_overrides": [
{"name": name, "element": element}
for name, element in sector_phase_overrides.items()
],
"llm": {
"configured": self.llm_configured,
"model": self.llm_primary_model if self.llm_configured else "",
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
}
@staticmethod
def _heaven_market_mode(
trade_date: str,
dashboard: dict[str, Any],
now: datetime | None = None,
) -> str:
"""区分盘中、今日收盘和历史,避免把 rt_k 数据来源误当成交易状态。"""
now = now or datetime.now().astimezone()
if trade_date != now.strftime("%Y%m%d"):
return "historical"
meta = dashboard.get("meta") or {}
status = str(meta.get("market_status") or "").lower()
local_time = now.time().replace(tzinfo=None)
if status == "closed" or local_time > datetime.strptime("15:05", "%H:%M").time():
return "closed"
if status in {"trading", "auction", "pre_open"} or (
bool(meta.get("realtime"))
and local_time >= datetime.strptime("09:15", "%H:%M").time()
):
return "intraday"
return "historical"
@staticmethod
def _heaven_trend_sources(
trade_date: str,
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
) -> list[dict[str, Any]]:
sector = sector or {}
stock = stock or {}
return [
{
"lines": "五爻、上爻",
"layer": "指数",
"source": index_context.get("source") or "unavailable",
"trade_date": index_context.get("trade_date") or "",
"realtime": bool(index_context.get("realtime")),
"detail": f"三大指数 {len(index_context.get('indices') or [])}/3",
},
{
"lines": "三爻、四爻",
"layer": "行业",
"source": sector.get("source") or "unavailable",
"trade_date": sector.get("trade_date") or "",
"realtime": bool(sector.get("realtime")),
"detail": (
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
),
},
{
"lines": "初爻、二爻",
"layer": "个股",
"source": stock.get("data_source") or "unavailable",
"trade_date": stock.get("trade_date") or trade_date,
"realtime": bool(stock.get("realtime")),
"detail": (
f"{stock.get('name') or '--'};换手基准 "
f"{stock.get('capital_trade_date') or '--'}"
),
},
]
@staticmethod
def _heaven_trend_quality_issues(
trade_date: str,
dashboard: dict[str, Any],
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
market_mode: str = "historical",
) -> list[str]:
issues: list[str] = []
intraday = market_mode == "intraday"
closed = market_mode == "closed"
if intraday:
meta = dashboard.get("meta") or {}
market_status = str(meta.get("market_status") or "")
now = datetime.now().astimezone()
try:
updated_at = datetime.fromisoformat(str(meta.get("updated_at") or ""))
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=now.tzinfo)
snapshot_age = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
except ValueError:
snapshot_age = float("inf")
if market_status in {"trading", "auction", "pre_open"} and snapshot_age > 120:
issues.append("主行情快照超过2分钟,请点击顶部刷新")
# 收盘后不再用 dashboard.market_status 作为阻断条件。盘后同步可能将
# rt_k 快照替换成同日盘后日线而不带该字段;六爻数据本身的日期、
# 完整性和来源校验已足以判断是否可以成卦。
index_date = str(index_context.get("trade_date") or "").replace("-", "")
index_rows = list(index_context.get("indices") or [])
index_row_dates = {
str(row.get("trade_date") or "").replace("-", "") for row in index_rows
}
if not index_context.get("precise") or len(index_rows) < 3:
issues.append("指数层缺少三大指数的有效行情")
elif index_date != trade_date or index_row_dates != {trade_date}:
issues.append("指数行情与目标交易日不一致")
elif intraday and not index_context.get("realtime"):
issues.append("盘中指数层缺少可核验的实时行情")
elif not intraday and (
index_context.get("realtime")
or str(index_context.get("source") or "") != "tushare"
):
issues.append("历史/收盘指数层必须使用 Tushare 官方指数日线")
sector = sector or {}
sector_date = str(sector.get("trade_date") or "").replace("-", "")
sector_coverage = float(sector.get("coverage") or 0)
sector_explained_count = int(
sector.get("explained_count")
if sector.get("explained_count") is not None
else sector.get("quote_count") or 0
)
sector_explained_coverage = float(
sector.get("explained_coverage")
if sector.get("explained_coverage") is not None
else sector_coverage
)
sector_coverage_issue = _sector_coverage_issue(
int(sector.get("member_count") or 0),
int(sector.get("quote_count") or 0),
sector_explained_coverage,
sector_explained_count,
)
if not sector:
issues.append("行业层缺少申万二级行业归属")
elif sector.get("taxonomy") != "sw_l2":
issues.append("行业层必须使用申万二级行业分类")
elif sector_date != trade_date:
issues.append("行业行情与目标交易日不一致")
elif intraday and not sector.get("realtime"):
issues.append("盘中行业层缺少申万实时行情")
elif market_mode == "historical" and sector.get("realtime"):
issues.append("历史行业层不能使用实时快照")
elif closed and sector.get("realtime") and not sector.get("finalized"):
issues.append("收盘行业层缺少15:00最终快照")
if not sector.get("inner_precise", sector.get("precise")):
issues.append("行业内核缺少可核验的成分行情")
if not sector.get("outer_precise", sector.get("precise")):
issues.append("行业外显缺少申万官方行情")
if sector and sector_coverage_issue:
issues.append(sector_coverage_issue)
if sector.get("realtime") and not sector.get("relative_turnover"):
issues.append("行业内核缺少相对全市场换手活跃度")
stock = stock or {}
stock_date = str(stock.get("trade_date") or "").replace("-", "")
if not stock or not stock.get("code"):
issues.append("个股层尚未载入有效标的")
elif not stock.get("precise"):
issues.append("个股层缺少可核验的行情数据")
elif stock_date != trade_date:
issues.append("个股行情与目标交易日不一致")
elif intraday and not stock.get("realtime"):
issues.append("盘中个股层不是 rt_k 实时行情")
elif not intraday and (
stock.get("realtime")
or str(stock.get("data_source") or "") != "tushare"
):
issues.append("历史/收盘个股层必须使用 Tushare 官方日线")
if intraday and stock and not stock.get("turnover_source"):
issues.append("个股内核缺少可核验的实时换手率")
elif intraday and stock.get("turnover_source") == "unavailable":
issues.append("个股内核缺少流通股本,无法计算实时换手率")
if intraday and stock.get("activity_source") == "unavailable":
issues.append("个股内核缺少近5日量能基准")
elif intraday and not stock.get("activity_source"):
issues.append("个股内核缺少同时间进度量能")
return issues
File diff suppressed because it is too large Load Diff
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import copy
from datetime import datetime
from statistics import median
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.tushare_client import TushareError
from backend.features.market.insights_context import _display_date
class MarketAuctionInsightsMixin:
def auction_center(
self,
requested_date: str,
force: bool = False,
user_id: int = 0,
) -> dict[str, Any]:
trade_date, previous_date = self._trade_context(requested_date)
session = self._auction_session(requested_date, trade_date)
phase = str(session["phase"])
ifind_ready = bool(self.ifind and self.ifind.configured)
live_dynamic = phase == "observing" and ifind_ready
use_ifind_snapshot = phase in {"observing", "selection", "finalized"} and ifind_ready
data_date = previous_date if phase == "pending" or (phase == "observing" and not live_dynamic) else trade_date
carried_forward = data_date != trade_date
cache_key = data_date
if not force and not live_dynamic:
cached = self.database.get_data_snapshot("auction_center_v6", cache_key)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {
**result.get("meta", {}),
**session,
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": bool((result.get("summary") or {}).get("stock_count")),
"cached": True,
}
return self._with_auction_watchlist(result, data_date, user_id)
if use_ifind_snapshot:
rows = self._dynamic_auction_rows(data_date, previous_date, user_id)
else:
rows = []
if not rows and not live_dynamic:
try:
rows = self.client.query("stk_auction", {"trade_date": data_date})
except TushareError:
rows = self.database.auction_factors_for_date(data_date)
if not rows:
return {
"meta": {
**session,
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": False,
"cached": False,
"notice": "该交易日暂无可用竞价快照",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
"stock_count": 0, "up_count": 0, "down_count": 0,
"limit_open_count": 0, "strong_open_count": 0,
"median_change": 0, "amount_billion": 0,
"candidate_count": 0, "focus_count": 0, "one_price_count": 0,
},
"expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0},
"candidate_meta": {"baseline_date": _display_date(previous_date)},
"themes": {"carry": [], "new_themes": []},
"amount_history": self._auction_amount_history(data_date),
"news_feedback": {"available": False, "message": "隔夜消息反馈暂不可用"},
"focus_rows": [], "one_price_rows": [], "rows": [],
"watchlist_rows": [], "watchlist_missing_count": 0,
}
master = self._stock_master()
try:
limit_rows = self.client.query(
"stk_limit",
{"trade_date": data_date},
"trade_date,ts_code,up_limit,down_limit",
)
except TushareError:
limit_rows = []
limit_map = {str(item.get("ts_code") or ""): item for item in limit_rows}
normalized = []
for row in rows:
ts_code = str(row.get("ts_code") or "")
stock = master.get(ts_code)
price = _number(row.get("price"))
pre_close = _number(row.get("pre_close"))
list_date = str((stock or {}).get("list_date") or "")
if (
not stock
or price <= 0
or pre_close <= 0
or (list_date and list_date >= data_date)
):
continue
change = (price / pre_close - 1) * 100
amount_million = _number(row.get("amount")) / 1_000_000
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
up_limit = _number((limit_map.get(ts_code) or {}).get("up_limit"))
is_one_price = bool(
up_limit > 0 and abs(price - up_limit) <= max(0.001, up_limit * 0.00005)
)
normalized.append(
{
"code": str(stock.get("code") or ts_code.split(".")[0]),
"ts_code": ts_code,
"name": str(stock.get("name") or "--"),
"sector": str(stock.get("industry") or "其他"),
"price": round(price, 2),
"pre_close": round(pre_close, 2),
"change": round(change, 2),
"volume_ten_thousand": round(_number(row.get("vol")) / 10_000, 2),
"amount_million": round(amount_million, 2),
"turnover_rate": round(turnover_rate, 4),
"volume_ratio": round(volume_ratio, 2),
"up_limit": round(up_limit, 2) if up_limit else None,
"is_one_price": is_one_price,
"signal": (
"竞价涨停" if change >= 9.5 else
"强势高开" if change >= 3 else
"高开" if change > 0.2 else
"深度低开" if change <= -3 else
"低开" if change < -0.2 else "平开"
),
}
)
normalized.sort(key=lambda item: (item["amount_million"], item["volume_ratio"]), reverse=True)
self.database.upsert_auction_factors(rows)
changes = [item["change"] for item in normalized]
total = len(normalized)
_, baseline_date = self._trade_context(data_date)
candidates, candidate_meta, focus_rows = self._auction_candidates(normalized, baseline_date)
candidate_map = {str(item.get("code") or ""): item for item in candidates}
one_price_rows = []
for row in normalized:
if not row.get("is_one_price"):
continue
enriched = candidate_map.get(str(row.get("code") or ""), {})
one_price_rows.append(
{
**row,
**enriched,
"attention_score": None,
"expectation": "",
"expected_change": None,
"expectation_reason": "竞价价格封于当日涨停价,已从普通异动评分中隔离",
}
)
one_price_codes = {str(item.get("code") or "") for item in one_price_rows}
candidates = [item for item in candidates if str(item.get("code") or "") not in one_price_codes]
focus_rows = [item for item in focus_rows if str(item.get("code") or "") not in one_price_codes]
one_price_rows.sort(
key=lambda item: (
bool(item.get("is_market_core")),
_number(item.get("prior_streak")),
_number(item.get("amount_million")),
),
reverse=True,
)
expectations = {
label: sum(item.get("expectation") == label for item in candidates)
for label in ("超预期", "符合预期", "低于预期")
}
prior_snapshot = self.database.get_snapshot(baseline_date) or {}
themes = self._auction_theme_evidence(prior_snapshot, candidates + one_price_rows)
self._ensure_auction_amount_history(data_date)
amount_history = self._auction_amount_history(data_date)
prior_amounts = [item["amount_billion"] for item in amount_history[:-1]]
current_amount = round(sum(item["amount_million"] for item in normalized) / 100, 2)
previous_amount = prior_amounts[-1] if prior_amounts else 0
five_day_amounts = prior_amounts[-5:]
five_day_average = sum(five_day_amounts) / len(five_day_amounts) if five_day_amounts else 0
result = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": bool(normalized),
**session,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
"stock_count": total,
"up_count": sum(value > 0.2 for value in changes),
"down_count": sum(value < -0.2 for value in changes),
"limit_open_count": len(one_price_rows),
"strong_open_count": sum(value >= 3 for value in changes),
"median_change": round(median(changes), 2) if changes else 0,
"amount_billion": current_amount,
"amount_change_previous": round((current_amount / previous_amount - 1) * 100, 1) if previous_amount else None,
"amount_change_5d": round((current_amount / five_day_average - 1) * 100, 1) if five_day_average else None,
"candidate_count": len(candidates),
"focus_count": len(focus_rows),
"one_price_count": len(one_price_rows),
},
"expectations": expectations,
"candidate_meta": candidate_meta,
"themes": themes,
"amount_history": amount_history,
"news_feedback": {
"available": False,
"message": "隔夜消息反馈暂不可用",
"detail": "待稳定的新闻与公告数据接入后开放",
},
"focus_rows": focus_rows,
"one_price_rows": one_price_rows,
"rows": candidates,
}
if not live_dynamic:
self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result)
return self._with_auction_watchlist(result, data_date, user_id)
@@ -0,0 +1,318 @@
from __future__ import annotations
import copy
from datetime import datetime, time as dt_time, timedelta
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.ifind_client import IfindError
from backend.data.providers.tushare_client import TushareError
from backend.features.market.insights_context import CHINA_TIMEZONE, _display_date
class MarketAuctionDataMixin:
def _auction_session(self, requested_date: str, trade_date: str) -> dict[str, Any]:
now = self._now_provider()
if now.tzinfo is None:
now = now.replace(tzinfo=CHINA_TIMEZONE)
else:
now = now.astimezone(CHINA_TIMEZONE)
requested = str(requested_date or "").replace("-", "")
today = now.strftime("%Y%m%d")
if requested != today or trade_date != today:
return {
"phase": "archive",
"actionable": False,
"next_transition_at": "",
}
local_time = now.time().replace(tzinfo=None)
transitions = (
(dt_time(9, 15), "pending", dt_time(9, 15)),
(dt_time(9, 25), "observing", dt_time(9, 25)),
(dt_time(9, 30), "selection", dt_time(9, 30)),
)
for boundary, phase, next_boundary in transitions:
if local_time < boundary:
transition = now.replace(
hour=next_boundary.hour,
minute=next_boundary.minute,
second=0,
microsecond=0,
)
return {
"phase": phase,
"actionable": phase == "selection",
"next_transition_at": transition.isoformat(timespec="seconds"),
}
return {
"phase": "finalized",
"actionable": False,
"next_transition_at": "",
}
def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]:
dates = self.database.auction_factor_dates(trade_date, 10)
stock_list_dates = {
str(item.get("ts_code") or ""): str(item.get("list_date") or "")
for item in self.database.list_stock_master()
if item.get("ts_code")
}
history = []
for current_date in dates:
rows = [
row for row in self.database.auction_factors_for_date(current_date)
if (
str(row.get("ts_code") or "") in stock_list_dates
and (
not stock_list_dates[str(row.get("ts_code") or "")]
or stock_list_dates[str(row.get("ts_code") or "")] < current_date
)
)
]
history.append(
{
"trade_date": _display_date(current_date),
"amount_billion": round(sum(_number(row.get("amount")) for row in rows) / 100_000_000, 2),
"stock_count": len(rows),
}
)
return history
def _ensure_auction_amount_history(self, trade_date: str, target_days: int = 10) -> None:
existing = set(self.database.auction_factor_dates(trade_date, target_days + 5))
if len(existing) >= target_days:
return
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=35)).strftime("%Y%m%d")
try:
calendar = self.client.query(
"trade_cal",
{
"exchange": "SSE",
"start_date": start,
"end_date": trade_date,
"is_open": 1,
},
"cal_date,is_open",
)
except TushareError:
return
dates = sorted(
str(item.get("cal_date") or "")
for item in calendar
if int(_number(item.get("is_open"))) == 1 and item.get("cal_date")
)[-target_days:]
for current_date in dates:
if current_date in existing:
continue
try:
rows = self.client.query(
"stk_auction",
{"trade_date": current_date},
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share",
)
except TushareError:
break
if rows:
self.database.upsert_auction_factors(rows)
existing.add(current_date)
def _with_auction_watchlist(
self,
result: dict[str, Any],
trade_date: str,
user_id: int,
) -> dict[str, Any]:
personalized = copy.deepcopy(result)
if not user_id:
personalized["watchlist_rows"] = []
personalized["watchlist_missing_count"] = 0
return personalized
watched = self.database.list_watchlist(user_id)
if not watched:
personalized["watchlist_rows"] = []
personalized["watchlist_missing_count"] = 0
return personalized
public_rows = {
str(item.get("code") or ""): item
for item in (
list(personalized.get("rows") or [])
+ list(personalized.get("one_price_rows") or [])
)
}
factors = {
str(item.get("ts_code") or "").split(".")[0]: item
for item in self.database.auction_factors_for_date(trade_date)
}
master = {
str(item.get("ts_code") or "").split(".")[0]: item
for item in self.database.list_stock_master()
}
rows = []
missing = 0
for item in watched:
code = str(item.get("code") or "")
if code in public_rows:
rows.append({**public_rows[code], "is_watchlist": True})
continue
factor = factors.get(code)
if not factor:
missing += 1
rows.append(
{
"code": code,
"name": str(item.get("name") or "--"),
"sector": str(item.get("sector") or "其他"),
"available": False,
"is_watchlist": True,
}
)
continue
stock = master.get(code, {})
price = _number(factor.get("price"))
pre_close = _number(factor.get("pre_close"))
change = (price / pre_close - 1) * 100 if price > 0 and pre_close > 0 else 0
row = {
"code": code,
"ts_code": str(factor.get("ts_code") or ""),
"name": str(item.get("name") or stock.get("name") or "--"),
"sector": str(item.get("sector") or stock.get("industry") or "其他"),
"price": round(price, 2),
"pre_close": round(pre_close, 2),
"change": round(change, 2),
"amount_million": round(_number(factor.get("amount")) / 1_000_000, 2),
"turnover_rate": round(_number(factor.get("turnover_rate")), 4),
"volume_ratio": round(_number(factor.get("volume_ratio")), 2),
"candidate_sources": ["我的自选"],
"source_label": "我的自选",
"prior_streak": 0,
"concepts": [],
"expected_change": 0.0,
"core_tags": [],
"is_market_core": False,
"is_watchlist": True,
"available": True,
}
actual_strength = change + self._auction_confirmation(row)
row["actual_strength"] = round(actual_strength, 2)
row["expectation"] = self._expectation_label(actual_strength, 0.0)
row["attention_score"] = self._attention_score(row, 0.0, [], ["我的自选"], 0, False)
direction = "高于" if change > 0 else "低于" if change < 0 else "贴合"
row["expectation_reason"] = f"自选观察;竞价涨幅{direction}个人观察基准{abs(change):.1f}个百分点,量比{row['volume_ratio']:.2f}"
rows.append(row)
rows.sort(
key=lambda row: (bool(row.get("available", True)), _number(row.get("attention_score"))),
reverse=True,
)
personalized["watchlist_rows"] = rows
personalized["watchlist_missing_count"] = missing
return personalized
def _dynamic_auction_rows(
self,
trade_date: str,
baseline_date: str,
user_id: int,
) -> list[dict[str, Any]]:
if not self.ifind or not self.ifind.configured:
return []
master = self._stock_master()
placeholders = [
{
"code": str(item.get("code") or ts_code.split(".")[0]),
"ts_code": ts_code,
"name": str(item.get("name") or "--"),
"sector": str(item.get("industry") or "其他"),
}
for ts_code, item in master.items()
]
candidates, _, _ = self._auction_candidates(placeholders, baseline_date)
selected_codes = {
str(item.get("ts_code") or "")
for item in candidates
if item.get("ts_code")
}
if user_id:
watched = {str(item.get("code") or "") for item in self.database.list_watchlist(user_id)}
selected_codes.update(
ts_code for ts_code in master if ts_code.split(".")[0] in watched
)
selected_codes.discard("")
if not selected_codes:
return []
display_date = _display_date(trade_date)
now = self._now_provider()
if now.tzinfo is None:
now = now.replace(tzinfo=CHINA_TIMEZONE)
else:
now = now.astimezone(CHINA_TIMEZONE)
end_time = min(now.time().replace(tzinfo=None), dt_time(9, 25))
end_stamp = f"{display_date} {end_time.strftime('%H:%M:%S')}"
start_stamp = f"{display_date} 09:15:00"
snapshot_rows: list[dict[str, Any]] = []
ordered_codes = sorted(selected_codes)
for index in range(0, len(ordered_codes), 80):
try:
snapshot_rows.extend(
self.ifind.snapshots(
ordered_codes[index:index + 80],
[
"latest", "volume", "amount", "preClose",
"bid1", "bidSize1", "ask1", "askSize1",
],
start_stamp,
end_stamp,
cache_ttl=8,
)
)
except IfindError:
continue
latest: dict[str, dict[str, Any]] = {}
for row in snapshot_rows:
ts_code = str(row.get("thscode") or "")
previous = latest.get(ts_code) or {}
if (
ts_code
and _number(row.get("latest")) > 0
and str(row.get("time") or "") >= str(previous.get("time") or "")
):
latest[ts_code] = row
prior_factors = {
str(item.get("ts_code") or ""): item
for item in self.database.auction_factors_for_date(baseline_date)
}
normalized = []
for ts_code, row in latest.items():
price = _number(row.get("latest"))
pre_close = _number(row.get("preClose"))
volume = _number(row.get("volume"))
bid_size = _number(row.get("bidSize1"))
ask_size = _number(row.get("askSize1"))
if volume <= 0 and bid_size > 0 and ask_size > 0:
volume = min(bid_size, ask_size)
amount = _number(row.get("amount"))
if amount <= 0 and price > 0 and volume > 0:
amount = price * volume
prior_volume = _number((prior_factors.get(ts_code) or {}).get("vol"))
normalized.append(
{
"ts_code": ts_code,
"trade_date": trade_date,
"vol": volume,
"price": price,
"amount": amount,
"pre_close": pre_close,
"turnover_rate": 0,
"volume_ratio": volume / prior_volume if prior_volume > 0 else 0,
"float_share": 0,
"bid_size1": bid_size,
"ask_size1": ask_size,
"snapshot_time": str(row.get("time") or ""),
"dynamic": True,
}
)
return normalized
@@ -0,0 +1,355 @@
from __future__ import annotations
from statistics import median
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.features.market.insights_context import _display_date
class MarketAuctionScoringMixin:
@staticmethod
def _expectation_label(actual_strength: float, expected_change: float) -> str:
difference = actual_strength - expected_change
if difference >= 1.5:
return "超预期"
if difference <= -1.5:
return "低于预期"
return "符合预期"
@staticmethod
def _auction_confirmation(row: dict[str, Any]) -> float:
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
amount_million = _number(row.get("amount_million"))
return (
(0.6 if volume_ratio >= 2 else 0.3 if volume_ratio >= 1.2 else -0.5 if volume_ratio < 0.6 else 0)
+ (0.25 if turnover_rate >= 0.15 else -0.25 if turnover_rate < 0.03 else 0)
+ (0.3 if amount_million >= 20 else 0.15 if amount_million >= 5 else -0.3 if amount_million < 1 else 0)
)
@staticmethod
def _attention_score(
row: dict[str, Any],
expected_change: float,
core_tags: list[str],
sources: list[str],
prior_streak: int,
strong_sector: bool,
) -> float:
if core_tags:
identity_score = 35.0
elif prior_streak >= 2:
identity_score = 27.0
elif any(source in {"昨日涨停", "昨日炸板"} for source in sources):
identity_score = 21.0
else:
identity_score = 14.0
deviation_score = min(30.0, abs(_number(row.get("change")) - expected_change) * 5)
volume_score = min(10.0, max(0.0, _number(row.get("volume_ratio"))) / 2 * 10)
amount_score = min(6.0, max(0.0, _number(row.get("amount_million"))) / 10 * 6)
turnover_score = min(4.0, max(0.0, _number(row.get("turnover_rate"))) / 0.2 * 4)
theme_score = 15.0 if strong_sector else 7.0 if row.get("concepts") else 0.0
return round(min(100.0, identity_score + deviation_score + volume_score + amount_score + turnover_score + theme_score), 1)
def _auction_candidates(
self,
rows: list[dict[str, Any]],
baseline_date: str,
) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
"""Build a narrow, explainable universe from prior limits, breaks and top-20 hot lists."""
snapshot = self.database.get_snapshot(baseline_date) or {}
prior_limits = list(snapshot.get("limits") or [])
prior_broken = list(snapshot.get("broken") or [])
prior_sectors = list(snapshot.get("sectors") or [])
strong_sector_names = {
str(item.get("name") or "") for item in prior_sectors[:5] if item.get("name")
}
ths_rows, dc_rows, errors = self._hot_rows(baseline_date)
candidates: dict[str, dict[str, Any]] = {}
core_tags: dict[str, set[str]] = {}
def ensure_candidate(item: dict[str, Any]) -> dict[str, Any] | None:
code = str(item.get("code") or str(item.get("ts_code") or "").split(".")[0])
if not code:
return None
return candidates.setdefault(
code,
{
"sources": [],
"streak": 0,
"sector": str(item.get("sector") or "其他"),
"name": str(item.get("name") or item.get("ts_name") or "--"),
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
for item in prior_limits:
candidate = ensure_candidate(item)
if candidate is None:
continue
candidate["sources"].append("昨日涨停")
candidate["streak"] = max(1, int(_number(item.get("streak"), 1)))
for item in prior_broken:
candidate = ensure_candidate(item)
if candidate is not None and "昨日炸板" not in candidate["sources"]:
candidate["sources"].append("昨日炸板")
limit_streaks = [max(1, int(_number(item.get("streak"), 1))) for item in prior_limits]
highest_streak = max(limit_streaks, default=0)
for item in prior_limits:
code = str(item.get("code") or "")
streak = max(1, int(_number(item.get("streak"), 1)))
if streak >= 3:
core_tags.setdefault(code, set()).add("三板以上")
if highest_streak and streak == highest_streak:
core_tags.setdefault(code, set()).add("市场最高板")
for sector in prior_sectors[:5]:
name = str(sector.get("name") or "")
members = [item for item in prior_limits if str(item.get("sector") or "其他") == name]
if not members:
continue
leader = max(
members,
key=lambda item: (
int(_number(item.get("streak"), 1)),
_number(item.get("amount_billion")),
-_number(item.get("open_times")),
),
)
core_tags.setdefault(str(leader.get("code") or ""), set()).add("题材核心")
leadership = sorted(
prior_limits,
key=lambda item: (
int(_number(item.get("streak"), 1)),
str(item.get("sector") or "") in strong_sector_names,
_number(item.get("amount_billion")),
),
reverse=True,
)
if leadership:
core_tags.setdefault(str(leadership[0].get("code") or ""), set()).add("市场领涨")
hot_records: dict[str, dict[str, Any]] = {}
for source, hot_rows, data_type in (
("同花顺热榜", ths_rows, "热股"),
("东方财富热榜", dc_rows, "A股市场"),
):
for item in hot_rows:
if str(item.get("data_type") or "") != data_type:
continue
ts_code = str(item.get("ts_code") or "")
code = ts_code.split(".")[0]
rank = max(1, int(_number(item.get("rank"), 9999)))
if not code or rank > 20:
continue
hot = hot_records.setdefault(
code,
{
"name": str(item.get("ts_name") or "--"),
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
hot["ths_rank" if source == "同花顺热榜" else "dc_rank"] = rank
if source == "同花顺热榜":
hot["concepts"] = self._parse_concepts(item.get("concept"))
ranked_hot = sorted(
hot_records.items(),
key=lambda pair: (
((21 - (pair[1].get("ths_rank") or 21)) / 20)
+ ((21 - (pair[1].get("dc_rank") or 21)) / 20)
+ (0.35 if pair[1].get("ths_rank") and pair[1].get("dc_rank") else 0)
),
reverse=True,
)
for code, _ in ranked_hot[:5]:
core_tags.setdefault(code, set()).add("人气前5")
for code, hot in hot_records.items():
ranks = [rank for rank in (hot.get("ths_rank"), hot.get("dc_rank")) if isinstance(rank, int)]
dual = len(ranks) == 2
if not ranks or (min(ranks) > 10 and not dual and code not in candidates and code not in core_tags):
continue
candidate = candidates.setdefault(
code,
{
"sources": [],
"streak": 0,
"sector": "其他",
"name": hot["name"],
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
candidate["ths_rank"] = hot.get("ths_rank")
candidate["dc_rank"] = hot.get("dc_rank")
candidate["concepts"] = hot.get("concepts") or []
if hot.get("ths_rank") and "同花顺热榜" not in candidate["sources"]:
candidate["sources"].append("同花顺热榜")
if hot.get("dc_rank") and "东方财富热榜" not in candidate["sources"]:
candidate["sources"].append("东方财富热榜")
normalized = []
for row in rows:
candidate = candidates.get(str(row.get("code") or ""))
if not candidate:
continue
streak = int(candidate["streak"])
expected_change = {1: 1.5, 2: 3.0, 3: 4.0}.get(streak, 5.0 if streak else 0.5)
ranks = [
rank for rank in (candidate.get("ths_rank"), candidate.get("dc_rank"))
if isinstance(rank, int)
]
if len(ranks) == 2:
expected_change += 0.8
elif ranks:
best_rank = min(ranks)
expected_change += 0.7 if best_rank <= 10 else 0.4 if best_rank <= 30 else 0.2
expected_change = min(expected_change, 6.5)
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
amount_million = _number(row.get("amount_million"))
confirmation = self._auction_confirmation(row)
actual_strength = _number(row.get("change")) + confirmation
label = self._expectation_label(actual_strength, expected_change)
is_broken = "昨日炸板" in candidate["sources"] and "昨日涨停" not in candidate["sources"]
identity = f"昨日{streak}" if streak > 1 else "昨日首板" if streak == 1 else "昨日炸板" if is_broken else "人气榜标的"
popularity = ",双榜共识" if len(ranks) == 2 else ",热榜靠前" if ranks and min(ranks) <= 10 else ""
difference = _number(row.get("change")) - expected_change
direction = "高于" if difference > 0 else "低于" if difference < 0 else "贴合"
reason = (
f"{identity}{popularity};竞价涨幅{direction}预期中枢"
f"{abs(difference):.1f}个百分点,量比{volume_ratio:.2f}"
)
tags = sorted(core_tags.get(str(row.get("code") or ""), set()))
scored_row = {
**row,
"concepts": candidate["concepts"],
}
attention_score = self._attention_score(
scored_row,
expected_change,
tags,
candidate["sources"],
streak,
str(candidate.get("sector") or row.get("sector") or "") in strong_sector_names,
)
normalized.append(
{
**scored_row,
"sector": candidate["sector"] if candidate["sector"] != "其他" else row.get("sector", "其他"),
"candidate_sources": candidate["sources"],
"source_label": " · ".join(candidate["sources"]),
"prior_streak": streak,
"concepts": candidate["concepts"],
"expected_change": round(expected_change, 2),
"actual_strength": round(actual_strength, 2),
"expectation": label,
"attention_score": attention_score,
"core_tags": tags,
"is_market_core": bool(tags),
"expectation_reason": reason,
}
)
normalized.sort(key=lambda item: (_number(item.get("attention_score")), _number(item.get("amount_million"))), reverse=True)
matched_top = {
str(item.get("code") or "")
for item in sorted(
(item for item in normalized if item.get("expectation") == "符合预期"),
key=lambda item: _number(item.get("attention_score")),
reverse=True,
)[:20]
}
focus_candidates = [
item for item in normalized
if item.get("is_market_core")
or (_number(item.get("attention_score")) >= 55 and item.get("expectation") != "符合预期")
or str(item.get("code") or "") in matched_top
]
mandatory = [item for item in focus_candidates if item.get("is_market_core")]
mandatory_codes = {str(item.get("code") or "") for item in mandatory}
optional = [item for item in focus_candidates if str(item.get("code") or "") not in mandatory_codes]
focus_rows = sorted(mandatory, key=lambda item: _number(item.get("attention_score")), reverse=True)
focus_rows.extend(optional[:max(0, 30 - len(focus_rows))])
focus_rows.sort(key=lambda item: _number(item.get("attention_score")), reverse=True)
return normalized, {
"baseline_date": _display_date(baseline_date),
"prior_limit_count": len(prior_limits),
"prior_broken_count": len(prior_broken),
"hot_candidate_count": sum(
any(source in {"同花顺热榜", "东方财富热榜"} for source in item["sources"])
for item in candidates.values()
),
"core_count": sum(bool(item.get("is_market_core")) for item in normalized),
"notice": "".join(errors),
}, focus_rows
@staticmethod
def _auction_theme_evidence(
prior_snapshot: dict[str, Any],
candidate_rows: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
prior_sectors = list(prior_snapshot.get("sectors") or [])
carry = []
for sector in prior_sectors[:10]:
name = str(sector.get("name") or "其他")
matched = [row for row in candidate_rows if str(row.get("sector") or "其他") == name]
changes = [_number(row.get("change")) for row in matched]
middle = median(changes) if changes else -10.0
positive_rate = sum(value > 0.2 for value in changes) / len(changes) * 100 if changes else 0.0
if middle >= 2 and positive_rate >= 60:
status = "强承接"
elif middle >= 0 and positive_rate >= 50:
status = "有承接"
elif middle > -2:
status = "分歧"
else:
status = "承接弱"
carry.append(
{
"name": name,
"status": status,
"prior_limit_count": int(_number(sector.get("count"))),
"leader": str(sector.get("leader") or "--"),
"matched_count": len(matched),
"median_change": round(middle, 2) if matched else None,
"positive_rate": round(positive_rate, 1),
"amount_million": round(sum(_number(row.get("amount_million")) for row in matched), 2),
}
)
concept_groups: dict[str, list[dict[str, Any]]] = {}
prior_names = {str(item.get("name") or "") for item in prior_sectors}
for row in candidate_rows:
for concept in row.get("concepts") or []:
if concept and concept not in prior_names:
concept_groups.setdefault(str(concept), []).append(row)
new_themes = []
for name, members in concept_groups.items():
unique = {str(item.get("code") or ""): item for item in members}
values = list(unique.values())
changes = [_number(item.get("change")) for item in values]
if len(values) < 2 or median(changes) < 2 or sum(value > 0.2 for value in changes) / len(values) < 0.67:
continue
new_themes.append(
{
"name": name,
"stock_count": len(values),
"median_change": round(median(changes), 2),
"amount_million": round(sum(_number(item.get("amount_million")) for item in values), 2),
"leaders": [str(item.get("name") or "--") for item in sorted(values, key=lambda value: _number(value.get("change")), reverse=True)[:3]],
}
)
new_themes.sort(key=lambda item: (item["stock_count"], item["median_change"], item["amount_million"]), reverse=True)
return {"carry": carry, "new_themes": new_themes[:8]}
@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Callable
from backend.data.providers.ifind_client import IfindHttpClient
from backend.data.providers.tushare_client import TushareClient, TushareError
if TYPE_CHECKING:
from database import ReviewDatabase
CHINA_TIMEZONE = timezone(timedelta(hours=8))
def _display_date(value: str) -> str:
text = str(value or "").replace("-", "")
if len(text) != 8:
return str(value or "")
return f"{text[:4]}-{text[4:6]}-{text[6:]}"
class MarketInsightsContextMixin:
def __init__(
self,
database: ReviewDatabase,
client: TushareClient,
now_provider: Callable[[], datetime] | None = None,
ifind: IfindHttpClient | None = None,
) -> None:
self.database = database
self.client = client
self._now_provider = now_provider or (lambda: datetime.now(CHINA_TIMEZONE))
self.ifind = ifind
def _trade_context(self, requested_date: str) -> tuple[str, str]:
"""Resolve trading dates without making cached feature pages depend on Tushare uptime."""
requested = str(requested_date or "").replace("-", "")
try:
return self.client.resolve_trade_context(requested)
except TushareError:
latest = self.database.get_latest_real_snapshot(requested) or {}
trade_date = str(
(latest.get("meta") or {}).get("trade_date")
or latest.get("_snapshot_date")
or requested
).replace("-", "")
previous = self.database.get_latest_real_snapshot(trade_date, strictly_before=True) or {}
previous_date = str(
(previous.get("meta") or {}).get("trade_date")
or previous.get("_snapshot_date")
or ""
).replace("-", "")
return trade_date, previous_date
def _latest_feature_snapshot(self, kind: str, trade_date: str) -> dict[str, Any] | None:
return self.database.get_latest_data_snapshot(kind, "", trade_date)
def _stock_master(self) -> dict[str, dict[str, Any]]:
rows = self.database.list_stock_master()
if not rows:
rows = self.client.query(
"stock_basic",
{"list_status": "L"},
"ts_code,name,industry,market,list_date",
)
self.database.upsert_stock_master(rows)
rows = self.database.list_stock_master()
return {str(row.get("ts_code") or ""): row for row in rows}
@staticmethod
def _parse_concepts(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item) for item in value if str(item).strip()]
text = str(value or "").strip()
if not text:
return []
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return [str(item) for item in parsed if str(item).strip()]
except json.JSONDecodeError:
pass
return [part.strip() for part in text.split(",") if part.strip()]
@@ -0,0 +1,156 @@
from __future__ import annotations
import copy
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.tushare_client import TushareError
from backend.features.market.insights_context import _display_date
class MarketPopularityInsightsMixin:
def popularity(self, requested_date: str, force: bool = False) -> dict[str, Any]:
trade_date, previous_date = self._trade_context(requested_date)
if not force:
cached = self.database.get_data_snapshot("popularity_v1", trade_date)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {**result.get("meta", {}), "cached": True}
return result
ths_rows, dc_rows, errors = self._hot_rows(trade_date)
actual_date = trade_date
carried_forward = False
if not ths_rows and not dc_rows and previous_date:
ths_rows, dc_rows, errors = self._hot_rows(previous_date)
actual_date = previous_date
carried_forward = bool(ths_rows or dc_rows)
if not ths_rows and not dc_rows:
fallback = self._latest_feature_snapshot("popularity_v1", trade_date)
if fallback:
result = copy.deepcopy(fallback)
result["meta"] = {
**result.get("meta", {}),
"requested_date": _display_date(requested_date),
"carried_forward": True,
"cached": True,
"notice": "当前榜单暂不可用,展示最近有效快照",
}
return result
return {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_date),
"carried_forward": False,
"cached": False,
"notice": "该交易日暂无可用人气榜",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {"ths_count": 0, "dc_count": 0, "dual_count": 0},
"combined": [], "ths": [], "dc": [],
}
prior_request = (datetime.strptime(actual_date, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d")
prior_date, _ = self._trade_context(prior_request)
previous_ths, previous_dc, _ = self._hot_rows(prior_date)
ths = self._normalize_hot(ths_rows, "热股", previous_ths)
dc = self._normalize_hot(dc_rows, "A股市场", previous_dc)
ths_map = {item["ts_code"]: item for item in ths}
dc_map = {item["ts_code"]: item for item in dc}
combined = []
for ts_code in set(ths_map) | set(dc_map):
ths_item = ths_map.get(ts_code)
dc_item = dc_map.get(ts_code)
base = ths_item or dc_item or {}
ths_rank = int(ths_item["rank"]) if ths_item else None
dc_rank = int(dc_item["rank"]) if dc_item else None
score = (
(101 - (ths_rank or 101)) * 0.5
+ (201 - (dc_rank or 201)) * 0.25
)
combined.append(
{
**base,
"ths_rank": ths_rank,
"dc_rank": dc_rank,
"score": round(score, 2),
"dual_source": bool(ths_item and dc_item),
"concepts": (ths_item or {}).get("concepts") or [],
}
)
combined.sort(key=lambda item: (item["dual_source"], item["score"]), reverse=True)
for index, item in enumerate(combined, 1):
item["rank"] = index
result = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(actual_date),
"previous_trade_date": _display_date(prior_date),
"carried_forward": carried_forward,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "".join(errors),
},
"summary": {
"ths_count": len(ths),
"dc_count": len(dc),
"dual_count": sum(item["dual_source"] for item in combined),
},
"combined": combined[:200],
"ths": ths,
"dc": dc,
}
self.database.save_data_snapshot("popularity_v1", trade_date, "market", result)
return result
def _hot_rows(self, trade_date: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
errors = []
try:
ths = self.client.query("ths_hot", {"trade_date": trade_date})
except TushareError:
ths = []
errors.append("同花顺榜单暂不可用")
try:
dc = self.client.query("dc_hot", {"trade_date": trade_date})
except TushareError:
dc = []
errors.append("东方财富榜单暂不可用")
return ths, dc, errors
def _normalize_hot(
self,
rows: list[dict[str, Any]],
data_type: str,
previous_rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
previous = {
str(row.get("ts_code") or ""): int(_number(row.get("rank")))
for row in previous_rows
if str(row.get("data_type") or "") == data_type
}
items = []
for row in rows:
if str(row.get("data_type") or "") != data_type:
continue
rank = int(_number(row.get("rank")))
ts_code = str(row.get("ts_code") or "")
prior_rank = previous.get(ts_code)
items.append(
{
"rank": rank,
"ts_code": ts_code,
"code": ts_code.split(".")[0],
"name": str(row.get("ts_name") or "--"),
"change": round(_number(row.get("pct_change")), 2),
"price": round(_number(row.get("current_price")), 2),
"hot": round(_number(row.get("hot")), 1),
"rank_change": (prior_rank - rank) if prior_rank else None,
"concepts": self._parse_concepts(row.get("concept")),
"reason": str(row.get("rank_reason") or ""),
"rank_time": str(row.get("rank_time") or ""),
}
)
items.sort(key=lambda item: item["rank"])
return items
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import copy
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.tushare_client import TushareError
from backend.features.market.insights_context import _display_date
class MarketThemeInsightsMixin:
def _theme_directory(self) -> list[dict[str, Any]]:
cached = self.database.get_data_snapshot("theme_directory_v1", "ths") or {}
if cached.get("items"):
return list(cached["items"])
rows = self.client.query(
"ths_index", {}, "ts_code,name,count,exchange,list_date,type"
)
items = [
{
"code": str(row.get("ts_code") or ""),
"name": str(row.get("name") or ""),
"member_count": int(_number(row.get("count"))),
"list_date": str(row.get("list_date") or ""),
}
for row in rows
if str(row.get("type") or "").upper() == "N"
and str(row.get("exchange") or "").upper() == "A"
and row.get("ts_code")
and row.get("name")
]
self.database.save_data_snapshot(
"theme_directory_v1", "ths", "market", {"items": items}
)
return items
def theme_library(self, requested_date: str, force: bool = False) -> dict[str, Any]:
trade_date, previous_date = self._trade_context(requested_date)
if not force:
cached = self.database.get_data_snapshot("theme_library_v1", trade_date)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {**result.get("meta", {}), "cached": True}
return result
try:
daily = self.client.query(
"ths_daily",
{"trade_date": trade_date},
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
)
except TushareError:
fallback = self._latest_feature_snapshot("theme_library_v1", trade_date)
if fallback:
result = copy.deepcopy(fallback)
result["meta"] = {
**result.get("meta", {}),
"requested_date": _display_date(requested_date),
"carried_forward": True,
"cached": True,
"notice": "当前题材行情暂不可用,展示最近有效快照",
}
return result
daily = []
actual_date = trade_date
carried_forward = False
if not daily and previous_date:
try:
daily = self.client.query(
"ths_daily",
{"trade_date": previous_date},
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
)
except TushareError:
daily = []
actual_date = previous_date
carried_forward = bool(daily)
daily_map = {str(row.get("ts_code") or ""): row for row in daily}
try:
hot_rows = self.client.query("ths_hot", {"trade_date": actual_date})
except TushareError:
hot_rows = []
hot_map = {
str(row.get("ts_code") or ""): int(_number(row.get("rank")))
for row in hot_rows
if str(row.get("data_type") or "") == "概念板块"
}
items = []
for item in self._theme_directory():
quote = daily_map.get(item["code"], {})
items.append(
{
**item,
"change": round(_number(quote.get("pct_change")), 2),
"close": round(_number(quote.get("close")), 3),
"turnover_rate": round(_number(quote.get("turnover_rate")), 2),
"volume": round(_number(quote.get("vol")), 2),
"hot_rank": hot_map.get(item["code"]),
"has_quote": bool(quote),
}
)
items.sort(
key=lambda item: (
item["has_quote"],
item["hot_rank"] is not None,
-(item["hot_rank"] or 9999),
item["change"],
),
reverse=True,
)
quoted = [item for item in items if item["has_quote"]]
result = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(actual_date),
"carried_forward": carried_forward,
"cached": False,
"notice": "" if quoted else "该交易日暂无题材行情,已保留题材目录",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
"theme_count": len(items),
"quoted_count": len(quoted),
"up_count": sum(item["change"] > 0 for item in quoted),
"down_count": sum(item["change"] < 0 for item in quoted),
"hot_count": len(hot_map),
},
"items": items,
}
self.database.save_data_snapshot("theme_library_v1", trade_date, "market", result)
return result
def theme_detail(self, code: str, requested_date: str) -> dict[str, Any]:
code = str(code or "").strip().upper()
library = self.theme_library(requested_date)
theme = next((item for item in library["items"] if item["code"] == code), None)
if not theme:
raise ValueError("未找到对应题材。")
actual_date = str(library["meta"]["trade_date"]).replace("-", "")
detail_key = f"{actual_date}:{code}"
cached_detail = self.database.get_data_snapshot("theme_detail_v1", detail_key)
if cached_detail:
return cached_detail
try:
members = self.client.query(
"ths_member", {"ts_code": code, "is_new": "Y"}, "ts_code,con_code,con_name"
)
except TushareError:
members = []
bars = self.database.daily_bars_for_date(actual_date)
if not bars:
bars = self.client.query(
"daily",
{"trade_date": actual_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
self.database.upsert_daily_bars(bars)
bar_map = {str(row.get("ts_code") or ""): row for row in bars}
normalized_members = []
for member in members:
ts_code = str(member.get("con_code") or "")
quote = bar_map.get(ts_code, {})
normalized_members.append(
{
"code": ts_code.split(".")[0],
"ts_code": ts_code,
"name": str(member.get("con_name") or "--"),
"price": round(_number(quote.get("close")), 2),
"change": round(_number(quote.get("pct_chg")), 2),
"amount_billion": round(_number(quote.get("amount")) / 100_000, 2),
"has_quote": bool(quote),
}
)
normalized_members.sort(
key=lambda item: (item["has_quote"], item["change"], item["amount_billion"]),
reverse=True,
)
end = datetime.strptime(actual_date, "%Y%m%d")
try:
history = self.client.query(
"ths_daily",
{
"ts_code": code,
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
"end_date": actual_date,
},
"ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate",
)
except TushareError:
history = []
history.sort(key=lambda row: str(row.get("trade_date") or ""))
series = [
{
"trade_date": _display_date(str(row.get("trade_date") or "")),
"open": _number(row.get("open")),
"high": _number(row.get("high")),
"low": _number(row.get("low")),
"close": _number(row.get("close")),
"change": _number(row.get("pct_change")),
"volume": _number(row.get("vol")),
}
for row in history[-90:]
]
result = {
"meta": {
"trade_date": _display_date(actual_date),
"notice": "" if members or history else "题材成分与走势暂不可用",
},
"theme": theme,
"series": series,
"members": normalized_members,
"summary": {
"member_count": len(normalized_members),
"up_count": sum(item["change"] > 0 for item in normalized_members if item["has_quote"]),
"down_count": sum(item["change"] < 0 for item in normalized_members if item["has_quote"]),
"quoted_count": sum(item["has_quote"] for item in normalized_members),
},
}
if members or history:
self.database.save_data_snapshot("theme_detail_v1", detail_key, "market", result)
return result
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.data.providers.tushare_client import TushareError
from backend.features.market import ChartDataError
class MarketRoutesMixin:
def _handle_market_get(self, parsed) -> bool:
if parsed.path == "/api/dashboard":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.get_dashboard(trade_date, False))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"数据加载失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
return True
if parsed.path == "/api/realtime-aggregate/health":
query = parse_qs(parsed.query)
try:
self.send_json(
{
"ok": True,
"aggregate": self.application_service.realtime_aggregate_health(
query.get("sector", [""])[0]
),
}
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/search":
query = parse_qs(parsed.query)
search_query = query.get("q", [""])[0]
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.search_entities(search_query, trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/search/detail":
query = parse_qs(parsed.query)
entity_type = query.get("type", [""])[0]
identifier = query.get("id", [""])[0]
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(
self.application_service.get_search_detail(entity_type, identifier, trade_date)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except TushareError as exc:
self.send_json({"error": f"行情加载失败:{exc}"}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/chart/intraday":
query = parse_qs(parsed.query)
entity_type = query.get("type", [""])[0]
identifier = query.get("id", [""])[0]
try:
self.send_json(self.application_service.get_intraday_chart(entity_type, identifier))
except (ValueError, ChartDataError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
stock_preview_match = re.fullmatch(r"/api/stock/(\d{6})/preview", parsed.path)
if stock_preview_match:
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(
self.application_service.get_stock_preview(stock_preview_match.group(1), trade_date, force)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
stock_match = re.fullmatch(r"/api/stock/(\d{6})", parsed.path)
if stock_match:
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(self.application_service.get_stock_detail(stock_match.group(1), trade_date, force))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import json
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class MentorRoutesMixin:
def _handle_mentor_get(self, parsed) -> bool:
if parsed.path == "/api/mentors/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.mentor_setup(trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/mentors/messages":
query = parse_qs(parsed.query)
try:
self.send_json(
{
"items": self.application_service.mentor_messages(
query.get("mentor_id", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
}
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_mentor_post(self, parsed) -> bool:
if parsed.path == "/api/mentors/preferences":
try:
result = self.application_service.save_mentor_preferences(self.read_json_body())
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_mentor_delete(self, parsed) -> bool:
if parsed.path == "/api/mentors/messages":
query = parse_qs(parsed.query)
try:
deleted = self.application_service.clear_mentor_messages(
query.get("mentor_id", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
self.send_json({"ok": True, "deleted": deleted})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
import json
from http import HTTPStatus
class PoolRoutesMixin:
def save_reason(self) -> None:
try:
body = self.read_json_body()
self.application_service.save_reason(
str(body.get("trade_date") or ""),
str(body.get("code") or ""),
str(body.get("reason") or ""),
)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.data.providers.tushare_client import TushareError
class PopularityRoutesMixin:
def _handle_popularity_get(self, parsed) -> bool:
if parsed.path == "/api/popularity":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.popularity(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class ReviewRoutesMixin:
def _handle_review_get(self, parsed) -> bool:
if parsed.path == "/api/trades":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.trade_entries(
query.get("start_date", [""])[0],
query.get("end_date", [""])[0],
query.get("code", [""])[0],
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/assistant/messages":
self.send_json({"items": self.application_service.assistant_messages()})
return True
if parsed.path == "/api/watchlist":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.review_watchlist(
query.get("trade_date", [date.today().isoformat()])[0]
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/notes":
query = parse_qs(parsed.query)
code = query.get("code", [""])[0]
trade_date = query.get("trade_date", [""])[0].replace("-", "")
scope = query.get("scope", ["all"])[0]
if scope not in {"all", "daily", "stock"}:
self.send_json({"error": "复盘记录范围不支持。"}, HTTPStatus.BAD_REQUEST)
return True
self.send_json(
{
"items": self.application_service.database.list_notes(
self.application_service.current_user_id, code, trade_date, scope
)
}
)
return True
return False
def _handle_review_delete(self, parsed) -> bool:
if parsed.path == "/api/assistant/messages":
deleted = self.application_service.clear_assistant_messages()
self.send_json({"ok": True, "deleted": deleted})
return True
watchlist_match = re.fullmatch(r"/api/watchlist/(\d{6})", parsed.path)
if watchlist_match:
deleted = self.application_service.database.delete_watchlist(
self.application_service.current_user_id, watchlist_match.group(1)
)
self.send_json({"ok": True, "deleted": deleted})
return True
note_match = re.fullmatch(r"/api/notes/(\d+)", parsed.path)
if note_match:
deleted = self.application_service.database.delete_note(
self.application_service.current_user_id, int(note_match.group(1))
)
self.send_json({"ok": True, "deleted": deleted})
return True
trade_match = re.fullmatch(r"/api/trades/(\d+)", parsed.path)
if trade_match:
self.send_json(
{"ok": True, **self.application_service.delete_trade_entry(int(trade_match.group(1)))}
)
return True
return False
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class RotationRoutesMixin:
def _handle_rotation_get(self, parsed) -> bool:
if parsed.path == "/api/rotation/history":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.rotation_history(trade_date, 9))
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/rotation/members":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.rotation_sector_members(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("sector", [""])[0],
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import statistics
from collections import defaultdict
from datetime import datetime
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.features.screener.factors import FactorBuilder
from backend.features.screener.formula import FormulaEvaluator
from database import ReviewDatabase
class BacktestRunner:
def __init__(
self,
database: ReviewDatabase,
factor_builder: FactorBuilder,
formula_evaluator: FormulaEvaluator,
) -> None:
self.database = database
self.factor_builder = factor_builder
self.formula_evaluator = formula_evaluator
self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {}
def build_factors(
self, trade_date: str, history_days: int
) -> tuple[list[dict[str, Any]], str]:
return self.factor_builder.build_factors(
trade_date, history_days=history_days
)
def apply_formula(
self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str
) -> list[dict[str, Any]]:
return self.formula_evaluator.apply_formula(rows, formula, regime)
def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]:
meta = formula.get("meta") or {}
history_days = max(21, min(260, int(meta.get("history_days") or 80)))
holding_days = max(1, min(30, int(meta.get("backtest_days") or 3)))
take_profit = max(0.5, min(50.0, float(meta.get("take_profit") or 3)))
stop_loss = min(-0.5, max(-50.0, float(meta.get("stop_loss") or -3)))
dates = self.database.factor_dates(trade_date, history_days + holding_days + 20)
eligible_dates = dates[:-holding_days] if len(dates) > holding_days else []
frequency = str(meta.get("frequency") or "每日")
if "" in frequency:
grouped = {}
for value in eligible_dates:
grouped[value[:6]] = value
evaluation_dates = list(grouped.values())[-8:]
elif "双周" in frequency:
weekly_dates = []
grouped = {}
for value in eligible_dates:
parsed = datetime.strptime(value, "%Y%m%d")
grouped[parsed.strftime("%G-%V")] = value
weekly_dates = list(grouped.values())
evaluation_dates = weekly_dates[-16::2][-8:]
elif "" in frequency:
grouped = {}
for value in eligible_dates:
parsed = datetime.strptime(value, "%Y%m%d")
grouped[parsed.strftime("%G-%V")] = value
evaluation_dates = list(grouped.values())[-8:]
else:
evaluation_dates = eligible_dates[-8:]
wins = 0
losses = 0
samples = 0
returns = []
drawdowns = []
all_data = self.database.load_factor_data(
trade_date, history_days + holding_days + 20
)
bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in all_data["bars"]:
bars_by_code[row["ts_code"]].append(row)
for bars in bars_by_code.values():
bars.sort(key=lambda item: item["trade_date"])
for current_date in evaluation_dates:
try:
cache_key = (current_date, history_days)
factors = self._backtest_factor_cache.get(cache_key)
if factors is None:
factors, _ = self.build_factors(
current_date, history_days=history_days
)
if len(self._backtest_factor_cache) >= 64:
self._backtest_factor_cache.pop(
next(iter(self._backtest_factor_cache))
)
self._backtest_factor_cache[cache_key] = factors
except ValueError:
continue
selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest")
for candidate in selected:
bars = bars_by_code.get(candidate["ts_code"], [])
index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1)
future = bars[index + 1:index + 1 + holding_days] if index >= 0 else []
if len(future) < holding_days:
continue
entry = candidate["price"]
won = False
lost = False
for day in future:
low_return = (_number(day["low"]) / entry - 1) * 100
high_return = (_number(day["high"]) / entry - 1) * 100
if low_return <= stop_loss:
lost = True
break
if high_return >= take_profit:
won = True
break
if won:
wins += 1
elif lost:
losses += 1
samples += 1
returns.append((_number(future[-1]["close"]) / entry - 1) * 100)
drawdowns.append(min((_number(day["low"]) / entry - 1) * 100 for day in future))
return {
"samples": samples,
"wins": wins,
"losses": losses,
"win_rate": round(wins / samples * 100, 1) if samples else 0,
"average_3d_return": round(statistics.fmean(returns), 2) if returns else 0,
"average_holding_return": round(statistics.fmean(returns), 2) if returns else 0,
"average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0,
"evaluation_days": len(evaluation_dates),
"frequency": frequency,
"holding_days": holding_days,
"take_profit": take_profit,
"stop_loss": stop_loss,
"definition": (
f"收盘后选股,未来{holding_days}日先触及+{take_profit:g}%且未先触及"
f"{stop_loss:g}%计为成功;同日双触发按失败处理。"
),
"approximate": True,
}
+707
View File
@@ -0,0 +1,707 @@
from __future__ import annotations
from backend.features.screener.strategies import ADVANCED_CURATED_STRATEGIES
REGIMES = {
"ice": "冰点",
"repair": "修复",
"fermentation": "发酵",
"climax": "高潮",
"divergence": "分化",
"retreat": "退潮",
}
FACTOR_FIELDS = {
"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": "下影线实体比",
}
FACTOR_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",
],
}
ALLOWED_OPERATORS = {">", ">=", "<", "<=", "==", "!=", "between", "in"}
BUILTIN_STRATEGIES = [
{
"name": "冰点抗跌先手",
"description": "寻找冰点中保持相对强度、低波动且有板块承接的个股,允许无结果。",
"regimes": ["ice"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [-3, 7]},
{"field": "return_5d", "op": ">=", "value": -5},
{"field": "amount_billion", "op": ">=", "value": 1},
{"field": "volatility_10d", "op": "<=", "value": 7},
],
"score": [
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
{"field": "sector_strength", "weight": 0.25, "direction": "desc"},
{"field": "volume_ratio_5d", "weight": 0.20, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.15, "direction": "asc"},
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
],
"limit": 12,
"min_score": 0.58,
},
},
{
"name": "修复先锋",
"description": "筛选率先站回趋势、温和放量并获得板块共振的修复前排。",
"regimes": ["repair"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [1, 9.7]},
{"field": "return_5d", "op": ">", "value": 0},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "volume_ratio_5d", "op": ">=", "value": 1.05},
],
"score": [
{"field": "sector_strength", "weight": 0.28, "direction": "desc"},
{"field": "relative_strength", "weight": 0.24, "direction": "desc"},
{"field": "volume_ratio_5d", "weight": 0.18, "direction": "desc"},
{"field": "net_flow_million", "weight": 0.16, "direction": "desc"},
{"field": "amount_billion", "weight": 0.14, "direction": "desc"},
],
"limit": 15,
"min_score": 0.54,
},
},
{
"name": "主线发酵跟随",
"description": "在主线扩散期寻找趋势、成交承载和板块涨停梯队共同增强的个股。",
"regimes": ["fermentation"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [0, 9.8]},
{"field": "return_5d", "op": ">=", "value": 3},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "amount_billion", "op": ">=", "value": 2},
],
"score": [
{"field": "sector_limit_count", "weight": 0.25, "direction": "desc"},
{"field": "sector_strength", "weight": 0.24, "direction": "desc"},
{"field": "return_10d", "weight": 0.20, "direction": "desc"},
{"field": "amount_billion", "weight": 0.16, "direction": "desc"},
{"field": "large_flow_million", "weight": 0.15, "direction": "desc"},
],
"limit": 15,
"min_score": 0.55,
},
},
{
"name": "高潮核心去后排",
"description": "高潮阶段只保留容量、趋势和辨识度较高的核心,降低后排跟风权重。",
"regimes": ["climax"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [-2, 7]},
{"field": "return_10d", "op": ">=", "value": 5},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "amount_billion", "op": ">=", "value": 5},
],
"score": [
{"field": "amount_billion", "weight": 0.28, "direction": "desc"},
{"field": "sector_strength", "weight": 0.22, "direction": "desc"},
{"field": "relative_strength", "weight": 0.20, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.15, "direction": "asc"},
{"field": "limit_streak", "weight": 0.15, "direction": "desc"},
],
"limit": 10,
"min_score": 0.62,
},
},
{
"name": "分化承接回流",
"description": "寻找分化中仍有趋势承接、板块强度和资金回流的核心候选。",
"regimes": ["divergence"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [-3, 7]},
{"field": "return_5d", "op": ">", "value": 0},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "volume_ratio_5d", "op": "between", "value": [0.7, 3.5]},
],
"score": [
{"field": "relative_strength", "weight": 0.28, "direction": "desc"},
{"field": "sector_strength", "weight": 0.24, "direction": "desc"},
{"field": "net_flow_million", "weight": 0.20, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.16, "direction": "asc"},
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
],
"limit": 12,
"min_score": 0.57,
},
},
{
"name": "退潮防守观察",
"description": "退潮期采用高门槛防守筛选,结果为空代表当前不宜主动出击。",
"regimes": ["retreat"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "pct_chg", "op": "between", "value": [-2, 4]},
{"field": "return_5d", "op": ">=", "value": -2},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "volatility_10d", "op": "<=", "value": 4.5},
{"field": "amount_billion", "op": ">=", "value": 2},
],
"score": [
{"field": "volatility_10d", "weight": 0.30, "direction": "asc"},
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
{"field": "sector_strength", "weight": 0.15, "direction": "desc"},
{"field": "net_flow_million", "weight": 0.10, "direction": "desc"},
],
"limit": 8,
"min_score": 0.68,
},
},
{
"name": "竞价强势确认",
"description": "用竞价涨幅、成交承载和量比确认修复或发酵阶段的主动进攻标的。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "auction_change", "op": "between", "value": [1, 7]},
{"field": "auction_amount_million", "op": ">=", "value": 3},
{"field": "auction_volume_ratio", "op": ">=", "value": 0.8},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "auction_amount_million", "weight": 0.26, "direction": "desc"},
{"field": "auction_volume_ratio", "weight": 0.22, "direction": "desc"},
{"field": "auction_change", "weight": 0.18, "direction": "desc"},
{"field": "sector_strength", "weight": 0.18, "direction": "desc"},
{"field": "relative_strength", "weight": 0.16, "direction": "desc"},
],
"limit": 15,
"min_score": 0.56,
},
},
]
for _strategy in BUILTIN_STRATEGIES:
_strategy["formula"].setdefault("meta", {
"library": "smart", "category": "周期策略", "quality": "系统",
"frequency": "每日", "risk": "随市场阶段", "data_group": "行情因子",
})
CURATED_STRATEGIES = [
{
"name": "连续分红质量",
"description": "寻找持续派息、盈利质量稳定且波动可控的长期现金回报型公司。",
"regimes": list(REGIMES),
"formula": {
"meta": {"library": "curated", "category": "红利价值", "quality": "A", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 1095},
"filters": [
{"field": "dividend_years", "op": ">=", "value": 4},
{"field": "dividend_yield_ttm", "op": ">=", "value": 2},
{"field": "roe", "op": ">=", "value": 6},
{"field": "pb", "op": "between", "value": [0.1, 4]},
],
"score": [
{"field": "dividend_yield_ttm", "weight": 0.30, "direction": "desc"},
{"field": "roe", "weight": 0.24, "direction": "desc"},
{"field": "ocf_to_opincome", "weight": 0.18, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.16, "direction": "asc"},
{"field": "total_mv_billion", "weight": 0.12, "direction": "desc"},
], "limit": 20, "min_score": 0.52,
},
},
{
"name": "ROIC质量低波",
"description": "以投入资本回报、毛利率和估值为核心,寻找低波动的高质量公司。",
"regimes": ["ice", "repair", "divergence", "retreat"],
"formula": {
"meta": {"library": "curated", "category": "质量价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 730},
"filters": [
{"field": "roic", "op": ">=", "value": 6},
{"field": "gross_margin", "op": ">=", "value": 15},
{"field": "pe_ttm", "op": "between", "value": [1, 45]},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "roic", "weight": 0.28, "direction": "desc"},
{"field": "gross_margin", "weight": 0.22, "direction": "desc"},
{"field": "ps_ttm", "weight": 0.18, "direction": "asc"},
{"field": "volatility_10d", "weight": 0.18, "direction": "asc"},
{"field": "total_mv_billion", "weight": 0.14, "direction": "desc"},
], "limit": 20, "min_score": 0.54,
},
},
{
"name": "低估值现金流白马",
"description": "筛选估值克制、经营现金流健康、资产回报稳定的大中型公司。",
"regimes": ["ice", "repair", "divergence", "retreat"],
"formula": {
"meta": {"library": "curated", "category": "现金流价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 730},
"filters": [
{"field": "pb", "op": "between", "value": [0.1, 1.8]},
{"field": "roa", "op": ">=", "value": 3},
{"field": "ocf_to_opincome", "op": ">", "value": 0},
{"field": "netprofit_yoy", "op": ">=", "value": -15},
{"field": "total_mv_billion", "op": ">=", "value": 100},
],
"score": [
{"field": "roa", "weight": 0.26, "direction": "desc"},
{"field": "ocf_to_opincome", "weight": 0.24, "direction": "desc"},
{"field": "pb", "weight": 0.20, "direction": "asc"},
{"field": "total_mv_billion", "weight": 0.16, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.14, "direction": "asc"},
], "limit": 20, "min_score": 0.53,
},
},
{
"name": "高增长合理估值",
"description": "在收入和利润同步增长的公司中,优先选择估值合理、趋势得到确认的标的。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "成长质量", "quality": "B+", "frequency": "月度", "risk": "", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 365},
"filters": [
{"field": "pe_ttm", "op": "between", "value": [1, 35]},
{"field": "revenue_yoy", "op": ">=", "value": 10},
{"field": "netprofit_yoy", "op": ">=", "value": 15},
{"field": "roe", "op": ">=", "value": 5},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "netprofit_yoy", "weight": 0.27, "direction": "desc"},
{"field": "revenue_yoy", "weight": 0.23, "direction": "desc"},
{"field": "roe", "weight": 0.20, "direction": "desc"},
{"field": "pe_ttm", "weight": 0.16, "direction": "asc"},
{"field": "relative_strength", "weight": 0.14, "direction": "desc"},
], "limit": 20, "min_score": 0.55,
},
},
{
"name": "行业宽度主线",
"description": "从行业站上20日线的覆盖率和板块强度出发,筛选主线中的强势个股。",
"regimes": ["repair", "fermentation", "climax", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "行业轮动", "quality": "B+", "frequency": "每周", "risk": "", "data_group": "行情与行业"},
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "sector_breadth_ma20", "op": ">=", "value": 55},
{"field": "sector_strength", "op": ">=", "value": 55},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "amount_billion", "op": ">=", "value": 2},
],
"score": [
{"field": "sector_breadth_ma20", "weight": 0.28, "direction": "desc"},
{"field": "sector_strength", "weight": 0.24, "direction": "desc"},
{"field": "relative_strength", "weight": 0.20, "direction": "desc"},
{"field": "sector_limit_count", "weight": 0.16, "direction": "desc"},
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
], "limit": 20, "min_score": 0.56,
},
},
{
"name": "首板低开",
"description": "昨日首板且位置不高,次日竞价温和低开并具备成交承载时进入候选。",
"regimes": ["ice", "repair", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "短线竞价", "quality": "B+", "frequency": "每日9:25", "risk": "", "data_group": "行情与竞价"},
"universe": {"exclude_st": True, "listed_days_min": 250},
"filters": [
{"field": "previous_first_limit", "op": "==", "value": 1},
{"field": "auction_change", "op": "between", "value": [-4.5, -2.5]},
{"field": "relative_position_60", "op": "<=", "value": 0.55},
{"field": "previous_amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "auction_amount_million", "weight": 0.28, "direction": "desc"},
{"field": "previous_amount_billion", "weight": 0.24, "direction": "desc"},
{"field": "relative_position_60", "weight": 0.20, "direction": "asc"},
{"field": "sector_strength", "weight": 0.16, "direction": "desc"},
{"field": "auction_volume_ratio", "weight": 0.12, "direction": "desc"},
], "limit": 12, "min_score": 0.50,
},
},
{
"name": "小碎步临界突破",
"description": "寻找近期窄幅爬升、接近阶段高点且具备历史活跃记忆的突破候选。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "形态突破", "quality": "B+", "frequency": "每日", "risk": "中高", "data_group": "历史行情"},
"universe": {"exclude_st": True, "listed_days_min": 250},
"filters": [
{"field": "no_limit_30d", "op": "==", "value": 1},
{"field": "had_limit_80d", "op": "==", "value": 1},
{"field": "max_abs_change_15d", "op": "<=", "value": 3},
{"field": "close_to_high_15d", "op": ">=", "value": 0.98},
{"field": "close_to_high_60d", "op": ">=", "value": 0.90},
],
"score": [
{"field": "close_to_high_15d", "weight": 0.26, "direction": "desc"},
{"field": "volume_ratio_5d", "weight": 0.22, "direction": "desc"},
{"field": "relative_strength", "weight": 0.20, "direction": "desc"},
{"field": "max_abs_change_15d", "weight": 0.18, "direction": "asc"},
{"field": "circ_mv_billion", "weight": 0.14, "direction": "asc"},
], "limit": 15, "min_score": 0.54,
},
},
{
"name": "连板龙头",
"description": "从昨日连板梯队中按高度、板块热度和成交承载筛选辨识度前排。",
"regimes": ["fermentation", "climax", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "连板接力", "quality": "B", "frequency": "每日", "risk": "很高", "data_group": "涨停结构"},
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "previous_limit_streak", "op": ">=", "value": 2},
{"field": "previous_amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "previous_limit_streak", "weight": 0.34, "direction": "desc"},
{"field": "sector_limit_count", "weight": 0.24, "direction": "desc"},
{"field": "previous_amount_billion", "weight": 0.18, "direction": "desc"},
{"field": "turnover_rate", "weight": 0.14, "direction": "desc"},
{"field": "sector_strength", "weight": 0.10, "direction": "desc"},
], "limit": 10, "min_score": 0.50,
},
},
{
"name": "微盘三正",
"description": "以正估值、正盈利和正经营现金流约束微盘暴露,保留明确风险提示。",
"regimes": ["repair", "fermentation"],
"formula": {
"meta": {"library": "curated", "category": "小盘质量", "quality": "B", "frequency": "每周", "risk": "", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 365},
"filters": [
{"field": "pb", "op": ">", "value": 0},
{"field": "roe", "op": ">", "value": 0},
{"field": "ocf_to_opincome", "op": ">", "value": 0},
{"field": "circ_mv_billion", "op": "between", "value": [5, 100]},
{"field": "amount_billion", "op": ">=", "value": 0.5},
],
"score": [
{"field": "circ_mv_billion", "weight": 0.32, "direction": "asc"},
{"field": "roe", "weight": 0.24, "direction": "desc"},
{"field": "ocf_to_opincome", "weight": 0.20, "direction": "desc"},
{"field": "turnover_rate", "weight": 0.14, "direction": "desc"},
{"field": "relative_strength", "weight": 0.10, "direction": "desc"},
], "limit": 20, "min_score": 0.52,
},
},
{
"name": "首板高开弱转强",
"description": "昨日涨停或触板后,使用9:25最终竞价涨幅、量比和板块承接确认强度。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "短线竞价", "quality": "B-", "frequency": "每日9:25", "risk": "", "data_group": "行情与竞价"},
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "previous_limit_signal", "op": "==", "value": 1},
{"field": "auction_change", "op": "between", "value": [1, 6]},
{"field": "auction_volume_ratio", "op": ">=", "value": 0.8},
{"field": "previous_amount_billion", "op": "between", "value": [3, 25]},
],
"score": [
{"field": "auction_amount_million", "weight": 0.28, "direction": "desc"},
{"field": "auction_volume_ratio", "weight": 0.24, "direction": "desc"},
{"field": "auction_change", "weight": 0.18, "direction": "desc"},
{"field": "sector_strength", "weight": 0.17, "direction": "desc"},
{"field": "relative_strength", "weight": 0.13, "direction": "desc"},
], "limit": 15, "min_score": 0.52,
},
},
]
CURATED_STRATEGIES.extend(ADVANCED_CURATED_STRATEGIES)
STRATEGY_ENVIRONMENT_NOTES = {
"连续分红质量": (
"防守市、低利率环境与中长期配置窗口",
"风险偏好快速上升时,稳健资产的价格弹性通常落后",
),
"ROIC质量低波": (
"震荡偏弱、重视盈利质量与回撤控制的市场",
"主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向",
),
"低估值现金流白马": (
"估值修复、价值回归及防守配置阶段",
"低估值可能来自基本面持续走弱,需警惕价值陷阱",
),
"高增长合理估值": (
"业绩驱动、成长风格占优且趋势获得确认的阶段",
"增长预期下修或估值快速收缩时,回撤可能明显放大",
),
"行业宽度主线": (
"主线清晰、行业内部多数个股同步走强的行情",
"板块快速轮动时,宽度信号容易在确认后迅速衰减",
),
"首板低开": (
"情绪修复期的分歧转一致与首板次日承接",
"退潮加速或低开缺少量能承接时,弱势可能继续扩大",
),
"小碎步临界突破": (
"趋势蓄势、波动收敛后临近突破的结构市",
"无量突破或指数剧烈震荡时,容易形成冲高回落",
),
"连板龙头": (
"高度拓展、题材梯队完整且接力情绪活跃的阶段",
"亏钱效应扩散或高位股集中退潮时,接力风险很高",
),
"微盘三正": (
"小盘风格活跃、流动性宽松且风险偏好较高的行情",
"风格切向大盘或微盘流动性收缩时,组合波动会显著上升",
),
"首板高开弱转强": (
"竞价承接明确、短线情绪修复或主线发酵阶段",
"高开缺乏板块共振时,竞价强势可能转为盘中兑现",
),
"中期动量·强者恒强": (
"趋势延续、主升段及强弱分化清晰的行情",
"无趋势震荡或快速轮动中,动量信号容易反复失效",
),
"强者回调": (
"主升趋势未破、强势股完成良性回踩的窗口",
"趋势已反转时,回调信号可能演变为下跌中继",
),
"超跌反转": (
"急跌后恐慌释放充分、市场进入修复预期的阶段",
"单边下跌初段容易过早介入,超跌不等于止跌",
),
"相对强度新高": (
"指数偏弱但结构性主线明确,或机构抱团强化的行情",
"基准快速补涨或强势方向瓦解时,相对优势可能迅速消失",
),
"均线多头排列": (
"中期趋势向上、回撤有序的趋势市与主升段",
"高位趋势末端或宽幅震荡中,均线信号通常反应滞后",
),
"唐奇安通道突破": (
"整理末端、放量突破并启动新趋势的行情",
"无量突破和宽幅震荡环境中,假突破出现概率较高",
),
"周线趋势·日线买点": (
"中期趋势稳定、日线回踩或再启动的多周期共振阶段",
"周线拐点尚未确认时,日线信号可能只是短暂反抽",
),
"空间板": (
"市场高度持续拓展、板块梯队完整的强接力环境",
"高度压缩或亏钱效应扩散时,最高板的补跌风险极高",
),
"龙头首阴": (
"主线龙头仍有辨识度、首次分歧后存在回流预期的阶段",
"题材退潮或龙头地位被替代后,首阴可能只是下跌起点",
),
"断板反包": (
"强势题材分歧后快速修复、核心股重新获得资金承接时",
"板块强度不足或反包缩量时,形态持续性通常较弱",
),
"核按钮反核": (
"恐慌释放后出现明确承接、短线情绪转暖的窗口",
"系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高",
),
"行业动量轮动": (
"主线相对清晰、行业趋势能够延续两周以上的结构市",
"行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减",
),
"主力资金行业流入": (
"板块轮动初期、资金先于价格形成连续净流入的阶段",
"资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势",
),
"景气-趋势-拥挤三维行业打分": (
"行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市",
"财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢",
),
"大小盘/成长价值风格切换(元策略)": (
"大小盘或成长价值风格形成持续相对强弱的阶段",
"风格快速往返切换时,近20日相对表现容易产生滞后信号",
),
"业绩超预期漂移(SUE/PEAD)": (
"业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时",
"预告与快报口径可能不同,公告后高开兑现会削弱漂移效应",
),
"多因子综合打分(IC动态加权)": (
"因子表现具备一定延续性、市场并非由单一极端主题主导时",
"近期有效因子可能快速失效,动态权重不能消除风格突变风险",
),
"热度突增潜伏(另类数据)": (
"人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期",
"榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高",
),
"机构榜溢价": (
"机构专用席位在相对低位形成明确净买入、且成交承载正常时",
"高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓",
),
}
for strategy in CURATED_STRATEGIES:
suitable_environment, failure_risk = STRATEGY_ENVIRONMENT_NOTES[strategy["name"]]
strategy["formula"]["meta"].update(
{
"suitable_environment": suitable_environment,
"failure_risk": failure_risk,
}
)
BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
+1 -1
View File
@@ -4,7 +4,7 @@ import json
from typing import Any
from backend.llm import transport as llm_transport
from backend.features.screener.engine import FACTOR_FIELDS, REGIMES
from backend.features.screener.catalog import FACTOR_FIELDS, REGIMES
class LLMCompilerError(RuntimeError):
+342
View File
@@ -0,0 +1,342 @@
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.features.screener.indicators import _optional_number
from database import ReviewDatabase
def _quarter_periods(trade_date: str, count: int) -> list[str]:
current = datetime.strptime(trade_date, "%Y%m%d")
quarter_ends = ((3, 31), (6, 30), (9, 30), (12, 31))
periods = []
year = current.year
while len(periods) < count:
for month, day in reversed(quarter_ends):
value = datetime(year, month, day)
if value <= current:
periods.append(value.strftime("%Y%m%d"))
if len(periods) == count:
break
year -= 1
return sorted(periods)
def _earnings_event_rows(
forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], trade_date: 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 ""))
ann_date = str(row.get("ann_date") or "")
if not all(key) or not ann_date or ann_date > trade_date:
continue
previous = forecast_map.get(key)
if previous is None or ann_date > str(previous.get("ann_date") or ""):
forecast_map[key] = row
result = []
for row in expresses:
ts_code = str(row.get("ts_code") or "")
end_date = str(row.get("end_date") or "")
ann_date = str(row.get("ann_date") or "")
forecast = forecast_map.get((ts_code, end_date))
if not forecast or not ts_code or not end_date or not ann_date or ann_date > trade_date:
continue
lower = _optional_number(forecast.get("net_profit_min"))
upper = _optional_number(forecast.get("net_profit_max"))
forecast_profit = statistics.fmean(
value for value in (lower, upper) if value is not None
) if lower is not None or upper is not None else None
actual_profit = _optional_number(row.get("n_income"))
if forecast_profit in (None, 0) or actual_profit is None:
continue
# forecast is reported in ten-thousand yuan while express uses yuan.
if abs(actual_profit) > max(abs(forecast_profit), 1) * 100:
actual_profit /= 10000
surprise_pct = (actual_profit / forecast_profit - 1) * 100
result.append(
{
"end_date": end_date,
"ann_date": ann_date,
"ts_code": ts_code,
"forecast_profit": forecast_profit,
"actual_profit": actual_profit,
"surprise_pct": surprise_pct,
"revenue_yoy": _optional_number(row.get("yoy_sales")),
"netprofit_yoy": _optional_number(row.get("yoy_net_profit")),
"source": "forecast+express",
}
)
return result
def _popularity_factor_rows(
trade_date: str,
ths_rows: list[dict[str, Any]],
dc_rows: list[dict[str, Any]],
previous_ths: list[dict[str, Any]],
previous_dc: list[dict[str, Any]],
) -> list[dict[str, Any]]:
def ranks(rows: list[dict[str, Any]], data_type: str) -> dict[str, int]:
result = {}
for row in rows:
if data_type and str(row.get("data_type") or "") != data_type:
continue
ts_code = str(row.get("ts_code") or "")
rank = int(_number(row.get("rank")))
if ts_code and rank > 0:
result[ts_code] = rank
return result
ths = ranks(ths_rows, "热股")
dc = ranks(dc_rows, "A股市场")
previous_ths_map = ranks(previous_ths, "热股")
previous_dc_map = ranks(previous_dc, "A股市场")
result = []
for ts_code in set(ths) | set(dc):
ths_rank = ths.get(ts_code)
dc_rank = dc.get(ts_code)
current_best = min(value for value in (ths_rank, dc_rank) if value is not None)
previous_candidates = [
value for value in (previous_ths_map.get(ts_code), previous_dc_map.get(ts_code))
if value is not None
]
previous_best = min(previous_candidates) if previous_candidates else None
score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25
result.append(
{
"trade_date": trade_date,
"ts_code": ts_code,
"ths_rank": ths_rank,
"dc_rank": dc_rank,
"combined_score": round(score, 2),
"rank_change": (
previous_best - current_best
if previous_best is not None
else min(30, max(0, 31 - current_best))
if previous_ths_map or previous_dc_map else 0
),
"dual_source": bool(ths_rank and dc_rank),
}
)
return result
class FactorDataService:
def __init__(self, database: ReviewDatabase, client: TushareClient) -> None:
self.database = database
self.client = client
def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]:
lookback = max(25, min(260, int(lookback)))
trade_date, _ = self.client.resolve_trade_context(requested_date)
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d")
calendar = self.client.query(
"trade_cal",
{"exchange": "SSE", "start_date": start, "end_date": trade_date, "is_open": 1},
"cal_date,is_open",
)
dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:]
existing = set(self.database.factor_dates(trade_date, lookback + 10))
dates_to_fetch = [value for value in dates if value not in existing or value == trade_date]
auction_source_dates = dates[-min(80, len(dates)):]
existing_auction = set(self.database.auction_factor_dates(trade_date, 90))
auction_dates_to_fetch = [
value for value in auction_source_dates
if value not in existing_auction or value == trade_date
]
long_calendar = self.client.query(
"trade_cal",
{
"exchange": "SSE",
"start_date": datetime(end.year - 5, 1, 1).strftime("%Y%m%d"),
"end_date": trade_date,
"is_open": 1,
},
"cal_date,is_open",
)
last_open_by_year: dict[str, str] = {}
last_open_by_month: dict[str, str] = {}
for row in long_calendar:
if row.get("is_open") == 1 and row.get("cal_date"):
value = str(row["cal_date"])
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
last_open_by_month[value[:6]] = max(last_open_by_month.get(value[:6], ""), value)
valuation_dates = set(dates[-min(80, len(dates)):])
valuation_dates.update(last_open_by_year.values())
valuation_dates.update(last_open_by_month.values())
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
indicator_dates_to_fetch = sorted(
value for value in valuation_dates if value not in existing_indicators or value == trade_date
)
master = self.client.query(
"stock_basic",
{"list_status": "L"},
"ts_code,name,industry,market,list_date",
)
master_count = self.database.upsert_stock_master(master)
bar_count = 0
for current_date in dates_to_fetch:
rows = self.client.query(
"daily",
{"trade_date": current_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
bar_count += self.database.upsert_daily_bars(rows)
indicator_count = 0
for current_date in indicator_dates_to_fetch:
indicators = self.client.query(
"daily_basic",
{"trade_date": current_date},
"ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv,"
"pe_ttm,pb,ps_ttm,dv_ttm",
)
indicator_count += self.database.upsert_daily_indicators(indicators)
notices = []
benchmark_count = 0
try:
benchmark_rows = self.client.query(
"index_daily",
{"ts_code": "000300.SH", "start_date": dates[0], "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
benchmark_count = self.database.upsert_benchmark_bars(benchmark_rows)
except TushareError as exc:
notices.append(f"沪深300基准暂不可用:{exc}")
fundamental_count = 0
existing_periods = set(self.database.fundamental_periods())
for period in _quarter_periods(trade_date, 9):
if period in existing_periods and period < trade_date[:4] + "0101":
continue
try:
rows = self.client.query(
"fina_indicator_vip",
{"period": period},
"ts_code,ann_date,end_date,roe,roa,roic,grossprofit_margin,"
"netprofit_yoy,or_yoy,ocf_to_opincome",
)
except TushareError as exc:
notices.append(f"财务质量接口不可用:{exc}")
break
published = [
row for row in rows
if not row.get("ann_date") or str(row.get("ann_date")) <= trade_date
]
published.sort(key=lambda row: str(row.get("ann_date") or ""))
fundamental_count += self.database.upsert_fundamental_indicators(published)
auction_count = 0
auction_dates = 0
for current_date in auction_dates_to_fetch:
try:
auction_rows = self.client.query(
"stk_auction",
{"trade_date": current_date},
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share",
)
if auction_rows:
auction_count += self.database.upsert_auction_factors(auction_rows)
auction_dates += 1
except TushareError as exc:
notices.append(f"竞价因子接口不可用:{exc}")
break
moneyflow_count = 0
moneyflow_dates = 0
for current_date in dates[-min(5, len(dates)):]:
try:
moneyflow = self.client.query(
"moneyflow",
{"trade_date": current_date},
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
)
moneyflow_count += self.database.upsert_moneyflow(moneyflow)
if moneyflow:
moneyflow_dates += 1
except TushareError as exc:
notices.append(f"资金流接口不可用:{exc}")
break
earnings_count = 0
forecasts: list[dict[str, Any]] = []
expresses: list[dict[str, Any]] = []
for period in _quarter_periods(trade_date, 5):
try:
forecast_rows = self.client.query(
"forecast_vip",
{"period": period},
"ts_code,ann_date,end_date,net_profit_min,net_profit_max,last_parent_net,p_change_min,p_change_max",
)
express_rows = self.client.query(
"express_vip",
{"period": period},
"ts_code,ann_date,end_date,n_income,yoy_net_profit,yoy_sales",
)
except TushareError as exc:
notices.append(f"业绩事件接口不可用:{exc}")
break
forecasts.extend(forecast_rows)
expresses.extend(express_rows)
if forecasts and expresses:
earnings_count = self.database.upsert_earnings_events(
_earnings_event_rows(forecasts, expresses, trade_date)
)
popularity_count = 0
previous_trade_date = dates[-2] if len(dates) >= 2 else ""
try:
ths_rows = self.client.query("ths_hot", {"trade_date": trade_date})
dc_rows = self.client.query("dc_hot", {"trade_date": trade_date})
previous_ths = (
self.client.query("ths_hot", {"trade_date": previous_trade_date})
if previous_trade_date else []
)
previous_dc = (
self.client.query("dc_hot", {"trade_date": previous_trade_date})
if previous_trade_date else []
)
popularity_count = self.database.upsert_popularity_factors(
_popularity_factor_rows(
trade_date, ths_rows, dc_rows, previous_ths, previous_dc
)
)
except TushareError as exc:
notices.append(f"人气榜因子不可用:{exc}")
institution_count = 0
try:
institution_rows = self.client.query(
"top_inst",
{"trade_date": trade_date},
"trade_date,ts_code,exalter,buy,sell,net_buy,side,reason",
)
institution_count = self.database.upsert_lhb_institutions(institution_rows)
except TushareError as exc:
notices.append(f"机构席位明细不可用:{exc}")
return {
"trade_date": trade_date,
"calendar_dates": len(dates),
"fetched_dates": len(dates_to_fetch),
"stocks": master_count,
"bars": bar_count,
"benchmark_bars": benchmark_count,
"indicators": indicator_count,
"indicator_dates": len(indicator_dates_to_fetch),
"fundamentals": fundamental_count,
"moneyflow": moneyflow_count,
"moneyflow_dates": moneyflow_dates,
"auction_rows": auction_count,
"auction_dates": auction_dates,
"earnings_events": earnings_count,
"popularity_rows": popularity_count,
"institution_rows": institution_count,
"notice": "".join(notices),
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
from __future__ import annotations
import statistics
from collections import defaultdict
from datetime import datetime
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.features.screener.indicators import (
_available_percentile_map,
_broken_reversal_metrics,
_ending_streak,
_is_limit_bar,
_limit_threshold,
_macd_last,
_macd_series,
_max_streak,
_optional_number,
_pearson,
_percentile_map,
_rounded_optional,
_rsi,
_touched_limit_bar,
_weekly_series,
)
from database import ReviewDatabase
class FactorBuilder:
def __init__(self, database: ReviewDatabase) -> None:
self.database = database
def build_factors(
self,
trade_date: str,
realtime_snapshot: dict[str, Any] | None = None,
history_days: int = 80,
) -> tuple[list[dict[str, Any]], str]:
history_days = max(21, min(260, int(history_days)))
data = self.database.load_factor_data(trade_date, history_days)
dates = [value for value in data["dates"] if value <= trade_date]
if len(dates) < 21:
raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。")
history_date = dates[-1]
realtime_map = {
str(row.get("ts_code") or ""): row
for row in (realtime_snapshot or {}).get("rows") or []
}
realtime_date = str((realtime_snapshot or {}).get("trade_date") or "")
use_realtime = bool(realtime_map and realtime_date == trade_date and history_date < trade_date)
actual_date = trade_date if use_realtime else history_date
master = {row["ts_code"]: row for row in data["master"]}
indicators = {row["ts_code"]: row for row in data["indicators"]}
fundamentals = {row["ts_code"]: row for row in data.get("fundamentals", [])}
indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("indicator_history", []):
indicator_history[str(row.get("ts_code") or "")].append(row)
indicator_series: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("indicator_series", []):
indicator_series[str(row.get("ts_code") or "")].append(row)
benchmark_by_date = {
str(row.get("trade_date") or ""): _number(row.get("close"))
for row in data.get("benchmarks", [])
if _number(row.get("close")) > 0
}
moneyflow = {row["ts_code"]: row for row in data["moneyflow"]}
moneyflow_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("moneyflow_history", []):
moneyflow_history[str(row.get("ts_code") or "")].append(row)
auction = {
row["ts_code"]: row
for row in data.get("auction", [])
if str(row.get("trade_date") or "") == actual_date
}
earnings_events: dict[str, dict[str, Any]] = {}
for row in data.get("earnings_events", []):
ts_code = str(row.get("ts_code") or "")
ann_date = str(row.get("ann_date") or "")
if ann_date <= actual_date and (
ts_code not in earnings_events
or ann_date > str(earnings_events[ts_code].get("ann_date") or "")
):
earnings_events[ts_code] = row
popularity = {
str(row.get("ts_code") or ""): row
for row in data.get("popularity", [])
}
institutions = {
str(row.get("ts_code") or ""): row
for row in data.get("institutions", [])
}
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data["bars"]:
if row["trade_date"] <= history_date:
grouped[row["ts_code"]].append(row)
snapshot = self.database.get_snapshot(actual_date) or {}
limit_map: dict[str, tuple[str, int]] = {}
for key, status in (("limits", "涨停"), ("broken", "炸板"), ("down_limits", "跌停")):
for row in snapshot.get(key) or []:
limit_map[str(row.get("code"))] = (status, int(row.get("streak") or 0))
factors = []
current_day = datetime.strptime(actual_date, "%Y%m%d")
for ts_code, bars in grouped.items():
bars.sort(key=lambda item: item["trade_date"])
if len(bars) < 21 or bars[-1]["trade_date"] != history_date:
continue
info = master.get(ts_code)
if not info:
continue
historical_closes = [_number(item["close"]) for item in bars]
historical_volumes = [_number(item["vol"]) for item in bars]
realtime = realtime_map.get(ts_code) if use_realtime else None
current = realtime or bars[-1]
closes = historical_closes + ([_number(realtime["close"])] if realtime else [])
volumes = historical_volumes + ([_number(realtime["vol"])] if realtime else [])
if closes[-1] <= 0:
continue
returns_10 = [_number(item["pct_chg"]) for item in bars[-10:]]
if realtime:
returns_10 = returns_10[-9:] + [_number(realtime.get("pct_chg"))]
previous_volume = statistics.fmean(volumes[-6:-1]) if any(volumes[-6:-1]) else 0
indicator = indicators.get(ts_code, {})
fundamental = fundamentals.get(ts_code, {})
flow = moneyflow.get(ts_code, {})
flow_history = moneyflow_history.get(ts_code, [])
auction_row = auction.get(ts_code, {})
list_date = str(info.get("list_date") or "")
try:
listed_days = (current_day - datetime.strptime(list_date, "%Y%m%d")).days
except ValueError:
listed_days = 9999
code = str(info.get("code") or ts_code.split(".")[0])
status, streak = limit_map.get(code, ("", 0))
name = str(info.get("name") or "--")
shape_rows = bars + ([realtime] if realtime else [])
shape_close = [_number(item.get("close")) for item in shape_rows]
shape_high = [_number(item.get("high") or item.get("close")) for item in shape_rows]
shape_low = [_number(item.get("low") or item.get("close")) for item in shape_rows]
shape_changes = [_number(item.get("pct_chg")) for item in shape_rows]
position_rows = shape_rows[-60:]
position_high = max((_number(item.get("high") or item.get("close")) for item in position_rows), default=0)
position_low = min((_number(item.get("low") or item.get("close")) for item in position_rows), default=0)
relative_position = (
(closes[-1] - position_low) / (position_high - position_low)
if position_high > position_low else 0.5
)
previous_index = len(bars) - 1 if realtime else len(bars) - 2
previous_bar = bars[previous_index] if previous_index >= 0 else {}
previous_limit = _is_limit_bar(bars, previous_index, code, name)
previous_touched = _touched_limit_bar(bars, previous_index, code, name)
recent_prior_signal = any(
_is_limit_bar(bars, index, code, name)
or _touched_limit_bar(bars, index, code, name)
for index in range(max(0, previous_index - 2), previous_index)
)
previous_streak = 0
streak_index = previous_index
while streak_index >= 0 and _is_limit_bar(bars, streak_index, code, name):
previous_streak += 1
streak_index -= 1
limit_flags = [
_is_limit_bar(shape_rows, index, code, name)
for index in range(len(shape_rows))
]
annual_dividend_rows = indicator_history.get(ts_code, [])
dividend_years = sum(
1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0)
)
current_streak = _ending_streak(limit_flags)
prior_streak = _ending_streak(limit_flags, len(limit_flags) - 2)
streak = max(streak, current_streak)
return_60d = (
(closes[-1] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0
)
momentum_60_5 = (
(closes[-6] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0
)
ma20 = statistics.fmean(closes[-20:])
ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else ma20
prior_ma20 = statistics.fmean(closes[-25:-5]) if len(closes) >= 25 else ma20
prior_ma60 = statistics.fmean(closes[-65:-5]) if len(closes) >= 65 else ma60
ma20_slope = (ma20 / prior_ma20 - 1) * 100 if prior_ma20 else 0
ma60_slope = (ma60 / prior_ma60 - 1) * 100 if prior_ma60 else 0
ma_values = [statistics.fmean(closes[-window:]) for window in (5, 10, 20, 60)]
high_250 = max(shape_high[-250:]) if len(shape_high) >= 250 else max(shape_high)
drawdown_250 = (1 - closes[-1] / high_250) * 100 if high_250 else 100
prior_high_20 = max(shape_high[-21:-1]) if len(shape_high) >= 21 else 0
breakout_pct = (closes[-1] / prior_high_20 - 1) * 100 if prior_high_20 else 0
prior_lows_20 = shape_low[-21:-1]
range_20d = (
(prior_high_20 / min(prior_lows_20) - 1) * 100
if prior_lows_20 and min(prior_lows_20) > 0 else 100
)
turnover_rows = sorted(
indicator_series.get(ts_code, []), key=lambda item: str(item.get("trade_date") or "")
)
turnover_values = [_number(item.get("turnover_rate")) for item in turnover_rows[-5:]]
if realtime and _number(realtime.get("turnover_rate")):
turnover_values = turnover_values[-4:] + [_number(realtime.get("turnover_rate"))]
turnover_5d = sum(turnover_values)
rs_values = [
_number(item.get("close")) / benchmark_by_date[str(item.get("trade_date"))]
for item in shape_rows[-120:]
if benchmark_by_date.get(str(item.get("trade_date"))) and _number(item.get("close")) > 0
]
benchmark_60 = [
benchmark_by_date.get(str(item.get("trade_date")))
for item in shape_rows[-61:]
if benchmark_by_date.get(str(item.get("trade_date")))
]
benchmark_return_60 = (
(benchmark_60[-1] / benchmark_60[0] - 1) * 100
if len(benchmark_60) >= 61 and benchmark_60[0] else 0
)
weekly_closes, weekly_amounts = _weekly_series(shape_rows)
weekly_dif, weekly_dea = _macd_last(weekly_closes)
daily_dif, daily_dea = _macd_series(closes)
daily_cross = (
len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1]
and daily_dif[-2] <= daily_dea[-2]
)
current_open = _number(current.get("open"))
daily_pullback = closes[-1] >= ma20 and current_open <= ma20 * 1.02 and closes[-1] > current_open
previous_close = closes[-2] if len(closes) >= 2 else closes[-1]
intraday_min = (
(_number(current.get("low")) / previous_close - 1) * 100 if previous_close else 0
)
body = abs(closes[-1] - current_open)
lower_shadow = max(0.0, min(current_open, closes[-1]) - _number(current.get("low")))
lower_shadow_ratio = lower_shadow / body if body > 0 else (10.0 if lower_shadow > 0 else 0.0)
previous_volume_value = volumes[-2] if len(volumes) >= 2 else 0
vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0
broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name)
netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy"))
earnings_event = earnings_events.get(ts_code, {})
announcement_date = str(earnings_event.get("ann_date") or "")
earnings_days = (
sum(1 for value in dates if announcement_date < value <= actual_date)
if announcement_date and announcement_date <= actual_date
else None
)
announcement_bar = next(
(item for item in shape_rows if str(item.get("trade_date") or "") == announcement_date),
None,
)
announcement_bad = False
if announcement_bar is not None:
bar_index = shape_rows.index(announcement_bar)
prior_volumes = [
_number(item.get("vol")) for item in shape_rows[max(0, bar_index - 5):bar_index]
if _number(item.get("vol")) > 0
]
volume_baseline = statistics.fmean(prior_volumes) if prior_volumes else 0
announcement_bad = (
_number(announcement_bar.get("close")) < _number(announcement_bar.get("open"))
and _number(announcement_bar.get("pct_chg")) < 0
and volume_baseline > 0
and _number(announcement_bar.get("vol")) / volume_baseline >= 1.8
)
popularity_row = popularity.get(ts_code)
institution_row = institutions.get(ts_code)
factors.append(
{
"code": code,
"ts_code": ts_code,
"name": name,
"sector": info.get("industry") or "其他",
"market": info.get("market") or "--",
"listed_days": listed_days,
"close": round(closes[-1], 2),
"price": round(closes[-1], 2),
"pct_chg": round(_number(current["pct_chg"]), 2),
"return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2),
"return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2),
"return_20d": round((closes[-1] / closes[-21] - 1) * 100, 2),
"return_60d": round(return_60d, 2),
"momentum_60_5": round(momentum_60_5, 2),
"above_ma20": int(closes[-1] > ma20),
"rsi_6": round(_rsi(closes, 6), 2),
"ma60_slope": round(ma60_slope, 3),
"ma20_slope_5d": round(ma20_slope, 3),
"ma_bull_alignment": int(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]),
"drawdown_from_high_250": round(drawdown_250, 2),
"donchian_breakout_pct": round(breakout_pct, 2),
"range_20d": round(range_20d, 2),
"rs_high_120": int(len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)),
"excess_return_60d": round(return_60d - benchmark_return_60, 2),
"weekly_trend_signal": int(len(weekly_closes) >= 30 and weekly_dif > 0 and weekly_dea > 0),
"daily_buy_trigger": int(daily_cross or daily_pullback),
"weekly_amount_trend": int(
len(weekly_amounts) >= 5
and weekly_amounts[-1] >= statistics.fmean(weekly_amounts[-5:-1])
),
"volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0,
"turnover_5d": round(turnover_5d, 2),
"volatility_10d": round(statistics.pstdev(returns_10), 2),
"amount_billion": round(
_number(current["amount"]) / (100000000 if realtime else 100000), 2
),
"turnover_rate": round(
_number(realtime.get("turnover_rate"))
if realtime else _number(indicator.get("turnover_rate")),
2,
),
"circ_mv_billion": round(_number(indicator.get("circ_mv")) / 10000, 2),
"total_mv_billion": round(_number(indicator.get("total_mv")) / 10000, 2),
"pe_ttm": _rounded_optional(indicator.get("pe_ttm"), 2),
"pb": _rounded_optional(indicator.get("pb"), 2),
"ps_ttm": _rounded_optional(indicator.get("ps_ttm"), 2),
"dividend_yield_ttm": _rounded_optional(indicator.get("dv_ttm"), 2),
"dividend_years": dividend_years,
"roe": _rounded_optional(fundamental.get("roe"), 2),
"roa": _rounded_optional(fundamental.get("roa"), 2),
"roic": _rounded_optional(fundamental.get("roic"), 2),
"gross_margin": _rounded_optional(fundamental.get("grossprofit_margin"), 2),
"netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2),
"revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2),
"ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
"earnings_surprise_pct": _rounded_optional(earnings_event.get("surprise_pct"), 2),
"earnings_days_since_announce": earnings_days,
"earnings_event_quality": int(not announcement_bad) if earnings_days is not None else None,
"popularity_score": _rounded_optional(
popularity_row.get("combined_score") if popularity_row else None, 2
),
"popularity_rank_change": (
int(popularity_row["rank_change"])
if popularity_row and popularity_row.get("rank_change") is not None else None
),
"popularity_dual_source": (
int(bool(popularity_row.get("dual_source"))) if popularity_row else None
),
"institution_net_buy_million": (
round(_number(institution_row.get("net_buy_amount")) / 1_000_000, 2)
if institution_row else None
),
"institution_seat_count": (
int(institution_row.get("seat_count") or 0) if institution_row else None
),
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
"net_flow_5d_million": round(
sum(_number(item.get("net_mf_amount")) for item in flow_history) / 100,
2,
),
"flow_to_circ_mv_5d": round(
sum(_number(item.get("net_mf_amount")) for item in flow_history)
/ _number(indicator.get("circ_mv")) * 100,
4,
) if _number(indicator.get("circ_mv")) else 0,
"limit_status": status,
"limit_streak": streak,
"is_limit_up_today": int(limit_flags[-1]),
"is_limit_down_today": int(_number(current.get("pct_chg")) <= -_limit_threshold(code, name)),
"auction_change": round(_number(auction_row.get("change")), 2),
"auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2),
"auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4),
"auction_volume_ratio": round(_number(auction_row.get("volume_ratio")), 2),
"relative_position_60": round(relative_position, 4),
"max_abs_change_15d": round(max((abs(value) for value in shape_changes[-15:]), default=0), 2),
"close_to_high_15d": round(closes[-1] / max(shape_high[-15:]), 4) if shape_high[-15:] and max(shape_high[-15:]) else 0,
"close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0,
"no_limit_30d": int(not any(limit_flags[-30:])),
"had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])),
"no_limit_down_20d": int(not any(
_number(item.get("pct_chg")) <= -_limit_threshold(code, name)
for item in shape_rows[-20:]
)),
"financial_risk": int(
"ST" in name.upper() or "退" in name
or (netprofit_yoy is not None and netprofit_yoy <= -100)
),
"prior_limit_streak": prior_streak,
"max_continuous_board_10d": _max_streak(limit_flags[-10:]),
"dragon_first_yin": int(
prior_streak >= 3 and not limit_flags[-1] and closes[-1] < current_open
),
"yin_day_pct": round(_number(current.get("pct_chg")), 2),
"vol_vs_previous": round(vol_vs_previous, 3),
"broken_reversal": broken["signal"],
"days_since_broken": broken["days"],
"close_above_broken_high": broken["recovered"],
"vol_vs_broken_day": broken["volume_ratio"],
"recent_limit_up_5d": sum(limit_flags[-5:]),
"intraday_min_pct": round(intraday_min, 2),
"lower_shadow_ratio": round(lower_shadow_ratio, 2),
"previous_first_limit": int(previous_limit and not recent_prior_signal),
"previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal),
"previous_limit_streak": previous_streak,
"previous_amount_billion": round(_number(previous_bar.get("amount")) / 100000, 2),
}
)
market_return = statistics.fmean(row["return_5d"] for row in factors) if factors else 0
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in factors:
sectors[row["sector"]].append(row)
sector_metrics = []
market_amount = sum(max(0.0, row["amount_billion"]) for row in factors)
for sector_name, sector_rows in sectors.items():
average_return = statistics.fmean(row["return_5d"] for row in sector_rows)
average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows)
sector_net_flow = sum(row["net_flow_5d_million"] for row in sector_rows)
limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows)
up_count = sum(row["pct_chg"] >= 5 for row in sector_rows)
breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100
sector_growth = [
statistics.fmean(values)
for row in sector_rows
if (values := [
value for value in (row.get("revenue_yoy"), row.get("netprofit_yoy"))
if value is not None
])
]
prosperity_raw = statistics.median(sector_growth) if sector_growth else -100.0
average_turnover = statistics.fmean(row["turnover_rate"] for row in sector_rows)
amount_share = (
sum(max(0.0, row["amount_billion"]) for row in sector_rows) / market_amount * 100
if market_amount else 0.0
)
crowding_raw = average_turnover + amount_share
trend_raw = average_return_20d + breadth_ma20 / 10
strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
sector_metrics.append(
{
"ts_code": sector_name,
"sector_return_20d": average_return_20d,
"sector_net_flow_5d_million": sector_net_flow,
"sector_prosperity_raw": prosperity_raw,
"sector_trend_raw": trend_raw,
"sector_crowding_raw": crowding_raw,
}
)
stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc")
for row in sector_rows:
row["sector_strength"] = round(strength, 1)
row["sector_return_5d"] = round(average_return, 2)
row["sector_return_20d"] = round(average_return_20d, 2)
row["sector_net_flow_5d_million"] = round(sector_net_flow, 2)
row["sector_stock_momentum_rank"] = round(
stock_momentum_ranks.get(row["ts_code"], 0.0), 4
)
row["sector_limit_count"] = limit_count
row["sector_up_count"] = up_count
row["sector_breadth_ma20"] = round(breadth_ma20, 1)
row["relative_strength"] = round(row["return_5d"] - market_return, 2)
sector_momentum_ranks = _percentile_map(
sector_metrics, "sector_return_20d", "desc"
)
sector_flow_ranks = _percentile_map(
sector_metrics, "sector_net_flow_5d_million", "desc"
)
sector_prosperity_ranks = _percentile_map(
sector_metrics, "sector_prosperity_raw", "desc"
)
sector_trend_ranks = _percentile_map(
sector_metrics, "sector_trend_raw", "desc"
)
sector_crowding_ranks = _percentile_map(
sector_metrics, "sector_crowding_raw", "desc"
)
for sector_name, sector_rows in sectors.items():
prosperity_rank = sector_prosperity_ranks.get(sector_name, 0.0)
trend_rank = sector_trend_ranks.get(sector_name, 0.0)
crowding_rank = sector_crowding_ranks.get(sector_name, 0.0)
composite_score = (
prosperity_rank * 0.40 + trend_rank * 0.30 + (1 - crowding_rank) * 0.30
)
for row in sector_rows:
row["sector_momentum_rank"] = round(
sector_momentum_ranks.get(sector_name, 0.0), 4
)
row["sector_flow_rank"] = round(
sector_flow_ranks.get(sector_name, 0.0), 4
)
row["sector_prosperity_rank"] = round(prosperity_rank, 4)
row["sector_trend_rank"] = round(trend_rank, 4)
row["sector_crowding_rank"] = round(crowding_rank, 4)
row["sector_composite_score"] = round(composite_score, 4)
factor_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_field, specs in factor_specs.items():
maps = [_available_percentile_map(factors, field, direction) for field, direction in specs]
for row in factors:
values = [mapping.get(row["ts_code"]) for mapping in maps]
available = [value for value in values if value is not None]
row[output_field] = round(statistics.fmean(available), 4) if available else None
return_rank_map = _available_percentile_map(factors, "return_20d", "desc")
factor_weights = {}
for output_field in factor_specs:
pairs = [
(row.get(output_field), return_rank_map.get(row["ts_code"]))
for row in factors
if row.get(output_field) is not None and return_rank_map.get(row["ts_code"]) is not None
]
correlation = _pearson([pair[0] for pair in pairs], [pair[1] for pair in pairs])
factor_weights[output_field] = max(0.05, correlation)
factor_weight_total = sum(factor_weights.values()) or 1
for row in factors:
weighted = [
(row.get(field), weight)
for field, weight in factor_weights.items()
if row.get(field) is not None
]
row["multi_factor_composite"] = round(
sum(value * weight for value, weight in weighted)
/ (sum(weight for _, weight in weighted) or factor_weight_total),
4,
) if weighted else None
size_ranks = _available_percentile_map(factors, "total_mv_billion", "desc")
large_rows = [row for row in factors if (size_ranks.get(row["ts_code"]) or 0) >= 0.70]
small_rows = [
row for row in factors
if size_ranks.get(row["ts_code"]) is not None
and size_ranks[row["ts_code"]] <= 0.30
]
large_return = statistics.fmean(row["return_20d"] for row in large_rows) if large_rows else 0
small_return = statistics.fmean(row["return_20d"] for row in small_rows) if small_rows else 0
prefer_large = large_return >= small_return
growth_rows = [row for row in factors if (row.get("factor_growth_score") or 0) >= 0.70]
value_rows = [row for row in factors if (row.get("factor_value_score") or 0) >= 0.70]
growth_return = statistics.fmean(row["return_20d"] for row in growth_rows) if growth_rows else 0
value_return = statistics.fmean(row["return_20d"] for row in value_rows) if value_rows else 0
prefer_growth = growth_return >= value_return
for row in factors:
size_rank = size_ranks.get(row["ts_code"])
row["style_size_fit"] = round(
size_rank if prefer_large else 1 - size_rank, 4
) if size_rank is not None else None
style_factor = "factor_growth_score" if prefer_growth else "factor_value_score"
row["style_growth_fit"] = row.get(style_factor)
style_values = [
value for value in (row.get("style_size_fit"), row.get("style_growth_fit"))
if value is not None
]
row["style_fit_score"] = round(statistics.fmean(style_values), 4) if style_values else None
momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc")
return_ranks = _percentile_map(factors, "return_5d", "desc")
market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0)
prior_market_height = max((int(row.get("prior_limit_streak") or 0) for row in factors), default=0)
for row in factors:
row["momentum_60_5_rank"] = round(momentum_ranks.get(row["ts_code"], 0.0), 4)
row["return_5d_rank"] = round(return_ranks.get(row["ts_code"], 0.0), 4)
is_height = market_height >= 2 and int(row.get("limit_streak") or 0) == market_height
row["is_market_height"] = int(is_height)
row["new_space_board"] = int(
is_height
and not (
prior_market_height >= 2
and int(row.get("prior_limit_streak") or 0) == prior_market_height
)
)
return factors, actual_date
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import copy
from typing import Any
from backend.features.screener.catalog import (
ALLOWED_OPERATORS,
BUILTIN_STRATEGIES,
FACTOR_FIELDS,
REGIMES,
)
from backend.features.screener.indicators import _matches, _percentile_map, _risk_flags
class FormulaEvaluator:
def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]:
if not isinstance(formula, dict):
raise ValueError("选股公式必须是 JSON 对象。")
result = copy.deepcopy(formula)
universe = result.setdefault("universe", {})
universe["exclude_st"] = bool(universe.get("exclude_st", True))
universe["listed_days_min"] = max(0, min(5000, int(universe.get("listed_days_min", 120))))
filters = result.setdefault("filters", [])
if not isinstance(filters, list) or len(filters) > 20:
raise ValueError("筛选条件必须是列表,且不能超过 20 条。")
for condition in filters:
field = condition.get("field")
operator = condition.get("op")
if field not in FACTOR_FIELDS:
raise ValueError(f"不支持的选股因子:{field}")
if operator not in ALLOWED_OPERATORS:
raise ValueError(f"不支持的运算符:{operator}")
if "value" not in condition:
raise ValueError(f"因子 {field} 缺少比较值。")
scores = result.setdefault("score", [])
if not isinstance(scores, list) or not scores or len(scores) > 12:
raise ValueError("评分因子应为 1 至 12 条。")
for item in scores:
if item.get("field") not in FACTOR_FIELDS:
raise ValueError(f"不支持的评分因子:{item.get('field')}")
item["weight"] = float(item.get("weight", 0))
if item["weight"] <= 0 or item["weight"] > 1:
raise ValueError("评分权重必须大于 0 且不超过 1。")
if item.get("direction", "desc") not in {"asc", "desc"}:
raise ValueError("评分方向只能是 asc 或 desc。")
item["direction"] = item.get("direction", "desc")
result["limit"] = max(1, min(50, int(result.get("limit", 15))))
result["min_score"] = max(0, min(1, float(result.get("min_score", 0))))
return result
def apply_formula(
self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str
) -> list[dict[str, Any]]:
universe = formula["universe"]
eligible = []
score_fields = [item["field"] for item in formula["score"]]
for row in rows:
name = str(row.get("name") or "")
if universe.get("exclude_st") and ("ST" in name.upper() or "退" in name):
continue
if row.get("listed_days", 0) < universe.get("listed_days_min", 0):
continue
if any(row.get(field) is None for field in score_fields):
continue
if all(_matches(row.get(item["field"]), item["op"], item["value"]) for item in formula["filters"]):
eligible.append(row)
if not eligible:
return []
percentiles = {
item["field"]: _percentile_map(eligible, item["field"], item["direction"])
for item in formula["score"]
}
weight_total = sum(item["weight"] for item in formula["score"])
results = []
for row in eligible:
contributions = []
score = 0.0
for item in formula["score"]:
percentile = percentiles[item["field"]].get(row["ts_code"], 0.5)
points = percentile * item["weight"] / weight_total
score += points
contributions.append(
{
"field": item["field"],
"label": FACTOR_FIELDS[item["field"]],
"value": row.get(item["field"], 0),
"points": round(points * 100, 1),
}
)
if score < formula["min_score"]:
continue
contributions.sort(key=lambda item: item["points"], reverse=True)
item = dict(row)
item["score"] = round(score, 4)
item["score_display"] = round(score * 100, 1)
item["contributions"] = contributions
item["reason"] = "".join(entry["label"] for entry in contributions[:3])
include_regime_risk = formula.get("meta", {}).get("library") != "curated"
item["risk_flags"] = _risk_flags(row, regime, include_regime_risk)
results.append(item)
results.sort(key=lambda item: item["score"], reverse=True)
return results[: formula["limit"]]
def compile_local_strategy(prompt: str, regime: str) -> dict[str, Any]:
base = next((item for item in BUILTIN_STRATEGIES if regime in item["regimes"]), BUILTIN_STRATEGIES[1])
formula = copy.deepcopy(base["formula"])
description = prompt.strip() or base["description"]
lowered = description.lower()
if "低吸" in description:
formula["filters"] = [item for item in formula["filters"] if item["field"] != "pct_chg"]
formula["filters"].append({"field": "pct_chg", "op": "between", "value": [-3, 3]})
if "放量" in description:
formula["filters"].append({"field": "volume_ratio_5d", "op": ">=", "value": 1.2})
if "强势" in description or "突破" in description:
formula["filters"].append({"field": "return_5d", "op": ">=", "value": 5})
if "低波" in description or "稳健" in description:
formula["score"].append({"field": "volatility_10d", "weight": 0.18, "direction": "asc"})
if "资金" in description or "主力" in description:
formula["score"].append({"field": "net_flow_million", "weight": 0.18, "direction": "desc"})
if "小市值" in description or "小盘" in description:
formula["score"].append({"field": "circ_mv_billion", "weight": 0.15, "direction": "asc"})
if "竞价" in description:
formula["filters"].extend(
[
{"field": "auction_change", "op": "between", "value": [0.5, 8]},
{"field": "auction_amount_million", "op": ">=", "value": 2},
]
)
formula["score"].extend(
[
{"field": "auction_volume_ratio", "weight": 0.20, "direction": "desc"},
{"field": "auction_amount_million", "weight": 0.18, "direction": "desc"},
]
)
if "少量" in description or "精选" in description:
formula["limit"] = min(formula["limit"], 8)
formula["score"] = formula["score"][:12]
return {
"name": f"{REGIMES.get(regime, regime)}自定义策略",
"description": description,
"regimes": [regime],
"formula": formula,
"compiler": "local_template",
}
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import math
import statistics
from datetime import datetime
from typing import Any
from backend.data.numbers import finite_number as _number
def _optional_number(value: Any) -> float | None:
if value in (None, ""):
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _rounded_optional(value: Any, digits: int = 2) -> float | None:
parsed = _optional_number(value)
return round(parsed, digits) if parsed is not None else None
def _limit_threshold(code: str, name: str) -> float:
if code.startswith(("4", "8")):
return 29.0
if code.startswith(("30", "68")):
return 19.0
return 9.5
def _ending_streak(flags: list[bool], end_index: int | None = None) -> int:
if not flags:
return 0
index = len(flags) - 1 if end_index is None else min(end_index, len(flags) - 1)
streak = 0
while index >= 0 and flags[index]:
streak += 1
index -= 1
return streak
def _max_streak(flags: list[bool]) -> int:
best = current = 0
for value in flags:
current = current + 1 if value else 0
best = max(best, current)
return best
def _rsi(values: list[float], period: int = 6) -> float:
if len(values) <= period:
return 50.0
changes = [values[index] - values[index - 1] for index in range(len(values) - period, len(values))]
gains = sum(max(change, 0.0) for change in changes) / period
losses = sum(max(-change, 0.0) for change in changes) / 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_series(values: list[float]) -> tuple[list[float], list[float]]:
fast = _ema(values, 12)
slow = _ema(values, 26)
dif = [left - right for left, right in zip(fast, slow)]
return dif, _ema(dif, 9)
def _macd_last(values: list[float]) -> tuple[float, float]:
dif, dea = _macd_series(values)
return (dif[-1], dea[-1]) if dif and dea else (0.0, 0.0)
def _weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]:
weeks: dict[str, tuple[float, float]] = {}
for row in rows:
trade_date = str(row.get("trade_date") or "")
try:
key = datetime.strptime(trade_date, "%Y%m%d").strftime("%G-%V")
except ValueError:
continue
close = _number(row.get("close"))
amount = _number(row.get("amount"))
previous = weeks.get(key, (close, 0.0))
weeks[key] = (close, previous[1] + amount)
ordered = list(weeks.values())
return [item[0] for item in ordered], [item[1] for item in ordered]
def _broken_reversal_metrics(
rows: list[dict[str, Any]], flags: list[bool], code: str, name: str,
) -> dict[str, Any]:
result = {"signal": 0, "days": 0, "recovered": 0, "volume_ratio": 0.0}
if not rows or not flags[-1]:
return result
current_close = _number(rows[-1].get("close"))
current_volume = _number(rows[-1].get("vol"))
for days in range(1, 4):
index = len(rows) - 1 - days
if index <= 0 or flags[index] or _ending_streak(flags, index - 1) < 2:
continue
broken_high = _number(rows[index].get("high"))
broken_volume = _number(rows[index].get("vol"))
recovered = int(current_close >= broken_high > 0)
volume_ratio = current_volume / broken_volume if broken_volume else 0.0
return {
"signal": int(recovered and volume_ratio >= 1),
"days": days,
"recovered": recovered,
"volume_ratio": round(volume_ratio, 3),
}
return result
def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
if index < 0 or index >= len(rows):
return False
return _number(rows[index].get("pct_chg")) >= _limit_threshold(code, name)
def _touched_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
if index <= 0 or index >= len(rows):
return False
previous_close = _number(rows[index - 1].get("close"))
high = _number(rows[index].get("high"))
if previous_close <= 0 or high <= 0:
return False
touched_change = (high / previous_close - 1) * 100
return touched_change >= _limit_threshold(code, name)
def _matches(actual: Any, operator: str, expected: Any) -> bool:
if actual is None:
return False
try:
if operator == "between":
return float(expected[0]) <= float(actual) <= float(expected[1])
if operator == "in":
return actual in expected
if operator == ">":
return float(actual) > float(expected)
if operator == ">=":
return float(actual) >= float(expected)
if operator == "<":
return float(actual) < float(expected)
if operator == "<=":
return float(actual) <= float(expected)
if operator == "==":
return actual == expected or float(actual) == float(expected)
if operator == "!=":
return actual != expected
except (TypeError, ValueError, IndexError):
return False
return False
def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]:
ordered = sorted(rows, key=lambda item: _number(item.get(field)))
denominator = max(1, len(ordered) - 1)
result = {}
for index, row in enumerate(ordered):
percentile = index / denominator
result[row["ts_code"]] = 1 - percentile if direction == "asc" else percentile
return result
def _available_percentile_map(
rows: list[dict[str, Any]], field: str, direction: str,
) -> dict[str, float | None]:
available = [row for row in rows if row.get(field) is not None]
result: dict[str, float | None] = {
str(row.get("ts_code") or ""): None for row in rows
}
if not available:
return result
ordered = sorted(available, key=lambda item: _number(item.get(field)))
denominator = max(1, len(ordered) - 1)
for index, row in enumerate(ordered):
percentile = 0.5 if len(ordered) == 1 else index / denominator
result[str(row.get("ts_code") or "")] = (
1 - percentile if direction == "asc" else percentile
)
return result
def _pearson(first: list[float], second: list[float]) -> float:
if len(first) != len(second) or len(first) < 20:
return 0.0
first_mean = statistics.fmean(first)
second_mean = statistics.fmean(second)
numerator = sum(
(left - first_mean) * (right - second_mean)
for left, right in zip(first, second)
)
left_sum = sum((value - first_mean) ** 2 for value in first)
right_sum = sum((value - second_mean) ** 2 for value in second)
denominator = math.sqrt(left_sum * right_sum)
return numerator / denominator if denominator else 0.0
def _risk_flags(
row: dict[str, Any], regime: str, include_regime_risk: bool = True
) -> list[str]:
flags = []
if row.get("pct_chg", 0) >= 9.5:
flags.append("当日接近涨停,次日存在高开与无法成交风险")
if row.get("return_10d", 0) >= 25:
flags.append("短期累计涨幅较高")
if row.get("volatility_10d", 0) >= 7:
flags.append("波动率偏高")
if row.get("amount_billion", 0) < 1:
flags.append("成交承载力偏弱")
if include_regime_risk and regime == "retreat":
flags.append("市场处于退潮阶段,策略可能选择空仓")
return flags
def _regime_reason(regime: str) -> str:
return {
"ice": "情绪和赚钱效应处于低位,重点观察率先抗跌与转折信号。",
"repair": "核心指标从低位改善,适合观察率先修复且有板块共振的方向。",
"fermentation": "赚钱效应扩散,主线和梯队持续增强。",
"climax": "情绪处于高位,后排跟风与兑现风险同时上升。",
"divergence": "指数或核心仍强,但广度、封板质量开始分化。",
"retreat": "情绪指标继续走弱,应提高筛选门槛并接受无候选结果。",
}.get(regime, "市场阶段待确认。")
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.features.screener.catalog import REGIMES
from backend.features.screener.indicators import _regime_reason
from backend.features.sentiment.engine import build_sentiment_history, latest_contiguous_history
from database import ReviewDatabase
class RegimeDetector:
def __init__(self, database: ReviewDatabase) -> None:
self.database = database
def detect_regime(self, trade_date: str) -> dict[str, Any]:
series = latest_contiguous_history(
build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 260))
)
if not series:
return {
"id": "repair", "label": REGIMES["repair"], "confidence": 25,
"reason": "复盘快照不足,暂按中性修复处理。", "evidence": [], "history": [],
}
current = series[-1]
previous = series[-2] if len(series) > 1 else current
score = _number(current.get("score"))
previous_score = _number(previous.get("score"))
delta = score - previous_score
seal_rate = _number(current.get("seal_rate"))
limit_up = _number(current.get("limit_up_count"))
broken = _number(current.get("broken_count"))
regime = next(
(key for key, label in REGIMES.items() if label == current.get("phase")),
"divergence",
)
confidence = min(92, 45 + len(series[-8:]) * 5 + min(abs(delta), 12))
evidence = [
f"情绪温度 {score:.0f},较前一交易日 {delta:+.0f}{current.get('direction') or '持平'}",
f"封板率 {seal_rate:.1f}%",
f"涨停 {limit_up:.0f} 家,炸板 {broken:.0f}",
]
return {
"id": regime,
"label": REGIMES[regime],
"confidence": round(confidence),
"reason": _regime_reason(regime),
"evidence": evidence,
"history": [
{"trade_date": item["trade_date"], "score": _number(item.get("score"))}
for item in series[-8:]
],
}
-5
View File
@@ -433,11 +433,6 @@ class ScreenerRepositoryMixin:
}
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
try:
from sentiment_engine import build_sentiment_history
except ModuleNotFoundError:
from .sentiment_engine import build_sentiment_history
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260))
return [
{
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import json
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class ScreenerRoutesMixin:
def _handle_screener_get(self, parsed) -> bool:
if parsed.path == "/api/screener/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.screener_setup(trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/screener/tracking":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.screener_tracking(int(query.get("limit", ["12"])[0]))
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_screener_post(self, parsed) -> bool:
if parsed.path == "/api/screener/tracking":
try:
result = self.application_service.add_screener_tracking(self.read_json_body())
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_screener_delete(self, parsed) -> bool:
strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path)
if strategy_match:
try:
result = self.application_service.delete_screener_strategy(int(strategy_match.group(1)))
self.send_json({"ok": True, **result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
tracking_match = re.fullmatch(r"/api/screener/tracking/(\d+)", parsed.path)
if tracking_match:
result = self.application_service.remove_screener_tracking(int(tracking_match.group(1)))
self.send_json({"ok": True, **result})
return True
return False
def sync_screener_data(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.sync_screener_data(
str(body.get("trade_date") or date.today().isoformat()),
int(body.get("lookback") or 45),
)
self.send_json({"ok": True, "result": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"因子数据同步失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def compile_screener_strategy(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.compile_screener_strategy(
str(body.get("prompt") or ""), str(body.get("regime") or "")
)
self.send_json({"ok": True, "strategy": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_screener_strategy(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.save_screener_strategy(body)
self.send_json({"ok": True, **result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def run_screener(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.run_screener(body)
self.send_json({"ok": True, "result": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"选股执行失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def refresh_screener_tracking(self) -> None:
try:
body = self.read_json_body(True)
trade_date = str(body.get("trade_date") or date.today().isoformat())
self.send_json({"ok": True, **self.application_service.refresh_screener_tracking(trade_date)})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"跟踪刷新失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from backend.bootstrap.config import display_compact_date as _display_date
from backend.features.screener.backtest import BacktestRunner
from backend.features.screener.catalog import REGIMES
from backend.features.screener.factors import FactorBuilder
from backend.features.screener.formula import FormulaEvaluator
from database import ReviewDatabase
class SelectionRunner:
def __init__(
self,
database: ReviewDatabase,
factor_builder: FactorBuilder,
formula_evaluator: FormulaEvaluator,
backtest_runner: BacktestRunner,
) -> None:
self.database = database
self.factor_builder = factor_builder
self.formula_evaluator = formula_evaluator
self.backtest_runner = backtest_runner
def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]:
return self.formula_evaluator.validate_formula(formula)
def build_factors(
self,
trade_date: str,
realtime_snapshot: dict[str, Any] | None,
history_days: int,
) -> tuple[list[dict[str, Any]], str]:
return self.factor_builder.build_factors(
trade_date, realtime_snapshot, history_days
)
def apply_formula(
self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str
) -> list[dict[str, Any]]:
return self.formula_evaluator.apply_formula(rows, formula, regime)
def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]:
return self.backtest_runner.backtest(trade_date, formula)
def screen(
self, user_id: int, trade_date: str, formula: dict[str, Any], regime: str,
strategy_name: str, run_backtest: bool = True,
realtime_snapshot: dict[str, Any] | None = None,
mode: str = "smart",
prepared_factors: list[dict[str, Any]] | None = None,
prepared_date: str = "",
) -> dict[str, Any]:
mode = mode if mode in {"smart", "curated", "quant"} else "smart"
formula = self.validate_formula(formula)
if prepared_factors is None:
history_days = int((formula.get("meta") or {}).get("history_days") or 80)
factors, actual_date = self.build_factors(
trade_date, realtime_snapshot, history_days
)
else:
factors = prepared_factors
actual_date = prepared_date or trade_date
candidates = self.apply_formula(factors, formula, regime)
backtest = self.backtest(actual_date, formula) if run_backtest else None
required_fields = sorted({
str(item.get("field") or "")
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
if item.get("field")
})
complete_rows = sum(
1 for row in factors
if all(row.get(field) is not None for field in required_fields)
)
coverage = round(complete_rows / len(factors) * 100, 1) if factors else 0.0
health_status = "normal" if candidates else "no_signal"
if backtest and backtest["samples"] >= 20:
for candidate in candidates:
estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35
candidate["historical_probability"] = round(min(95, max(5, estimate)), 1)
candidate["probability_samples"] = backtest["samples"]
else:
for candidate in candidates:
candidate["historical_probability"] = None
candidate["probability_samples"] = backtest["samples"] if backtest else 0
result = {
"meta": {
"trade_date": _display_date(actual_date),
"regime": regime,
"regime_label": REGIMES.get(regime, regime),
"strategy_name": strategy_name,
"mode": mode,
"library_version": int(
(formula.get("meta") or {}).get("library_version") or 0
),
"universe_count": len(factors),
"candidate_count": len(candidates),
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"health": {
"status": health_status,
"required_field_count": len(required_fields),
"complete_rows": complete_rows,
"universe_rows": len(factors),
"coverage": coverage,
"signal_count": len(candidates),
},
"selection_source": (
"tushare_rt_k+history" if realtime_snapshot else "historical_eod"
),
"realtime": bool(realtime_snapshot),
"history_cutoff": (
str(realtime_snapshot.get("previous_trade_date") or "")
if realtime_snapshot else actual_date
),
"factor_freshness": {
"realtime": [
"价格", "涨跌幅", "成交量", "成交额", "换手率",
"均线位置", "5/10日动量", "板块强度", "开盘竞价",
] if realtime_snapshot else [],
"historical": ["历史波动率", "流通市值", "资金流", "竞价因子", "回测"],
},
},
"formula": formula,
"candidates": candidates,
"backtest": backtest,
"disclaimer": (
"候选仅由策略条件与当日数据计算;历史统计不代表未来收益。"
if mode == "curated"
else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。"
),
}
run_id = self.database.save_screener_run(
user_id, actual_date, regime, strategy_name, formula, result, mode
)
result["meta"]["run_id"] = run_id
return result
+3 -7
View File
@@ -12,13 +12,9 @@ from backend.features.screener.compiler import (
LLMCompilerError,
compile_strategy_with_llm,
)
from backend.features.screener.engine import (
FACTOR_FIELDS,
FACTOR_GROUPS,
REGIMES,
FactorDataService,
compile_local_strategy,
)
from backend.features.screener.catalog import FACTOR_FIELDS, FACTOR_GROUPS, REGIMES
from backend.features.screener.data_sync import FactorDataService
from backend.features.screener.formula import compile_local_strategy
SCREENER_LIBRARY_VERSION = 8
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class SentimentRoutesMixin:
def _handle_sentiment_get(self, parsed) -> bool:
if parsed.path == "/api/sentiment/history":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
limit = int(query.get("limit", ["20"])[0])
self.send_json(self.application_service.sentiment_history(trade_date, limit))
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
class SystemRoutesMixin:
def _handle_system_public_get(self, parsed) -> bool:
if parsed.path == "/api/health":
self.send_json(
{
"ok": True,
"storage": "sqlite",
"account_required": True,
"time": datetime.now().astimezone().isoformat(timespec="seconds"),
}
)
return True
return False
def _handle_system_get(self, parsed) -> bool:
if parsed.path == "/api/admin/settings":
self.send_json(
{"ok": True, **self.application_service.system_status(), "users": self.application_service.admin_users()}
)
return True
return False
def backfill_data(self) -> None:
try:
body = self.read_json_body()
results = self.application_service.backfill(
str(body.get("start_date") or ""),
str(body.get("end_date") or ""),
)
self.send_json({"ok": True, "results": results})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"历史回补失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
+254
View File
@@ -0,0 +1,254 @@
from __future__ import annotations
import re
import secrets
from typing import Any
from backend.bootstrap.config import TOKEN_PATTERN, validate_text
class SystemServiceMixin:
def _load_system_credentials(self, environment: dict[str, str]) -> dict[str, Any]:
encrypted = self.database.get_system_setting("credentials")
current = self.vault.decrypt_json(encrypted) if encrypted else {}
changed = False
first_user_id = self.database.first_user_id()
first_personal: dict[str, Any] = {}
if first_user_id:
first_encrypted = self.database.get_user_credentials(first_user_id)
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
defaults = {
"tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "",
"ifind_refresh_token": environment.get("ifind_refresh_token") or "",
"ifind_access_token": environment.get("ifind_access_token") or "",
"platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "",
"platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1",
"platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
"platform_llm_fallback_api_key": environment.get("platform_llm_fallback_api_key") or first_personal.get("llm_fallback_api_key") or "",
"platform_llm_fallback_base_url": environment.get("platform_llm_fallback_base_url") or first_personal.get("llm_fallback_base_url") or "",
"platform_llm_fallback_model": environment.get("platform_llm_fallback_model") or first_personal.get("llm_fallback_model") or "",
"member_daily_limit": 50,
"background_refresh_enabled": True,
}
for key, value in defaults.items():
if key not in current:
current[key] = value
changed = True
if not isinstance(current.get("llm_models"), list):
migrated_models: list[dict[str, str]] = []
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
profile = {
"api_key": str(current.get(f"platform_llm_{role}_api_key") or ""),
"base_url": str(current.get(f"platform_llm_{role}_base_url") or ""),
"model": str(current.get(f"platform_llm_{role}_model") or ""),
}
if profile["api_key"] or profile["model"]:
model_id = f"migrated-{role}"
migrated_models.append(
{"id": model_id, "name": label, **profile}
)
current[f"{role}_model_id"] = model_id
current["llm_models"] = migrated_models
current.setdefault("primary_model_id", "")
current.setdefault("fallback_model_id", "")
changed = True
if changed or not encrypted:
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
for row in self.database.list_user_credentials():
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
if "tushare_token" in personal:
personal.pop("tushare_token", None)
self.database.save_user_credentials(
int(row["user_id"]), self.vault.encrypt_json(personal)
)
return current
def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
with self.system_lock:
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
self._system_credentials = dict(credentials)
if hasattr(self, "ifind"):
self.ifind.set_credentials(
str(credentials.get("ifind_refresh_token") or ""),
str(credentials.get("ifind_access_token") or ""),
)
@property
def configured(self) -> bool:
return bool(self.token)
def _credentials(self) -> dict[str, str]:
credentials = getattr(self._request_context, "credentials", {})
return {
"llm_primary_api_key": str(credentials.get("llm_primary_api_key") or ""),
"llm_primary_base_url": str(
credentials.get("llm_primary_base_url") or "https://api.openai.com/v1"
),
"llm_primary_model": str(credentials.get("llm_primary_model") or ""),
"llm_fallback_api_key": str(credentials.get("llm_fallback_api_key") or ""),
"llm_fallback_base_url": str(credentials.get("llm_fallback_base_url") or ""),
"llm_fallback_model": str(credentials.get("llm_fallback_model") or ""),
}
def _save_credentials(self, credentials: dict[str, str]) -> None:
self.database.save_user_credentials(
self.current_user_id,
self.vault.encrypt_json(credentials),
)
self._request_context.credentials = dict(credentials)
@property
def token(self) -> str:
return str(self._system_credentials.get("tushare_token") or "")
def system_status(self) -> dict[str, Any]:
platform = self._platform_llm_profile()
model_pool = []
for item in self._system_credentials.get("llm_models") or []:
if not isinstance(item, dict):
continue
profile = {
"api_key": str(item.get("api_key") or ""),
"base_url": str(item.get("base_url") or ""),
"model": str(item.get("model") or ""),
}
model_pool.append(
{
"id": str(item.get("id") or ""),
"name": str(item.get("name") or ""),
"base_url": profile["base_url"],
"model": profile["model"],
"configured": self._profile_configured(profile),
}
)
return {
"data": {
"configured": self.configured,
"ifind": self.ifind.status(),
"background_refresh_enabled": bool(
self._system_credentials.get("background_refresh_enabled", True)
),
**self.database.status(),
"jobs": self.jobs.repository.recent(12),
},
"llm": {
"primary_configured": self._profile_configured(platform["primary"]),
"fallback_configured": self._profile_configured(platform["fallback"]),
"models": model_pool,
"primary_model_id": str(self._system_credentials.get("primary_model_id") or ""),
"fallback_model_id": str(self._system_credentials.get("fallback_model_id") or ""),
},
"membership": {
"member_daily_limit": max(
1, int(self._system_credentials.get("member_daily_limit") or 50)
)
},
}
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
current = dict(self._system_credentials)
token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip()
if token and not TOKEN_PATTERN.fullmatch(token):
raise ValueError("Tushare Token 格式不正确。")
ifind_refresh_token = str(
payload.get("ifind_refresh_token")
or current.get("ifind_refresh_token")
or ""
).strip()
if ifind_refresh_token and (
len(ifind_refresh_token) > 2048
or any(character.isspace() for character in ifind_refresh_token)
):
raise ValueError("iFinD Refresh Token 格式不正确。")
existing_models = {
str(item.get("id") or ""): item
for item in current.get("llm_models") or []
if isinstance(item, dict) and item.get("id")
}
raw_models = payload.get("models")
models: list[dict[str, str]] = []
if raw_models is not None:
if not isinstance(raw_models, list) or len(raw_models) > 20:
raise ValueError("模型池格式不正确,最多可保存 20 个模型。")
seen_ids: set[str] = set()
seen_names: set[str] = set()
for index, raw in enumerate(raw_models, start=1):
if not isinstance(raw, dict):
raise ValueError("模型池条目格式不正确。")
model_id = str(raw.get("id") or f"model-{secrets.token_hex(6)}").strip()
if not re.fullmatch(r"[A-Za-z0-9_-]{3,80}", model_id) or model_id in seen_ids:
raise ValueError("模型 ID 不正确或重复。")
name = validate_text(raw.get("name"), f"模型 {index} 名称", 50, required=True)
normalized_name = name.casefold()
if normalized_name in seen_names:
raise ValueError("模型名称不能重复。")
profile = self._validate_llm_profile(
raw,
existing_models.get(model_id) or {},
required=True,
label=name,
)
models.append({"id": model_id, "name": name, **profile})
seen_ids.add(model_id)
seen_names.add(normalized_name)
else:
models = [dict(item) for item in existing_models.values()]
model_ids = {item["id"] for item in models}
primary_model_id = str(
payload.get("primary_model_id", current.get("primary_model_id") or "") or ""
).strip()
fallback_model_id = str(
payload.get("fallback_model_id", current.get("fallback_model_id") or "") or ""
).strip()
if models and primary_model_id not in model_ids:
raise ValueError("请从模型池选择主模型。")
if not models:
primary_model_id = ""
fallback_model_id = ""
if fallback_model_id and fallback_model_id not in model_ids:
raise ValueError("辅助模型不在模型池中。")
if fallback_model_id and fallback_model_id == primary_model_id:
raise ValueError("主模型与辅助模型不能相同。")
try:
daily_limit = max(
1,
min(
1000,
int(payload.get("member_daily_limit", current.get("member_daily_limit") or 50)),
),
)
except (TypeError, ValueError) as exc:
raise ValueError("会员每日额度应为 1 至 1000。") from exc
current.update(
{
"tushare_token": token,
"ifind_refresh_token": ifind_refresh_token,
"llm_models": models,
"primary_model_id": primary_model_id,
"fallback_model_id": fallback_model_id,
"member_daily_limit": daily_limit,
"background_refresh_enabled": bool(
payload.get(
"background_refresh_enabled",
current.get("background_refresh_enabled", True),
)
),
}
)
self._save_system_credentials(current)
return self.system_status()
def status(self) -> dict[str, Any]:
llm_access = self.llm_access_status()
return {
"configured": self.configured,
"mode": "tushare" if self.configured else "unavailable",
"llm_configured": self.llm_configured,
"llm_model": self.llm_primary_model if self.llm_configured else "",
"llm_fallback_configured": self.llm_fallback_configured,
"llm_fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
"llm_access": llm_access,
"birth_profile_configured": bool(self.stored_birth_profile()),
"birth_profile": self.stored_birth_profile(),
**self.database.status(),
}
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.data.providers.tushare_client import TushareError
class ThemeRoutesMixin:
def _handle_themes_get(self, parsed) -> bool:
if parsed.path == "/api/themes":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.theme_library(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/themes/detail":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.theme_detail(
query.get("code", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False