Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bab921d14 | ||
|
|
cf2aad28ec | ||
|
|
814e75730a | ||
|
|
b3555d2603 | ||
|
|
a4264326bd |
@@ -1,486 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical curated strategy library."""
|
||||||
|
|
||||||
from typing import Any
|
import sys
|
||||||
|
|
||||||
|
from backend.features.screener import strategies as _implementation
|
||||||
|
|
||||||
def _meta(
|
sys.modules[__name__] = _implementation
|
||||||
category: str,
|
|
||||||
quality: str,
|
|
||||||
frequency: str,
|
|
||||||
risk: str,
|
|
||||||
data_group: str,
|
|
||||||
history_days: int,
|
|
||||||
backtest_days: int,
|
|
||||||
take_profit: float,
|
|
||||||
stop_loss: float,
|
|
||||||
**extra: Any,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"library": "curated",
|
|
||||||
"category": category,
|
|
||||||
"quality": quality,
|
|
||||||
"frequency": frequency,
|
|
||||||
"risk": risk,
|
|
||||||
"data_group": data_group,
|
|
||||||
"history_days": history_days,
|
|
||||||
"backtest_days": backtest_days,
|
|
||||||
"take_profit": take_profit,
|
|
||||||
"stop_loss": stop_loss,
|
|
||||||
**extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ADVANCED_CURATED_STRATEGIES = [
|
|
||||||
{
|
|
||||||
"name": "中期动量·强者恒强",
|
|
||||||
"description": "用60日至5日前的中期动量识别持续强势,同时剔除当日无法正常成交的涨停标的。",
|
|
||||||
"regimes": ["repair", "fermentation", "climax", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("动量反转", "A-", "每周", "中", "历史行情", 80, 10, 8, -5),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "close", "op": "between", "value": [3, 100]},
|
|
||||||
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.90},
|
|
||||||
{"field": "is_limit_up_today", "op": "==", "value": 0},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "momentum_60_5", "weight": 0.55, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 25,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "强者回调",
|
|
||||||
"description": "在中期强势股池中寻找回踩20日线、短期超卖且近20日无跌停的牛回头候选。",
|
|
||||||
"regimes": ["repair", "fermentation", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("动量反转", "A-", "每日", "中", "历史行情", 80, 10, 8, -5),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.70},
|
|
||||||
{"field": "return_5d_rank", "op": "<=", "value": 0.20},
|
|
||||||
{"field": "above_ma20", "op": "==", "value": 1},
|
|
||||||
{"field": "rsi_6", "op": "<=", "value": 30},
|
|
||||||
{"field": "no_limit_down_20d", "op": "==", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "momentum_60_5", "weight": 0.42, "direction": "desc"},
|
|
||||||
{"field": "return_5d", "weight": 0.33, "direction": "asc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 20,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "超跌反转",
|
|
||||||
"description": "筛选短期极端回撤、充分换手但尚未形成长期单边下跌的修复候选。",
|
|
||||||
"regimes": ["ice", "repair"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("动量反转", "B+", "每日", "高", "行情与财务", 80, 5, 8, -5),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "return_5d_rank", "op": "<=", "value": 0.05},
|
|
||||||
{"field": "turnover_5d", "op": ">=", "value": 30},
|
|
||||||
{"field": "return_60d", "op": ">=", "value": -40},
|
|
||||||
{"field": "financial_risk", "op": "==", "value": 0},
|
|
||||||
{"field": "is_limit_down_today", "op": "==", "value": 0},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "return_5d", "weight": 0.45, "direction": "asc"},
|
|
||||||
{"field": "turnover_5d", "weight": 0.30, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 10,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "相对强度新高",
|
|
||||||
"description": "以个股相对沪深300的强度线识别弱市领涨和结构性抱团标的。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("动量反转", "A", "每周", "中", "行情与指数", 130, 20, 12, -7, requires_benchmark=True),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 250},
|
|
||||||
"filters": [
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
{"field": "rs_high_120", "op": "==", "value": 1},
|
|
||||||
{"field": "excess_return_60d", "op": ">=", "value": 10},
|
|
||||||
{"field": "ma60_slope", "op": ">", "value": 0},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "excess_return_60d", "weight": 0.50, "direction": "desc"},
|
|
||||||
{"field": "ma60_slope", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 20,
|
|
||||||
"min_score": 0.52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "均线多头排列",
|
|
||||||
"description": "使用5、10、20、60日均线多头结构、20日线斜率和250日位置确认趋势。",
|
|
||||||
"regimes": ["repair", "fermentation", "climax", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("趋势追踪", "A-", "每周", "中低", "历史行情", 260, 20, 12, -7),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 365},
|
|
||||||
"filters": [
|
|
||||||
{"field": "ma_bull_alignment", "op": "==", "value": 1},
|
|
||||||
{"field": "ma20_slope_5d", "op": ">", "value": 0},
|
|
||||||
{"field": "drawdown_from_high_250", "op": "<=", "value": 20},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "ma20_slope_5d", "weight": 0.38, "direction": "desc"},
|
|
||||||
{"field": "drawdown_from_high_250", "weight": 0.32, "direction": "asc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 30,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "唐奇安通道突破",
|
|
||||||
"description": "收盘突破前20日高点,并以突破幅度、量能和突破前振幅过滤假突破。",
|
|
||||||
"regimes": ["repair", "fermentation", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("趋势追踪", "A-", "每日", "中", "历史行情", 80, 20, 12, -7),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "donchian_breakout_pct", "op": ">=", "value": 2},
|
|
||||||
{"field": "volume_ratio_5d", "op": ">=", "value": 1.8},
|
|
||||||
{"field": "range_20d", "op": "<=", "value": 35},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "volume_ratio_5d", "weight": 0.40, "direction": "desc"},
|
|
||||||
{"field": "donchian_breakout_pct", "weight": 0.35, "direction": "desc"},
|
|
||||||
{"field": "range_20d", "weight": 0.25, "direction": "asc"},
|
|
||||||
],
|
|
||||||
"limit": 15,
|
|
||||||
"min_score": 0.52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "周线趋势·日线买点",
|
|
||||||
"description": "周线MACD位于多头区间,日线金叉或回踩20日线收阳时确认多周期共振。",
|
|
||||||
"regimes": ["repair", "fermentation", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("趋势追踪", "A", "每周", "中低", "多周期行情", 180, 20, 12, -7),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 365},
|
|
||||||
"filters": [
|
|
||||||
{"field": "weekly_trend_signal", "op": "==", "value": 1},
|
|
||||||
{"field": "daily_buy_trigger", "op": "==", "value": 1},
|
|
||||||
{"field": "weekly_amount_trend", "op": "==", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "ma20_slope_5d", "weight": 0.35, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.35, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.30, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 20,
|
|
||||||
"min_score": 0.52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
ADVANCED_CURATED_STRATEGIES.extend(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "空间板",
|
|
||||||
"description": "识别当日新晋市场最高板,并要求所属方向具备足够的涨停支撑。",
|
|
||||||
"regimes": ["repair", "fermentation"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("连板接力", "B+", "每日", "很高", "涨停结构", 80, 3, 8, -6),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "is_market_height", "op": "==", "value": 1},
|
|
||||||
{"field": "new_space_board", "op": "==", "value": 1},
|
|
||||||
{"field": "sector_limit_count", "op": ">=", "value": 3},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "limit_streak", "weight": 0.50, "direction": "desc"},
|
|
||||||
{"field": "sector_limit_count", "weight": 0.30, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 5,
|
|
||||||
"min_score": 0.45,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "龙头首阴",
|
|
||||||
"description": "筛选三板以上强势股断板后的首次缩量阴线,并结合板块强度观察承接质量。",
|
|
||||||
"regimes": ["fermentation", "climax"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("低吸反核", "B", "每日", "很高", "涨停结构", 80, 5, 8, -6),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "max_continuous_board_10d", "op": ">=", "value": 3},
|
|
||||||
{"field": "dragon_first_yin", "op": "==", "value": 1},
|
|
||||||
{"field": "yin_day_pct", "op": ">=", "value": -7},
|
|
||||||
{"field": "vol_vs_previous", "op": "<=", "value": 0.8},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "max_continuous_board_10d", "weight": 0.45, "direction": "desc"},
|
|
||||||
{"field": "vol_vs_previous", "weight": 0.30, "direction": "asc"},
|
|
||||||
{"field": "sector_strength", "weight": 0.25, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 5,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "断板反包",
|
|
||||||
"description": "连板断板后1至3日内,以涨停收复断板高点和量能确认N字反包。",
|
|
||||||
"regimes": ["repair", "fermentation"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("低吸反核", "B+", "每日", "高", "涨停结构", 80, 3, 8, -6),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "broken_reversal", "op": "==", "value": 1},
|
|
||||||
{"field": "days_since_broken", "op": "between", "value": [1, 3]},
|
|
||||||
{"field": "close_above_broken_high", "op": "==", "value": 1},
|
|
||||||
{"field": "vol_vs_broken_day", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "days_since_broken", "weight": 0.35, "direction": "asc"},
|
|
||||||
{"field": "vol_vs_broken_day", "weight": 0.35, "direction": "desc"},
|
|
||||||
{"field": "sector_strength", "weight": 0.30, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 5,
|
|
||||||
"min_score": 0.46,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "核按钮反核",
|
|
||||||
"description": "近5日强势股盘中深水急杀后收回,并以长下影和非放量结构确认承接。",
|
|
||||||
"regimes": ["repair", "fermentation"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("低吸反核", "B+", "每日", "很高", "历史行情", 80, 5, 8, -6),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "recent_limit_up_5d", "op": ">=", "value": 1},
|
|
||||||
{"field": "intraday_min_pct", "op": "<=", "value": -7},
|
|
||||||
{"field": "pct_chg", "op": ">=", "value": -3},
|
|
||||||
{"field": "lower_shadow_ratio", "op": ">=", "value": 2},
|
|
||||||
{"field": "vol_vs_previous", "op": "<=", "value": 1.1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "lower_shadow_ratio", "weight": 0.42, "direction": "desc"},
|
|
||||||
{"field": "intraday_min_pct", "weight": 0.30, "direction": "asc"},
|
|
||||||
{"field": "sector_strength", "weight": 0.28, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 5,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
ADVANCED_CURATED_STRATEGIES.extend(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "景气-趋势-拥挤三维行业打分",
|
|
||||||
"description": "以行业财务景气、价格趋势和交易拥挤度合成行业得分,再选取行业内动量与成交承载靠前的公司。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"行业轮动", "A-", "双周", "中", "行业、财务与交易拥挤", 80, 20, 12, -7,
|
|
||||||
requires_fundamental=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "sector_composite_score", "op": ">=", "value": 0.58},
|
|
||||||
{"field": "sector_crowding_rank", "op": "<=", "value": 0.90},
|
|
||||||
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.50},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "sector_composite_score", "weight": 0.55, "direction": "desc"},
|
|
||||||
{"field": "sector_stock_momentum_rank", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "sector_crowding_rank", "weight": 0.20, "direction": "asc"},
|
|
||||||
],
|
|
||||||
"limit": 12,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "大小盘/成长价值风格切换(元策略)",
|
|
||||||
"description": "比较大小盘与成长价值组合近20日相对表现,动态选择当前占优风格中的匹配标的。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"元策略", "A-", "每周", "中低", "行情、估值与财务", 80, 20, 12, -7,
|
|
||||||
requires_fundamental=True, requires_valuation=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 250},
|
|
||||||
"filters": [
|
|
||||||
{"field": "style_fit_score", "op": ">=", "value": 0.65},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "style_fit_score", "weight": 0.70, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 20,
|
|
||||||
"min_score": 0.52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "业绩超预期漂移(SUE/PEAD)",
|
|
||||||
"description": "以业绩预告和业绩快报的同报告期差异识别超预期事件,并限定在公告后的首个交易窗口。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"业绩事件", "A-", "事件驱动", "中", "业绩预告与快报", 80, 20, 12, -7,
|
|
||||||
requires_earnings_events=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "earnings_surprise_pct", "op": ">=", "value": 10},
|
|
||||||
{"field": "revenue_yoy", "op": ">", "value": 0},
|
|
||||||
{"field": "earnings_event_quality", "op": "==", "value": 1},
|
|
||||||
{"field": "earnings_days_since_announce", "op": "between", "value": [1, 5]},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "earnings_surprise_pct", "weight": 0.60, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 15,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "多因子综合打分(IC动态加权)",
|
|
||||||
"description": "将价值、成长、质量、动量和交易情绪标准化,并按近期横截面有效性动态合成综合分。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"多因子", "A-", "每周", "中", "行情、估值与财务", 260, 20, 12, -7,
|
|
||||||
requires_fundamental=True, requires_valuation=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 250},
|
|
||||||
"filters": [
|
|
||||||
{"field": "multi_factor_composite", "op": ">=", "value": 0.65},
|
|
||||||
{"field": "financial_risk", "op": "==", "value": 0},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "multi_factor_composite", "weight": 0.75, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.15, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 30,
|
|
||||||
"min_score": 0.55,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "热度突增潜伏(另类数据)",
|
|
||||||
"description": "从同花顺和东方财富人气榜中寻找排名快速跃升、但价格尚未明显兑现的观察候选。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"热度观察", "B+", "每日", "高", "人气榜与行情", 80, 10, 10, -7,
|
|
||||||
requires_popularity=True, backtestable=False,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "popularity_score", "op": ">=", "value": 15},
|
|
||||||
{"field": "return_10d", "op": "<=", "value": 5},
|
|
||||||
{"field": "recent_limit_up_5d", "op": "==", "value": 0},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 0.5},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "popularity_score", "weight": 0.50, "direction": "desc"},
|
|
||||||
{"field": "popularity_rank_change", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "popularity_dual_source", "weight": 0.10, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 10,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "机构榜溢价",
|
|
||||||
"description": "筛选龙虎榜机构专用席位低位净买入的公司,并以席位数量和成交承载确认信号。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"资金席位", "B+", "每日", "中高", "龙虎榜机构席位", 80, 10, 10, -7,
|
|
||||||
requires_institutions=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "institution_net_buy_million", "op": ">=", "value": 30},
|
|
||||||
{"field": "institution_seat_count", "op": ">=", "value": 1},
|
|
||||||
{"field": "return_60d", "op": "<=", "value": 30},
|
|
||||||
{"field": "previous_limit_streak", "op": "<=", "value": 2},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "institution_net_buy_million", "weight": 0.55, "direction": "desc"},
|
|
||||||
{"field": "institution_seat_count", "weight": 0.15, "direction": "desc"},
|
|
||||||
{"field": "relative_position_60", "weight": 0.20, "direction": "asc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 10,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
ADVANCED_CURATED_STRATEGIES.extend(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "行业动量轮动",
|
|
||||||
"description": "选择20日涨幅居前的行业,并在行业内部保留趋势与成交承载更强的前排公司。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("行业轮动", "A-", "双周", "中", "行业与历史行情", 80, 20, 12, -7),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "sector_momentum_rank", "op": ">=", "value": 0.90},
|
|
||||||
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.80},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "sector_return_20d", "weight": 0.38, "direction": "desc"},
|
|
||||||
{"field": "return_20d", "weight": 0.32, "direction": "desc"},
|
|
||||||
{"field": "total_mv_billion", "weight": 0.18, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 12,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "主力资金行业流入",
|
|
||||||
"description": "寻找近5日主力资金持续净流入、行业涨幅尚未充分兑现的板块前排。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"行业轮动", "B+", "每周", "中高", "行业与资金流", 80, 10, 10, -7,
|
|
||||||
requires_moneyflow_history=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "sector_flow_rank", "op": ">=", "value": 0.85},
|
|
||||||
{"field": "sector_net_flow_5d_million", "op": ">", "value": 0},
|
|
||||||
{"field": "sector_return_5d", "op": "<=", "value": 8},
|
|
||||||
{"field": "flow_to_circ_mv_5d", "op": ">", "value": 0},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "flow_to_circ_mv_5d", "weight": 0.42, "direction": "desc"},
|
|
||||||
{"field": "sector_net_flow_5d_million", "weight": 0.30, "direction": "desc"},
|
|
||||||
{"field": "sector_return_5d", "weight": 0.16, "direction": "asc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 15,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ from backend.data import DataGateway, build_data_gateway
|
|||||||
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
||||||
from backend.features.alerts import AlertService
|
from backend.features.alerts import AlertService
|
||||||
from backend.features.review import TradeJournalService
|
from backend.features.review import TradeJournalService
|
||||||
from backend.features.screener import StrategyTrackingService
|
from backend.features.screener.tracking import StrategyTrackingService
|
||||||
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
|
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
|
||||||
from chart_data_provider import MarketChartClient
|
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from ifind_client import IfindHttpClient
|
|
||||||
from mentor_agent import MentorSkillRegistry
|
from mentor_agent import MentorSkillRegistry
|
||||||
from realtime_aggregator import WebRealtimeAggregator
|
|
||||||
from screener import ScreenerEngine
|
from screener import ScreenerEngine
|
||||||
|
from backend.data.providers.ifind_client import IfindHttpClient
|
||||||
|
from backend.data.realtime import WebRealtimeAggregator
|
||||||
|
from backend.features.market.charts import MarketChartClient
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from .gateway import DataGateway, build_data_gateway
|
|
||||||
from .policy import DataPolicyError, DataSourcePolicy
|
from .policy import DataPolicyError, DataSourcePolicy
|
||||||
from .quality import DataQualityError, DataQualityGate, QualityEvidence, QualityReport
|
from .quality import DataQualityError, DataQualityGate, QualityEvidence, QualityReport
|
||||||
|
|
||||||
@@ -12,3 +11,11 @@ __all__ = [
|
|||||||
"QualityReport",
|
"QualityReport",
|
||||||
"build_data_gateway",
|
"build_data_gateway",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if name in {"DataGateway", "build_data_gateway"}:
|
||||||
|
from .gateway import DataGateway, build_data_gateway
|
||||||
|
|
||||||
|
return {"DataGateway": DataGateway, "build_data_gateway": build_data_gateway}[name]
|
||||||
|
raise AttributeError(name)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ from backend.data.contracts import DataUsage
|
|||||||
from backend.data.policy import DataSourcePolicy
|
from backend.data.policy import DataSourcePolicy
|
||||||
from backend.data.providers import IfindProvider, TushareProvider
|
from backend.data.providers import IfindProvider, TushareProvider
|
||||||
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
||||||
from chart_data_provider import EastmoneyChartClient, MarketChartClient
|
from backend.data.providers.ifind_client import IfindHttpClient
|
||||||
from ifind_client import IfindHttpClient
|
from backend.data.providers.tushare_client import TushareClient
|
||||||
from realtime_aggregator import WebRealtimeAggregator
|
from backend.data.realtime import WebRealtimeAggregator
|
||||||
from tushare_client import TushareClient
|
from backend.features.market.charts import EastmoneyChartClient, MarketChartClient
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ifind_client import IfindHttpClient
|
from backend.data.providers.ifind_client import IfindHttpClient
|
||||||
|
|
||||||
|
|
||||||
class IfindProvider:
|
class IfindProvider:
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class IfindError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IfindHttpClient:
|
||||||
|
BASE_URL = "https://quantapi.51ifind.com/api/v1"
|
||||||
|
AUTH_ENDPOINT = "get_access_token"
|
||||||
|
AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
refresh_token: str = "",
|
||||||
|
access_token: str = "",
|
||||||
|
timeout: int = 15,
|
||||||
|
) -> None:
|
||||||
|
self.timeout = max(3, int(timeout))
|
||||||
|
self._refresh_token = str(refresh_token or "").strip()
|
||||||
|
self._access_token = str(access_token or "").strip()
|
||||||
|
self._access_expires_at: datetime | None = None
|
||||||
|
self._token_lock = threading.Lock()
|
||||||
|
self._cache_lock = threading.Lock()
|
||||||
|
self._cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self._refresh_token or self._access_token)
|
||||||
|
|
||||||
|
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
||||||
|
refresh_token = str(refresh_token or "").strip()
|
||||||
|
access_token = str(access_token or "").strip()
|
||||||
|
with self._token_lock:
|
||||||
|
refresh_changed = refresh_token != self._refresh_token
|
||||||
|
self._refresh_token = refresh_token
|
||||||
|
if access_token or refresh_changed:
|
||||||
|
self._access_token = access_token
|
||||||
|
self._access_expires_at = None
|
||||||
|
if refresh_changed:
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"configured": self.configured,
|
||||||
|
"access_ready": bool(self._access_token),
|
||||||
|
"access_expires_at": (
|
||||||
|
self._access_expires_at.isoformat(timespec="seconds")
|
||||||
|
if self._access_expires_at
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_connection(self) -> dict[str, Any]:
|
||||||
|
payload = self.real_time(
|
||||||
|
"000001.SH",
|
||||||
|
["open", "high", "low", "latest", "preClose"],
|
||||||
|
cache_ttl=0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": bool(payload),
|
||||||
|
"sample_time": str(payload[0].get("time") or "") if payload else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def real_time(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
cache_ttl: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
code_text = self._codes(codes)
|
||||||
|
payload = self._request(
|
||||||
|
"real_time_quotation",
|
||||||
|
{"codes": code_text, "indicators": ",".join(indicators)},
|
||||||
|
cache_key=f"rq:{code_text}:{','.join(indicators)}",
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
)
|
||||||
|
return self._table_rows(payload)
|
||||||
|
|
||||||
|
def history(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
cache_ttl: int = 300,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
code_text = self._codes(codes)
|
||||||
|
payload = self._request(
|
||||||
|
"cmd_history_quotation",
|
||||||
|
{
|
||||||
|
"codes": code_text,
|
||||||
|
"indicators": ",".join(indicators),
|
||||||
|
"startdate": self._display_date(start_date),
|
||||||
|
"enddate": self._display_date(end_date),
|
||||||
|
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
|
||||||
|
},
|
||||||
|
cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}",
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
)
|
||||||
|
return self._table_rows(payload)
|
||||||
|
|
||||||
|
def intraday(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
cache_ttl: int = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"]
|
||||||
|
payload = self._request(
|
||||||
|
"high_frequency",
|
||||||
|
{
|
||||||
|
"codes": self._codes(code),
|
||||||
|
"indicators": ",".join(indicators),
|
||||||
|
"starttime": start_time,
|
||||||
|
"endtime": end_time,
|
||||||
|
"functionpara": {
|
||||||
|
"CPS": "forward1",
|
||||||
|
"Fill": "Previous",
|
||||||
|
"Timeformat": "LocalTime",
|
||||||
|
"Interval": "1",
|
||||||
|
"Limitstart": "09:30:00",
|
||||||
|
"Limitend": "15:00:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cache_key=f"hf:{code}:{start_time}:{end_time}",
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
)
|
||||||
|
return self._table_rows(payload)
|
||||||
|
|
||||||
|
def snapshots(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
cache_ttl: int = 8,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
code_text = self._codes(codes)
|
||||||
|
payload = self._request(
|
||||||
|
"snap_shot",
|
||||||
|
{
|
||||||
|
"codes": code_text,
|
||||||
|
"indicators": ",".join(indicators),
|
||||||
|
"starttime": start_time,
|
||||||
|
"endtime": end_time,
|
||||||
|
},
|
||||||
|
cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}",
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
)
|
||||||
|
return self._table_rows(payload)
|
||||||
|
|
||||||
|
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
|
||||||
|
normalized = " ".join(str(query or "").split())
|
||||||
|
if not normalized:
|
||||||
|
raise IfindError("问财查询不能为空。")
|
||||||
|
payload = self._request(
|
||||||
|
"smart_stock_picking",
|
||||||
|
{"searchstring": normalized, "searchtype": search_type},
|
||||||
|
cache_key=f"wc:{search_type}:{normalized}",
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
)
|
||||||
|
return self._table_rows(payload)
|
||||||
|
|
||||||
|
def report_query(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
begin_date: str,
|
||||||
|
end_date: str,
|
||||||
|
cache_ttl: int = 300,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
code_text = self._codes(codes)
|
||||||
|
payload = self._request(
|
||||||
|
"report_query",
|
||||||
|
{
|
||||||
|
"codes": code_text,
|
||||||
|
"beginrDate": self._display_date(begin_date),
|
||||||
|
"endrDate": self._display_date(end_date),
|
||||||
|
"outputpara": (
|
||||||
|
"reportDate:Y,thscode:Y,secName:Y,ctime:Y,"
|
||||||
|
"reportTitle:Y,pdfURL:Y,seq:Y"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
cache_key=f"report:{code_text}:{begin_date}:{end_date}",
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
)
|
||||||
|
return self._table_rows(payload)
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
cache_key: str = "",
|
||||||
|
cache_ttl: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not self.configured:
|
||||||
|
raise IfindError("iFinD 尚未配置。")
|
||||||
|
if cache_key and cache_ttl > 0:
|
||||||
|
cached = self._cached(cache_key, cache_ttl)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
payload = self._post(endpoint, body, self._ensure_access_token())
|
||||||
|
if self._is_auth_error(payload) and self._refresh_token:
|
||||||
|
self._invalidate_access_token()
|
||||||
|
payload = self._post(endpoint, body, self._ensure_access_token(force=True))
|
||||||
|
self._validate_payload(payload)
|
||||||
|
if cache_key and cache_ttl > 0:
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache[cache_key] = {
|
||||||
|
"created_at": time.time(),
|
||||||
|
"payload": copy.deepcopy(payload),
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _ensure_access_token(self, force: bool = False) -> str:
|
||||||
|
with self._token_lock:
|
||||||
|
now = datetime.now().astimezone().replace(tzinfo=None)
|
||||||
|
token_valid = bool(self._access_token) and (
|
||||||
|
self._access_expires_at is None
|
||||||
|
or self._access_expires_at > now + timedelta(minutes=2)
|
||||||
|
)
|
||||||
|
if token_valid and not force:
|
||||||
|
return self._access_token
|
||||||
|
if not self._refresh_token:
|
||||||
|
if self._access_token:
|
||||||
|
return self._access_token
|
||||||
|
raise IfindError("iFinD Refresh Token 尚未配置。")
|
||||||
|
payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token)
|
||||||
|
self._validate_payload(payload)
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
token = str(data.get("access_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise IfindError("iFinD 未返回 Access Token。")
|
||||||
|
expires_at = self._parse_datetime(data.get("expired_time"))
|
||||||
|
self._access_token = token
|
||||||
|
self._access_expires_at = expires_at
|
||||||
|
return token
|
||||||
|
|
||||||
|
def _post(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
access_token: str,
|
||||||
|
refresh_token: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "XiaobaiReviewWeb/1.0",
|
||||||
|
"ifindlang": "cn",
|
||||||
|
}
|
||||||
|
if access_token:
|
||||||
|
headers["access_token"] = access_token
|
||||||
|
if refresh_token:
|
||||||
|
headers["refresh_token"] = refresh_token
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.BASE_URL}/{endpoint}",
|
||||||
|
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
|
||||||
|
headers=headers,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
detail_payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||||||
|
detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "")
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
raise IfindError(f"iFinD HTTP {exc.code}{f':{detail[:160]}' if detail else ''}") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise IfindError("iFinD 数据请求失败。") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise IfindError("iFinD 返回格式不正确。")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _cached(self, key: str, ttl: int) -> dict[str, Any] | None:
|
||||||
|
with self._cache_lock:
|
||||||
|
cached = self._cache.get(key)
|
||||||
|
if not cached:
|
||||||
|
return None
|
||||||
|
if time.time() - float(cached.get("created_at") or 0) > ttl:
|
||||||
|
self._cache.pop(key, None)
|
||||||
|
return None
|
||||||
|
return copy.deepcopy(cached["payload"])
|
||||||
|
|
||||||
|
def _invalidate_access_token(self) -> None:
|
||||||
|
with self._token_lock:
|
||||||
|
self._access_token = ""
|
||||||
|
self._access_expires_at = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
error_code = int(payload.get("errorcode") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
error_code = -1
|
||||||
|
if error_code != 0:
|
||||||
|
message = str(payload.get("errmsg") or "未知错误")
|
||||||
|
raise IfindError(f"iFinD 返回错误:{message[:200]}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_auth_error(cls, payload: dict[str, Any]) -> bool:
|
||||||
|
try:
|
||||||
|
error_code = int(payload.get("errorcode") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
error_code = 0
|
||||||
|
message = str(payload.get("errmsg") or "").casefold()
|
||||||
|
return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
tables = payload.get("tables") or []
|
||||||
|
if isinstance(tables, dict):
|
||||||
|
tables = [tables]
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for block in tables if isinstance(tables, list) else []:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
table = block.get("table") or {}
|
||||||
|
if not isinstance(table, dict):
|
||||||
|
continue
|
||||||
|
times = block.get("time") or []
|
||||||
|
codes = block.get("thscode") or block.get("thscodes") or []
|
||||||
|
if isinstance(codes, str):
|
||||||
|
codes = [codes]
|
||||||
|
lengths = [len(value) for value in table.values() if isinstance(value, list)]
|
||||||
|
row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0])
|
||||||
|
for index in range(row_count):
|
||||||
|
row: dict[str, Any] = {}
|
||||||
|
if isinstance(times, list) and index < len(times):
|
||||||
|
row["time"] = times[index]
|
||||||
|
if codes:
|
||||||
|
row["thscode"] = codes[index] if index < len(codes) else codes[0]
|
||||||
|
for field, values in table.items():
|
||||||
|
if isinstance(values, list):
|
||||||
|
row[field] = values[index] if index < len(values) else None
|
||||||
|
elif index == 0:
|
||||||
|
row[field] = values
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _codes(codes: str | list[str]) -> str:
|
||||||
|
if isinstance(codes, list):
|
||||||
|
values = [str(code or "").strip().upper() for code in codes]
|
||||||
|
else:
|
||||||
|
values = [part.strip().upper() for part in str(codes or "").split(",")]
|
||||||
|
values = [value for value in values if value]
|
||||||
|
if not values:
|
||||||
|
raise IfindError("iFinD 证券代码不能为空。")
|
||||||
|
if len(values) > 100:
|
||||||
|
raise IfindError("iFinD 单次证券代码过多。")
|
||||||
|
return ",".join(values)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _display_date(value: str) -> str:
|
||||||
|
compact = str(value or "").replace("-", "")
|
||||||
|
if len(compact) != 8 or not compact.isdigit():
|
||||||
|
raise IfindError("iFinD 日期格式不正确。")
|
||||||
|
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_datetime(value: Any) -> datetime | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from tushare_client import TushareClient
|
from backend.data.providers.tushare_client import TushareClient
|
||||||
|
|
||||||
|
|
||||||
class TushareProvider:
|
class TushareProvider:
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeAggregateError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
|
||||||
|
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
||||||
|
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
|
||||||
|
THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
|
||||||
|
XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail"
|
||||||
|
BROWSER_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/138.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebRealtimeAggregator:
|
||||||
|
timeout: int = 8
|
||||||
|
retry_attempts: int = 3
|
||||||
|
retry_delay_seconds: float = 0.2
|
||||||
|
response_cache_ttl_seconds: int = 90
|
||||||
|
_sector_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_sector_cache_lock: ClassVar[Lock] = Lock()
|
||||||
|
_response_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_response_cache_lock: ClassVar[Lock] = Lock()
|
||||||
|
|
||||||
|
def health_snapshot(self, sector: str = "") -> dict[str, Any]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
sources: dict[str, dict[str, Any]] = {}
|
||||||
|
indices: list[dict[str, Any]] = []
|
||||||
|
sector_payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
indices, sources["eastmoney_indices"] = self._capture(self.eastmoney_indices)
|
||||||
|
if sector.strip():
|
||||||
|
sector_payload, sources["eastmoney_sector"] = self._capture(
|
||||||
|
lambda: self.eastmoney_sector(sector)
|
||||||
|
)
|
||||||
|
ths_observation, sources["ths_limit_pool"] = self._capture(self.ths_limit_pool)
|
||||||
|
xgb_observation, sources["xgb_limit_pool"] = self._capture(self.xgb_limit_pool)
|
||||||
|
|
||||||
|
index_times = [int(item.get("quote_time_epoch") or 0) for item in indices or []]
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
max_skew = 120 if now.hour >= 15 else 15
|
||||||
|
index_consistent = bool(index_times) and max(index_times) - min(index_times) <= max_skew
|
||||||
|
ready = (
|
||||||
|
bool(indices)
|
||||||
|
and len(indices) == 3
|
||||||
|
and index_consistent
|
||||||
|
and (not sector.strip() or bool(sector_payload))
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ready": ready,
|
||||||
|
"isolated": True,
|
||||||
|
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"elapsed_ms": round((time.perf_counter() - started) * 1000),
|
||||||
|
"indices": indices or [],
|
||||||
|
"index_consistent": index_consistent,
|
||||||
|
"sector": sector_payload,
|
||||||
|
"sources": sources,
|
||||||
|
"observations": {
|
||||||
|
"ths_limit_pool": ths_observation,
|
||||||
|
"xgb_limit_pool": xgb_observation,
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"integration": "heaven_realtime_fallback",
|
||||||
|
"max_index_time_skew_seconds": max_skew,
|
||||||
|
"notice": "聚合源仅作为盘中观势的实时指数与板块外显,主行情快照仍由Tushare维护。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def eastmoney_indices(self) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
payload = self._get_json(
|
||||||
|
EASTMONEY_INDEX_URL,
|
||||||
|
{
|
||||||
|
"secids": "1.000001,0.399001,0.399006",
|
||||||
|
"fltt": "2",
|
||||||
|
"invt": "2",
|
||||||
|
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124",
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/",
|
||||||
|
)
|
||||||
|
except RealtimeAggregateError:
|
||||||
|
return self.tencent_indices()
|
||||||
|
cache_meta = payload.get("_aggregate_cache") or {}
|
||||||
|
rows = list((payload.get("data") or {}).get("diff") or [])
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
code = str(row.get("f12") or "")
|
||||||
|
if code not in {"000001", "399001", "399006"}:
|
||||||
|
continue
|
||||||
|
epoch = int(_number(row.get("f124")))
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"name": row.get("f14") or code,
|
||||||
|
"price": _number(row.get("f2")),
|
||||||
|
"change": _number(row.get("f3")),
|
||||||
|
"change_amount": _number(row.get("f4")),
|
||||||
|
"open": _number(row.get("f17")),
|
||||||
|
"high": _number(row.get("f15")),
|
||||||
|
"low": _number(row.get("f16")),
|
||||||
|
"previous_close": _number(row.get("f18")),
|
||||||
|
"amount_billion": round(_number(row.get("f6")) / 100000000, 2),
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"quote_time": (
|
||||||
|
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||||
|
if epoch else ""
|
||||||
|
),
|
||||||
|
"source": (
|
||||||
|
"eastmoney_push2_cache" if cache_meta else "eastmoney_push2"
|
||||||
|
),
|
||||||
|
"cache_age_seconds": cache_meta.get("age_seconds", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(result) != 3:
|
||||||
|
raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def tencent_indices(self) -> list[dict[str, Any]]:
|
||||||
|
raw, cache_age = self._get_text(
|
||||||
|
TENCENT_INDEX_URL,
|
||||||
|
referer="https://gu.qq.com/",
|
||||||
|
encoding="gb18030",
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if '="' not in line:
|
||||||
|
continue
|
||||||
|
fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~")
|
||||||
|
if len(fields) < 38:
|
||||||
|
continue
|
||||||
|
code = fields[2]
|
||||||
|
if code not in {"000001", "399001", "399006"}:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RealtimeAggregateError(
|
||||||
|
f"Tencent returned invalid quote time for {code}"
|
||||||
|
) from exc
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"name": fields[1] or code,
|
||||||
|
"price": _number(fields[3]),
|
||||||
|
"change": _number(fields[32]),
|
||||||
|
"change_amount": _number(fields[31]),
|
||||||
|
"open": _number(fields[5]),
|
||||||
|
"high": _number(fields[33]),
|
||||||
|
"low": _number(fields[34]),
|
||||||
|
"previous_close": _number(fields[4]),
|
||||||
|
"amount_billion": round(_number(fields[37]) / 10000, 2),
|
||||||
|
"quote_time_epoch": int(quote_time.timestamp()),
|
||||||
|
"quote_time": quote_time.isoformat(timespec="seconds"),
|
||||||
|
"source": "tencent_qt_cache" if cache_age else "tencent_qt",
|
||||||
|
"cache_age_seconds": cache_age,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(result) != 3:
|
||||||
|
raise RealtimeAggregateError(f"Tencent returned {len(result)}/3 indices")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def eastmoney_sector(self, query: str) -> dict[str, Any]:
|
||||||
|
target = _normalize_sector(query)
|
||||||
|
candidates = self._eastmoney_sector_catalog()
|
||||||
|
matched = _match_sector(candidates, target)
|
||||||
|
if not matched:
|
||||||
|
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
|
||||||
|
epoch = int(_number(matched.get("f124")))
|
||||||
|
return {
|
||||||
|
"code": matched.get("f12") or "",
|
||||||
|
"name": matched.get("f14") or query,
|
||||||
|
"price": _number(matched.get("f2")),
|
||||||
|
"change": _number(matched.get("f3")),
|
||||||
|
"change_amount": _number(matched.get("f4")),
|
||||||
|
"turnover_rate": _number(matched.get("f8")),
|
||||||
|
"up_count": int(_number(matched.get("f104"))),
|
||||||
|
"down_count": int(_number(matched.get("f105"))),
|
||||||
|
"leader": matched.get("f128") or "--",
|
||||||
|
"leader_code": matched.get("f140") or "",
|
||||||
|
"leading_pct": _number(matched.get("f136")),
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"quote_time": (
|
||||||
|
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||||
|
if epoch else ""
|
||||||
|
),
|
||||||
|
"source": "eastmoney_push2",
|
||||||
|
"match_query": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _eastmoney_sector_catalog(self) -> list[dict[str, Any]]:
|
||||||
|
now = time.time()
|
||||||
|
with self._sector_cache_lock:
|
||||||
|
cached = self._sector_cache.get("eastmoney")
|
||||||
|
if cached and now - float(cached.get("created_at") or 0) < 600:
|
||||||
|
return list(cached.get("rows") or [])
|
||||||
|
|
||||||
|
def load_page(page: int) -> list[dict[str, Any]]:
|
||||||
|
payload = self._get_json(
|
||||||
|
EASTMONEY_SECTOR_URL,
|
||||||
|
{
|
||||||
|
"pn": str(page),
|
||||||
|
"pz": "100",
|
||||||
|
"po": "1",
|
||||||
|
"np": "1",
|
||||||
|
"fltt": "2",
|
||||||
|
"invt": "2",
|
||||||
|
"fid": "f3",
|
||||||
|
"fs": "m:90+t:2",
|
||||||
|
"fields": "f12,f14,f2,f3,f4,f8,f104,f105,f128,f136,f140,f124",
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/center/boardlist.html",
|
||||||
|
)
|
||||||
|
return list((payload.get("data") or {}).get("diff") or [])
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
|
pages = list(executor.map(load_page, range(1, 6)))
|
||||||
|
rows = [row for page in pages for row in page]
|
||||||
|
if not rows:
|
||||||
|
raise RealtimeAggregateError("Eastmoney sector catalog is empty")
|
||||||
|
with self._sector_cache_lock:
|
||||||
|
self._sector_cache["eastmoney"] = {"created_at": now, "rows": rows}
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def ths_limit_pool(self) -> dict[str, Any]:
|
||||||
|
payload = self._get_json(
|
||||||
|
THS_LIMIT_URL,
|
||||||
|
{"page": "1", "limit": "3", "field": "199112"},
|
||||||
|
referer="https://data.10jqka.com.cn/limit_up/",
|
||||||
|
)
|
||||||
|
data = payload.get("data") or payload
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"keys": sorted(str(key) for key in data.keys()) if isinstance(data, dict) else [],
|
||||||
|
"source": "ths_web_dataapi",
|
||||||
|
}
|
||||||
|
|
||||||
|
def xgb_limit_pool(self) -> dict[str, Any]:
|
||||||
|
payload = self._get_json(
|
||||||
|
XGB_POOL_URL,
|
||||||
|
{"pool_name": "limit_up"},
|
||||||
|
referer="https://xuangubao.cn/",
|
||||||
|
)
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
rows = data if isinstance(data, list) else data.get("pool") or data.get("list") or []
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"count": len(rows) if isinstance(rows, list) else 0,
|
||||||
|
"source": "xuangubao_web_api",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _capture(self, operation):
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
value = operation()
|
||||||
|
return value, {
|
||||||
|
"ok": True,
|
||||||
|
"elapsed_ms": round((time.perf_counter() - started) * 1000),
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return None, {
|
||||||
|
"ok": False,
|
||||||
|
"elapsed_ms": round((time.perf_counter() - started) * 1000),
|
||||||
|
"error": str(exc)[:500],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_json(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
params: dict[str, str],
|
||||||
|
referer: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request_url = f"{url}?{urllib.parse.urlencode(params)}"
|
||||||
|
last_error: Exception | None = None
|
||||||
|
attempts = max(1, int(self.retry_attempts))
|
||||||
|
for attempt in range(attempts):
|
||||||
|
request = urllib.request.Request(
|
||||||
|
request_url,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json,text/plain,*/*",
|
||||||
|
"Connection": "close",
|
||||||
|
"Referer": referer,
|
||||||
|
"User-Agent": BROWSER_USER_AGENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
raw = response.read().decode("utf-8", errors="replace")
|
||||||
|
if "json" not in content_type.lower() and not raw.lstrip().startswith(("{", "[")):
|
||||||
|
raise RealtimeAggregateError(
|
||||||
|
f"non-JSON response: {raw[:120].strip()}"
|
||||||
|
)
|
||||||
|
payload = json.loads(raw)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RealtimeAggregateError("unexpected response shape")
|
||||||
|
if payload.get("rc") not in (None, 0):
|
||||||
|
raise RealtimeAggregateError(f"provider rc={payload.get('rc')}")
|
||||||
|
with self._response_cache_lock:
|
||||||
|
self._response_cache[request_url] = {
|
||||||
|
"created_at": time.time(),
|
||||||
|
"payload": copy.deepcopy(payload),
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
except (
|
||||||
|
urllib.error.URLError,
|
||||||
|
TimeoutError,
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
http.client.HTTPException,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
RealtimeAggregateError,
|
||||||
|
) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
|
||||||
|
time.sleep(self.retry_delay_seconds * (attempt + 1))
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
with self._response_cache_lock:
|
||||||
|
cached = self._response_cache.get(request_url)
|
||||||
|
cache_age = now - float((cached or {}).get("created_at") or 0)
|
||||||
|
if cached and cache_age <= self.response_cache_ttl_seconds:
|
||||||
|
payload = copy.deepcopy(cached.get("payload") or {})
|
||||||
|
payload["_aggregate_cache"] = {"age_seconds": round(cache_age, 1)}
|
||||||
|
return payload
|
||||||
|
raise RealtimeAggregateError(f"request failed after {attempts} attempts: {last_error}") from last_error
|
||||||
|
|
||||||
|
def _get_text(
|
||||||
|
self,
|
||||||
|
request_url: str,
|
||||||
|
referer: str,
|
||||||
|
encoding: str = "utf-8",
|
||||||
|
) -> tuple[str, float]:
|
||||||
|
cache_key = f"text:{request_url}"
|
||||||
|
last_error: Exception | None = None
|
||||||
|
attempts = max(1, int(self.retry_attempts))
|
||||||
|
for attempt in range(attempts):
|
||||||
|
request = urllib.request.Request(
|
||||||
|
request_url,
|
||||||
|
headers={
|
||||||
|
"Accept": "text/plain,*/*",
|
||||||
|
"Connection": "close",
|
||||||
|
"Referer": referer,
|
||||||
|
"User-Agent": BROWSER_USER_AGENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
raw = response.read().decode(encoding, errors="replace")
|
||||||
|
if not raw.strip():
|
||||||
|
raise RealtimeAggregateError("empty text response")
|
||||||
|
with self._response_cache_lock:
|
||||||
|
self._response_cache[cache_key] = {
|
||||||
|
"created_at": time.time(),
|
||||||
|
"payload": raw,
|
||||||
|
}
|
||||||
|
return raw, 0
|
||||||
|
except (
|
||||||
|
urllib.error.URLError,
|
||||||
|
TimeoutError,
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
http.client.HTTPException,
|
||||||
|
RealtimeAggregateError,
|
||||||
|
) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
|
||||||
|
time.sleep(self.retry_delay_seconds * (attempt + 1))
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
with self._response_cache_lock:
|
||||||
|
cached = self._response_cache.get(cache_key)
|
||||||
|
cache_age = now - float((cached or {}).get("created_at") or 0)
|
||||||
|
if cached and cache_age <= self.response_cache_ttl_seconds:
|
||||||
|
return str(cached.get("payload") or ""), round(cache_age, 1)
|
||||||
|
raise RealtimeAggregateError(
|
||||||
|
f"text request failed after {attempts} attempts: {last_error}"
|
||||||
|
) from last_error
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sector(value: Any) -> str:
|
||||||
|
text = str(value or "").strip().replace(" ", "")
|
||||||
|
for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"):
|
||||||
|
text = text.replace(suffix, "")
|
||||||
|
aliases = {"元器件": "元件", "电子元器件": "元件"}
|
||||||
|
return aliases.get(text, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_sector(rows: list[dict[str, Any]], target: str) -> dict[str, Any] | None:
|
||||||
|
exact = [row for row in rows if _normalize_sector(row.get("f14")) == target]
|
||||||
|
if exact:
|
||||||
|
return min(exact, key=lambda row: len(str(row.get("f14") or "")))
|
||||||
|
fuzzy = [
|
||||||
|
row for row in rows
|
||||||
|
if target and (
|
||||||
|
target in _normalize_sector(row.get("f14"))
|
||||||
|
or _normalize_sector(row.get("f14")) in target
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return min(fuzzy, key=lambda row: len(_normalize_sector(row.get("f14")))) if fuzzy else None
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .repository import AuctionRepositoryMixin
|
||||||
|
from .service import AuctionServiceMixin
|
||||||
|
|
||||||
|
__all__ = ["AuctionRepositoryMixin", "AuctionServiceMixin"]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AuctionRepositoryMixin:
|
||||||
|
def upsert_auction_factors(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = []
|
||||||
|
for row in rows:
|
||||||
|
trade_date = str(row.get("trade_date") or "")
|
||||||
|
ts_code = str(row.get("ts_code") or "")
|
||||||
|
price = float(row.get("price") or 0)
|
||||||
|
pre_close = float(row.get("pre_close") or 0)
|
||||||
|
if not trade_date or not ts_code or price <= 0 or pre_close <= 0:
|
||||||
|
continue
|
||||||
|
values.append(
|
||||||
|
(
|
||||||
|
trade_date,
|
||||||
|
ts_code,
|
||||||
|
price,
|
||||||
|
pre_close,
|
||||||
|
(price / pre_close - 1) * 100,
|
||||||
|
float(row.get("vol") or 0),
|
||||||
|
float(row.get("amount") or 0),
|
||||||
|
float(row.get("turnover_rate") or 0),
|
||||||
|
float(row.get("volume_ratio") or 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO auction_factors
|
||||||
|
(trade_date, ts_code, price, pre_close, change, vol, amount,
|
||||||
|
turnover_rate, volume_ratio)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
price=excluded.price, pre_close=excluded.pre_close,
|
||||||
|
change=excluded.change, vol=excluded.vol, amount=excluded.amount,
|
||||||
|
turnover_rate=excluded.turnover_rate,
|
||||||
|
volume_ratio=excluded.volume_ratio
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def auction_factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
|
||||||
|
where = "WHERE trade_date <= ?" if end_date else ""
|
||||||
|
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"SELECT DISTINCT trade_date FROM auction_factors {where} "
|
||||||
|
"ORDER BY trade_date DESC LIMIT ?",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
return [row["trade_date"] for row in reversed(rows)]
|
||||||
|
|
||||||
|
def auction_factors_for_date(self, trade_date: str) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT * FROM auction_factors WHERE trade_date = ? ORDER BY ts_code",
|
||||||
|
(trade_date,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import normalize_date
|
||||||
|
from backend.features.market.insights import MarketInsightsService
|
||||||
|
|
||||||
|
|
||||||
|
class AuctionServiceMixin:
|
||||||
|
def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]:
|
||||||
|
return self._market_insights().auction_center(
|
||||||
|
normalize_date(trade_date), force, self.current_user_id
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .repository import DragonTigerRepositoryMixin
|
||||||
|
from .service import DragonTigerServiceMixin
|
||||||
|
|
||||||
|
__all__ = ["DragonTigerRepositoryMixin", "DragonTigerServiceMixin"]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class DragonTigerRepositoryMixin:
|
||||||
|
def list_seat_aliases(self) -> dict[str, str]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute("SELECT seat_name, alias FROM seat_aliases").fetchall()
|
||||||
|
return {row["seat_name"]: row["alias"] for row in rows}
|
||||||
|
|
||||||
|
def save_seat_alias(self, seat_name: str, alias: str) -> None:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO seat_aliases (seat_name, alias, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(seat_name) DO UPDATE SET
|
||||||
|
alias = excluded.alias,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(seat_name, alias, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert_lhb_institutions(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
grouped: dict[tuple[str, str], dict[str, float | int]] = {}
|
||||||
|
for row in rows:
|
||||||
|
trade_date = str(row.get("trade_date") or "")
|
||||||
|
ts_code = str(row.get("ts_code") or "")
|
||||||
|
seat_name = str(row.get("exalter") or row.get("seat_name") or "")
|
||||||
|
if not trade_date or not ts_code or "机构专用" not in seat_name:
|
||||||
|
continue
|
||||||
|
group = grouped.setdefault(
|
||||||
|
(trade_date, ts_code),
|
||||||
|
{"net": 0.0, "buy": 0.0, "sell": 0.0, "seats": 0},
|
||||||
|
)
|
||||||
|
group["net"] = float(group["net"]) + float(row.get("net_buy") or row.get("net_amount") or 0)
|
||||||
|
group["buy"] = float(group["buy"]) + float(row.get("buy") or row.get("buy_amount") or 0)
|
||||||
|
group["sell"] = float(group["sell"]) + float(row.get("sell") or row.get("sell_amount") or 0)
|
||||||
|
group["seats"] = int(group["seats"]) + 1
|
||||||
|
values = [
|
||||||
|
(trade_date, ts_code, item["net"], item["buy"], item["sell"], item["seats"])
|
||||||
|
for (trade_date, ts_code), item in grouped.items()
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO lhb_institution_daily
|
||||||
|
(trade_date, ts_code, net_buy_amount, buy_amount, sell_amount, seat_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
net_buy_amount=excluded.net_buy_amount,
|
||||||
|
buy_amount=excluded.buy_amount,
|
||||||
|
sell_amount=excluded.sell_amount,
|
||||||
|
seat_count=excluded.seat_count
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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 TushareError
|
||||||
|
|
||||||
|
|
||||||
|
class DragonTigerServiceMixin:
|
||||||
|
def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]:
|
||||||
|
cache_kind = "hot_money_profiles_v1"
|
||||||
|
cache_key = "directory"
|
||||||
|
cached = self.database.get_data_snapshot(cache_kind, cache_key)
|
||||||
|
if cached and not force:
|
||||||
|
cached["meta"] = {**cached.get("meta", {}), "cached": True}
|
||||||
|
return cached
|
||||||
|
if self.configured:
|
||||||
|
try:
|
||||||
|
payload = self._tushare_client().hot_money_profiles()
|
||||||
|
except TushareError:
|
||||||
|
if cached:
|
||||||
|
cached["meta"] = {
|
||||||
|
**cached.get("meta", {}),
|
||||||
|
"cached": True,
|
||||||
|
"stale": True,
|
||||||
|
"notice": "名录暂未完成更新,当前展示最近一次收录结果。",
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"source": "unavailable",
|
||||||
|
"status": "unavailable",
|
||||||
|
"schema_version": 1,
|
||||||
|
"cached": False,
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"notice": "游资名录暂不可用,请稍后重试。",
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"profile_count": 0,
|
||||||
|
"described_count": 0,
|
||||||
|
"organization_count": 0,
|
||||||
|
},
|
||||||
|
"profiles": [],
|
||||||
|
}
|
||||||
|
payload["meta"]["cached"] = False
|
||||||
|
if payload.get("meta", {}).get("status") == "success":
|
||||||
|
self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload)
|
||||||
|
return payload
|
||||||
|
if cached:
|
||||||
|
cached["meta"] = {**cached.get("meta", {}), "cached": True}
|
||||||
|
return cached
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"source": "unavailable",
|
||||||
|
"status": "unavailable",
|
||||||
|
"schema_version": 1,
|
||||||
|
"cached": False,
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"notice": "游资名录暂不可用,请联系管理员检查行情配置。",
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"profile_count": 0,
|
||||||
|
"described_count": 0,
|
||||||
|
"organization_count": 0,
|
||||||
|
},
|
||||||
|
"profiles": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
cache_kind = "hot_money_detail_v3"
|
||||||
|
if not force:
|
||||||
|
cached = self.database.get_data_snapshot(cache_kind, normalized_date)
|
||||||
|
if (
|
||||||
|
cached
|
||||||
|
and cached.get("meta", {}).get("source") == "tushare"
|
||||||
|
and cached.get("meta", {}).get("status") == "success"
|
||||||
|
and int(cached.get("meta", {}).get("schema_version") or 0) == 3
|
||||||
|
):
|
||||||
|
cached["meta"] = {**cached.get("meta", {}), "cached": True}
|
||||||
|
return cached
|
||||||
|
if self.configured:
|
||||||
|
try:
|
||||||
|
payload = self._tushare_client().dragon_tiger(normalized_date)
|
||||||
|
except TushareError as exc:
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
|
||||||
|
"trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
|
||||||
|
"source": "tushare_error",
|
||||||
|
"status": "error",
|
||||||
|
"schema_version": 3,
|
||||||
|
"cached": False,
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"notice": "龙虎榜数据暂不可用,请稍后重试。",
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"trader_count": 0,
|
||||||
|
"identity_count": 0,
|
||||||
|
"operation_count": 0,
|
||||||
|
"active_stock_count": 0,
|
||||||
|
"seat_net_buy_million": 0,
|
||||||
|
"unclassified_count": 0,
|
||||||
|
"directory_count": 0,
|
||||||
|
},
|
||||||
|
"traders": [],
|
||||||
|
"unclassified_seats": [],
|
||||||
|
"rows": [],
|
||||||
|
}
|
||||||
|
payload["meta"]["cached"] = False
|
||||||
|
if payload.get("meta", {}).get("status") == "success":
|
||||||
|
self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
|
||||||
|
"trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
|
||||||
|
"source": "unavailable",
|
||||||
|
"status": "unavailable",
|
||||||
|
"schema_version": 3,
|
||||||
|
"cached": False,
|
||||||
|
"notice": "龙虎榜数据暂不可用,请联系管理员检查行情配置。",
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"trader_count": 0,
|
||||||
|
"identity_count": 0,
|
||||||
|
"operation_count": 0,
|
||||||
|
"active_stock_count": 0,
|
||||||
|
"seat_net_buy_million": 0,
|
||||||
|
"unclassified_count": 0,
|
||||||
|
"directory_count": 0,
|
||||||
|
},
|
||||||
|
"traders": [],
|
||||||
|
"unclassified_seats": [],
|
||||||
|
"rows": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_seat_aliases(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
aliases = self.database.list_seat_aliases()
|
||||||
|
result = dict(payload)
|
||||||
|
rows = payload.get("rows") or []
|
||||||
|
for row in rows:
|
||||||
|
for institution in row.get("institutions") or []:
|
||||||
|
institution["alias"] = aliases.get(institution.get("seat_name", ""), "")
|
||||||
|
traders: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
unclassified: dict[str, dict[str, Any]] = {}
|
||||||
|
seen_operations: set[tuple[Any, ...]] = set()
|
||||||
|
builtin_aliases = {
|
||||||
|
"国泰海通证券股份有限公司南京太平南路证券营业部": "作手新一",
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
for institution in row.get("institutions") or []:
|
||||||
|
seat_name = str(institution.get("seat_name") or "未知席位").strip()
|
||||||
|
saved_alias = str(institution.get("alias") or "").strip()
|
||||||
|
builtin_alias = builtin_aliases.get(seat_name, "")
|
||||||
|
if saved_alias or builtin_alias:
|
||||||
|
identity_name = saved_alias or builtin_alias
|
||||||
|
identity_type = "trader"
|
||||||
|
recognized = True
|
||||||
|
identity_source = "manual" if saved_alias else "builtin"
|
||||||
|
elif "机构专用" in seat_name:
|
||||||
|
identity_name = "机构专用"
|
||||||
|
identity_type = "institution"
|
||||||
|
recognized = True
|
||||||
|
identity_source = "system"
|
||||||
|
elif "沪股通专用" in seat_name or "深股通专用" in seat_name:
|
||||||
|
identity_name = "北向资金"
|
||||||
|
identity_type = "channel"
|
||||||
|
recognized = True
|
||||||
|
identity_source = "system"
|
||||||
|
else:
|
||||||
|
identity_name = seat_name
|
||||||
|
identity_type = "unclassified"
|
||||||
|
recognized = False
|
||||||
|
identity_source = "raw"
|
||||||
|
|
||||||
|
buy = round(float(institution.get("buy_million") or 0), 2)
|
||||||
|
sell = round(float(institution.get("sell_million") or 0), 2)
|
||||||
|
net_buy = round(float(institution.get("net_buy_million") or 0), 2)
|
||||||
|
operation_key = (row.get("code"), seat_name, buy, sell, net_buy)
|
||||||
|
if operation_key in seen_operations:
|
||||||
|
continue
|
||||||
|
seen_operations.add(operation_key)
|
||||||
|
|
||||||
|
group_key = (identity_type, identity_name)
|
||||||
|
group = traders.setdefault(
|
||||||
|
group_key,
|
||||||
|
{
|
||||||
|
"name": identity_name,
|
||||||
|
"identity_type": identity_type,
|
||||||
|
"identity_source": identity_source,
|
||||||
|
"recognized": recognized,
|
||||||
|
"buy_million": 0.0,
|
||||||
|
"sell_million": 0.0,
|
||||||
|
"net_buy_million": 0.0,
|
||||||
|
"seat_names": set(),
|
||||||
|
"stock_codes": set(),
|
||||||
|
"operations": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
group["buy_million"] += buy
|
||||||
|
group["sell_million"] += sell
|
||||||
|
group["net_buy_million"] += net_buy
|
||||||
|
group["seat_names"].add(seat_name)
|
||||||
|
group["stock_codes"].add(str(row.get("code") or ""))
|
||||||
|
group["operations"].append(
|
||||||
|
{
|
||||||
|
"code": row.get("code") or "",
|
||||||
|
"name": row.get("name") or "--",
|
||||||
|
"change": row.get("change") or 0,
|
||||||
|
"direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平",
|
||||||
|
"buy_million": buy,
|
||||||
|
"sell_million": sell,
|
||||||
|
"net_buy_million": net_buy,
|
||||||
|
"reason": row.get("reason") or "--",
|
||||||
|
"seat_name": seat_name,
|
||||||
|
"seat_alias": identity_name if recognized else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not recognized:
|
||||||
|
pending = unclassified.setdefault(
|
||||||
|
seat_name,
|
||||||
|
{
|
||||||
|
"seat_name": seat_name,
|
||||||
|
"stock_codes": set(),
|
||||||
|
"operation_count": 0,
|
||||||
|
"buy_million": 0.0,
|
||||||
|
"sell_million": 0.0,
|
||||||
|
"net_buy_million": 0.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pending["stock_codes"].add(str(row.get("code") or ""))
|
||||||
|
pending["operation_count"] += 1
|
||||||
|
pending["buy_million"] += buy
|
||||||
|
pending["sell_million"] += sell
|
||||||
|
pending["net_buy_million"] += net_buy
|
||||||
|
|
||||||
|
type_order = {"trader": 0, "institution": 1, "channel": 2, "unclassified": 3}
|
||||||
|
aggregated = list(traders.values())
|
||||||
|
aggregated.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
type_order.get(item["identity_type"], 9),
|
||||||
|
-abs(item["net_buy_million"]),
|
||||||
|
item["name"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for index, group in enumerate(aggregated, start=1):
|
||||||
|
group["id"] = f"identity-{index}"
|
||||||
|
group["buy_million"] = round(group["buy_million"], 2)
|
||||||
|
group["sell_million"] = round(group["sell_million"], 2)
|
||||||
|
group["net_buy_million"] = round(group["net_buy_million"], 2)
|
||||||
|
group["seat_count"] = len(group.pop("seat_names"))
|
||||||
|
group["stock_count"] = len(group.pop("stock_codes"))
|
||||||
|
group["operation_count"] = len(group["operations"])
|
||||||
|
group["operations"].sort(
|
||||||
|
key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_seats = list(unclassified.values())
|
||||||
|
for pending in pending_seats:
|
||||||
|
pending["stock_count"] = len(pending.pop("stock_codes"))
|
||||||
|
pending["buy_million"] = round(pending["buy_million"], 2)
|
||||||
|
pending["sell_million"] = round(pending["sell_million"], 2)
|
||||||
|
pending["net_buy_million"] = round(pending["net_buy_million"], 2)
|
||||||
|
pending_seats.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True)
|
||||||
|
|
||||||
|
operation_count = sum(item["operation_count"] for item in aggregated)
|
||||||
|
active_stocks = {
|
||||||
|
operation["code"] for item in aggregated for operation in item["operations"]
|
||||||
|
}
|
||||||
|
seat_net_buy = round(sum(item["net_buy_million"] for item in aggregated), 2)
|
||||||
|
result["rows"] = rows
|
||||||
|
result["traders"] = aggregated
|
||||||
|
result["unclassified_seats"] = pending_seats
|
||||||
|
result["summary"] = {
|
||||||
|
**(payload.get("summary") or {}),
|
||||||
|
"trader_count": sum(item["identity_type"] == "trader" for item in aggregated),
|
||||||
|
"identity_count": len(aggregated),
|
||||||
|
"operation_count": operation_count,
|
||||||
|
"active_stock_count": len(active_stocks),
|
||||||
|
"seat_net_buy_million": seat_net_buy,
|
||||||
|
"unclassified_count": len(pending_seats),
|
||||||
|
}
|
||||||
|
return result
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Public market data, search, detail and chart feature."""
|
||||||
|
|
||||||
|
from .charts import ChartDataError, EastmoneyChartClient, MarketChartClient
|
||||||
|
from .repository import MarketRepositoryMixin
|
||||||
|
from .service import MarketServiceMixin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChartDataError",
|
||||||
|
"EastmoneyChartClient",
|
||||||
|
"MarketChartClient",
|
||||||
|
"MarketRepositoryMixin",
|
||||||
|
"MarketServiceMixin",
|
||||||
|
]
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, time as dt_time, timedelta
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from backend.data.providers.ifind_client import IfindError, IfindHttpClient
|
||||||
|
|
||||||
|
|
||||||
|
class ChartDataError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
|
||||||
|
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
|
||||||
|
BROWSER_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/138.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
INDEX_SECIDS = {
|
||||||
|
"000001.SH": "1.000001",
|
||||||
|
"399001.SZ": "0.399001",
|
||||||
|
"399006.SZ": "0.399006",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MarketChartClient:
|
||||||
|
"""Prefer iFinD for display charts and retain Eastmoney as a last resort."""
|
||||||
|
|
||||||
|
def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None:
|
||||||
|
self.ifind = ifind
|
||||||
|
self.fallback = fallback
|
||||||
|
|
||||||
|
def stock_intraday(self, code: str) -> dict[str, Any]:
|
||||||
|
normalized = str(code or "").strip()
|
||||||
|
if not re.fullmatch(r"\d{6}", normalized):
|
||||||
|
raise ChartDataError("Invalid stock code")
|
||||||
|
ifind_code = _stock_market_code(normalized)
|
||||||
|
try:
|
||||||
|
return self._ifind_intraday(ifind_code, "stock", normalized)
|
||||||
|
except (IfindError, ChartDataError):
|
||||||
|
return self.fallback.stock_intraday(normalized)
|
||||||
|
|
||||||
|
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
||||||
|
normalized = str(code or "").strip()
|
||||||
|
if not re.fullmatch(r"\d{6}", normalized):
|
||||||
|
raise ChartDataError("Invalid stock code")
|
||||||
|
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
|
||||||
|
|
||||||
|
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
||||||
|
normalized = str(identifier or "").strip().upper()
|
||||||
|
if normalized not in INDEX_SECIDS:
|
||||||
|
raise ChartDataError("Unsupported index")
|
||||||
|
return self._ifind_daily(normalized, end_date, limit)
|
||||||
|
|
||||||
|
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
||||||
|
normalized = str(identifier or "").strip().upper()
|
||||||
|
if not normalized:
|
||||||
|
raise ChartDataError("Invalid board code")
|
||||||
|
return self._ifind_daily(normalized, end_date, limit)
|
||||||
|
|
||||||
|
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
||||||
|
normalized = str(identifier or "").strip().upper()
|
||||||
|
if normalized not in INDEX_SECIDS:
|
||||||
|
raise ChartDataError("Unsupported index")
|
||||||
|
try:
|
||||||
|
return self._ifind_intraday(normalized, "index", normalized)
|
||||||
|
except (IfindError, ChartDataError):
|
||||||
|
return self.fallback.index_intraday(normalized)
|
||||||
|
|
||||||
|
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
||||||
|
normalized = str(identifier or "").strip().upper()
|
||||||
|
try:
|
||||||
|
return self._ifind_intraday(normalized, "board", normalized, name)
|
||||||
|
except (IfindError, ChartDataError):
|
||||||
|
return self.fallback.board_intraday(normalized, name)
|
||||||
|
|
||||||
|
def _ifind_intraday(
|
||||||
|
self,
|
||||||
|
ifind_code: str,
|
||||||
|
entity_type: str,
|
||||||
|
identifier: str,
|
||||||
|
name: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not self.ifind.configured:
|
||||||
|
raise ChartDataError("iFinD is not configured")
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for offset in range(0, 8):
|
||||||
|
candidate = now.date() - timedelta(days=offset)
|
||||||
|
if candidate.weekday() >= 5:
|
||||||
|
continue
|
||||||
|
display_date = candidate.isoformat()
|
||||||
|
rows = self.ifind.intraday(
|
||||||
|
ifind_code,
|
||||||
|
f"{display_date} 09:30:00",
|
||||||
|
f"{display_date} 15:00:00",
|
||||||
|
cache_ttl=20 if offset == 0 else 6 * 60 * 60,
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
break
|
||||||
|
points = [point for row in rows if (point := _ifind_point(row))]
|
||||||
|
if not points:
|
||||||
|
raise ChartDataError("No iFinD intraday chart data returned")
|
||||||
|
latest_date = points[-1]["date"]
|
||||||
|
points = [point for point in points if point["date"] == latest_date]
|
||||||
|
previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"])
|
||||||
|
return {
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"identifier": identifier,
|
||||||
|
"name": name,
|
||||||
|
"code": identifier,
|
||||||
|
"trade_date": latest_date,
|
||||||
|
"previous_close": previous_close,
|
||||||
|
"points": points,
|
||||||
|
"source": "ifind",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ifind_daily(
|
||||||
|
self, ifind_code: str, end_date: str, limit: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not self.ifind.configured:
|
||||||
|
raise ChartDataError("iFinD is not configured")
|
||||||
|
compact_end = str(end_date or "").replace("-", "")
|
||||||
|
if not re.fullmatch(r"\d{8}", compact_end):
|
||||||
|
raise ChartDataError("Invalid chart end date")
|
||||||
|
end = datetime.strptime(compact_end, "%Y%m%d")
|
||||||
|
start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d")
|
||||||
|
try:
|
||||||
|
rows = self.ifind.history(
|
||||||
|
ifind_code,
|
||||||
|
["open", "high", "low", "close", "volume", "amount"],
|
||||||
|
start,
|
||||||
|
compact_end,
|
||||||
|
cache_ttl=300,
|
||||||
|
)
|
||||||
|
except IfindError as exc:
|
||||||
|
raise ChartDataError("No iFinD daily chart data returned") from exc
|
||||||
|
normalized = []
|
||||||
|
for row in rows:
|
||||||
|
stamp = str(row.get("time") or "").strip()
|
||||||
|
trade_date = stamp[:10]
|
||||||
|
close = _number(row.get("close"))
|
||||||
|
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0:
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"open": _number(row.get("open")),
|
||||||
|
"high": _number(row.get("high")),
|
||||||
|
"low": _number(row.get("low")),
|
||||||
|
"close": close,
|
||||||
|
"volume": _number(row.get("volume")),
|
||||||
|
"amount_billion": _number(row.get("amount")) / 100_000_000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
normalized.sort(key=lambda row: row["trade_date"])
|
||||||
|
for index, row in enumerate(normalized):
|
||||||
|
previous = normalized[index - 1]["close"] if index > 0 else 0
|
||||||
|
row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0
|
||||||
|
|
||||||
|
market_now = datetime.now().astimezone()
|
||||||
|
today = market_now.strftime("%Y%m%d")
|
||||||
|
market_open = (
|
||||||
|
market_now.weekday() < 5
|
||||||
|
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
||||||
|
)
|
||||||
|
today_display = market_now.date().isoformat()
|
||||||
|
if normalized and normalized[-1]["trade_date"] == today_display:
|
||||||
|
current_bar = normalized[-1]
|
||||||
|
current_bar_is_valid = (
|
||||||
|
current_bar["open"] > 0
|
||||||
|
and current_bar["high"] >= max(current_bar["open"], current_bar["close"])
|
||||||
|
and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"])
|
||||||
|
and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0)
|
||||||
|
)
|
||||||
|
if not market_open or not current_bar_is_valid:
|
||||||
|
normalized.pop()
|
||||||
|
if compact_end == today and market_open:
|
||||||
|
try:
|
||||||
|
quote_rows = self.ifind.real_time(
|
||||||
|
ifind_code,
|
||||||
|
["open", "high", "low", "latest", "preClose", "volume", "amount"],
|
||||||
|
cache_ttl=10,
|
||||||
|
)
|
||||||
|
quote = quote_rows[0] if quote_rows else {}
|
||||||
|
latest = _number(quote.get("latest"))
|
||||||
|
previous = _number(quote.get("preClose"))
|
||||||
|
open_price = _number(quote.get("open"))
|
||||||
|
high = _number(quote.get("high"))
|
||||||
|
low = _number(quote.get("low"))
|
||||||
|
volume = _number(quote.get("volume"))
|
||||||
|
amount = _number(quote.get("amount"))
|
||||||
|
quote_date = str(quote.get("time") or "")[:10].replace("-", "")
|
||||||
|
quote_is_current = not quote_date or quote_date == today
|
||||||
|
has_market_activity = volume > 0 or amount > 0
|
||||||
|
if (
|
||||||
|
latest > 0
|
||||||
|
and open_price > 0
|
||||||
|
and high >= max(open_price, latest)
|
||||||
|
and 0 < low <= min(open_price, latest)
|
||||||
|
and has_market_activity
|
||||||
|
and quote_is_current
|
||||||
|
):
|
||||||
|
realtime = {
|
||||||
|
"trade_date": end.strftime("%Y-%m-%d"),
|
||||||
|
"open": open_price,
|
||||||
|
"high": high,
|
||||||
|
"low": low,
|
||||||
|
"close": latest,
|
||||||
|
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
|
||||||
|
"volume": volume,
|
||||||
|
"amount_billion": amount / 100_000_000,
|
||||||
|
"realtime": True,
|
||||||
|
}
|
||||||
|
if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
|
||||||
|
normalized[-1] = realtime
|
||||||
|
else:
|
||||||
|
normalized.append(realtime)
|
||||||
|
except IfindError:
|
||||||
|
pass
|
||||||
|
if not normalized:
|
||||||
|
raise ChartDataError("No iFinD daily chart data returned")
|
||||||
|
return normalized[-max(20, min(180, int(limit))):]
|
||||||
|
|
||||||
|
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
|
||||||
|
today = datetime.now().astimezone().date().isoformat()
|
||||||
|
if trade_date == today:
|
||||||
|
try:
|
||||||
|
quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20)
|
||||||
|
value = _number((quote[0] if quote else {}).get("preClose"))
|
||||||
|
if value > 0:
|
||||||
|
return value
|
||||||
|
except IfindError:
|
||||||
|
pass
|
||||||
|
end = datetime.strptime(trade_date, "%Y-%m-%d")
|
||||||
|
try:
|
||||||
|
rows = self.ifind.history(
|
||||||
|
code,
|
||||||
|
["close"],
|
||||||
|
(end - timedelta(days=12)).strftime("%Y%m%d"),
|
||||||
|
end.strftime("%Y%m%d"),
|
||||||
|
cache_ttl=6 * 60 * 60,
|
||||||
|
)
|
||||||
|
closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0]
|
||||||
|
if len(closes) >= 2:
|
||||||
|
return closes[-2]
|
||||||
|
except IfindError:
|
||||||
|
pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EastmoneyChartClient:
|
||||||
|
"""Isolated display-only minute chart source.
|
||||||
|
|
||||||
|
The returned data must not be used by market snapshots, scoring, screening,
|
||||||
|
or divination. Its only consumer is a chart-rendering endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
timeout: int = 6
|
||||||
|
cache_ttl_seconds: int = 20
|
||||||
|
retry_attempts: int = 2
|
||||||
|
_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_cache_lock: ClassVar[Lock] = Lock()
|
||||||
|
_board_catalog: ClassVar[dict[str, dict[str, str]]] = {}
|
||||||
|
_board_catalog_at: ClassVar[float] = 0.0
|
||||||
|
_board_catalog_lock: ClassVar[Lock] = Lock()
|
||||||
|
|
||||||
|
def stock_intraday(self, code: str) -> dict[str, Any]:
|
||||||
|
normalized = str(code or "").strip()
|
||||||
|
if not re.fullmatch(r"\d{6}", normalized):
|
||||||
|
raise ChartDataError("Invalid stock code")
|
||||||
|
market = "1" if normalized.startswith(("5", "6", "9")) else "0"
|
||||||
|
return self._intraday(f"{market}.{normalized}", "stock", normalized)
|
||||||
|
|
||||||
|
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
||||||
|
normalized = str(identifier or "").strip().upper()
|
||||||
|
secid = INDEX_SECIDS.get(normalized)
|
||||||
|
if not secid:
|
||||||
|
raise ChartDataError("Unsupported index")
|
||||||
|
return self._intraday(secid, "index", normalized)
|
||||||
|
|
||||||
|
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
||||||
|
normalized = str(identifier or "").strip().upper()
|
||||||
|
if re.fullmatch(r"BK\d{4}", normalized):
|
||||||
|
board_code = normalized
|
||||||
|
else:
|
||||||
|
board_code = self._resolve_board_code(name or identifier)
|
||||||
|
return self._intraday(f"90.{board_code}", "board", board_code)
|
||||||
|
|
||||||
|
def _intraday(self, secid: str, entity_type: str, identifier: str) -> dict[str, Any]:
|
||||||
|
cache_key = f"{entity_type}:{identifier}"
|
||||||
|
cached = self._get_cached(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
payload = self._request_json(
|
||||||
|
TRENDS_URL,
|
||||||
|
{
|
||||||
|
"secid": secid,
|
||||||
|
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
|
||||||
|
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
|
||||||
|
"iscr": "0",
|
||||||
|
"ndays": "1",
|
||||||
|
},
|
||||||
|
"https://quote.eastmoney.com/",
|
||||||
|
)
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
|
||||||
|
if not points:
|
||||||
|
raise ChartDataError("No intraday chart data returned")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"identifier": identifier,
|
||||||
|
"name": str(data.get("name") or ""),
|
||||||
|
"code": str(data.get("code") or identifier),
|
||||||
|
"trade_date": points[-1]["date"],
|
||||||
|
"previous_close": _number(data.get("preClose")),
|
||||||
|
"points": points,
|
||||||
|
}
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache[cache_key] = {"created_at": time.time(), "payload": result}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _get_cached(self, cache_key: str) -> dict[str, Any] | None:
|
||||||
|
with self._cache_lock:
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
if not cached:
|
||||||
|
return None
|
||||||
|
if time.time() - float(cached.get("created_at") or 0) > self.cache_ttl_seconds:
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache.pop(cache_key, None)
|
||||||
|
return None
|
||||||
|
return dict(cached["payload"])
|
||||||
|
|
||||||
|
def _resolve_board_code(self, name: str) -> str:
|
||||||
|
normalized = _normalize_name(name)
|
||||||
|
if not normalized:
|
||||||
|
raise ChartDataError("Board name is required")
|
||||||
|
catalog = self._load_board_catalog()
|
||||||
|
item = catalog.get(normalized)
|
||||||
|
if not item:
|
||||||
|
raise ChartDataError("No matching chart board")
|
||||||
|
return item["code"]
|
||||||
|
|
||||||
|
def _load_board_catalog(self) -> dict[str, dict[str, str]]:
|
||||||
|
now = time.time()
|
||||||
|
with self._board_catalog_lock:
|
||||||
|
if self._board_catalog and now - self._board_catalog_at < 6 * 60 * 60:
|
||||||
|
return dict(self._board_catalog)
|
||||||
|
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for board_type in ("1", "2", "3"):
|
||||||
|
for page in range(1, 6):
|
||||||
|
payload = self._request_json(
|
||||||
|
BOARD_LIST_URL,
|
||||||
|
{
|
||||||
|
"pn": str(page),
|
||||||
|
"pz": "100",
|
||||||
|
"po": "1",
|
||||||
|
"np": "1",
|
||||||
|
"fltt": "2",
|
||||||
|
"invt": "2",
|
||||||
|
"fid": "f3",
|
||||||
|
"fs": f"m:90+t:{board_type}",
|
||||||
|
"fields": "f12,f14",
|
||||||
|
},
|
||||||
|
"https://quote.eastmoney.com/center/boardlist.html",
|
||||||
|
)
|
||||||
|
page_rows = (payload.get("data") or {}).get("diff") or []
|
||||||
|
rows.extend(page_rows)
|
||||||
|
if len(page_rows) < 100:
|
||||||
|
break
|
||||||
|
|
||||||
|
catalog: dict[str, dict[str, str]] = {}
|
||||||
|
for row in rows:
|
||||||
|
code = str(row.get("f12") or "").strip().upper()
|
||||||
|
board_name = str(row.get("f14") or "").strip()
|
||||||
|
if re.fullmatch(r"BK\d{4}", code) and board_name:
|
||||||
|
catalog.setdefault(_normalize_name(board_name), {"code": code, "name": board_name})
|
||||||
|
if not catalog:
|
||||||
|
raise ChartDataError("Board chart directory is unavailable")
|
||||||
|
with self._board_catalog_lock:
|
||||||
|
type(self)._board_catalog = catalog
|
||||||
|
type(self)._board_catalog_at = now
|
||||||
|
return dict(catalog)
|
||||||
|
|
||||||
|
def _request_json(
|
||||||
|
self, url: str, params: dict[str, str], referer: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request_url = f"{url}?{urllib.parse.urlencode(params)}"
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(max(1, int(self.retry_attempts))):
|
||||||
|
request = urllib.request.Request(
|
||||||
|
request_url,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json,text/plain,*/*",
|
||||||
|
"Connection": "close",
|
||||||
|
"Referer": referer,
|
||||||
|
"User-Agent": BROWSER_USER_AGENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ChartDataError("Invalid intraday chart response")
|
||||||
|
return payload
|
||||||
|
except (
|
||||||
|
urllib.error.URLError,
|
||||||
|
TimeoutError,
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
http.client.HTTPException,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
ChartDataError,
|
||||||
|
) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt + 1 < self.retry_attempts:
|
||||||
|
time.sleep(0.12)
|
||||||
|
raise ChartDataError("Intraday chart request failed") from last_error
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_trend(raw: Any) -> dict[str, Any] | None:
|
||||||
|
fields = str(raw or "").split(",")
|
||||||
|
if len(fields) < 8 or " " not in fields[0]:
|
||||||
|
return None
|
||||||
|
stamp = fields[0].strip()
|
||||||
|
trade_date, trade_time = stamp.split(" ", 1)
|
||||||
|
close = _number(fields[2])
|
||||||
|
if close <= 0:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"date": trade_date,
|
||||||
|
"time": trade_time[:5],
|
||||||
|
"open": _number(fields[1]),
|
||||||
|
"close": close,
|
||||||
|
"high": _number(fields[3]),
|
||||||
|
"low": _number(fields[4]),
|
||||||
|
"volume": _number(fields[5]),
|
||||||
|
"amount": _number(fields[6]),
|
||||||
|
"average": _number(fields[7]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
stamp = str(row.get("time") or "").strip()
|
||||||
|
if " " not in stamp:
|
||||||
|
return None
|
||||||
|
trade_date, trade_time = stamp.split(" ", 1)
|
||||||
|
close = _number(row.get("close"))
|
||||||
|
if close <= 0:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"date": trade_date,
|
||||||
|
"time": trade_time[:5],
|
||||||
|
"open": _number(row.get("open")),
|
||||||
|
"close": close,
|
||||||
|
"high": _number(row.get("high")),
|
||||||
|
"low": _number(row.get("low")),
|
||||||
|
"volume": _number(row.get("volume")),
|
||||||
|
"amount": _number(row.get("amount")),
|
||||||
|
"average": _number(row.get("avgPrice")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_market_code(code: str) -> str:
|
||||||
|
if code.startswith(("4", "8", "9")):
|
||||||
|
suffix = "BJ"
|
||||||
|
elif code.startswith("6"):
|
||||||
|
suffix = "SH"
|
||||||
|
else:
|
||||||
|
suffix = "SZ"
|
||||||
|
return f"{code}.{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
return float(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_name(value: Any) -> str:
|
||||||
|
normalized = re.sub(r"[\s·・()()\-_/]", "", str(value or "")).casefold()
|
||||||
|
return re.sub(r"(?:概念|行业|[ⅠⅡⅢ])$", "", normalized)
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class MarketRepositoryMixin:
|
||||||
|
def upsert_stock_master(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
row.get("ts_code", ""),
|
||||||
|
str(row.get("ts_code", "")).split(".")[0],
|
||||||
|
row.get("name") or "--",
|
||||||
|
row.get("industry") or "",
|
||||||
|
row.get("market") or "",
|
||||||
|
str(row.get("list_date") or ""),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
for row in rows if row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO stock_master
|
||||||
|
(ts_code, code, name, industry, market, list_date, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ts_code) DO UPDATE SET
|
||||||
|
code=excluded.code, name=excluded.name, industry=excluded.industry,
|
||||||
|
market=excluded.market, list_date=excluded.list_date, updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def list_stock_master(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT ts_code, code, name, industry, market, list_date FROM stock_master"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def upsert_daily_bars(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("trade_date") or ""), row.get("ts_code", ""),
|
||||||
|
float(row.get("open") or 0), float(row.get("high") or 0),
|
||||||
|
float(row.get("low") or 0), float(row.get("close") or 0),
|
||||||
|
float(row.get("pct_chg") or 0), float(row.get("vol") or 0),
|
||||||
|
float(row.get("amount") or 0),
|
||||||
|
)
|
||||||
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_bars
|
||||||
|
(trade_date, ts_code, open, high, low, close, pct_chg, vol, amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
open=excluded.open, high=excluded.high, low=excluded.low,
|
||||||
|
close=excluded.close, pct_chg=excluded.pct_chg,
|
||||||
|
vol=excluded.vol, amount=excluded.amount
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT * FROM daily_bars WHERE trade_date = ? ORDER BY ts_code",
|
||||||
|
(trade_date,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
def get_snapshot(self, trade_date: str) -> dict[str, Any] | None:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload FROM dashboard_snapshots WHERE trade_date = ?",
|
||||||
|
(trade_date,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(row["payload"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_latest_real_snapshot(
|
||||||
|
self, trade_date: str, strictly_before: bool = False
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
operator = "<" if strictly_before else "<="
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT payload FROM dashboard_snapshots
|
||||||
|
WHERE trade_date {operator} ? AND source != 'demo'
|
||||||
|
ORDER BY trade_date DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(trade_date,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(row["payload"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_snapshot(self, trade_date: str, source: str, payload: dict[str, Any]) -> None:
|
||||||
|
updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
record_count = sum(
|
||||||
|
len(payload.get(key) or [])
|
||||||
|
for key in ("limits", "broken", "down_limits", "yesterday_limits")
|
||||||
|
)
|
||||||
|
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO dashboard_snapshots
|
||||||
|
(trade_date, source, payload, record_count, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date) DO UPDATE SET
|
||||||
|
source = excluded.source,
|
||||||
|
payload = excluded.payload,
|
||||||
|
record_count = excluded.record_count,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(trade_date, source, content, record_count, updated_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_data_snapshot(self, kind: str, cache_key: str) -> dict[str, Any] | None:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload FROM data_snapshots WHERE kind = ? AND cache_key = ?",
|
||||||
|
(kind, cache_key),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(row["payload"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_latest_data_snapshot(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
cache_key_prefix: str,
|
||||||
|
maximum_cache_key: str,
|
||||||
|
exclude_source: str = "",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
source_clause = " AND source != ?" if exclude_source else ""
|
||||||
|
parameters: list[Any] = [kind, f"{cache_key_prefix}%", maximum_cache_key]
|
||||||
|
if exclude_source:
|
||||||
|
parameters.append(exclude_source)
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT payload FROM data_snapshots
|
||||||
|
WHERE kind = ? AND cache_key LIKE ? AND cache_key <= ?{source_clause}
|
||||||
|
ORDER BY cache_key DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(row["payload"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_data_snapshot(
|
||||||
|
self, kind: str, cache_key: str, source: str, payload: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO data_snapshots (kind, cache_key, source, payload, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(kind, cache_key) DO UPDATE SET
|
||||||
|
source = excluded.source,
|
||||||
|
payload = excluded.payload,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(kind, cache_key, source, content, updated_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_stock_master(self, query: str, limit: int = 12) -> list[dict[str, Any]]:
|
||||||
|
text = str(query or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT ts_code, code, name, industry, market, list_date
|
||||||
|
FROM stock_master
|
||||||
|
WHERE code = ? OR name = ? OR name LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 ELSE 2 END,
|
||||||
|
list_date DESC,
|
||||||
|
code
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(text, text, f"%{escaped}%", text, text, max(1, min(30, int(limit)))),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT trade_date, payload FROM dashboard_snapshots
|
||||||
|
WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
(end_date, limit),
|
||||||
|
).fetchall()
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in reversed(rows):
|
||||||
|
try:
|
||||||
|
payload = json.loads(row["payload"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
payload["_snapshot_date"] = row["trade_date"]
|
||||||
|
result.append(payload)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def start_sync(self, trade_date: str, source: str) -> int:
|
||||||
|
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sync_runs (trade_date, source, status, started_at)
|
||||||
|
VALUES (?, ?, 'running', ?)
|
||||||
|
""",
|
||||||
|
(trade_date, source, started_at),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def finish_sync(
|
||||||
|
self,
|
||||||
|
sync_id: int,
|
||||||
|
status: str,
|
||||||
|
record_count: int = 0,
|
||||||
|
message: str = "",
|
||||||
|
source: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
finished_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sync_runs
|
||||||
|
SET status = ?, finished_at = ?, record_count = ?, message = ?,
|
||||||
|
source = COALESCE(?, source)
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(status, finished_at, record_count, message[:1000], source, sync_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
last_sync = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, trade_date, source, status, started_at, finished_at,
|
||||||
|
record_count, message
|
||||||
|
FROM sync_runs ORDER BY id DESC LIMIT 1
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
snapshot_stats = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS dates, COALESCE(SUM(record_count), 0) AS records,
|
||||||
|
MAX(updated_at) AS updated_at
|
||||||
|
FROM dashboard_snapshots
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
watchlist_count = connection.execute("SELECT COUNT(*) FROM watchlist").fetchone()[0]
|
||||||
|
note_count = connection.execute("SELECT COUNT(*) FROM review_notes").fetchone()[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"database": str(self.path.name),
|
||||||
|
"snapshot_dates": int(snapshot_stats["dates"]),
|
||||||
|
"snapshot_records": int(snapshot_stats["records"]),
|
||||||
|
"updated_at": snapshot_stats["updated_at"],
|
||||||
|
"last_sync": dict(last_sync) if last_sync else None,
|
||||||
|
"watchlist_count": int(watchlist_count),
|
||||||
|
"note_count": int(note_count),
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,958 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime, time as dt_time, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import (
|
||||||
|
normalize_date,
|
||||||
|
tushare_code,
|
||||||
|
validate_stock_code,
|
||||||
|
validate_text,
|
||||||
|
)
|
||||||
|
from backend.data.providers.ifind_client import IfindError
|
||||||
|
from backend.data.providers.tushare_client import TushareClient, TushareError
|
||||||
|
from backend.features.market.charts import ChartDataError
|
||||||
|
from backend.features.market.insights import MarketInsightsService
|
||||||
|
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
SEARCH_INDEXES = (
|
||||||
|
{"id": "000001.SH", "code": "000001.SH", "name": "上证指数", "type": "index", "subtitle": "沪市综合指数"},
|
||||||
|
{"id": "399001.SZ", "code": "399001.SZ", "name": "深证成指", "type": "index", "subtitle": "深市成份指数"},
|
||||||
|
{"id": "399006.SZ", "code": "399006.SZ", "name": "创业板指", "type": "index", "subtitle": "创业板核心指数"},
|
||||||
|
)
|
||||||
|
SEARCH_TYPE_LABELS = {
|
||||||
|
"stock": "股票",
|
||||||
|
"sector": "板块",
|
||||||
|
"theme": "题材",
|
||||||
|
"index": "指数",
|
||||||
|
}
|
||||||
|
THS_SEARCH_TYPES = {
|
||||||
|
"I": ("sector", "行业板块"),
|
||||||
|
"R": ("sector", "地域板块"),
|
||||||
|
"N": ("theme", "概念题材"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MarketServiceMixin:
|
||||||
|
def _market_insights(self) -> MarketInsightsService:
|
||||||
|
if not self.configured:
|
||||||
|
raise ValueError("行情数据尚未配置。")
|
||||||
|
return MarketInsightsService(
|
||||||
|
self.database,
|
||||||
|
self._tushare_client(),
|
||||||
|
ifind=self.ifind,
|
||||||
|
)
|
||||||
|
def _tushare_client(self) -> TushareClient:
|
||||||
|
gateway = getattr(self, "data_gateway", None)
|
||||||
|
if gateway is not None:
|
||||||
|
return gateway.tushare()
|
||||||
|
# Compatibility for isolated legacy unit-test service stubs.
|
||||||
|
return TushareClient(self.token)
|
||||||
|
|
||||||
|
def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
if (
|
||||||
|
normalized_date == now.strftime("%Y%m%d")
|
||||||
|
and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time()
|
||||||
|
):
|
||||||
|
previous = self.database.get_latest_real_snapshot(normalized_date, strictly_before=True)
|
||||||
|
if previous:
|
||||||
|
carried = self._carry_dashboard(previous, normalized_date, "盘前沿用最近交易日收盘行情")
|
||||||
|
return self._apply_reason_overrides(self._with_storage(carried, cached=True))
|
||||||
|
if not force:
|
||||||
|
snapshot = self.database.get_snapshot(normalized_date)
|
||||||
|
if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo":
|
||||||
|
snapshot = copy.deepcopy(snapshot)
|
||||||
|
if normalized_date != now.strftime("%Y%m%d"):
|
||||||
|
snapshot.setdefault("meta", {}).update(
|
||||||
|
{"realtime": False, "market_status": "closed"}
|
||||||
|
)
|
||||||
|
if not self._dashboard_sentiment_ready(snapshot):
|
||||||
|
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
|
||||||
|
self.database.save_snapshot(
|
||||||
|
normalized_date,
|
||||||
|
str((snapshot.get("meta") or {}).get("source") or "tushare"),
|
||||||
|
snapshot,
|
||||||
|
)
|
||||||
|
snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
|
||||||
|
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
|
||||||
|
resolved = self.database.get_data_snapshot(
|
||||||
|
"dashboard_request_v1", normalized_date
|
||||||
|
)
|
||||||
|
if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo":
|
||||||
|
resolved = copy.deepcopy(resolved)
|
||||||
|
resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date(
|
||||||
|
normalized_date
|
||||||
|
)
|
||||||
|
return self._apply_reason_overrides(
|
||||||
|
self._with_storage(resolved, cached=True)
|
||||||
|
)
|
||||||
|
if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5:
|
||||||
|
previous = self.database.get_latest_real_snapshot(normalized_date)
|
||||||
|
if previous:
|
||||||
|
carried = self._carry_dashboard(
|
||||||
|
previous,
|
||||||
|
normalized_date,
|
||||||
|
"非交易日沿用最近交易日收盘行情",
|
||||||
|
)
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"dashboard_request_v1", normalized_date, "sqlite", carried
|
||||||
|
)
|
||||||
|
return self._apply_reason_overrides(
|
||||||
|
self._with_storage(carried, cached=True)
|
||||||
|
)
|
||||||
|
return self.sync_dashboard(normalized_date)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
|
||||||
|
overview = dashboard.get("overview") or {}
|
||||||
|
return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all(
|
||||||
|
key in overview
|
||||||
|
for key in (
|
||||||
|
"sentiment_score",
|
||||||
|
"sentiment_label",
|
||||||
|
"sentiment_phase",
|
||||||
|
"sentiment_direction",
|
||||||
|
"sentiment_components",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _display_compact_date(compact: str) -> str:
|
||||||
|
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
|
||||||
|
|
||||||
|
def _carry_dashboard(
|
||||||
|
self, snapshot: dict[str, Any], requested_date: str, reason: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
carried = copy.deepcopy(snapshot)
|
||||||
|
meta = carried.setdefault("meta", {})
|
||||||
|
meta.update(
|
||||||
|
{
|
||||||
|
"requested_date": self._display_compact_date(requested_date),
|
||||||
|
"carried_forward": True,
|
||||||
|
"realtime": False,
|
||||||
|
"market_status": "closed",
|
||||||
|
"notice": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return carried
|
||||||
|
|
||||||
|
def _realtime_snapshot_due(
|
||||||
|
self,
|
||||||
|
normalized_date: str,
|
||||||
|
snapshot: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
if not self.configured or normalized_date != date.today().strftime("%Y%m%d"):
|
||||||
|
return False
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
local_time = now.time().replace(tzinfo=None)
|
||||||
|
realtime_start = datetime.strptime("09:15", "%H:%M").time()
|
||||||
|
morning_end = datetime.strptime("11:35", "%H:%M").time()
|
||||||
|
afternoon_start = datetime.strptime("12:55", "%H:%M").time()
|
||||||
|
realtime_end = datetime.strptime("15:05", "%H:%M").time()
|
||||||
|
in_session = (
|
||||||
|
realtime_start <= local_time < morning_end
|
||||||
|
or afternoon_start <= local_time < realtime_end
|
||||||
|
)
|
||||||
|
if not in_session:
|
||||||
|
return False
|
||||||
|
meta = snapshot.get("meta") or {}
|
||||||
|
snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "")
|
||||||
|
if snapshot_trade_date and snapshot_trade_date != normalized_date:
|
||||||
|
return False
|
||||||
|
if not meta.get("realtime"):
|
||||||
|
return True
|
||||||
|
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)
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
|
||||||
|
return age_seconds >= 8
|
||||||
|
|
||||||
|
def sync_dashboard(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
source = "tushare"
|
||||||
|
with self.sync_lock:
|
||||||
|
sync_id = self.database.start_sync(normalized_date, source)
|
||||||
|
try:
|
||||||
|
if not self.configured:
|
||||||
|
raise TushareError("公共行情尚未配置")
|
||||||
|
dashboard = self._tushare_client().dashboard(normalized_date)
|
||||||
|
|
||||||
|
dashboard["meta"]["source"] = source
|
||||||
|
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
|
||||||
|
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
|
||||||
|
record_count = self._record_count(dashboard)
|
||||||
|
actual_date = normalize_date(
|
||||||
|
str(dashboard.get("meta", {}).get("trade_date") or normalized_date)
|
||||||
|
)
|
||||||
|
self.database.save_snapshot(actual_date, source, dashboard)
|
||||||
|
if actual_date != normalized_date:
|
||||||
|
dashboard.setdefault("meta", {}).update(
|
||||||
|
{
|
||||||
|
"carried_forward": True,
|
||||||
|
"realtime": False,
|
||||||
|
"market_status": "closed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"dashboard_request_v1", normalized_date, source, dashboard
|
||||||
|
)
|
||||||
|
self.database.finish_sync(
|
||||||
|
sync_id,
|
||||||
|
"success",
|
||||||
|
record_count,
|
||||||
|
dashboard.get("meta", {}).get("notice", ""),
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
return self._apply_reason_overrides(self._with_storage(dashboard, cached=False))
|
||||||
|
except TushareError as exc:
|
||||||
|
fallback = self.database.get_latest_real_snapshot(normalized_date)
|
||||||
|
if fallback:
|
||||||
|
carried = self._carry_dashboard(
|
||||||
|
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}"
|
||||||
|
)
|
||||||
|
self.database.finish_sync(
|
||||||
|
sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
|
||||||
|
)
|
||||||
|
return self._apply_reason_overrides(self._with_storage(carried, cached=True))
|
||||||
|
self.database.finish_sync(sync_id, "failed", message=str(exc))
|
||||||
|
raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
self.database.finish_sync(sync_id, "failed", message=str(exc))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def realtime_aggregate_health(self, sector: str = "") -> dict[str, Any]:
|
||||||
|
sector = validate_text(sector, "板块名称", 50)
|
||||||
|
return self.realtime_aggregator.health_snapshot(sector)
|
||||||
|
|
||||||
|
def _search_market_directory(self) -> list[dict[str, Any]]:
|
||||||
|
cached = self.database.get_data_snapshot("search_directory", "ths") or {}
|
||||||
|
cached_items = list(cached.get("items") or [])
|
||||||
|
if cached_items and int(cached.get("schema_version") or 0) >= 2:
|
||||||
|
return cached_items
|
||||||
|
if not self.configured:
|
||||||
|
return cached_items
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = self._tushare_client().query(
|
||||||
|
"ths_index",
|
||||||
|
{},
|
||||||
|
"ts_code,name,count,exchange,list_date,type",
|
||||||
|
)
|
||||||
|
except TushareError:
|
||||||
|
return cached_items
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
mapping = THS_SEARCH_TYPES.get(str(row.get("type") or "").upper())
|
||||||
|
code = str(row.get("ts_code") or "").strip().upper()
|
||||||
|
name = str(row.get("name") or "").strip()
|
||||||
|
if not mapping or not code or not name or str(row.get("exchange") or "").upper() != "A":
|
||||||
|
continue
|
||||||
|
entity_type, subtitle = mapping
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": code,
|
||||||
|
"code": code,
|
||||||
|
"name": name,
|
||||||
|
"type": entity_type,
|
||||||
|
"subtitle": subtitle,
|
||||||
|
"member_count": int(float(row.get("count") or 0)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if items:
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"search_directory", "ths", "tushare", {"schema_version": 2, "items": items}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _search_match_score(item: dict[str, Any], query: str) -> tuple[int, int, str]:
|
||||||
|
name = str(item.get("name") or "").casefold()
|
||||||
|
code = str(item.get("code") or item.get("id") or "").casefold()
|
||||||
|
needle = query.casefold()
|
||||||
|
if code == needle:
|
||||||
|
rank = 0
|
||||||
|
elif name == needle:
|
||||||
|
rank = 1
|
||||||
|
elif code.startswith(needle):
|
||||||
|
rank = 2
|
||||||
|
elif name.startswith(needle):
|
||||||
|
rank = 3
|
||||||
|
else:
|
||||||
|
rank = 4
|
||||||
|
return rank, len(name), code
|
||||||
|
|
||||||
|
def search_entities(self, query: str, trade_date: str) -> dict[str, Any]:
|
||||||
|
needle = str(query or "").strip()
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
groups: dict[str, list[dict[str, Any]]] = {
|
||||||
|
"stocks": [],
|
||||||
|
"sectors": [],
|
||||||
|
"themes": [],
|
||||||
|
"indices": [],
|
||||||
|
}
|
||||||
|
if not needle:
|
||||||
|
return {"query": "", "trade_date": normalized_date, "groups": groups}
|
||||||
|
|
||||||
|
stocks = []
|
||||||
|
for row in self.database.search_stock_master(needle, 12):
|
||||||
|
stocks.append(
|
||||||
|
{
|
||||||
|
"id": str(row.get("code") or ""),
|
||||||
|
"code": str(row.get("code") or ""),
|
||||||
|
"name": str(row.get("name") or "--"),
|
||||||
|
"type": "stock",
|
||||||
|
"type_label": SEARCH_TYPE_LABELS["stock"],
|
||||||
|
"industry": str(row.get("industry") or "其他"),
|
||||||
|
"market": str(row.get("market") or ""),
|
||||||
|
"subtitle": " · ".join(
|
||||||
|
part for part in (str(row.get("industry") or ""), str(row.get("market") or "")) if part
|
||||||
|
) or "A股",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
groups["stocks"] = stocks[:8]
|
||||||
|
|
||||||
|
market_items = list(self._search_market_directory()) + [dict(item) for item in SEARCH_INDEXES]
|
||||||
|
matched = [
|
||||||
|
item for item in market_items
|
||||||
|
if needle.casefold() in str(item.get("name") or "").casefold()
|
||||||
|
or needle.casefold() in str(item.get("code") or "").casefold()
|
||||||
|
]
|
||||||
|
matched.sort(key=lambda item: self._search_match_score(item, needle))
|
||||||
|
group_keys = {"sector": "sectors", "theme": "themes", "index": "indices"}
|
||||||
|
for item in matched:
|
||||||
|
group_key = group_keys.get(str(item.get("type") or ""))
|
||||||
|
if not group_key or len(groups[group_key]) >= 8:
|
||||||
|
continue
|
||||||
|
groups[group_key].append(
|
||||||
|
{
|
||||||
|
**item,
|
||||||
|
"type_label": SEARCH_TYPE_LABELS[str(item["type"])],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"query": needle, "trade_date": normalized_date, "groups": groups}
|
||||||
|
|
||||||
|
def get_search_detail(
|
||||||
|
self, entity_type: str, identifier: str, trade_date: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
entity_type = str(entity_type or "").strip().lower()
|
||||||
|
identifier = str(identifier or "").strip().upper()
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
if entity_type not in {"sector", "theme", "index"}:
|
||||||
|
raise ValueError("搜索详情类型不支持。")
|
||||||
|
if not re.fullmatch(r"[A-Z0-9.]{3,24}", identifier):
|
||||||
|
raise ValueError("搜索详情标识无效。")
|
||||||
|
if not self.configured:
|
||||||
|
raise ValueError("行情数据源尚未配置。")
|
||||||
|
|
||||||
|
if entity_type == "index":
|
||||||
|
index_basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None)
|
||||||
|
if not index_basic:
|
||||||
|
raise ValueError("暂不支持该指数详情。")
|
||||||
|
return self._index_search_detail(index_basic, normalized_date)
|
||||||
|
|
||||||
|
directory = self._search_market_directory()
|
||||||
|
basic = next(
|
||||||
|
(
|
||||||
|
item for item in directory
|
||||||
|
if item.get("id") == identifier and item.get("type") == entity_type
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not basic:
|
||||||
|
raise ValueError("未找到对应的板块或题材。")
|
||||||
|
return self._ths_search_detail(basic, normalized_date)
|
||||||
|
|
||||||
|
def get_intraday_chart(
|
||||||
|
self, entity_type: str, identifier: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
entity_type = str(entity_type or "").strip().lower()
|
||||||
|
identifier = str(identifier or "").strip().upper()
|
||||||
|
if entity_type == "stock":
|
||||||
|
code = validate_stock_code(identifier)
|
||||||
|
chart = self.chart_data.stock_intraday(code)
|
||||||
|
type_label = SEARCH_TYPE_LABELS["stock"]
|
||||||
|
elif entity_type == "index":
|
||||||
|
basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None)
|
||||||
|
if not basic:
|
||||||
|
raise ValueError("暂不支持该指数分时行情。")
|
||||||
|
chart = self.chart_data.index_intraday(identifier)
|
||||||
|
type_label = SEARCH_TYPE_LABELS["index"]
|
||||||
|
elif entity_type in {"sector", "theme"}:
|
||||||
|
basic = next(
|
||||||
|
(
|
||||||
|
item for item in self._search_market_directory()
|
||||||
|
if item.get("id") == identifier and item.get("type") == entity_type
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not basic:
|
||||||
|
raise ValueError("未找到对应的板块或题材。")
|
||||||
|
chart = self.chart_data.board_intraday(identifier, str(basic.get("name") or ""))
|
||||||
|
type_label = SEARCH_TYPE_LABELS[entity_type]
|
||||||
|
else:
|
||||||
|
raise ValueError("分时行情类型不支持。")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": str(chart.get("trade_date") or ""),
|
||||||
|
"previous_close": float(chart.get("previous_close") or 0),
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"id": identifier,
|
||||||
|
"code": str(chart.get("code") or identifier),
|
||||||
|
"name": str(chart.get("name") or ""),
|
||||||
|
"type": entity_type,
|
||||||
|
"type_label": type_label,
|
||||||
|
},
|
||||||
|
"points": list(chart.get("points") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ths_search_detail(
|
||||||
|
self, basic: dict[str, Any], trade_date: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
client = self._tushare_client()
|
||||||
|
resolved_date, _ = client.resolve_trade_context(trade_date)
|
||||||
|
end = datetime.strptime(resolved_date, "%Y%m%d")
|
||||||
|
start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
|
||||||
|
identifier = str(basic["id"])
|
||||||
|
snapshot = client.sector_snapshot(identifier, resolved_date)
|
||||||
|
rows = client.query(
|
||||||
|
"ths_daily",
|
||||||
|
{"ts_code": identifier, "start_date": start_date, "end_date": resolved_date},
|
||||||
|
"ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate,total_mv,float_mv",
|
||||||
|
)
|
||||||
|
rows.sort(key=lambda item: str(item.get("trade_date") or ""))
|
||||||
|
series = [
|
||||||
|
{
|
||||||
|
"trade_date": self._display_compact_date(str(row.get("trade_date") or "")),
|
||||||
|
"open": float(row.get("open") or 0),
|
||||||
|
"high": float(row.get("high") or 0),
|
||||||
|
"low": float(row.get("low") or 0),
|
||||||
|
"close": float(row.get("close") or 0),
|
||||||
|
"change": float(row.get("pct_change") or 0),
|
||||||
|
"volume": float(row.get("vol") or 0),
|
||||||
|
"turnover_rate": float(row.get("turnover_rate") or 0),
|
||||||
|
}
|
||||||
|
for row in rows[-90:]
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
chart_series = self.chart_data.board_daily(identifier, resolved_date, 90)
|
||||||
|
if chart_series:
|
||||||
|
series = chart_series
|
||||||
|
except (AttributeError, ChartDataError):
|
||||||
|
pass
|
||||||
|
latest = series[-1] if series else {}
|
||||||
|
snapshot_is_current = str(snapshot.get("trade_date") or "").replace("-", "") == resolved_date
|
||||||
|
change = float(
|
||||||
|
snapshot.get("change")
|
||||||
|
if snapshot_is_current and snapshot.get("change") is not None
|
||||||
|
else latest.get("change") or 0
|
||||||
|
)
|
||||||
|
if latest.get("realtime"):
|
||||||
|
change = float(latest.get("change") or 0)
|
||||||
|
turnover_rate = float(
|
||||||
|
snapshot.get("turnover_rate")
|
||||||
|
if snapshot_is_current and snapshot.get("turnover_rate") is not None
|
||||||
|
else latest.get("turnover_rate") or 0
|
||||||
|
)
|
||||||
|
metrics = [
|
||||||
|
{"label": "涨跌幅", "value": round(change, 2), "unit": "%", "tone": "change"},
|
||||||
|
{"label": "换手率", "value": round(turnover_rate, 2), "unit": "%"},
|
||||||
|
{"label": "成份数量", "value": int(float(basic.get("member_count") or 0)), "unit": "只"},
|
||||||
|
]
|
||||||
|
up_count = int(float(snapshot.get("up_count") or 0))
|
||||||
|
down_count = int(float(snapshot.get("down_count") or 0))
|
||||||
|
if up_count or down_count:
|
||||||
|
metrics.extend(
|
||||||
|
[
|
||||||
|
{"label": "上涨家数", "value": up_count, "unit": "家"},
|
||||||
|
{"label": "下跌家数", "value": down_count, "unit": "家"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
leader = str(snapshot.get("leader") or "").strip()
|
||||||
|
if leader and leader != "--":
|
||||||
|
metrics.extend(
|
||||||
|
[
|
||||||
|
{"label": "领涨标的", "value": leader, "unit": ""},
|
||||||
|
{"label": "领涨幅", "value": round(float(snapshot.get("leading_pct") or 0), 2), "unit": "%", "tone": "change"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": self._display_compact_date(resolved_date),
|
||||||
|
"realtime": bool(snapshot.get("realtime")),
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"id": identifier,
|
||||||
|
"code": identifier,
|
||||||
|
"name": str(snapshot.get("name") or basic.get("name") or "--"),
|
||||||
|
"type": str(basic.get("type") or "sector"),
|
||||||
|
"type_label": SEARCH_TYPE_LABELS[str(basic.get("type") or "sector")],
|
||||||
|
"subtitle": str(basic.get("subtitle") or ""),
|
||||||
|
"value": float(latest.get("close") or 0),
|
||||||
|
"change": change,
|
||||||
|
},
|
||||||
|
"series": series,
|
||||||
|
"metrics": metrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _index_search_detail(
|
||||||
|
self, basic: dict[str, Any], trade_date: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
client = self._tushare_client()
|
||||||
|
resolved_date, _ = client.resolve_trade_context(trade_date)
|
||||||
|
payload = (
|
||||||
|
client.realtime_market_indices(resolved_date)
|
||||||
|
if client.should_use_realtime(trade_date, resolved_date)
|
||||||
|
else client.market_indices(resolved_date, 90)
|
||||||
|
)
|
||||||
|
current = next(
|
||||||
|
(item for item in payload.get("indices") or [] if item.get("ts_code") == basic["id"]),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not current:
|
||||||
|
raise ValueError("该指数暂无可用行情。")
|
||||||
|
end = datetime.strptime(resolved_date, "%Y%m%d")
|
||||||
|
rows = client.query(
|
||||||
|
"index_daily",
|
||||||
|
{
|
||||||
|
"ts_code": basic["id"],
|
||||||
|
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
|
||||||
|
"end_date": resolved_date,
|
||||||
|
},
|
||||||
|
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
||||||
|
)
|
||||||
|
rows.sort(key=lambda item: str(item.get("trade_date") or ""))
|
||||||
|
series = [
|
||||||
|
{
|
||||||
|
"trade_date": self._display_compact_date(str(row.get("trade_date") or "")),
|
||||||
|
"open": float(row.get("open") or 0),
|
||||||
|
"high": float(row.get("high") or 0),
|
||||||
|
"low": float(row.get("low") or 0),
|
||||||
|
"close": float(row.get("close") or 0),
|
||||||
|
"change": float(row.get("pct_chg") or 0),
|
||||||
|
"volume": float(row.get("vol") or 0),
|
||||||
|
}
|
||||||
|
for row in rows[-90:]
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90)
|
||||||
|
if chart_series:
|
||||||
|
series = chart_series
|
||||||
|
except (AttributeError, ChartDataError):
|
||||||
|
pass
|
||||||
|
latest = series[-1] if series else {}
|
||||||
|
latest_close = float(latest.get("close") or current.get("close") or 0)
|
||||||
|
latest_change = float(latest.get("change") or current.get("pct_chg") or 0)
|
||||||
|
|
||||||
|
def series_return(days: int) -> float:
|
||||||
|
if len(series) <= days:
|
||||||
|
return 0.0
|
||||||
|
previous = float(series[-days - 1].get("close") or 0)
|
||||||
|
return (latest_close / previous - 1) * 100 if previous > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": self._display_compact_date(str(current.get("trade_date") or resolved_date)),
|
||||||
|
"realtime": bool(payload.get("realtime")),
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
**basic,
|
||||||
|
"type_label": SEARCH_TYPE_LABELS["index"],
|
||||||
|
"value": latest_close,
|
||||||
|
"change": latest_change,
|
||||||
|
},
|
||||||
|
"series": series,
|
||||||
|
"metrics": [
|
||||||
|
{"label": "涨跌幅", "value": round(latest_change, 2), "unit": "%", "tone": "change"},
|
||||||
|
{"label": "近5日", "value": round(series_return(5), 2), "unit": "%", "tone": "change"},
|
||||||
|
{"label": "近20日", "value": round(series_return(20), 2), "unit": "%", "tone": "change"},
|
||||||
|
{"label": "成交额", "value": round(float(current.get("amount_billion") or 0), 2), "unit": "亿"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_stock_detail(
|
||||||
|
self, code: str, trade_date: str, force: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
code = validate_stock_code(code)
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
cache_key = f"{code}:{normalized_date}"
|
||||||
|
if not force:
|
||||||
|
cached = self.database.get_data_snapshot("stock_detail", cache_key)
|
||||||
|
if cached and str((cached.get("meta") or {}).get("source") or "") != "demo":
|
||||||
|
if not self._stock_detail_cache_needs_refresh(cached, normalized_date):
|
||||||
|
cached["meta"] = {**cached.get("meta", {}), "cached": True}
|
||||||
|
return self._prepare_stock_detail(cached, code, normalized_date)
|
||||||
|
|
||||||
|
name, sector = self._stock_identity(code, normalized_date)
|
||||||
|
source = "tushare"
|
||||||
|
if self.configured:
|
||||||
|
try:
|
||||||
|
payload = self._tushare_client().stock_detail(
|
||||||
|
tushare_code(code), normalized_date
|
||||||
|
)
|
||||||
|
if not payload.get("prices"):
|
||||||
|
raise TushareError("No price history returned")
|
||||||
|
except TushareError as exc:
|
||||||
|
payload = self.database.get_latest_data_snapshot(
|
||||||
|
"stock_detail", f"{code}:", cache_key, exclude_source="demo"
|
||||||
|
)
|
||||||
|
if not payload:
|
||||||
|
raise ValueError(f"暂无 {code} 的真实行情数据:{exc}") from exc
|
||||||
|
payload = copy.deepcopy(payload)
|
||||||
|
payload["meta"] = {
|
||||||
|
**payload.get("meta", {}),
|
||||||
|
"cached": True,
|
||||||
|
"notice": "最新行情暂不可用,已沿用最近真实收盘数据。",
|
||||||
|
}
|
||||||
|
return self._prepare_stock_detail(payload, code, normalized_date)
|
||||||
|
else:
|
||||||
|
payload = self.database.get_latest_data_snapshot(
|
||||||
|
"stock_detail", f"{code}:", cache_key, exclude_source="demo"
|
||||||
|
)
|
||||||
|
if not payload:
|
||||||
|
raise ValueError(f"暂无 {code} 的真实行情数据,请等待后台完成首次同步。")
|
||||||
|
payload = copy.deepcopy(payload)
|
||||||
|
payload["meta"] = {
|
||||||
|
**payload.get("meta", {}),
|
||||||
|
"cached": True,
|
||||||
|
"notice": "公共行情尚未配置,已沿用最近真实收盘数据。",
|
||||||
|
}
|
||||||
|
return self._prepare_stock_detail(payload, code, normalized_date)
|
||||||
|
payload["meta"]["source"] = source
|
||||||
|
payload["meta"]["cached"] = False
|
||||||
|
self.database.save_data_snapshot("stock_detail", cache_key, source, payload)
|
||||||
|
return self._prepare_stock_detail(payload, code, normalized_date)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stock_detail_bar_date(payload: dict[str, Any]) -> str:
|
||||||
|
prices = list(payload.get("prices") or [])
|
||||||
|
return str((prices[-1] if prices else {}).get("trade_date") or "").replace("-", "")
|
||||||
|
|
||||||
|
def _stock_detail_cache_needs_refresh(
|
||||||
|
self, payload: dict[str, Any], requested_date: str
|
||||||
|
) -> bool:
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
return (
|
||||||
|
requested_date == now.strftime("%Y%m%d")
|
||||||
|
and now.time().replace(tzinfo=None) >= dt_time(15, 0)
|
||||||
|
and self._stock_detail_bar_date(payload) < requested_date
|
||||||
|
)
|
||||||
|
|
||||||
|
def _prepare_stock_detail(
|
||||||
|
self, payload: dict[str, Any], code: str, requested_date: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = copy.deepcopy(payload)
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
try:
|
||||||
|
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
|
||||||
|
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
|
||||||
|
except (AttributeError, ChartDataError):
|
||||||
|
pass
|
||||||
|
result = self._sanitize_stock_detail_prices(result, now)
|
||||||
|
actual_date = self._stock_detail_bar_date(result)
|
||||||
|
if actual_date:
|
||||||
|
result["meta"] = {
|
||||||
|
**(result.get("meta") or {}),
|
||||||
|
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
|
||||||
|
}
|
||||||
|
today = now.strftime("%Y%m%d")
|
||||||
|
should_merge = (
|
||||||
|
requested_date == today
|
||||||
|
and actual_date <= today
|
||||||
|
and now.weekday() < 5
|
||||||
|
and now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
||||||
|
)
|
||||||
|
if should_merge:
|
||||||
|
quote = self._ifind_realtime_stock_quote(code)
|
||||||
|
if quote and self._valid_realtime_stock_quote(quote, today):
|
||||||
|
self._merge_realtime_stock_detail(result, quote, requested_date)
|
||||||
|
elif self.configured and actual_date < today:
|
||||||
|
client = self._tushare_client()
|
||||||
|
try:
|
||||||
|
resolved_date, _ = client.resolve_trade_context(requested_date)
|
||||||
|
if resolved_date == today:
|
||||||
|
quote = client.realtime_stock_quote(tushare_code(code), requested_date)
|
||||||
|
if self._valid_realtime_stock_quote(quote, today):
|
||||||
|
self._merge_realtime_stock_detail(result, quote, requested_date)
|
||||||
|
except TushareError:
|
||||||
|
pass
|
||||||
|
return self._enrich_stock_detail(result)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_stock_detail_prices(
|
||||||
|
payload: dict[str, Any], market_now: datetime
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = copy.deepcopy(payload)
|
||||||
|
raw_prices = list(result.get("prices") or [])
|
||||||
|
raw_latest_date = str(
|
||||||
|
(raw_prices[-1] if raw_prices else {}).get("trade_date") or ""
|
||||||
|
).replace("-", "")
|
||||||
|
prices = []
|
||||||
|
for bar in raw_prices:
|
||||||
|
open_price = float(bar.get("open") or 0)
|
||||||
|
high = float(bar.get("high") or 0)
|
||||||
|
low = float(bar.get("low") or 0)
|
||||||
|
close = float(bar.get("close") or 0)
|
||||||
|
if (
|
||||||
|
open_price > 0
|
||||||
|
and high >= max(open_price, close)
|
||||||
|
and 0 < low <= min(open_price, close)
|
||||||
|
and close > 0
|
||||||
|
):
|
||||||
|
prices.append(bar)
|
||||||
|
|
||||||
|
today = market_now.strftime("%Y%m%d")
|
||||||
|
market_open = (
|
||||||
|
market_now.weekday() < 5
|
||||||
|
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
||||||
|
)
|
||||||
|
if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == today:
|
||||||
|
current = prices[-1]
|
||||||
|
has_market_activity = (
|
||||||
|
float(current.get("volume") or 0) > 0
|
||||||
|
or float(current.get("amount_billion") or 0) > 0
|
||||||
|
)
|
||||||
|
if not market_open or not has_market_activity:
|
||||||
|
prices.pop()
|
||||||
|
|
||||||
|
if raw_latest_date == today and (
|
||||||
|
not prices
|
||||||
|
or str(prices[-1].get("trade_date") or "").replace("-", "") != today
|
||||||
|
):
|
||||||
|
result["meta"] = {**(result.get("meta") or {}), "realtime": False}
|
||||||
|
|
||||||
|
result["prices"] = prices
|
||||||
|
if prices:
|
||||||
|
latest = prices[-1]
|
||||||
|
stock = dict(result.get("stock") or {})
|
||||||
|
stock.update(
|
||||||
|
{
|
||||||
|
"price": float(latest.get("close") or 0),
|
||||||
|
"change": float(latest.get("change") or 0),
|
||||||
|
"amount_billion": float(latest.get("amount_billion") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result["stock"] = stock
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _valid_realtime_stock_quote(quote: dict[str, Any], trade_date: str) -> bool:
|
||||||
|
price = float(quote.get("price") or 0)
|
||||||
|
open_price = float(quote.get("open") or 0)
|
||||||
|
high = float(quote.get("high") or 0)
|
||||||
|
low = float(quote.get("low") or 0)
|
||||||
|
volume = float(quote.get("volume") or 0)
|
||||||
|
amount = float(quote.get("amount_billion") or 0)
|
||||||
|
quote_date = str(quote.get("quote_time") or "")[:10].replace("-", "")
|
||||||
|
return (
|
||||||
|
price > 0
|
||||||
|
and open_price > 0
|
||||||
|
and high >= max(open_price, price)
|
||||||
|
and 0 < low <= min(open_price, price)
|
||||||
|
and (volume > 0 or amount > 0)
|
||||||
|
and (not quote_date or quote_date == trade_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None:
|
||||||
|
ifind = getattr(self, "ifind", None)
|
||||||
|
if not ifind or not ifind.configured:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
rows = ifind.real_time(
|
||||||
|
tushare_code(code),
|
||||||
|
[
|
||||||
|
"open", "high", "low", "latest", "preClose",
|
||||||
|
"volume", "amount", "turnoverRatio",
|
||||||
|
],
|
||||||
|
cache_ttl=10,
|
||||||
|
)
|
||||||
|
except IfindError:
|
||||||
|
return None
|
||||||
|
row = rows[0] if rows else {}
|
||||||
|
price = float(row.get("latest") or 0)
|
||||||
|
previous_close = float(row.get("preClose") or 0)
|
||||||
|
if price <= 0:
|
||||||
|
return None
|
||||||
|
change = (price / previous_close - 1) * 100 if previous_close > 0 else 0.0
|
||||||
|
stock = self._stock_identity(code, date.today().strftime("%Y%m%d"))
|
||||||
|
return {
|
||||||
|
"name": stock[0],
|
||||||
|
"sector": stock[1],
|
||||||
|
"price": price,
|
||||||
|
"open": float(row.get("open") or price),
|
||||||
|
"high": float(row.get("high") or price),
|
||||||
|
"low": float(row.get("low") or price),
|
||||||
|
"change": round(change, 4),
|
||||||
|
"volume": float(row.get("volume") or 0),
|
||||||
|
"volume_unit": "lots",
|
||||||
|
"amount_billion": float(row.get("amount") or 0) / 100_000_000,
|
||||||
|
"turnover_rate": float(row.get("turnoverRatio") or 0),
|
||||||
|
"quote_time": str(row.get("time") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_realtime_stock_detail(
|
||||||
|
payload: dict[str, Any], quote: dict[str, Any], trade_date: str
|
||||||
|
) -> None:
|
||||||
|
display_date = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}"
|
||||||
|
realtime_bar = {
|
||||||
|
"trade_date": display_date,
|
||||||
|
"open": quote["open"],
|
||||||
|
"high": quote["high"],
|
||||||
|
"low": quote["low"],
|
||||||
|
"close": quote["price"],
|
||||||
|
"change": quote["change"],
|
||||||
|
"volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100,
|
||||||
|
"amount_billion": quote["amount_billion"],
|
||||||
|
"realtime": True,
|
||||||
|
}
|
||||||
|
prices = list(payload.get("prices") or [])
|
||||||
|
if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == trade_date:
|
||||||
|
prices[-1] = realtime_bar
|
||||||
|
else:
|
||||||
|
prices.append(realtime_bar)
|
||||||
|
payload["prices"] = prices[-90:]
|
||||||
|
stock = dict(payload.get("stock") or {})
|
||||||
|
stock.update(
|
||||||
|
{
|
||||||
|
"name": quote["name"],
|
||||||
|
"industry": quote["sector"],
|
||||||
|
"price": quote["price"],
|
||||||
|
"change": quote["change"],
|
||||||
|
"amount_billion": quote["amount_billion"],
|
||||||
|
"turnover_rate": quote["turnover_rate"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload["stock"] = stock
|
||||||
|
payload["meta"] = {
|
||||||
|
**(payload.get("meta") or {}),
|
||||||
|
"trade_date": display_date,
|
||||||
|
"realtime": True,
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_stock_preview(
|
||||||
|
self, code: str, trade_date: str, force: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
code = validate_stock_code(code)
|
||||||
|
# Hover previews deliberately follow the latest market day, independent
|
||||||
|
# from the review date selected by the page.
|
||||||
|
detail = self.get_stock_detail(code, date.today().strftime("%Y%m%d"), force)
|
||||||
|
detail_meta = detail.get("meta") or {}
|
||||||
|
resolved_date = str(detail_meta.get("trade_date") or trade_date)
|
||||||
|
intraday_points: list[dict[str, Any]] = []
|
||||||
|
intraday_status = "unavailable"
|
||||||
|
intraday_notice = "分时行情暂不可用。"
|
||||||
|
|
||||||
|
intraday_trade_date = ""
|
||||||
|
intraday_previous_close = 0.0
|
||||||
|
try:
|
||||||
|
intraday = self.chart_data.stock_intraday(code)
|
||||||
|
intraday_points = list(intraday.get("points") or [])
|
||||||
|
intraday_trade_date = str(intraday.get("trade_date") or "")
|
||||||
|
intraday_previous_close = float(intraday.get("previous_close") or 0)
|
||||||
|
if intraday_points:
|
||||||
|
intraday_status = "available"
|
||||||
|
intraday_notice = ""
|
||||||
|
else:
|
||||||
|
intraday_status = "empty"
|
||||||
|
intraday_notice = "最近交易日暂无分时数据。"
|
||||||
|
except ChartDataError:
|
||||||
|
intraday_status = "unavailable"
|
||||||
|
intraday_notice = "分时行情暂不可用,请稍后重试。"
|
||||||
|
|
||||||
|
prices = list(detail.get("prices") or [])[-60:]
|
||||||
|
stock = dict(detail.get("stock") or {"code": code})
|
||||||
|
realtime = bool(detail_meta.get("realtime"))
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": resolved_date,
|
||||||
|
"source": detail_meta.get("source") or "unavailable",
|
||||||
|
"notice": detail_meta.get("notice") or "",
|
||||||
|
"intraday_status": intraday_status,
|
||||||
|
"intraday_notice": intraday_notice,
|
||||||
|
"intraday_trade_date": intraday_trade_date,
|
||||||
|
"intraday_previous_close": intraday_previous_close,
|
||||||
|
"realtime": realtime,
|
||||||
|
"refresh_interval_seconds": 10 if realtime else 0,
|
||||||
|
},
|
||||||
|
"stock": stock,
|
||||||
|
"prices": prices,
|
||||||
|
"intraday": intraday_points,
|
||||||
|
}
|
||||||
|
|
||||||
|
def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]:
|
||||||
|
start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date()
|
||||||
|
end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date()
|
||||||
|
if start > end:
|
||||||
|
raise ValueError("开始日期不能晚于结束日期。")
|
||||||
|
weekdays = []
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
if current.weekday() < 5:
|
||||||
|
weekdays.append(current)
|
||||||
|
current += timedelta(days=1)
|
||||||
|
if len(weekdays) > 15:
|
||||||
|
raise ValueError("单次最多回补 15 个工作日。")
|
||||||
|
results = []
|
||||||
|
for day in weekdays:
|
||||||
|
dashboard = self.sync_dashboard(day.strftime("%Y%m%d"))
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"requested_date": day.isoformat(),
|
||||||
|
"trade_date": dashboard["meta"]["trade_date"],
|
||||||
|
"source": dashboard["meta"]["source"],
|
||||||
|
"records": self._record_count(dashboard),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]:
|
||||||
|
snapshot = self.database.get_snapshot(trade_date) or {}
|
||||||
|
for key in ("limits", "broken", "down_limits"):
|
||||||
|
for row in snapshot.get(key) or []:
|
||||||
|
if str(row.get("code")) == code:
|
||||||
|
return row.get("name") or "--", row.get("sector") or "其他"
|
||||||
|
for item in self.database.list_watchlist(self.current_user_id):
|
||||||
|
if item["code"] == code:
|
||||||
|
return item["name"], item["sector"] or "其他"
|
||||||
|
return "--", "其他"
|
||||||
|
|
||||||
|
def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(payload)
|
||||||
|
stock = dict(payload.get("stock") or {})
|
||||||
|
code = str(stock.get("code") or "")
|
||||||
|
watched = {
|
||||||
|
item["code"]: item
|
||||||
|
for item in self.database.list_watchlist(self.current_user_id)
|
||||||
|
}
|
||||||
|
stock["watchlist"] = watched.get(code)
|
||||||
|
result["stock"] = stock
|
||||||
|
result["notes"] = self.database.list_notes(self.current_user_id, code=code)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
|
||||||
|
result = dict(dashboard)
|
||||||
|
result["meta"] = {
|
||||||
|
**dashboard.get("meta", {}),
|
||||||
|
"storage": "sqlite",
|
||||||
|
"cached": cached,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record_count(dashboard: dict[str, Any]) -> int:
|
||||||
|
return sum(
|
||||||
|
len(dashboard.get(key) or [])
|
||||||
|
for key in ("limits", "broken", "down_limits", "yesterday_limits")
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Limit-up, broken-board, limit-down and prior-limit pool feature."""
|
||||||
|
|
||||||
|
from .repository import PoolRepositoryMixin
|
||||||
|
from .service import PoolServiceMixin
|
||||||
|
|
||||||
|
__all__ = ["PoolRepositoryMixin", "PoolServiceMixin"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PoolRepositoryMixin:
|
||||||
|
def save_reason_override(self, trade_date: str, code: str, reason: str) -> None:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO reason_overrides (trade_date, code, reason, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, code) DO UPDATE SET
|
||||||
|
reason = excluded.reason,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(trade_date, code, reason, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def reason_overrides(self, trade_date: str) -> dict[str, str]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT code, reason FROM reason_overrides WHERE trade_date = ?",
|
||||||
|
(trade_date,),
|
||||||
|
).fetchall()
|
||||||
|
return {row["code"]: row["reason"] for row in rows}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, time as dt_time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import normalize_date, validate_stock_code
|
||||||
|
from backend.data.providers.ifind_client import IfindError
|
||||||
|
|
||||||
|
|
||||||
|
class PoolServiceMixin:
|
||||||
|
def save_reason(self, trade_date: str, code: str, reason: str) -> None:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
code = validate_stock_code(code)
|
||||||
|
reason = reason.strip()
|
||||||
|
if not reason or len(reason) > 200:
|
||||||
|
raise ValueError("涨停原因应为 1 至 200 个字符。")
|
||||||
|
self.database.save_reason_override(normalized_date, code, reason)
|
||||||
|
|
||||||
|
def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
trade_date = str(dashboard.get("meta", {}).get("trade_date", "")).replace("-", "")
|
||||||
|
enrichment = self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date)
|
||||||
|
if enrichment:
|
||||||
|
self._merge_ifind_event_enrichment(dashboard, enrichment)
|
||||||
|
else:
|
||||||
|
self._schedule_ifind_event_enrichment(trade_date)
|
||||||
|
overrides = self.database.reason_overrides(trade_date)
|
||||||
|
if not overrides:
|
||||||
|
return dashboard
|
||||||
|
for key in ("limits", "broken", "down_limits"):
|
||||||
|
for row in dashboard.get(key) or []:
|
||||||
|
if row.get("code") in overrides:
|
||||||
|
row["reason"] = overrides[row["code"]]
|
||||||
|
row["reason_source"] = "manual"
|
||||||
|
return dashboard
|
||||||
|
|
||||||
|
def _schedule_ifind_event_enrichment(self, trade_date: str) -> None:
|
||||||
|
ifind = getattr(self, "ifind", None)
|
||||||
|
if not ifind or not ifind.configured or not re.fullmatch(r"\d{8}", trade_date):
|
||||||
|
return
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
if trade_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) < dt_time(15, 0):
|
||||||
|
return
|
||||||
|
self.jobs.submit(
|
||||||
|
"market.ifind-event-enrichment",
|
||||||
|
f"{trade_date}:v1",
|
||||||
|
lambda: self._refresh_ifind_event_enrichment(trade_date),
|
||||||
|
{"trade_date": trade_date, "trigger": "dashboard-enrichment"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _refresh_ifind_event_enrichment(self, trade_date: str) -> None:
|
||||||
|
if not self._ifind_event_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date):
|
||||||
|
return
|
||||||
|
ifind = getattr(self, "ifind", None)
|
||||||
|
if not ifind or not ifind.configured:
|
||||||
|
return
|
||||||
|
current = datetime.strptime(trade_date, "%Y%m%d")
|
||||||
|
display_date = f"{current.year}年{current.month}月{current.day}日"
|
||||||
|
requests = {
|
||||||
|
"limits": (
|
||||||
|
f"{display_date}涨停股票,股票代码、股票简称、涨停原因、"
|
||||||
|
"首次涨停时间、最终涨停时间、开板次数"
|
||||||
|
),
|
||||||
|
"broken": (
|
||||||
|
f"{display_date}曾涨停但收盘未涨停的股票,股票代码、股票简称、"
|
||||||
|
"涨停原因、首次涨停时间、开板次数"
|
||||||
|
),
|
||||||
|
"down_limits": (
|
||||||
|
f"{display_date}跌停股票,股票代码、股票简称、跌停原因"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"limits": {}, "broken": {}, "down_limits": {}, "partial": False,
|
||||||
|
}
|
||||||
|
for kind, query in requests.items():
|
||||||
|
try:
|
||||||
|
rows = ifind.wencai(query, "stock", cache_ttl=900)
|
||||||
|
except IfindError:
|
||||||
|
result["partial"] = True
|
||||||
|
continue
|
||||||
|
for raw in rows:
|
||||||
|
code = self._ifind_row_code(raw)
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
reason_tokens = (
|
||||||
|
("跌停原因", "风险线索", "原因")
|
||||||
|
if kind == "down_limits"
|
||||||
|
else ("涨停原因类别", "涨停原因", "触板逻辑", "原因")
|
||||||
|
)
|
||||||
|
reason = str(self._ifind_field(raw, reason_tokens) or "").strip()
|
||||||
|
first_time = self._normalize_ifind_event_time(
|
||||||
|
self._ifind_field(raw, ("首次涨停时间", "首次触板时间", "首次封板时间"))
|
||||||
|
)
|
||||||
|
last_time = self._normalize_ifind_event_time(
|
||||||
|
self._ifind_field(raw, ("最终涨停时间", "最后涨停时间", "最后封板时间"))
|
||||||
|
)
|
||||||
|
open_times = self._ifind_field(raw, ("开板次数", "打开涨停次数"))
|
||||||
|
try:
|
||||||
|
open_count = max(0, int(float(open_times))) if open_times not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
open_count = None
|
||||||
|
result[kind][code] = {
|
||||||
|
"reason": reason,
|
||||||
|
"first_time": first_time,
|
||||||
|
"last_time": last_time,
|
||||||
|
"open_times": open_count,
|
||||||
|
}
|
||||||
|
if any(result[kind] for kind in ("limits", "broken", "down_limits")):
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"ifind_event_enrichment_v1", trade_date, "ifind", result
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._ifind_event_lock.release()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_ifind_event_time(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
match = re.search(r"(?:^|\s)(\d{1,2}:\d{2}(?::\d{2})?)(?:$|\s)", text)
|
||||||
|
if not match:
|
||||||
|
match = re.search(r"(?<!\d)(\d{6})(?!\d)", text)
|
||||||
|
if match:
|
||||||
|
compact = match.group(1)
|
||||||
|
return f"{compact[:2]}:{compact[2:4]}:{compact[4:]}"
|
||||||
|
return ""
|
||||||
|
parts = match.group(1).split(":")
|
||||||
|
return ":".join(part.zfill(2) for part in parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_ifind_event_enrichment(
|
||||||
|
dashboard: dict[str, Any], enrichment: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
for kind in ("limits", "broken", "down_limits"):
|
||||||
|
records = enrichment.get(kind) or {}
|
||||||
|
for row in dashboard.get(kind) or []:
|
||||||
|
event = records.get(str(row.get("code") or "")) or {}
|
||||||
|
reason = str(event.get("reason") or "").strip()
|
||||||
|
if reason:
|
||||||
|
row["reason"] = reason
|
||||||
|
row["reason_source"] = "market_event"
|
||||||
|
if event.get("first_time"):
|
||||||
|
row["first_time"] = event["first_time"]
|
||||||
|
if event.get("last_time"):
|
||||||
|
row["last_time"] = event["last_time"]
|
||||||
|
if event.get("open_times") is not None:
|
||||||
|
row["open_times"] = event["open_times"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .repository import PopularityRepositoryMixin
|
||||||
|
from .service import PopularityServiceMixin
|
||||||
|
|
||||||
|
__all__ = ["PopularityRepositoryMixin", "PopularityServiceMixin"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PopularityRepositoryMixin:
|
||||||
|
def upsert_popularity_factors(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("trade_date") or ""),
|
||||||
|
str(row.get("ts_code") or ""),
|
||||||
|
int(row["ths_rank"]) if row.get("ths_rank") not in (None, "") else None,
|
||||||
|
int(row["dc_rank"]) if row.get("dc_rank") not in (None, "") else None,
|
||||||
|
float(row.get("combined_score") or 0),
|
||||||
|
int(row["rank_change"]) if row.get("rank_change") not in (None, "") else None,
|
||||||
|
int(bool(row.get("dual_source"))),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row.get("trade_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO popularity_factors
|
||||||
|
(trade_date, ts_code, ths_rank, dc_rank, combined_score,
|
||||||
|
rank_change, dual_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
ths_rank=excluded.ths_rank,
|
||||||
|
dc_rank=excluded.dc_rank,
|
||||||
|
combined_score=excluded.combined_score,
|
||||||
|
rank_change=excluded.rank_change,
|
||||||
|
dual_source=excluded.dual_source
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import normalize_date
|
||||||
|
from backend.features.market.insights import MarketInsightsService
|
||||||
|
|
||||||
|
|
||||||
|
class PopularityServiceMixin:
|
||||||
|
def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]:
|
||||||
|
return self._market_insights().popularity(normalize_date(trade_date), force)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Sector rotation history and constituent detail feature."""
|
||||||
|
|
||||||
|
from .service import RotationServiceMixin
|
||||||
|
|
||||||
|
__all__ = ["RotationServiceMixin"]
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import normalize_date, validate_text
|
||||||
|
from backend.data.providers.tushare_client import TushareError
|
||||||
|
from backend.features.sentiment.engine import (
|
||||||
|
build_sentiment_history,
|
||||||
|
latest_contiguous_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RotationServiceMixin:
|
||||||
|
def rotation_history(self, trade_date: str, limit: int = 9) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
# 板块轮动固定展示最近 9 个交易日,按由近到远排列。
|
||||||
|
limit = 9
|
||||||
|
snapshots = self.database.list_snapshot_payloads(normalized_date, 240)
|
||||||
|
by_trade_date: dict[str, dict[str, Any]] = {}
|
||||||
|
for snapshot in snapshots:
|
||||||
|
meta = snapshot.get("meta") or {}
|
||||||
|
actual_date = str(meta.get("trade_date") or snapshot.get("_snapshot_date") or "")
|
||||||
|
compact_date = actual_date.replace("-", "")
|
||||||
|
if len(compact_date) == 8:
|
||||||
|
by_trade_date[compact_date] = snapshot
|
||||||
|
|
||||||
|
sentiment_dates = {
|
||||||
|
str(row.get("trade_date") or "").replace("-", "")
|
||||||
|
for row in latest_contiguous_history(build_sentiment_history(snapshots))
|
||||||
|
}
|
||||||
|
ordered_dates = sorted(
|
||||||
|
date_key for date_key in by_trade_date
|
||||||
|
if not sentiment_dates or date_key in sentiment_dates
|
||||||
|
)[-limit:][::-1]
|
||||||
|
rows = []
|
||||||
|
for date_key in ordered_dates:
|
||||||
|
snapshot = by_trade_date[date_key]
|
||||||
|
sector_context = {
|
||||||
|
str(item.get("name") or ""): item
|
||||||
|
for item in snapshot.get("sectors") or []
|
||||||
|
}
|
||||||
|
sectors = []
|
||||||
|
for item in (snapshot.get("sector_rotation") or [])[:12]:
|
||||||
|
name = str(item.get("name") or "").strip()
|
||||||
|
context = sector_context.get(name, {})
|
||||||
|
sectors.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"rank": int(item.get("rank") or len(sectors) + 1),
|
||||||
|
"trend": item.get("trend") or "持平",
|
||||||
|
"count": int(item.get("count") or 0),
|
||||||
|
"strength": float(item.get("strength") or context.get("strength") or 0),
|
||||||
|
"change": float(context.get("change") or 0),
|
||||||
|
"leader": item.get("leader") or context.get("leader") or "--",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"trade_date": f"{date_key[:4]}-{date_key[4:6]}-{date_key[6:]}",
|
||||||
|
"sectors": sectors,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"trade_date": rows[0]["trade_date"] if rows else normalized_date,
|
||||||
|
"available_days": len(ordered_dates),
|
||||||
|
"requested_days": limit,
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
sector_name = validate_text(sector_name, "板块名称", 60, required=True)
|
||||||
|
dashboard = self.get_dashboard(normalized_date)
|
||||||
|
actual_date = normalize_date(
|
||||||
|
str((dashboard.get("meta") or {}).get("trade_date") or normalized_date)
|
||||||
|
)
|
||||||
|
cache_key = f"{actual_date}:{sector_name}"
|
||||||
|
cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key)
|
||||||
|
if cached:
|
||||||
|
cached["meta"] = {**(cached.get("meta") or {}), "cached": True}
|
||||||
|
return cached
|
||||||
|
if not self.configured:
|
||||||
|
raise ValueError("板块成分数据暂不可用。")
|
||||||
|
|
||||||
|
representative = next(
|
||||||
|
(
|
||||||
|
item for item in dashboard.get("limits") or []
|
||||||
|
if str(item.get("sector") or "").strip() == sector_name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not representative:
|
||||||
|
raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。")
|
||||||
|
raw_code = str(representative.get("ts_code") or representative.get("code") or "")
|
||||||
|
if "." in raw_code:
|
||||||
|
ts_code = raw_code
|
||||||
|
elif raw_code.startswith(("4", "8", "92")):
|
||||||
|
ts_code = f"{raw_code}.BJ"
|
||||||
|
elif raw_code.startswith(("6", "68", "90")):
|
||||||
|
ts_code = f"{raw_code}.SH"
|
||||||
|
else:
|
||||||
|
ts_code = f"{raw_code}.SZ"
|
||||||
|
client = self._tushare_client()
|
||||||
|
try:
|
||||||
|
industry = client.sw_stock_industry(ts_code, actual_date)
|
||||||
|
sector_code = str(industry.get("l2_code") or "")
|
||||||
|
members = client.sw_sector_members(sector_code, actual_date)
|
||||||
|
except TushareError as exc:
|
||||||
|
raise ValueError(f"该板块成分股暂不可用:{exc}") from exc
|
||||||
|
|
||||||
|
daily_rows = self.database.daily_bars_for_date(actual_date)
|
||||||
|
if len(daily_rows) < 1000:
|
||||||
|
try:
|
||||||
|
daily_rows = client.query(
|
||||||
|
"daily",
|
||||||
|
{"trade_date": actual_date},
|
||||||
|
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
||||||
|
)
|
||||||
|
if daily_rows:
|
||||||
|
self.database.upsert_daily_bars(daily_rows)
|
||||||
|
except TushareError:
|
||||||
|
daily_rows = self.database.daily_bars_for_date(actual_date)
|
||||||
|
daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows}
|
||||||
|
rows = []
|
||||||
|
for member in members:
|
||||||
|
member_code = str(member.get("ts_code") or "")
|
||||||
|
quote = daily_map.get(member_code) or {}
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"code": member_code.split(".")[0],
|
||||||
|
"ts_code": member_code,
|
||||||
|
"name": str(member.get("name") or "--"),
|
||||||
|
"change": quote.get("pct_chg"),
|
||||||
|
"open": quote.get("open"),
|
||||||
|
"close": quote.get("close"),
|
||||||
|
"amount_billion": (
|
||||||
|
round(float(quote.get("amount") or 0) / 100000, 2)
|
||||||
|
if quote else None
|
||||||
|
),
|
||||||
|
"quoted": bool(quote),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
bool(item.get("quoted")),
|
||||||
|
float(item.get("change") or -999),
|
||||||
|
float(item.get("amount_billion") or 0),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": self._display_compact_date(actual_date),
|
||||||
|
"sector_name": str(industry.get("l2_name") or sector_name),
|
||||||
|
"sector_code": sector_code,
|
||||||
|
"member_count": len(rows),
|
||||||
|
"quoted_count": sum(bool(item.get("quoted")) for item in rows),
|
||||||
|
"cached": False,
|
||||||
|
},
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"rotation_sector_members_v1", cache_key, "tushare", result
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -1,3 +1 @@
|
|||||||
from .tracking import StrategyTrackingService
|
"""Stock screening, custom selection, and strategy tracking feature."""
|
||||||
|
|
||||||
__all__ = ["StrategyTrackingService"]
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from screener import FACTOR_FIELDS, REGIMES
|
||||||
|
|
||||||
|
|
||||||
|
class LLMCompilerError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_connection(
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not api_key or not model:
|
||||||
|
raise LLMCompilerError("API Key 或模型未配置。")
|
||||||
|
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": "只回复 OK"}],
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
endpoint,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": "XiaobaiReviewWeb/0.5",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
reply = str(result["choices"][0]["message"]["content"]).strip()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise LLMCompilerError(_http_error_message(exc)) from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
||||||
|
raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"model": model,
|
||||||
|
"reply": reply[:100],
|
||||||
|
"latency_ms": round((time.perf_counter() - started) * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compile_strategy_with_llm(
|
||||||
|
prompt: str,
|
||||||
|
regime: str,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str,
|
||||||
|
timeout: int = 45,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not api_key or not model:
|
||||||
|
raise LLMCompilerError("尚未配置 LLM API Key 或模型。")
|
||||||
|
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
||||||
|
schema = {
|
||||||
|
"name": "策略名称",
|
||||||
|
"description": "策略说明",
|
||||||
|
"regimes": [regime],
|
||||||
|
"formula": {
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [{"field": "return_5d", "op": ">=", "value": 0}],
|
||||||
|
"score": [{"field": "sector_strength", "weight": 0.3, "direction": "desc"}],
|
||||||
|
"limit": 15,
|
||||||
|
"min_score": 0.55,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
system_prompt = (
|
||||||
|
"你是A股量化策略编译器。只输出JSON对象,不输出Markdown。"
|
||||||
|
"不得生成Python、SQL、网络请求或未提供的因子。"
|
||||||
|
f"当前市场阶段为{REGIMES.get(regime, regime)}。"
|
||||||
|
f"可用因子为:{json.dumps(FACTOR_FIELDS, ensure_ascii=False)}。"
|
||||||
|
"运算符只能使用 >, >=, <, <=, ==, !=, between, in。"
|
||||||
|
"score权重均大于0且不超过1,direction只能是asc或desc。"
|
||||||
|
"退潮和冰点策略必须提高门槛并允许结果为空。"
|
||||||
|
f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}"
|
||||||
|
)
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": prompt[:3000]},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
endpoint,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": "XiaobaiReviewWeb/0.4",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
content = result["choices"][0]["message"]["content"].strip()
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = content.strip("`")
|
||||||
|
if content.startswith("json"):
|
||||||
|
content = content[4:].strip()
|
||||||
|
compiled = json.loads(content)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
||||||
|
raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc
|
||||||
|
compiled["compiler"] = "llm"
|
||||||
|
compiled["model"] = model
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||||||
|
error = payload.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
detail = str(error.get("message") or error.get("code") or "")
|
||||||
|
elif error:
|
||||||
|
detail = str(error)
|
||||||
|
elif payload.get("message"):
|
||||||
|
detail = str(payload["message"])
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
detail = ""
|
||||||
|
suffix = f":{detail[:300]}" if detail else ""
|
||||||
|
return f"模型连接测试失败(HTTP {exc.code}){suffix}"
|
||||||
@@ -0,0 +1,814 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.features.sentiment.engine import build_sentiment_history
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_float(value: Any) -> float | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenerRepositoryMixin:
|
||||||
|
def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("trade_date") or ""), str(row.get("ts_code") or ""),
|
||||||
|
float(row.get("close") or 0), float(row.get("pct_chg") or 0),
|
||||||
|
)
|
||||||
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
close=excluded.close, pct_chg=excluded.pct_chg
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("trade_date") or ""), row.get("ts_code", ""),
|
||||||
|
float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0),
|
||||||
|
float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0),
|
||||||
|
_optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")),
|
||||||
|
_optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")),
|
||||||
|
)
|
||||||
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_indicators
|
||||||
|
(trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv,
|
||||||
|
pe_ttm, pb, ps_ttm, dv_ttm)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio,
|
||||||
|
total_mv=excluded.total_mv, circ_mv=excluded.circ_mv,
|
||||||
|
pe_ttm=excluded.pe_ttm, pb=excluded.pb,
|
||||||
|
ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("end_date") or ""), str(row.get("ann_date") or ""),
|
||||||
|
str(row.get("ts_code") or ""), _optional_float(row.get("roe")),
|
||||||
|
_optional_float(row.get("roa")), _optional_float(row.get("roic")),
|
||||||
|
_optional_float(row.get("grossprofit_margin")),
|
||||||
|
_optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")),
|
||||||
|
_optional_float(row.get("ocf_to_opincome")),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row.get("end_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO fundamental_indicators
|
||||||
|
(end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin,
|
||||||
|
netprofit_yoy, or_yoy, ocf_to_opincome)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(end_date, ts_code) DO UPDATE SET
|
||||||
|
ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa,
|
||||||
|
roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin,
|
||||||
|
netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy,
|
||||||
|
ocf_to_opincome=excluded.ocf_to_opincome
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_moneyflow(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = []
|
||||||
|
for row in rows:
|
||||||
|
if not row.get("trade_date") or not row.get("ts_code"):
|
||||||
|
continue
|
||||||
|
large_net = (
|
||||||
|
float(row.get("buy_lg_amount") or 0) + float(row.get("buy_elg_amount") or 0)
|
||||||
|
- float(row.get("sell_lg_amount") or 0) - float(row.get("sell_elg_amount") or 0)
|
||||||
|
)
|
||||||
|
medium_net = float(row.get("buy_md_amount") or 0) - float(row.get("sell_md_amount") or 0)
|
||||||
|
small_net = float(row.get("buy_sm_amount") or 0) - float(row.get("sell_sm_amount") or 0)
|
||||||
|
values.append((
|
||||||
|
str(row["trade_date"]), row["ts_code"], float(row.get("net_mf_amount") or 0),
|
||||||
|
large_net, medium_net, small_net,
|
||||||
|
))
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO moneyflow_daily
|
||||||
|
(trade_date, ts_code, net_mf_amount, large_net_amount, medium_net_amount, small_net_amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
net_mf_amount=excluded.net_mf_amount, large_net_amount=excluded.large_net_amount,
|
||||||
|
medium_net_amount=excluded.medium_net_amount, small_net_amount=excluded.small_net_amount
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("end_date") or ""),
|
||||||
|
str(row.get("ann_date") or ""),
|
||||||
|
str(row.get("ts_code") or ""),
|
||||||
|
_optional_float(row.get("forecast_profit")),
|
||||||
|
_optional_float(row.get("actual_profit")),
|
||||||
|
_optional_float(row.get("surprise_pct")),
|
||||||
|
_optional_float(row.get("revenue_yoy")),
|
||||||
|
_optional_float(row.get("netprofit_yoy")),
|
||||||
|
str(row.get("source") or ""),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row.get("end_date") and row.get("ann_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO earnings_events
|
||||||
|
(end_date, ann_date, ts_code, forecast_profit, actual_profit,
|
||||||
|
surprise_pct, revenue_yoy, netprofit_yoy, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET
|
||||||
|
forecast_profit=excluded.forecast_profit,
|
||||||
|
actual_profit=excluded.actual_profit,
|
||||||
|
surprise_pct=excluded.surprise_pct,
|
||||||
|
revenue_yoy=excluded.revenue_yoy,
|
||||||
|
netprofit_yoy=excluded.netprofit_yoy,
|
||||||
|
source=excluded.source
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]:
|
||||||
|
where = "WHERE trade_date <= ?" if end_date else ""
|
||||||
|
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"SELECT DISTINCT trade_date FROM daily_indicators {where} "
|
||||||
|
"ORDER BY trade_date DESC LIMIT ?",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
return [row["trade_date"] for row in reversed(rows)]
|
||||||
|
|
||||||
|
def fundamental_periods(self) -> list[str]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date"
|
||||||
|
).fetchall()
|
||||||
|
return [str(row["end_date"]) for row in rows]
|
||||||
|
|
||||||
|
def factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
|
||||||
|
where = "WHERE trade_date <= ?" if end_date else ""
|
||||||
|
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"SELECT DISTINCT trade_date FROM daily_bars {where} ORDER BY trade_date DESC LIMIT ?",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
return [row["trade_date"] for row in reversed(rows)]
|
||||||
|
|
||||||
|
def factor_health_summary(self, end_date: str) -> dict[str, Any]:
|
||||||
|
dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101"
|
||||||
|
with self.connect() as connection:
|
||||||
|
market = connection.execute(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
auction = connection.execute(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
benchmark_rows = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
indicator_date = connection.execute(
|
||||||
|
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
if indicator_date:
|
||||||
|
valuation_rows, valuation_available = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0)
|
||||||
|
FROM daily_indicators WHERE trade_date = ?
|
||||||
|
""",
|
||||||
|
(indicator_date,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
valuation_rows, valuation_available = 0, 0
|
||||||
|
dividend_years = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT substr(trade_date, 1, 4))
|
||||||
|
FROM daily_indicators
|
||||||
|
WHERE trade_date <= ? AND trade_date >= ?
|
||||||
|
""",
|
||||||
|
(end_date, dividend_start),
|
||||||
|
).fetchone()[0]
|
||||||
|
fundamental_rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM fundamental_indicators fi
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
|
||||||
|
FROM fundamental_indicators
|
||||||
|
WHERE ann_date = '' OR ann_date <= ?
|
||||||
|
GROUP BY ts_code
|
||||||
|
) latest
|
||||||
|
ON latest.ts_code = fi.ts_code
|
||||||
|
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
moneyflow_dates = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT trade_date)
|
||||||
|
FROM moneyflow_daily
|
||||||
|
WHERE trade_date IN (
|
||||||
|
SELECT DISTINCT trade_date
|
||||||
|
FROM daily_bars
|
||||||
|
WHERE trade_date <= ?
|
||||||
|
ORDER BY trade_date DESC
|
||||||
|
LIMIT 5
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
earnings_rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM earnings_events
|
||||||
|
WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '')
|
||||||
|
""",
|
||||||
|
(end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"),
|
||||||
|
).fetchone()[0]
|
||||||
|
popularity_rows = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
institution_rows = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
return {
|
||||||
|
"market": bool(market),
|
||||||
|
"auction": bool(auction),
|
||||||
|
"benchmark": int(benchmark_rows or 0) >= 60,
|
||||||
|
"benchmark_rows": int(benchmark_rows or 0),
|
||||||
|
"valuation": bool(valuation_available),
|
||||||
|
"fundamental": int(fundamental_rows or 0) >= 100,
|
||||||
|
"dividend_history": int(dividend_years or 0) >= 4,
|
||||||
|
"valuation_rows": int(valuation_rows or 0),
|
||||||
|
"fundamental_rows": int(fundamental_rows or 0),
|
||||||
|
"dividend_years": int(dividend_years or 0),
|
||||||
|
"moneyflow_history": int(moneyflow_dates or 0) >= 5,
|
||||||
|
"moneyflow_dates": int(moneyflow_dates or 0),
|
||||||
|
"earnings_events": int(earnings_rows or 0) > 0,
|
||||||
|
"earnings_event_rows": int(earnings_rows or 0),
|
||||||
|
"popularity": int(popularity_rows or 0) > 0,
|
||||||
|
"popularity_rows": int(popularity_rows or 0),
|
||||||
|
"institutions": int(institution_rows or 0) > 0,
|
||||||
|
"institution_rows": int(institution_rows or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
|
||||||
|
dates = self.factor_dates(end_date, limit_dates)
|
||||||
|
if not dates:
|
||||||
|
return {
|
||||||
|
"dates": [], "bars": [], "master": [], "indicators": [],
|
||||||
|
"indicator_history": [], "indicator_series": [], "fundamentals": [],
|
||||||
|
"moneyflow": [], "moneyflow_history": [], "auction": [],
|
||||||
|
"benchmarks": [], "fundamental_history": [],
|
||||||
|
"earnings_events": [], "popularity": [], "institutions": [],
|
||||||
|
}
|
||||||
|
placeholders = ",".join("?" for _ in dates)
|
||||||
|
with self.connect() as connection:
|
||||||
|
bars = connection.execute(
|
||||||
|
f"SELECT * FROM daily_bars WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
|
master = connection.execute("SELECT * FROM stock_master").fetchall()
|
||||||
|
indicators = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM daily_indicators
|
||||||
|
WHERE trade_date = (
|
||||||
|
SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
indicator_history = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT di.* FROM daily_indicators di
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT ts_code, substr(trade_date, 1, 4) AS year_key,
|
||||||
|
MAX(trade_date) AS max_date
|
||||||
|
FROM daily_indicators
|
||||||
|
WHERE trade_date <= ? AND trade_date >= ?
|
||||||
|
GROUP BY ts_code, substr(trade_date, 1, 4)
|
||||||
|
) latest
|
||||||
|
ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date
|
||||||
|
ORDER BY di.trade_date, di.ts_code
|
||||||
|
""",
|
||||||
|
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
|
||||||
|
).fetchall()
|
||||||
|
indicator_series = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT trade_date, ts_code, turnover_rate, volume_ratio,
|
||||||
|
total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm
|
||||||
|
FROM daily_indicators
|
||||||
|
WHERE trade_date IN ({placeholders})
|
||||||
|
ORDER BY trade_date, ts_code
|
||||||
|
""",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
|
fundamentals = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT fi.* FROM fundamental_indicators fi
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
|
||||||
|
FROM fundamental_indicators
|
||||||
|
WHERE ann_date = '' OR ann_date <= ?
|
||||||
|
GROUP BY ts_code
|
||||||
|
) latest
|
||||||
|
ON latest.ts_code = fi.ts_code
|
||||||
|
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
fundamental_history = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM fundamental_indicators
|
||||||
|
WHERE ann_date = '' OR ann_date <= ?
|
||||||
|
ORDER BY ann_date, end_date, ts_code
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
moneyflow = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM moneyflow_daily
|
||||||
|
WHERE trade_date = (
|
||||||
|
SELECT MAX(trade_date) FROM moneyflow_daily WHERE trade_date <= ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
flow_dates = dates[-min(5, len(dates)):]
|
||||||
|
flow_placeholders = ",".join("?" for _ in flow_dates)
|
||||||
|
moneyflow_history = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM moneyflow_daily
|
||||||
|
WHERE trade_date IN ({flow_placeholders})
|
||||||
|
ORDER BY trade_date, ts_code
|
||||||
|
""",
|
||||||
|
flow_dates,
|
||||||
|
).fetchall()
|
||||||
|
auction = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM auction_factors
|
||||||
|
WHERE trade_date = (
|
||||||
|
SELECT MAX(trade_date) FROM auction_factors WHERE trade_date <= ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
benchmarks = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM benchmark_bars
|
||||||
|
WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders})
|
||||||
|
ORDER BY trade_date
|
||||||
|
""",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
|
earnings_events = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM earnings_events
|
||||||
|
WHERE ann_date <= ?
|
||||||
|
ORDER BY ann_date, end_date, ts_code
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
popularity = connection.execute(
|
||||||
|
"SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
institutions = connection.execute(
|
||||||
|
"SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"dates": dates,
|
||||||
|
"bars": [dict(row) for row in bars],
|
||||||
|
"master": [dict(row) for row in master],
|
||||||
|
"indicators": [dict(row) for row in indicators],
|
||||||
|
"indicator_history": [dict(row) for row in indicator_history],
|
||||||
|
"indicator_series": [dict(row) for row in indicator_series],
|
||||||
|
"fundamentals": [dict(row) for row in fundamentals],
|
||||||
|
"fundamental_history": [dict(row) for row in fundamental_history],
|
||||||
|
"moneyflow": [dict(row) for row in moneyflow],
|
||||||
|
"moneyflow_history": [dict(row) for row in moneyflow_history],
|
||||||
|
"auction": [dict(row) for row in auction],
|
||||||
|
"benchmarks": [dict(row) for row in benchmarks],
|
||||||
|
"earnings_events": [dict(row) for row in earnings_events],
|
||||||
|
"popularity": [dict(row) for row in popularity],
|
||||||
|
"institutions": [dict(row) for row in institutions],
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [
|
||||||
|
{
|
||||||
|
"trade_date": row["trade_date"],
|
||||||
|
"sentiment_score": row["score"],
|
||||||
|
"seal_rate": row["seal_rate"],
|
||||||
|
"limit_up_count": row["limit_up_count"],
|
||||||
|
"limit_down_count": row["limit_down_count"],
|
||||||
|
"broken_count": row["broken_count"],
|
||||||
|
"up_count": row["up_count"],
|
||||||
|
"down_count": row["down_count"],
|
||||||
|
"amount_billion": row["amount_billion"],
|
||||||
|
}
|
||||||
|
for row in series[-limit:]
|
||||||
|
]
|
||||||
|
|
||||||
|
def save_screener_strategy(
|
||||||
|
self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any],
|
||||||
|
builtin: bool = False, strategy_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
regimes_json = json.dumps(regimes, ensure_ascii=False)
|
||||||
|
formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
with self.connect() as connection:
|
||||||
|
if strategy_id:
|
||||||
|
if builtin:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
|
||||||
|
builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1
|
||||||
|
""",
|
||||||
|
(name, description, regimes_json, formula_json, now, strategy_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
|
||||||
|
updated_at=? WHERE id=? AND builtin=0 AND user_id=?
|
||||||
|
""",
|
||||||
|
(name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise ValueError("选股策略不存在。")
|
||||||
|
return strategy_id
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO screener_strategies
|
||||||
|
(user_id, name, description, regimes, formula, builtin, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
if user_id is None:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id"
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM screener_strategies
|
||||||
|
WHERE builtin = 1 OR user_id = ?
|
||||||
|
ORDER BY builtin DESC, updated_at DESC, id
|
||||||
|
""",
|
||||||
|
(int(user_id),),
|
||||||
|
).fetchall()
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["regimes"] = json.loads(item["regimes"])
|
||||||
|
item["formula"] = json.loads(item["formula"])
|
||||||
|
item["builtin"] = bool(item["builtin"])
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT builtin, user_id FROM screener_strategies WHERE id = ?",
|
||||||
|
(strategy_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("选股策略不存在。")
|
||||||
|
if bool(row["builtin"]):
|
||||||
|
raise ValueError("内置策略不能删除。")
|
||||||
|
if int(row["user_id"] or 0) != int(user_id):
|
||||||
|
raise ValueError("无权删除其他账号的策略。")
|
||||||
|
cursor = connection.execute(
|
||||||
|
"DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?",
|
||||||
|
(strategy_id, int(user_id)),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def save_screener_run(
|
||||||
|
self, user_id: int, trade_date: str, regime: str, strategy_name: str,
|
||||||
|
formula: dict[str, Any], result: dict[str, Any], mode: str = "smart",
|
||||||
|
) -> int:
|
||||||
|
normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart"
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO screener_runs
|
||||||
|
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(None if int(user_id) == 0 else int(user_id), trade_date, regime,
|
||||||
|
normalized_mode, strategy_name,
|
||||||
|
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
result = json.loads(row["result"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
result.setdefault("meta", {}).update(
|
||||||
|
{
|
||||||
|
"run_id": int(row["id"]),
|
||||||
|
"trade_date": str(row["trade_date"] or ""),
|
||||||
|
"regime": str(row["regime"] or ""),
|
||||||
|
"mode": str(row["mode"] or "smart"),
|
||||||
|
"strategy_name": str(row["strategy_name"] or ""),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def latest_screener_run(
|
||||||
|
self, user_id: int, trade_date: str, mode: str = "",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date,)
|
||||||
|
mode_clause = ""
|
||||||
|
if mode in {"smart", "curated", "quant"}:
|
||||||
|
mode_clause = " AND mode = ?"
|
||||||
|
parameters += (mode,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date <= ?{mode_clause}
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchone()
|
||||||
|
return self._screener_run_payload(row) if row else None
|
||||||
|
|
||||||
|
def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
|
||||||
|
runs.strategy_name, runs.result, runs.created_at
|
||||||
|
FROM screener_runs runs
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT mode, MAX(id) AS id
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date <= ?
|
||||||
|
GROUP BY mode
|
||||||
|
) latest ON latest.id = runs.id
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
results: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
mode = str(row["mode"] or "smart")
|
||||||
|
payload = self._screener_run_payload(row)
|
||||||
|
if mode in {"smart", "curated", "quant"} and payload:
|
||||||
|
results[mode] = payload
|
||||||
|
return results
|
||||||
|
|
||||||
|
def latest_screener_context_runs(
|
||||||
|
self, user_id: int, trade_date: str, limit: int = 60,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
safe_limit = max(1, min(120, int(limit)))
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date, safe_limit)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY
|
||||||
|
mode,
|
||||||
|
CASE WHEN mode = 'smart' THEN regime ELSE '' END,
|
||||||
|
CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END
|
||||||
|
ORDER BY id DESC
|
||||||
|
) AS context_rank
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date <= ?
|
||||||
|
)
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM ranked
|
||||||
|
WHERE context_rank = 1
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
payload
|
||||||
|
for row in rows
|
||||||
|
if (payload := self._screener_run_payload(row)) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
def screener_runs_for_date(
|
||||||
|
self, user_id: int, trade_date: str, limit: int = 80,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
safe_limit = max(1, min(160, int(limit)))
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date, safe_limit)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
result = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for row in rows:
|
||||||
|
key = (
|
||||||
|
str(row["mode"] or "smart"),
|
||||||
|
str(row["regime"] or ""),
|
||||||
|
str(row["strategy_name"] or ""),
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
payload = self._screener_run_payload(row)
|
||||||
|
if payload is not None:
|
||||||
|
result.append(payload)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = (int(run_id),)
|
||||||
|
if int(user_id) != 0:
|
||||||
|
parameters += (int(user_id),)
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM screener_runs WHERE id = ? AND {owner_clause}
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = self._screener_run_payload(row)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
result.setdefault("meta", {}).update(
|
||||||
|
{
|
||||||
|
"run_id": int(row["id"]),
|
||||||
|
"trade_date": row["trade_date"],
|
||||||
|
"mode": str(row["mode"] or "smart"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result["strategy_name"] = row["strategy_name"]
|
||||||
|
result["regime"] = row["regime"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save_strategy_tracks(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
run_id: int,
|
||||||
|
selection_date: str,
|
||||||
|
strategy_name: str,
|
||||||
|
candidates: list[dict[str, Any]],
|
||||||
|
) -> int:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
values = []
|
||||||
|
for item in candidates:
|
||||||
|
ts_code = str(item.get("ts_code") or "").strip()
|
||||||
|
code = str(item.get("code") or ts_code.split(".")[0]).strip()
|
||||||
|
entry_price = float(item.get("price") or 0)
|
||||||
|
if not ts_code or not code or entry_price <= 0:
|
||||||
|
continue
|
||||||
|
values.append(
|
||||||
|
(
|
||||||
|
int(user_id), int(run_id), selection_date, strategy_name, ts_code, code,
|
||||||
|
str(item.get("name") or "--"), str(item.get("sector") or "其他"),
|
||||||
|
entry_price, now, now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO strategy_tracks
|
||||||
|
(user_id, run_id, selection_date, strategy_name, ts_code, code,
|
||||||
|
name, sector, entry_price, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET
|
||||||
|
name=excluded.name, sector=excluded.sector,
|
||||||
|
entry_price=excluded.entry_price, updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]:
|
||||||
|
limit_batches = max(1, min(50, int(limit_batches)))
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM strategy_tracks
|
||||||
|
WHERE user_id = ? AND run_id IN (
|
||||||
|
SELECT run_id FROM strategy_tracks WHERE user_id = ?
|
||||||
|
GROUP BY run_id ORDER BY run_id DESC LIMIT ?
|
||||||
|
)
|
||||||
|
ORDER BY run_id DESC, id
|
||||||
|
""",
|
||||||
|
(int(user_id), int(user_id), limit_batches),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def delete_strategy_track(self, user_id: int, track_id: int) -> bool:
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?",
|
||||||
|
(int(track_id), int(user_id)),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def load_tracking_bars(
|
||||||
|
self, targets: list[tuple[str, str]], limit: int = 5
|
||||||
|
) -> dict[tuple[str, str], list[dict[str, Any]]]:
|
||||||
|
unique_targets = set(targets)
|
||||||
|
if not unique_targets:
|
||||||
|
return {}
|
||||||
|
codes = sorted({ts_code for ts_code, _ in unique_targets})
|
||||||
|
earliest_date = min(selection_date for _, selection_date in unique_targets)
|
||||||
|
placeholders = ",".join("?" for _ in codes)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT ts_code, trade_date, open, high, low, close FROM daily_bars
|
||||||
|
WHERE ts_code IN ({placeholders}) AND trade_date > ?
|
||||||
|
ORDER BY ts_code, trade_date
|
||||||
|
""",
|
||||||
|
[*codes, earliest_date],
|
||||||
|
).fetchall()
|
||||||
|
by_code: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
by_code.setdefault(str(item["ts_code"]), []).append(item)
|
||||||
|
row_limit = max(1, min(20, int(limit)))
|
||||||
|
return {
|
||||||
|
(ts_code, selection_date): [
|
||||||
|
row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date
|
||||||
|
][:row_limit]
|
||||||
|
for ts_code, selection_date in unique_targets
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import normalize_date, validate_text
|
||||||
|
from backend.data.providers.tushare_client import TushareError
|
||||||
|
from backend.llm import LLMGatewayError
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCREENER_LIBRARY_VERSION = 8
|
||||||
|
|
||||||
|
|
||||||
|
def automatic_screener_jobs(
|
||||||
|
strategies: list[dict[str, Any]], regime_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build the close-of-day jobs; only stage screening is regime-gated."""
|
||||||
|
smart_strategy = next(
|
||||||
|
(
|
||||||
|
item for item in strategies
|
||||||
|
if item.get("formula", {}).get("meta", {}).get("library") != "curated"
|
||||||
|
and regime_id in (item.get("regimes") or [])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
curated = [
|
||||||
|
item for item in strategies
|
||||||
|
if item.get("formula", {}).get("meta", {}).get("library") == "curated"
|
||||||
|
]
|
||||||
|
jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else [])
|
||||||
|
jobs.extend({"mode": "curated", "strategy": item} for item in curated)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenerServiceMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _strategy_missing_data(
|
||||||
|
strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
|
||||||
|
) -> list[str]:
|
||||||
|
formula = strategy.get("formula") or {}
|
||||||
|
meta = formula.get("meta") or {}
|
||||||
|
used_fields = {
|
||||||
|
str(item.get("field") or "")
|
||||||
|
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
|
||||||
|
}
|
||||||
|
valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
|
||||||
|
fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
|
||||||
|
auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
|
||||||
|
missing = []
|
||||||
|
required_history = max(21, min(260, int(meta.get("history_days") or 21)))
|
||||||
|
if len(factor_dates) < required_history:
|
||||||
|
missing.append(f"历史行情(需{required_history}日)")
|
||||||
|
if used_fields & valuation_fields and not factor_health["valuation"]:
|
||||||
|
missing.append("估值数据")
|
||||||
|
if used_fields & fundamental_fields and not factor_health["fundamental"]:
|
||||||
|
missing.append("财务质量")
|
||||||
|
if meta.get("requires_valuation") and not factor_health["valuation"]:
|
||||||
|
missing.append("估值数据")
|
||||||
|
if meta.get("requires_fundamental") and not factor_health["fundamental"]:
|
||||||
|
missing.append("财务质量")
|
||||||
|
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
|
||||||
|
missing.append("历年分红")
|
||||||
|
if used_fields & auction_fields and not factor_health["auction"]:
|
||||||
|
missing.append("竞价数据")
|
||||||
|
if meta.get("requires_benchmark") and not factor_health.get("benchmark"):
|
||||||
|
missing.append("沪深300基准")
|
||||||
|
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
|
||||||
|
missing.append("近5日资金流")
|
||||||
|
if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"):
|
||||||
|
missing.append("业绩预告与快报")
|
||||||
|
if meta.get("requires_popularity") and not factor_health.get("popularity"):
|
||||||
|
missing.append("当日人气榜")
|
||||||
|
if meta.get("requires_institutions") and not factor_health.get("institutions"):
|
||||||
|
missing.append("龙虎榜机构席位")
|
||||||
|
return list(dict.fromkeys(missing))
|
||||||
|
|
||||||
|
def screener_setup(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
regime = self.screener.detect_regime(normalized_date)
|
||||||
|
factor_dates = self.database.factor_dates(normalized_date, 300)
|
||||||
|
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
|
||||||
|
factor_health = self.screener.factor_health(normalized_date)
|
||||||
|
strategies = self.database.list_screener_strategies(self.current_user_id)
|
||||||
|
for strategy in strategies:
|
||||||
|
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
|
||||||
|
strategy["data_ready"] = not missing
|
||||||
|
strategy["missing_data"] = missing
|
||||||
|
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
|
||||||
|
personal_results = self.database.screener_runs_for_date(
|
||||||
|
self.current_user_id, normalized_date
|
||||||
|
)
|
||||||
|
recent_results = [
|
||||||
|
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
|
||||||
|
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
|
||||||
|
]
|
||||||
|
latest_results: dict[str, dict[str, Any]] = {}
|
||||||
|
for result in reversed(recent_results):
|
||||||
|
mode = str(result.get("meta", {}).get("mode") or "smart")
|
||||||
|
latest_results[mode] = result
|
||||||
|
automatic_status = self.database.get_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date
|
||||||
|
) or {}
|
||||||
|
return {
|
||||||
|
"trade_date": normalized_date,
|
||||||
|
"regime": regime,
|
||||||
|
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
|
||||||
|
"strategies": strategies,
|
||||||
|
"factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()],
|
||||||
|
"factor_groups": [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields],
|
||||||
|
}
|
||||||
|
for name, fields in FACTOR_GROUPS.items()
|
||||||
|
],
|
||||||
|
"operators": [">", ">=", "<", "<=", "==", "between"],
|
||||||
|
"factor_data": {
|
||||||
|
"date_count": len(factor_dates),
|
||||||
|
"start_date": factor_dates[0] if factor_dates else "",
|
||||||
|
"end_date": factor_dates[-1] if factor_dates else "",
|
||||||
|
"ready": len(factor_dates) >= 21,
|
||||||
|
"auction_date_count": len(auction_dates),
|
||||||
|
"auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False,
|
||||||
|
"health": factor_health,
|
||||||
|
},
|
||||||
|
"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 "",
|
||||||
|
},
|
||||||
|
"latest_results": latest_results,
|
||||||
|
"recent_results": recent_results,
|
||||||
|
"automatic_status": automatic_status,
|
||||||
|
# Kept during the client transition for compatibility with older frontends.
|
||||||
|
"latest_result": latest_results.get("smart"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
|
||||||
|
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
|
||||||
|
|
||||||
|
def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
run_id = int(payload.get("run_id") or 0)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("选股批次无效。") from exc
|
||||||
|
code = str(payload.get("code") or "").strip()
|
||||||
|
if run_id <= 0 or not re.fullmatch(r"\d{6}", code):
|
||||||
|
raise ValueError("选股批次或股票代码无效。")
|
||||||
|
return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code)
|
||||||
|
|
||||||
|
def remove_screener_tracking(self, track_id: int) -> dict[str, Any]:
|
||||||
|
return self.strategy_tracking.remove_candidate(self.current_user_id, track_id)
|
||||||
|
|
||||||
|
def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
notice = ""
|
||||||
|
if self.configured:
|
||||||
|
try:
|
||||||
|
FactorDataService(self.database, self._tushare_client()).sync(
|
||||||
|
normalized_date, 15
|
||||||
|
)
|
||||||
|
except TushareError:
|
||||||
|
notice = "最新日线暂未补齐,已按现有数据更新跟踪。"
|
||||||
|
else:
|
||||||
|
notice = "公共行情尚未配置,已按现有数据更新跟踪。"
|
||||||
|
return {
|
||||||
|
"tracking": self.screener_tracking(),
|
||||||
|
"notice": notice,
|
||||||
|
}
|
||||||
|
|
||||||
|
def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
|
||||||
|
if not self.configured:
|
||||||
|
raise ValueError("请先配置 Tushare Token。")
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
lookback = max(25, min(260, int(lookback)))
|
||||||
|
with self.sync_lock:
|
||||||
|
return FactorDataService(self.database, self._tushare_client()).sync(
|
||||||
|
normalized_date, lookback
|
||||||
|
)
|
||||||
|
|
||||||
|
def _schedule_automatic_screeners(
|
||||||
|
self, trade_date: str, snapshot: dict[str, Any] | None = None
|
||||||
|
) -> bool:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
if (
|
||||||
|
normalized_date != now.strftime("%Y%m%d")
|
||||||
|
or now.weekday() >= 5
|
||||||
|
or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time()
|
||||||
|
or self.auto_screener_lock.locked()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
snapshot = snapshot or self.database.get_snapshot(normalized_date) or {}
|
||||||
|
actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "")
|
||||||
|
if actual_date != normalized_date:
|
||||||
|
return False
|
||||||
|
marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {}
|
||||||
|
if (
|
||||||
|
marker.get("status") == "complete"
|
||||||
|
and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
last_attempt = self._auto_screener_last_attempt.get(normalized_date)
|
||||||
|
if last_attempt and (now - last_attempt).total_seconds() < 600:
|
||||||
|
return False
|
||||||
|
self._auto_screener_last_attempt[normalized_date] = now
|
||||||
|
return self.jobs.submit(
|
||||||
|
"screener.automatic",
|
||||||
|
f"{normalized_date}:v{SCREENER_LIBRARY_VERSION}",
|
||||||
|
lambda: self.run_automatic_screeners(normalized_date),
|
||||||
|
{"trade_date": normalized_date, "trigger": "post-close"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
with self.auto_screener_lock:
|
||||||
|
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
status: dict[str, Any] = {
|
||||||
|
"trade_date": normalized_date,
|
||||||
|
"library_version": SCREENER_LIBRARY_VERSION,
|
||||||
|
"status": "running",
|
||||||
|
"started_at": started_at,
|
||||||
|
"completed": [],
|
||||||
|
"skipped": [],
|
||||||
|
"failed": [],
|
||||||
|
}
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date, "system", status
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
factor_sync = FactorDataService(
|
||||||
|
self.database, self._tushare_client()
|
||||||
|
).sync(normalized_date, 260)
|
||||||
|
factor_dates = self.database.factor_dates(normalized_date, 300)
|
||||||
|
if not factor_dates or factor_dates[-1] != normalized_date:
|
||||||
|
raise ValueError("当日收盘行情尚未入库")
|
||||||
|
factor_health = self.screener.factor_health(normalized_date)
|
||||||
|
regime = self.screener.detect_regime(normalized_date)
|
||||||
|
regime_id = str(regime.get("id") or "repair")
|
||||||
|
strategies = self.database.list_screener_strategies(None)
|
||||||
|
jobs = automatic_screener_jobs(strategies, regime_id)
|
||||||
|
existing = {
|
||||||
|
(
|
||||||
|
str(item.get("meta", {}).get("mode") or "smart"),
|
||||||
|
str(item.get("meta", {}).get("strategy_name") or ""),
|
||||||
|
)
|
||||||
|
for item in self.database.screener_runs_for_date(0, normalized_date)
|
||||||
|
if int(item.get("meta", {}).get("library_version") or 0)
|
||||||
|
== SCREENER_LIBRARY_VERSION
|
||||||
|
}
|
||||||
|
required_history = max(
|
||||||
|
[
|
||||||
|
int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80)
|
||||||
|
for job in jobs if job.get("strategy")
|
||||||
|
] or [80]
|
||||||
|
)
|
||||||
|
factors, actual_date = self.screener.build_factors(
|
||||||
|
normalized_date, history_days=required_history
|
||||||
|
)
|
||||||
|
if actual_date != normalized_date:
|
||||||
|
raise ValueError("当日因子尚未完成收盘定格")
|
||||||
|
for job in jobs:
|
||||||
|
strategy = job["strategy"]
|
||||||
|
mode = str(job["mode"])
|
||||||
|
name = str(strategy.get("name") or "未命名策略")
|
||||||
|
if (mode, name) in existing:
|
||||||
|
status["completed"].append({"mode": mode, "name": name, "cached": True})
|
||||||
|
continue
|
||||||
|
missing = self._strategy_missing_data(
|
||||||
|
strategy, factor_dates, factor_health
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
status["skipped"].append(
|
||||||
|
{"mode": mode, "name": name, "reason": "、".join(missing)}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
formula = copy.deepcopy(strategy.get("formula") or {})
|
||||||
|
formula.setdefault("meta", {})["library_version"] = (
|
||||||
|
SCREENER_LIBRARY_VERSION
|
||||||
|
)
|
||||||
|
result = self.screener.screen(
|
||||||
|
0,
|
||||||
|
normalized_date,
|
||||||
|
formula,
|
||||||
|
regime_id,
|
||||||
|
name,
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
mode,
|
||||||
|
factors,
|
||||||
|
actual_date,
|
||||||
|
)
|
||||||
|
status["completed"].append(
|
||||||
|
{
|
||||||
|
"mode": mode,
|
||||||
|
"name": name,
|
||||||
|
"candidate_count": len(result.get("candidates") or []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status["failed"].append(
|
||||||
|
{"mode": mode, "name": name, "reason": str(exc)}
|
||||||
|
)
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
"status": "complete" if not status["failed"] else "partial",
|
||||||
|
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"factor_sync": factor_sync,
|
||||||
|
"regime": regime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date, "system", status
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
|
||||||
|
prompt = prompt.strip()
|
||||||
|
if not prompt or len(prompt) > 3000:
|
||||||
|
raise ValueError("策略描述应为 1 至 3000 个字符。")
|
||||||
|
if regime not in REGIMES:
|
||||||
|
raise ValueError("市场阶段不支持。")
|
||||||
|
notice = ""
|
||||||
|
source = self.llm_source
|
||||||
|
if source == "platform":
|
||||||
|
try:
|
||||||
|
gateway_result = self.llm_gateway.call(
|
||||||
|
"screener",
|
||||||
|
"strategy-compiler-v1",
|
||||||
|
lambda profile: compile_strategy_with_llm(
|
||||||
|
prompt,
|
||||||
|
regime,
|
||||||
|
profile.api_key,
|
||||||
|
profile.base_url,
|
||||||
|
profile.model,
|
||||||
|
),
|
||||||
|
(LLMCompilerError,),
|
||||||
|
)
|
||||||
|
compiled = gateway_result.value
|
||||||
|
if gateway_result.role == "fallback":
|
||||||
|
compiled["compiler"] = "llm_fallback"
|
||||||
|
notice = "智能策略生成服务已自动切换。"
|
||||||
|
except LLMGatewayError as exc:
|
||||||
|
if exc.code != "unavailable":
|
||||||
|
raise
|
||||||
|
compiled = compile_local_strategy(prompt, regime)
|
||||||
|
notice = "智能策略生成暂不可用,已使用本地模板。"
|
||||||
|
else:
|
||||||
|
compiled = compile_local_strategy(prompt, regime)
|
||||||
|
notice = "智能策略生成暂不可用,已使用本地模板。"
|
||||||
|
compiled["formula"] = self.screener.validate_formula(compiled["formula"])
|
||||||
|
compiled["notice"] = notice
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
def save_screener_strategy(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = validate_text(payload.get("name"), "策略名称", 60, required=True)
|
||||||
|
description = validate_text(payload.get("description"), "策略说明", 1000)
|
||||||
|
regimes = payload.get("regimes") or []
|
||||||
|
if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes):
|
||||||
|
raise ValueError("策略适用阶段不正确。")
|
||||||
|
formula = self.screener.validate_formula(payload.get("formula") or {})
|
||||||
|
strategy_id = self.database.save_screener_strategy(
|
||||||
|
self.current_user_id, name, description, regimes, formula
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": strategy_id,
|
||||||
|
"strategies": self.database.list_screener_strategies(self.current_user_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]:
|
||||||
|
deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id)
|
||||||
|
return {
|
||||||
|
"deleted": deleted,
|
||||||
|
"strategies": self.database.list_screener_strategies(self.current_user_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
|
||||||
|
regime = str(payload.get("regime") or "")
|
||||||
|
if regime not in REGIMES:
|
||||||
|
raise ValueError("市场阶段不支持。")
|
||||||
|
strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True)
|
||||||
|
formula = payload.get("formula") or {}
|
||||||
|
requested_mode = str(payload.get("mode") or "").strip()
|
||||||
|
if requested_mode and requested_mode not in {"smart", "curated", "quant"}:
|
||||||
|
raise ValueError("选股模式不受支持。")
|
||||||
|
if requested_mode:
|
||||||
|
mode = requested_mode
|
||||||
|
else:
|
||||||
|
meta = formula.get("meta") if isinstance(formula, dict) else {}
|
||||||
|
library = str((meta or {}).get("library") or "")
|
||||||
|
category = str((meta or {}).get("category") or "")
|
||||||
|
if library == "curated":
|
||||||
|
mode = "curated"
|
||||||
|
elif library == "quant" or (library == "custom" and category == "量化公式"):
|
||||||
|
mode = "quant"
|
||||||
|
else:
|
||||||
|
mode = "smart"
|
||||||
|
realtime_snapshot = None
|
||||||
|
dashboard = self.get_dashboard(trade_date)
|
||||||
|
if self.configured and dashboard.get("meta", {}).get("realtime"):
|
||||||
|
try:
|
||||||
|
realtime_snapshot = self._tushare_client().realtime_factor_snapshot(trade_date)
|
||||||
|
except TushareError as exc:
|
||||||
|
raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc
|
||||||
|
result = self.screener.screen(
|
||||||
|
self.current_user_id, trade_date, formula, regime, strategy_name,
|
||||||
|
bool(payload.get("run_backtest", True)),
|
||||||
|
realtime_snapshot,
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _meta(
|
||||||
|
category: str,
|
||||||
|
quality: str,
|
||||||
|
frequency: str,
|
||||||
|
risk: str,
|
||||||
|
data_group: str,
|
||||||
|
history_days: int,
|
||||||
|
backtest_days: int,
|
||||||
|
take_profit: float,
|
||||||
|
stop_loss: float,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"library": "curated",
|
||||||
|
"category": category,
|
||||||
|
"quality": quality,
|
||||||
|
"frequency": frequency,
|
||||||
|
"risk": risk,
|
||||||
|
"data_group": data_group,
|
||||||
|
"history_days": history_days,
|
||||||
|
"backtest_days": backtest_days,
|
||||||
|
"take_profit": take_profit,
|
||||||
|
"stop_loss": stop_loss,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ADVANCED_CURATED_STRATEGIES = [
|
||||||
|
{
|
||||||
|
"name": "中期动量·强者恒强",
|
||||||
|
"description": "用60日至5日前的中期动量识别持续强势,同时剔除当日无法正常成交的涨停标的。",
|
||||||
|
"regimes": ["repair", "fermentation", "climax", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("动量反转", "A-", "每周", "中", "历史行情", 80, 10, 8, -5),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "close", "op": "between", "value": [3, 100]},
|
||||||
|
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.90},
|
||||||
|
{"field": "is_limit_up_today", "op": "==", "value": 0},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "momentum_60_5", "weight": 0.55, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 25,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "强者回调",
|
||||||
|
"description": "在中期强势股池中寻找回踩20日线、短期超卖且近20日无跌停的牛回头候选。",
|
||||||
|
"regimes": ["repair", "fermentation", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("动量反转", "A-", "每日", "中", "历史行情", 80, 10, 8, -5),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.70},
|
||||||
|
{"field": "return_5d_rank", "op": "<=", "value": 0.20},
|
||||||
|
{"field": "above_ma20", "op": "==", "value": 1},
|
||||||
|
{"field": "rsi_6", "op": "<=", "value": 30},
|
||||||
|
{"field": "no_limit_down_20d", "op": "==", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "momentum_60_5", "weight": 0.42, "direction": "desc"},
|
||||||
|
{"field": "return_5d", "weight": 0.33, "direction": "asc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "超跌反转",
|
||||||
|
"description": "筛选短期极端回撤、充分换手但尚未形成长期单边下跌的修复候选。",
|
||||||
|
"regimes": ["ice", "repair"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("动量反转", "B+", "每日", "高", "行情与财务", 80, 5, 8, -5),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "return_5d_rank", "op": "<=", "value": 0.05},
|
||||||
|
{"field": "turnover_5d", "op": ">=", "value": 30},
|
||||||
|
{"field": "return_60d", "op": ">=", "value": -40},
|
||||||
|
{"field": "financial_risk", "op": "==", "value": 0},
|
||||||
|
{"field": "is_limit_down_today", "op": "==", "value": 0},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "return_5d", "weight": 0.45, "direction": "asc"},
|
||||||
|
{"field": "turnover_5d", "weight": 0.30, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 10,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "相对强度新高",
|
||||||
|
"description": "以个股相对沪深300的强度线识别弱市领涨和结构性抱团标的。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("动量反转", "A", "每周", "中", "行情与指数", 130, 20, 12, -7, requires_benchmark=True),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 250},
|
||||||
|
"filters": [
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
{"field": "rs_high_120", "op": "==", "value": 1},
|
||||||
|
{"field": "excess_return_60d", "op": ">=", "value": 10},
|
||||||
|
{"field": "ma60_slope", "op": ">", "value": 0},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "excess_return_60d", "weight": 0.50, "direction": "desc"},
|
||||||
|
{"field": "ma60_slope", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"min_score": 0.52,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "均线多头排列",
|
||||||
|
"description": "使用5、10、20、60日均线多头结构、20日线斜率和250日位置确认趋势。",
|
||||||
|
"regimes": ["repair", "fermentation", "climax", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("趋势追踪", "A-", "每周", "中低", "历史行情", 260, 20, 12, -7),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 365},
|
||||||
|
"filters": [
|
||||||
|
{"field": "ma_bull_alignment", "op": "==", "value": 1},
|
||||||
|
{"field": "ma20_slope_5d", "op": ">", "value": 0},
|
||||||
|
{"field": "drawdown_from_high_250", "op": "<=", "value": 20},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "ma20_slope_5d", "weight": 0.38, "direction": "desc"},
|
||||||
|
{"field": "drawdown_from_high_250", "weight": 0.32, "direction": "asc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 30,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "唐奇安通道突破",
|
||||||
|
"description": "收盘突破前20日高点,并以突破幅度、量能和突破前振幅过滤假突破。",
|
||||||
|
"regimes": ["repair", "fermentation", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("趋势追踪", "A-", "每日", "中", "历史行情", 80, 20, 12, -7),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "donchian_breakout_pct", "op": ">=", "value": 2},
|
||||||
|
{"field": "volume_ratio_5d", "op": ">=", "value": 1.8},
|
||||||
|
{"field": "range_20d", "op": "<=", "value": 35},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "volume_ratio_5d", "weight": 0.40, "direction": "desc"},
|
||||||
|
{"field": "donchian_breakout_pct", "weight": 0.35, "direction": "desc"},
|
||||||
|
{"field": "range_20d", "weight": 0.25, "direction": "asc"},
|
||||||
|
],
|
||||||
|
"limit": 15,
|
||||||
|
"min_score": 0.52,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "周线趋势·日线买点",
|
||||||
|
"description": "周线MACD位于多头区间,日线金叉或回踩20日线收阳时确认多周期共振。",
|
||||||
|
"regimes": ["repair", "fermentation", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("趋势追踪", "A", "每周", "中低", "多周期行情", 180, 20, 12, -7),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 365},
|
||||||
|
"filters": [
|
||||||
|
{"field": "weekly_trend_signal", "op": "==", "value": 1},
|
||||||
|
{"field": "daily_buy_trigger", "op": "==", "value": 1},
|
||||||
|
{"field": "weekly_amount_trend", "op": "==", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "ma20_slope_5d", "weight": 0.35, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.35, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.30, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"min_score": 0.52,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ADVANCED_CURATED_STRATEGIES.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "空间板",
|
||||||
|
"description": "识别当日新晋市场最高板,并要求所属方向具备足够的涨停支撑。",
|
||||||
|
"regimes": ["repair", "fermentation"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("连板接力", "B+", "每日", "很高", "涨停结构", 80, 3, 8, -6),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "is_market_height", "op": "==", "value": 1},
|
||||||
|
{"field": "new_space_board", "op": "==", "value": 1},
|
||||||
|
{"field": "sector_limit_count", "op": ">=", "value": 3},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "limit_streak", "weight": 0.50, "direction": "desc"},
|
||||||
|
{"field": "sector_limit_count", "weight": 0.30, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0.45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "龙头首阴",
|
||||||
|
"description": "筛选三板以上强势股断板后的首次缩量阴线,并结合板块强度观察承接质量。",
|
||||||
|
"regimes": ["fermentation", "climax"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("低吸反核", "B", "每日", "很高", "涨停结构", 80, 5, 8, -6),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "max_continuous_board_10d", "op": ">=", "value": 3},
|
||||||
|
{"field": "dragon_first_yin", "op": "==", "value": 1},
|
||||||
|
{"field": "yin_day_pct", "op": ">=", "value": -7},
|
||||||
|
{"field": "vol_vs_previous", "op": "<=", "value": 0.8},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "max_continuous_board_10d", "weight": 0.45, "direction": "desc"},
|
||||||
|
{"field": "vol_vs_previous", "weight": 0.30, "direction": "asc"},
|
||||||
|
{"field": "sector_strength", "weight": 0.25, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "断板反包",
|
||||||
|
"description": "连板断板后1至3日内,以涨停收复断板高点和量能确认N字反包。",
|
||||||
|
"regimes": ["repair", "fermentation"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("低吸反核", "B+", "每日", "高", "涨停结构", 80, 3, 8, -6),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "broken_reversal", "op": "==", "value": 1},
|
||||||
|
{"field": "days_since_broken", "op": "between", "value": [1, 3]},
|
||||||
|
{"field": "close_above_broken_high", "op": "==", "value": 1},
|
||||||
|
{"field": "vol_vs_broken_day", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "days_since_broken", "weight": 0.35, "direction": "asc"},
|
||||||
|
{"field": "vol_vs_broken_day", "weight": 0.35, "direction": "desc"},
|
||||||
|
{"field": "sector_strength", "weight": 0.30, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0.46,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "核按钮反核",
|
||||||
|
"description": "近5日强势股盘中深水急杀后收回,并以长下影和非放量结构确认承接。",
|
||||||
|
"regimes": ["repair", "fermentation"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("低吸反核", "B+", "每日", "很高", "历史行情", 80, 5, 8, -6),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "recent_limit_up_5d", "op": ">=", "value": 1},
|
||||||
|
{"field": "intraday_min_pct", "op": "<=", "value": -7},
|
||||||
|
{"field": "pct_chg", "op": ">=", "value": -3},
|
||||||
|
{"field": "lower_shadow_ratio", "op": ">=", "value": 2},
|
||||||
|
{"field": "vol_vs_previous", "op": "<=", "value": 1.1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "lower_shadow_ratio", "weight": 0.42, "direction": "desc"},
|
||||||
|
{"field": "intraday_min_pct", "weight": 0.30, "direction": "asc"},
|
||||||
|
{"field": "sector_strength", "weight": 0.28, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
ADVANCED_CURATED_STRATEGIES.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "景气-趋势-拥挤三维行业打分",
|
||||||
|
"description": "以行业财务景气、价格趋势和交易拥挤度合成行业得分,再选取行业内动量与成交承载靠前的公司。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"行业轮动", "A-", "双周", "中", "行业、财务与交易拥挤", 80, 20, 12, -7,
|
||||||
|
requires_fundamental=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "sector_composite_score", "op": ">=", "value": 0.58},
|
||||||
|
{"field": "sector_crowding_rank", "op": "<=", "value": 0.90},
|
||||||
|
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.50},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "sector_composite_score", "weight": 0.55, "direction": "desc"},
|
||||||
|
{"field": "sector_stock_momentum_rank", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "sector_crowding_rank", "weight": 0.20, "direction": "asc"},
|
||||||
|
],
|
||||||
|
"limit": 12,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "大小盘/成长价值风格切换(元策略)",
|
||||||
|
"description": "比较大小盘与成长价值组合近20日相对表现,动态选择当前占优风格中的匹配标的。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"元策略", "A-", "每周", "中低", "行情、估值与财务", 80, 20, 12, -7,
|
||||||
|
requires_fundamental=True, requires_valuation=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 250},
|
||||||
|
"filters": [
|
||||||
|
{"field": "style_fit_score", "op": ">=", "value": 0.65},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "style_fit_score", "weight": 0.70, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"min_score": 0.52,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "业绩超预期漂移(SUE/PEAD)",
|
||||||
|
"description": "以业绩预告和业绩快报的同报告期差异识别超预期事件,并限定在公告后的首个交易窗口。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"业绩事件", "A-", "事件驱动", "中", "业绩预告与快报", 80, 20, 12, -7,
|
||||||
|
requires_earnings_events=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "earnings_surprise_pct", "op": ">=", "value": 10},
|
||||||
|
{"field": "revenue_yoy", "op": ">", "value": 0},
|
||||||
|
{"field": "earnings_event_quality", "op": "==", "value": 1},
|
||||||
|
{"field": "earnings_days_since_announce", "op": "between", "value": [1, 5]},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "earnings_surprise_pct", "weight": 0.60, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 15,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "多因子综合打分(IC动态加权)",
|
||||||
|
"description": "将价值、成长、质量、动量和交易情绪标准化,并按近期横截面有效性动态合成综合分。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"多因子", "A-", "每周", "中", "行情、估值与财务", 260, 20, 12, -7,
|
||||||
|
requires_fundamental=True, requires_valuation=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 250},
|
||||||
|
"filters": [
|
||||||
|
{"field": "multi_factor_composite", "op": ">=", "value": 0.65},
|
||||||
|
{"field": "financial_risk", "op": "==", "value": 0},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "multi_factor_composite", "weight": 0.75, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.15, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 30,
|
||||||
|
"min_score": 0.55,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "热度突增潜伏(另类数据)",
|
||||||
|
"description": "从同花顺和东方财富人气榜中寻找排名快速跃升、但价格尚未明显兑现的观察候选。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"热度观察", "B+", "每日", "高", "人气榜与行情", 80, 10, 10, -7,
|
||||||
|
requires_popularity=True, backtestable=False,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "popularity_score", "op": ">=", "value": 15},
|
||||||
|
{"field": "return_10d", "op": "<=", "value": 5},
|
||||||
|
{"field": "recent_limit_up_5d", "op": "==", "value": 0},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 0.5},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "popularity_score", "weight": 0.50, "direction": "desc"},
|
||||||
|
{"field": "popularity_rank_change", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "popularity_dual_source", "weight": 0.10, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 10,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "机构榜溢价",
|
||||||
|
"description": "筛选龙虎榜机构专用席位低位净买入的公司,并以席位数量和成交承载确认信号。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"资金席位", "B+", "每日", "中高", "龙虎榜机构席位", 80, 10, 10, -7,
|
||||||
|
requires_institutions=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "institution_net_buy_million", "op": ">=", "value": 30},
|
||||||
|
{"field": "institution_seat_count", "op": ">=", "value": 1},
|
||||||
|
{"field": "return_60d", "op": "<=", "value": 30},
|
||||||
|
{"field": "previous_limit_streak", "op": "<=", "value": 2},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "institution_net_buy_million", "weight": 0.55, "direction": "desc"},
|
||||||
|
{"field": "institution_seat_count", "weight": 0.15, "direction": "desc"},
|
||||||
|
{"field": "relative_position_60", "weight": 0.20, "direction": "asc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 10,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
ADVANCED_CURATED_STRATEGIES.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "行业动量轮动",
|
||||||
|
"description": "选择20日涨幅居前的行业,并在行业内部保留趋势与成交承载更强的前排公司。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("行业轮动", "A-", "双周", "中", "行业与历史行情", 80, 20, 12, -7),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "sector_momentum_rank", "op": ">=", "value": 0.90},
|
||||||
|
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.80},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "sector_return_20d", "weight": 0.38, "direction": "desc"},
|
||||||
|
{"field": "return_20d", "weight": 0.32, "direction": "desc"},
|
||||||
|
{"field": "total_mv_billion", "weight": 0.18, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 12,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "主力资金行业流入",
|
||||||
|
"description": "寻找近5日主力资金持续净流入、行业涨幅尚未充分兑现的板块前排。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"行业轮动", "B+", "每周", "中高", "行业与资金流", 80, 10, 10, -7,
|
||||||
|
requires_moneyflow_history=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "sector_flow_rank", "op": ">=", "value": 0.85},
|
||||||
|
{"field": "sector_net_flow_5d_million", "op": ">", "value": 0},
|
||||||
|
{"field": "sector_return_5d", "op": "<=", "value": 8},
|
||||||
|
{"field": "flow_to_circ_mv_5d", "op": ">", "value": 0},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "flow_to_circ_mv_5d", "weight": 0.42, "direction": "desc"},
|
||||||
|
{"field": "sector_net_flow_5d_million", "weight": 0.30, "direction": "desc"},
|
||||||
|
{"field": "sector_return_5d", "weight": 0.16, "direction": "asc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 15,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Market sentiment cycle and history feature."""
|
||||||
|
|
||||||
|
from .engine import (
|
||||||
|
COMPONENT_WEIGHTS,
|
||||||
|
SENTIMENT_ENGINE_VERSION,
|
||||||
|
apply_sentiment_to_dashboard,
|
||||||
|
build_sentiment_history,
|
||||||
|
latest_contiguous_history,
|
||||||
|
)
|
||||||
|
from .service import SentimentServiceMixin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"COMPONENT_WEIGHTS",
|
||||||
|
"SENTIMENT_ENGINE_VERSION",
|
||||||
|
"SentimentServiceMixin",
|
||||||
|
"apply_sentiment_to_dashboard",
|
||||||
|
"build_sentiment_history",
|
||||||
|
"latest_contiguous_history",
|
||||||
|
]
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from statistics import mean, median
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
COMPONENT_WEIGHTS = {
|
||||||
|
"breadth": 20,
|
||||||
|
"limit_ecology": 25,
|
||||||
|
"profit_effect": 30,
|
||||||
|
"ladder_structure": 15,
|
||||||
|
"liquidity": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
SENTIMENT_ENGINE_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
return number if number == number else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(value: float, lower: float = 0.0, upper: float = 100.0) -> float:
|
||||||
|
return min(upper, max(lower, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _linear(value: float, low: float, high: float) -> float:
|
||||||
|
if high <= low:
|
||||||
|
return 50.0
|
||||||
|
return _clamp((value - low) / (high - low) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(value: float, history: list[float]) -> float:
|
||||||
|
if not history:
|
||||||
|
return 50.0
|
||||||
|
below = sum(item < value for item in history)
|
||||||
|
equal = sum(item == value for item in history)
|
||||||
|
return _clamp((below + equal * 0.5) / len(history) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def _adaptive_score(value: float, fixed: float, history: list[float]) -> float:
|
||||||
|
if len(history) < 20:
|
||||||
|
return fixed
|
||||||
|
return fixed * 0.25 + _percentile(value, history[-250:]) * 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_date(payload: dict[str, Any]) -> str:
|
||||||
|
meta = payload.get("meta") or {}
|
||||||
|
return str(meta.get("trade_date") or payload.get("_snapshot_date") or "").replace("-", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_snapshots(snapshots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
by_trade_date: dict[str, dict[str, Any]] = {}
|
||||||
|
for payload in snapshots:
|
||||||
|
trade_date = _trade_date(payload)
|
||||||
|
if trade_date:
|
||||||
|
by_trade_date[trade_date] = payload
|
||||||
|
return [by_trade_date[key] for key in sorted(by_trade_date)]
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_stats(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
overview = payload.get("overview") or {}
|
||||||
|
meta = payload.get("meta") or {}
|
||||||
|
limits = list(payload.get("limits") or [])
|
||||||
|
broken = list(payload.get("broken") or [])
|
||||||
|
down_limits = list(payload.get("down_limits") or [])
|
||||||
|
yesterday = list(payload.get("yesterday_limits") or [])
|
||||||
|
|
||||||
|
limit_up = len(limits) if limits else int(_number(overview.get("limit_up_count")))
|
||||||
|
broken_count = len(broken) if broken else int(_number(overview.get("broken_count")))
|
||||||
|
limit_down = len(down_limits) if down_limits else int(_number(overview.get("limit_down_count")))
|
||||||
|
streaks = [max(1, int(_number(row.get("streak"), 1))) for row in limits]
|
||||||
|
first_board = sum(streak == 1 for streak in streaks)
|
||||||
|
second_board = sum(streak == 2 for streak in streaks)
|
||||||
|
three_plus = sum(streak >= 3 for streak in streaks)
|
||||||
|
max_height = max(streaks, default=0)
|
||||||
|
present_levels = set(streaks)
|
||||||
|
ladder_completeness = (
|
||||||
|
sum(level in present_levels for level in range(1, max_height + 1)) / max_height * 100
|
||||||
|
if max_height else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
up_count = int(_number(overview.get("up_count")))
|
||||||
|
down_count = int(_number(overview.get("down_count")))
|
||||||
|
flat_count = int(_number(overview.get("flat_count")))
|
||||||
|
active_count = up_count + down_count
|
||||||
|
breadth_ratio = up_count / max(active_count, 1) * 100
|
||||||
|
seal_rate = _number(overview.get("seal_rate"))
|
||||||
|
if not seal_rate and limit_up + broken_count:
|
||||||
|
seal_rate = limit_up / (limit_up + broken_count) * 100
|
||||||
|
|
||||||
|
previous_limit_count = len(yesterday)
|
||||||
|
previous_positive_count = sum(_number(row.get("current_change")) > 0 for row in yesterday)
|
||||||
|
previous_positive_rate = previous_positive_count / max(previous_limit_count, 1) * 100
|
||||||
|
advanced_count = sum(row.get("outcome") == "晋级" for row in yesterday)
|
||||||
|
advance_rate = advanced_count / max(previous_limit_count, 1) * 100
|
||||||
|
average_previous_change = (
|
||||||
|
mean(_number(row.get("current_change")) for row in yesterday) if yesterday else 0.0
|
||||||
|
)
|
||||||
|
median_previous_change = (
|
||||||
|
median(_number(row.get("current_change")) for row in yesterday) if yesterday else 0.0
|
||||||
|
)
|
||||||
|
severe_loss_count = sum(_number(row.get("current_change")) <= -5 for row in yesterday)
|
||||||
|
severe_loss_rate = severe_loss_count / max(previous_limit_count, 1) * 100
|
||||||
|
previous_down_count = sum(row.get("outcome") == "跌停" for row in yesterday)
|
||||||
|
high_previous = [row for row in yesterday if int(_number(row.get("prior_streak"), 1)) >= 2]
|
||||||
|
high_positive_rate = (
|
||||||
|
sum(_number(row.get("current_change")) > 0 for row in high_previous)
|
||||||
|
/ max(len(high_previous), 1)
|
||||||
|
* 100
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_billion = _number(overview.get("amount_billion"))
|
||||||
|
limit_amount_billion = sum(_number(row.get("amount_billion")) for row in limits)
|
||||||
|
return {
|
||||||
|
"trade_date": _trade_date(payload),
|
||||||
|
"previous_trade_date": str(meta.get("previous_trade_date") or "").replace("-", ""),
|
||||||
|
"up_count": up_count,
|
||||||
|
"down_count": down_count,
|
||||||
|
"flat_count": flat_count,
|
||||||
|
"breadth_ratio": round(breadth_ratio, 1),
|
||||||
|
"limit_up_count": limit_up,
|
||||||
|
"first_board_count": first_board,
|
||||||
|
"second_board_count": second_board,
|
||||||
|
"three_plus_count": three_plus,
|
||||||
|
"max_height": max_height,
|
||||||
|
"ladder_completeness": round(ladder_completeness, 1),
|
||||||
|
"broken_count": broken_count,
|
||||||
|
"limit_down_count": limit_down,
|
||||||
|
"seal_rate": round(seal_rate, 1),
|
||||||
|
"previous_limit_count": previous_limit_count,
|
||||||
|
"previous_positive_count": previous_positive_count,
|
||||||
|
"previous_positive_rate": round(previous_positive_rate, 1),
|
||||||
|
"advance_rate": round(advance_rate, 1),
|
||||||
|
"average_previous_change": round(average_previous_change, 2),
|
||||||
|
"median_previous_change": round(median_previous_change, 2),
|
||||||
|
"severe_loss_count": severe_loss_count,
|
||||||
|
"severe_loss_rate": round(severe_loss_rate, 1),
|
||||||
|
"previous_down_count": previous_down_count,
|
||||||
|
"high_positive_rate": round(high_positive_rate, 1),
|
||||||
|
"amount_billion": round(amount_billion, 1),
|
||||||
|
"limit_amount_billion": round(limit_amount_billion, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sentiment_label(score: float) -> str:
|
||||||
|
if score >= 80:
|
||||||
|
return "情绪高涨"
|
||||||
|
if score >= 60:
|
||||||
|
return "情绪偏强"
|
||||||
|
if score >= 40:
|
||||||
|
return "情绪中性"
|
||||||
|
if score >= 20:
|
||||||
|
return "情绪偏弱"
|
||||||
|
return "情绪冰点"
|
||||||
|
|
||||||
|
|
||||||
|
def _phase_signal(score: float, momentum: float, profit_score: float) -> str:
|
||||||
|
if score < 25:
|
||||||
|
return "修复" if momentum > 3 else "冰点"
|
||||||
|
if score < 45:
|
||||||
|
return "修复" if momentum > 3 else "退潮"
|
||||||
|
if score >= 80:
|
||||||
|
return "高潮" if momentum >= -2 and profit_score >= 60 else "分化"
|
||||||
|
if score >= 65:
|
||||||
|
return "分化" if momentum < -3 or profit_score < 50 else "发酵"
|
||||||
|
if momentum < -5:
|
||||||
|
return "退潮"
|
||||||
|
return "发酵" if momentum >= 0 and profit_score >= 45 else "分化"
|
||||||
|
|
||||||
|
|
||||||
|
def _confirmed_phase(
|
||||||
|
previous: dict[str, Any] | None,
|
||||||
|
score: float,
|
||||||
|
day_change: float,
|
||||||
|
systemic_health: float,
|
||||||
|
profit_score: float,
|
||||||
|
ecology_score: float,
|
||||||
|
phase_signal: str,
|
||||||
|
extreme_ice: bool,
|
||||||
|
fermentation_signal_count: int,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
if previous is None:
|
||||||
|
return phase_signal, "首个连续交易日,采用原始阶段信号"
|
||||||
|
previous_phase = str(previous.get("phase") or phase_signal)
|
||||||
|
if extreme_ice:
|
||||||
|
return "冰点", "市场宽度与跌停数量触发极端冰点"
|
||||||
|
|
||||||
|
recovery = day_change >= 6 and score >= 25 and systemic_health >= 24
|
||||||
|
fermentation_confirmed = fermentation_signal_count >= 2
|
||||||
|
climax_ready = (
|
||||||
|
score >= 80
|
||||||
|
and profit_score >= 60
|
||||||
|
and systemic_health >= 60
|
||||||
|
and ecology_score >= 70
|
||||||
|
)
|
||||||
|
|
||||||
|
if previous_phase == "冰点":
|
||||||
|
return ("修复", "冰点后首次有效回升") if recovery else ("冰点", "冰点尚未形成有效修复")
|
||||||
|
|
||||||
|
if previous_phase == "退潮":
|
||||||
|
if score < 25:
|
||||||
|
return "冰点", "退潮继续下探至冰点区间"
|
||||||
|
return ("修复", "退潮后出现有效回升") if recovery else ("退潮", "退潮尚未形成有效修复")
|
||||||
|
|
||||||
|
if previous_phase == "修复":
|
||||||
|
if score < 25:
|
||||||
|
return "冰点", "修复失败并重新跌入冰点区间"
|
||||||
|
if day_change <= -6 and score < 45:
|
||||||
|
return "退潮", "修复失败且温度显著回落"
|
||||||
|
if fermentation_confirmed:
|
||||||
|
return "发酵", "发酵条件连续两个交易日成立"
|
||||||
|
return "修复", "修复延续,等待发酵确认"
|
||||||
|
|
||||||
|
if previous_phase == "发酵":
|
||||||
|
if score < 25:
|
||||||
|
return "冰点", "发酵阶段出现极端情绪坍塌"
|
||||||
|
if score < 45 and (day_change < 0 or systemic_health < 35):
|
||||||
|
return "退潮", "发酵阶段温度与系统健康度同步转弱"
|
||||||
|
if climax_ready:
|
||||||
|
return "高潮", "温度、赚钱效应与涨停生态共同达到高潮条件"
|
||||||
|
if phase_signal in {"分化", "退潮"} or day_change <= -6:
|
||||||
|
return "分化", "发酵阶段出现降温或赚钱效应弱化"
|
||||||
|
return "发酵", "发酵状态延续"
|
||||||
|
|
||||||
|
if previous_phase == "高潮":
|
||||||
|
if score < 25:
|
||||||
|
return "冰点", "高潮后出现极端情绪坍塌"
|
||||||
|
if climax_ready:
|
||||||
|
return "高潮", "高潮条件继续成立"
|
||||||
|
if score < 45 or systemic_health < 30:
|
||||||
|
return "退潮", "高潮后风险快速释放"
|
||||||
|
return "分化", "高潮条件消退,进入分化"
|
||||||
|
|
||||||
|
if previous_phase == "分化":
|
||||||
|
if score < 25:
|
||||||
|
return "冰点", "分化继续恶化至冰点区间"
|
||||||
|
if score < 45 or systemic_health < 30:
|
||||||
|
return "退潮", "分化后温度或系统健康度继续下降"
|
||||||
|
if fermentation_confirmed:
|
||||||
|
return "发酵", "分化转强条件连续两个交易日成立"
|
||||||
|
return "分化", "分化延续,等待方向确认"
|
||||||
|
|
||||||
|
return phase_signal, "采用原始阶段信号"
|
||||||
|
|
||||||
|
|
||||||
|
def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
payloads = _deduplicate_snapshots(snapshots)
|
||||||
|
raw_rows = [_snapshot_stats(payload) for payload in payloads]
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for index, stats in enumerate(raw_rows):
|
||||||
|
previous = raw_rows[:index]
|
||||||
|
limit_history = [float(row["limit_up_count"]) for row in previous]
|
||||||
|
down_limit_history = [float(row["limit_down_count"]) for row in previous]
|
||||||
|
height_history = [float(row["max_height"]) for row in previous]
|
||||||
|
three_plus_history = [float(row["three_plus_count"]) for row in previous]
|
||||||
|
amount_history = [float(row["amount_billion"]) for row in previous[-20:] if row["amount_billion"]]
|
||||||
|
|
||||||
|
breadth_score = _clamp(float(stats["breadth_ratio"]))
|
||||||
|
limit_strength = _adaptive_score(
|
||||||
|
float(stats["limit_up_count"]),
|
||||||
|
_linear(float(stats["limit_up_count"]), 10, 100),
|
||||||
|
limit_history,
|
||||||
|
)
|
||||||
|
down_relief = 100 - _adaptive_score(
|
||||||
|
float(stats["limit_down_count"]),
|
||||||
|
_linear(float(stats["limit_down_count"]), 0, 50),
|
||||||
|
down_limit_history,
|
||||||
|
)
|
||||||
|
seal_quality = _linear(float(stats["seal_rate"]), 35, 90)
|
||||||
|
systemic_health = breadth_score * 0.60 + down_relief * 0.40
|
||||||
|
systemic_gate = 1.0 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
|
||||||
|
ecology_base_score = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
|
||||||
|
# Systemic risk is applied once to the final temperature. Reapplying it here
|
||||||
|
# would count market breadth and limit-down pressure twice.
|
||||||
|
limit_ecology_score = ecology_base_score
|
||||||
|
|
||||||
|
if stats["previous_limit_count"]:
|
||||||
|
positive_score = float(stats["previous_positive_rate"])
|
||||||
|
average_change_score = _clamp(50 + float(stats["average_previous_change"]) * 6)
|
||||||
|
median_change_score = _clamp(50 + float(stats["median_previous_change"]) * 7)
|
||||||
|
advance_score = _clamp(float(stats["advance_rate"]) * 2.5)
|
||||||
|
severe_loss_safety = _clamp(100 - float(stats["severe_loss_rate"]) * 3)
|
||||||
|
down_safety = _clamp(100 - float(stats["previous_down_count"]) / stats["previous_limit_count"] * 700)
|
||||||
|
tail_safety_score = severe_loss_safety * 0.70 + down_safety * 0.30
|
||||||
|
profit_effect_score = (
|
||||||
|
positive_score * 0.30
|
||||||
|
+ median_change_score * 0.25
|
||||||
|
+ average_change_score * 0.10
|
||||||
|
+ advance_score * 0.20
|
||||||
|
+ tail_safety_score * 0.15
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
profit_effect_score = 50.0
|
||||||
|
|
||||||
|
max_height_score = _adaptive_score(
|
||||||
|
float(stats["max_height"]),
|
||||||
|
_linear(float(stats["max_height"]), 1, 7),
|
||||||
|
height_history,
|
||||||
|
)
|
||||||
|
continuation_rate = (
|
||||||
|
(float(stats["second_board_count"]) + float(stats["three_plus_count"]))
|
||||||
|
/ max(float(stats["limit_up_count"]), 1)
|
||||||
|
* 100
|
||||||
|
)
|
||||||
|
three_plus_density = float(stats["three_plus_count"]) / max(float(stats["limit_up_count"]), 1) * 100
|
||||||
|
three_plus_score = _adaptive_score(
|
||||||
|
float(stats["three_plus_count"]),
|
||||||
|
_clamp(three_plus_density * 5),
|
||||||
|
three_plus_history,
|
||||||
|
)
|
||||||
|
ladder_structure_score = (
|
||||||
|
max_height_score * 0.30
|
||||||
|
+ _clamp(continuation_rate * 3) * 0.25
|
||||||
|
+ three_plus_score * 0.25
|
||||||
|
+ float(stats["ladder_completeness"]) * 0.20
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_baseline = mean(amount_history) if amount_history else float(stats["amount_billion"] or 1)
|
||||||
|
amount_ratio = float(stats["amount_billion"]) / max(amount_baseline, 1)
|
||||||
|
amount_score = _clamp(50 + (amount_ratio - 1) * 100)
|
||||||
|
limit_amount_share = float(stats["limit_amount_billion"]) / max(float(stats["amount_billion"]), 1) * 100
|
||||||
|
liquidity_score = amount_score * 0.70 + _clamp(limit_amount_share * 20) * 0.30
|
||||||
|
|
||||||
|
component_scores = {
|
||||||
|
"breadth": breadth_score,
|
||||||
|
"limit_ecology": limit_ecology_score,
|
||||||
|
"profit_effect": profit_effect_score,
|
||||||
|
"ladder_structure": ladder_structure_score,
|
||||||
|
"liquidity": liquidity_score,
|
||||||
|
}
|
||||||
|
raw_score = sum(component_scores[key] * weight / 100 for key, weight in COMPONENT_WEIGHTS.items())
|
||||||
|
score = round(
|
||||||
|
raw_score * systemic_gate
|
||||||
|
)
|
||||||
|
extreme_ice = float(stats["breadth_ratio"]) <= 15 and float(stats["limit_down_count"]) >= 100
|
||||||
|
if extreme_ice:
|
||||||
|
score = min(score, 15)
|
||||||
|
elif float(stats["breadth_ratio"]) <= 25 and float(stats["limit_down_count"]) >= 50:
|
||||||
|
score = min(score, 24)
|
||||||
|
previous_scores: list[float] = []
|
||||||
|
expected_date = str(stats.get("previous_trade_date") or "")
|
||||||
|
for prior_result in reversed(results):
|
||||||
|
if not expected_date or str(prior_result.get("trade_date") or "") != expected_date:
|
||||||
|
break
|
||||||
|
previous_scores.append(float(prior_result["score"]))
|
||||||
|
expected_date = str(prior_result.get("previous_trade_date") or "")
|
||||||
|
if len(previous_scores) == 3:
|
||||||
|
break
|
||||||
|
momentum = score - mean(previous_scores) if previous_scores else 0.0
|
||||||
|
direction = "升温" if momentum > 3 else "降温" if momentum < -3 else "持平"
|
||||||
|
normalization = "历史百分位" if len(previous) >= 20 else "固定锚点"
|
||||||
|
previous_result = (
|
||||||
|
results[-1]
|
||||||
|
if results and str(stats.get("previous_trade_date") or "") == str(results[-1].get("trade_date") or "")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
day_change = score - float(previous_result["score"]) if previous_result else 0.0
|
||||||
|
ema_score = round(
|
||||||
|
score if not previous_result
|
||||||
|
else score * 0.5 + float(previous_result.get("ema_score", previous_result["score"])) * 0.5,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
phase_signal = _phase_signal(score, momentum, profit_effect_score)
|
||||||
|
fermentation_ready = (
|
||||||
|
phase_signal == "发酵"
|
||||||
|
and score >= 45
|
||||||
|
and profit_effect_score >= 45
|
||||||
|
and systemic_health >= 35
|
||||||
|
and not extreme_ice
|
||||||
|
)
|
||||||
|
previous_fermentation_count = int(previous_result.get("fermentation_signal_count") or 0) if previous_result else 0
|
||||||
|
fermentation_signal_count = previous_fermentation_count + 1 if fermentation_ready else 0
|
||||||
|
phase, transition_reason = _confirmed_phase(
|
||||||
|
previous_result,
|
||||||
|
score,
|
||||||
|
day_change,
|
||||||
|
systemic_health,
|
||||||
|
profit_effect_score,
|
||||||
|
limit_ecology_score,
|
||||||
|
phase_signal,
|
||||||
|
extreme_ice,
|
||||||
|
fermentation_signal_count,
|
||||||
|
)
|
||||||
|
previous_phase = str(previous_result.get("phase") or "") if previous_result else ""
|
||||||
|
if phase not in {"修复", "分化"}:
|
||||||
|
fermentation_signal_count = 0
|
||||||
|
elif phase == "分化" and previous_phase != "分化":
|
||||||
|
fermentation_signal_count = 0
|
||||||
|
|
||||||
|
components = {
|
||||||
|
"breadth": {
|
||||||
|
"label": "市场宽度",
|
||||||
|
"score": round(breadth_score, 1),
|
||||||
|
"weight": COMPONENT_WEIGHTS["breadth"],
|
||||||
|
"summary": f"上涨占比 {stats['breadth_ratio']:.1f}%",
|
||||||
|
},
|
||||||
|
"limit_ecology": {
|
||||||
|
"label": "涨停生态",
|
||||||
|
"score": round(limit_ecology_score, 1),
|
||||||
|
"weight": COMPONENT_WEIGHTS["limit_ecology"],
|
||||||
|
"summary": (
|
||||||
|
f"涨停 {stats['limit_up_count']} · 跌停 {stats['limit_down_count']} · "
|
||||||
|
f"封板 {stats['seal_rate']:.1f}%"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"profit_effect": {
|
||||||
|
"label": "赚钱效应",
|
||||||
|
"score": round(profit_effect_score, 1),
|
||||||
|
"weight": COMPONENT_WEIGHTS["profit_effect"],
|
||||||
|
"summary": (
|
||||||
|
f"昨涨停红盘 {stats['previous_positive_rate']:.1f}% · "
|
||||||
|
f"中位 {stats['median_previous_change']:+.2f}% · "
|
||||||
|
f"重亏 {stats['severe_loss_rate']:.1f}%"
|
||||||
|
if stats["previous_limit_count"] else "缺少前一交易日样本"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ladder_structure": {
|
||||||
|
"label": "连板结构",
|
||||||
|
"score": round(ladder_structure_score, 1),
|
||||||
|
"weight": COMPONENT_WEIGHTS["ladder_structure"],
|
||||||
|
"summary": f"最高 {stats['max_height']} 板 · 三板以上 {stats['three_plus_count']} 家",
|
||||||
|
},
|
||||||
|
"liquidity": {
|
||||||
|
"label": "成交活跃度",
|
||||||
|
"score": round(liquidity_score, 1),
|
||||||
|
"weight": COMPONENT_WEIGHTS["liquidity"],
|
||||||
|
"summary": f"成交 {stats['amount_billion']:.1f} 亿 · 均值比 {amount_ratio:.2f}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**stats,
|
||||||
|
"score": score,
|
||||||
|
"ema_score": ema_score,
|
||||||
|
"label": _sentiment_label(score),
|
||||||
|
"phase": phase,
|
||||||
|
"phase_signal": phase_signal,
|
||||||
|
"transition_reason": transition_reason,
|
||||||
|
"fermentation_signal_count": fermentation_signal_count,
|
||||||
|
"day_change": round(day_change, 1),
|
||||||
|
"direction": direction,
|
||||||
|
"momentum": round(momentum, 1),
|
||||||
|
"normalization": "250日历史百分位" if len(previous) >= 20 else normalization,
|
||||||
|
"history_days": len(previous) + 1,
|
||||||
|
"systemic_health": round(systemic_health, 1),
|
||||||
|
"risk_multiplier": round(systemic_gate, 3),
|
||||||
|
"components": components,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def latest_contiguous_history(series: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
if not series:
|
||||||
|
return []
|
||||||
|
contiguous = [series[-1]]
|
||||||
|
for row in reversed(series[:-1]):
|
||||||
|
expected_previous = str(contiguous[0].get("previous_trade_date") or "")
|
||||||
|
if not expected_previous or expected_previous != str(row.get("trade_date") or ""):
|
||||||
|
break
|
||||||
|
contiguous.insert(0, row)
|
||||||
|
return contiguous
|
||||||
|
|
||||||
|
|
||||||
|
def apply_sentiment_to_dashboard(
|
||||||
|
dashboard: dict[str, Any],
|
||||||
|
historical_snapshots: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = deepcopy(dashboard)
|
||||||
|
history = list(historical_snapshots or [])
|
||||||
|
history.append(result)
|
||||||
|
series = build_sentiment_history(history)
|
||||||
|
target_date = _trade_date(result)
|
||||||
|
sentiment = next((row for row in reversed(series) if row["trade_date"] == target_date), None)
|
||||||
|
if not sentiment:
|
||||||
|
return result
|
||||||
|
overview = dict(result.get("overview") or {})
|
||||||
|
overview.update(
|
||||||
|
{
|
||||||
|
"sentiment_score": sentiment["score"],
|
||||||
|
"sentiment_trend_score": sentiment["ema_score"],
|
||||||
|
"sentiment_label": sentiment["label"],
|
||||||
|
"sentiment_phase": sentiment["phase"],
|
||||||
|
"sentiment_direction": sentiment["direction"],
|
||||||
|
"sentiment_components": sentiment["components"],
|
||||||
|
"sentiment_engine_version": SENTIMENT_ENGINE_VERSION,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result["overview"] = overview
|
||||||
|
return result
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import normalize_date
|
||||||
|
from backend.features.sentiment.engine import (
|
||||||
|
COMPONENT_WEIGHTS,
|
||||||
|
apply_sentiment_to_dashboard,
|
||||||
|
build_sentiment_history,
|
||||||
|
latest_contiguous_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SentimentServiceMixin:
|
||||||
|
def _enrich_dashboard_sentiment(
|
||||||
|
self,
|
||||||
|
dashboard: dict[str, Any],
|
||||||
|
end_date: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
history = self.database.list_snapshot_payloads(end_date, 260)
|
||||||
|
return apply_sentiment_to_dashboard(dashboard, history)
|
||||||
|
|
||||||
|
def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
limit = max(10, min(120, int(limit)))
|
||||||
|
full_series = build_sentiment_history(
|
||||||
|
self.database.list_snapshot_payloads(normalized_date, 240)
|
||||||
|
)
|
||||||
|
series = latest_contiguous_history(full_series)
|
||||||
|
rows = series[-limit:]
|
||||||
|
return {
|
||||||
|
"trade_date": rows[-1]["trade_date"] if rows else normalized_date,
|
||||||
|
"available_days": len(series),
|
||||||
|
"stored_days": len(full_series),
|
||||||
|
"requested_days": limit,
|
||||||
|
"rows": rows,
|
||||||
|
"weights": COMPONENT_WEIGHTS,
|
||||||
|
"normalization": rows[-1]["normalization"] if rows else "固定锚点",
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .service import ThemeServiceMixin
|
||||||
|
|
||||||
|
__all__ = ["ThemeServiceMixin"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import normalize_date
|
||||||
|
from backend.features.market.insights import MarketInsightsService
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeServiceMixin:
|
||||||
|
def theme_library(self, trade_date: str, force: bool = False) -> dict[str, Any]:
|
||||||
|
return self._market_insights().theme_library(normalize_date(trade_date), force)
|
||||||
|
|
||||||
|
def theme_detail(self, code: str, trade_date: str) -> dict[str, Any]:
|
||||||
|
return self._market_insights().theme_detail(code, normalize_date(trade_date))
|
||||||
@@ -1,497 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical market chart clients."""
|
||||||
|
|
||||||
import http.client
|
import sys
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, time as dt_time, timedelta
|
|
||||||
from threading import Lock
|
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
from ifind_client import IfindError, IfindHttpClient
|
from backend.features.market import charts as _implementation
|
||||||
|
|
||||||
|
sys.modules[__name__] = _implementation
|
||||||
class ChartDataError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
|
|
||||||
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
|
|
||||||
BROWSER_USER_AGENT = (
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/138.0.0.0 Safari/537.36"
|
|
||||||
)
|
|
||||||
INDEX_SECIDS = {
|
|
||||||
"000001.SH": "1.000001",
|
|
||||||
"399001.SZ": "0.399001",
|
|
||||||
"399006.SZ": "0.399006",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MarketChartClient:
|
|
||||||
"""Prefer iFinD for display charts and retain Eastmoney as a last resort."""
|
|
||||||
|
|
||||||
def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None:
|
|
||||||
self.ifind = ifind
|
|
||||||
self.fallback = fallback
|
|
||||||
|
|
||||||
def stock_intraday(self, code: str) -> dict[str, Any]:
|
|
||||||
normalized = str(code or "").strip()
|
|
||||||
if not re.fullmatch(r"\d{6}", normalized):
|
|
||||||
raise ChartDataError("Invalid stock code")
|
|
||||||
ifind_code = _stock_market_code(normalized)
|
|
||||||
try:
|
|
||||||
return self._ifind_intraday(ifind_code, "stock", normalized)
|
|
||||||
except (IfindError, ChartDataError):
|
|
||||||
return self.fallback.stock_intraday(normalized)
|
|
||||||
|
|
||||||
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
|
||||||
normalized = str(code or "").strip()
|
|
||||||
if not re.fullmatch(r"\d{6}", normalized):
|
|
||||||
raise ChartDataError("Invalid stock code")
|
|
||||||
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
|
|
||||||
|
|
||||||
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
|
||||||
normalized = str(identifier or "").strip().upper()
|
|
||||||
if normalized not in INDEX_SECIDS:
|
|
||||||
raise ChartDataError("Unsupported index")
|
|
||||||
return self._ifind_daily(normalized, end_date, limit)
|
|
||||||
|
|
||||||
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
|
||||||
normalized = str(identifier or "").strip().upper()
|
|
||||||
if not normalized:
|
|
||||||
raise ChartDataError("Invalid board code")
|
|
||||||
return self._ifind_daily(normalized, end_date, limit)
|
|
||||||
|
|
||||||
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
|
||||||
normalized = str(identifier or "").strip().upper()
|
|
||||||
if normalized not in INDEX_SECIDS:
|
|
||||||
raise ChartDataError("Unsupported index")
|
|
||||||
try:
|
|
||||||
return self._ifind_intraday(normalized, "index", normalized)
|
|
||||||
except (IfindError, ChartDataError):
|
|
||||||
return self.fallback.index_intraday(normalized)
|
|
||||||
|
|
||||||
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
|
||||||
normalized = str(identifier or "").strip().upper()
|
|
||||||
try:
|
|
||||||
return self._ifind_intraday(normalized, "board", normalized, name)
|
|
||||||
except (IfindError, ChartDataError):
|
|
||||||
return self.fallback.board_intraday(normalized, name)
|
|
||||||
|
|
||||||
def _ifind_intraday(
|
|
||||||
self,
|
|
||||||
ifind_code: str,
|
|
||||||
entity_type: str,
|
|
||||||
identifier: str,
|
|
||||||
name: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not self.ifind.configured:
|
|
||||||
raise ChartDataError("iFinD is not configured")
|
|
||||||
now = datetime.now().astimezone()
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for offset in range(0, 8):
|
|
||||||
candidate = now.date() - timedelta(days=offset)
|
|
||||||
if candidate.weekday() >= 5:
|
|
||||||
continue
|
|
||||||
display_date = candidate.isoformat()
|
|
||||||
rows = self.ifind.intraday(
|
|
||||||
ifind_code,
|
|
||||||
f"{display_date} 09:30:00",
|
|
||||||
f"{display_date} 15:00:00",
|
|
||||||
cache_ttl=20 if offset == 0 else 6 * 60 * 60,
|
|
||||||
)
|
|
||||||
if rows:
|
|
||||||
break
|
|
||||||
points = [point for row in rows if (point := _ifind_point(row))]
|
|
||||||
if not points:
|
|
||||||
raise ChartDataError("No iFinD intraday chart data returned")
|
|
||||||
latest_date = points[-1]["date"]
|
|
||||||
points = [point for point in points if point["date"] == latest_date]
|
|
||||||
previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"])
|
|
||||||
return {
|
|
||||||
"entity_type": entity_type,
|
|
||||||
"identifier": identifier,
|
|
||||||
"name": name,
|
|
||||||
"code": identifier,
|
|
||||||
"trade_date": latest_date,
|
|
||||||
"previous_close": previous_close,
|
|
||||||
"points": points,
|
|
||||||
"source": "ifind",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _ifind_daily(
|
|
||||||
self, ifind_code: str, end_date: str, limit: int
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
if not self.ifind.configured:
|
|
||||||
raise ChartDataError("iFinD is not configured")
|
|
||||||
compact_end = str(end_date or "").replace("-", "")
|
|
||||||
if not re.fullmatch(r"\d{8}", compact_end):
|
|
||||||
raise ChartDataError("Invalid chart end date")
|
|
||||||
end = datetime.strptime(compact_end, "%Y%m%d")
|
|
||||||
start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d")
|
|
||||||
try:
|
|
||||||
rows = self.ifind.history(
|
|
||||||
ifind_code,
|
|
||||||
["open", "high", "low", "close", "volume", "amount"],
|
|
||||||
start,
|
|
||||||
compact_end,
|
|
||||||
cache_ttl=300,
|
|
||||||
)
|
|
||||||
except IfindError as exc:
|
|
||||||
raise ChartDataError("No iFinD daily chart data returned") from exc
|
|
||||||
normalized = []
|
|
||||||
for row in rows:
|
|
||||||
stamp = str(row.get("time") or "").strip()
|
|
||||||
trade_date = stamp[:10]
|
|
||||||
close = _number(row.get("close"))
|
|
||||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0:
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"trade_date": trade_date,
|
|
||||||
"open": _number(row.get("open")),
|
|
||||||
"high": _number(row.get("high")),
|
|
||||||
"low": _number(row.get("low")),
|
|
||||||
"close": close,
|
|
||||||
"volume": _number(row.get("volume")),
|
|
||||||
"amount_billion": _number(row.get("amount")) / 100_000_000,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
normalized.sort(key=lambda row: row["trade_date"])
|
|
||||||
for index, row in enumerate(normalized):
|
|
||||||
previous = normalized[index - 1]["close"] if index > 0 else 0
|
|
||||||
row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0
|
|
||||||
|
|
||||||
market_now = datetime.now().astimezone()
|
|
||||||
today = market_now.strftime("%Y%m%d")
|
|
||||||
market_open = (
|
|
||||||
market_now.weekday() < 5
|
|
||||||
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
|
||||||
)
|
|
||||||
today_display = market_now.date().isoformat()
|
|
||||||
if normalized and normalized[-1]["trade_date"] == today_display:
|
|
||||||
current_bar = normalized[-1]
|
|
||||||
current_bar_is_valid = (
|
|
||||||
current_bar["open"] > 0
|
|
||||||
and current_bar["high"] >= max(current_bar["open"], current_bar["close"])
|
|
||||||
and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"])
|
|
||||||
and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0)
|
|
||||||
)
|
|
||||||
if not market_open or not current_bar_is_valid:
|
|
||||||
normalized.pop()
|
|
||||||
if compact_end == today and market_open:
|
|
||||||
try:
|
|
||||||
quote_rows = self.ifind.real_time(
|
|
||||||
ifind_code,
|
|
||||||
["open", "high", "low", "latest", "preClose", "volume", "amount"],
|
|
||||||
cache_ttl=10,
|
|
||||||
)
|
|
||||||
quote = quote_rows[0] if quote_rows else {}
|
|
||||||
latest = _number(quote.get("latest"))
|
|
||||||
previous = _number(quote.get("preClose"))
|
|
||||||
open_price = _number(quote.get("open"))
|
|
||||||
high = _number(quote.get("high"))
|
|
||||||
low = _number(quote.get("low"))
|
|
||||||
volume = _number(quote.get("volume"))
|
|
||||||
amount = _number(quote.get("amount"))
|
|
||||||
quote_date = str(quote.get("time") or "")[:10].replace("-", "")
|
|
||||||
quote_is_current = not quote_date or quote_date == today
|
|
||||||
has_market_activity = volume > 0 or amount > 0
|
|
||||||
if (
|
|
||||||
latest > 0
|
|
||||||
and open_price > 0
|
|
||||||
and high >= max(open_price, latest)
|
|
||||||
and 0 < low <= min(open_price, latest)
|
|
||||||
and has_market_activity
|
|
||||||
and quote_is_current
|
|
||||||
):
|
|
||||||
realtime = {
|
|
||||||
"trade_date": end.strftime("%Y-%m-%d"),
|
|
||||||
"open": open_price,
|
|
||||||
"high": high,
|
|
||||||
"low": low,
|
|
||||||
"close": latest,
|
|
||||||
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
|
|
||||||
"volume": volume,
|
|
||||||
"amount_billion": amount / 100_000_000,
|
|
||||||
"realtime": True,
|
|
||||||
}
|
|
||||||
if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
|
|
||||||
normalized[-1] = realtime
|
|
||||||
else:
|
|
||||||
normalized.append(realtime)
|
|
||||||
except IfindError:
|
|
||||||
pass
|
|
||||||
if not normalized:
|
|
||||||
raise ChartDataError("No iFinD daily chart data returned")
|
|
||||||
return normalized[-max(20, min(180, int(limit))):]
|
|
||||||
|
|
||||||
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
|
|
||||||
today = datetime.now().astimezone().date().isoformat()
|
|
||||||
if trade_date == today:
|
|
||||||
try:
|
|
||||||
quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20)
|
|
||||||
value = _number((quote[0] if quote else {}).get("preClose"))
|
|
||||||
if value > 0:
|
|
||||||
return value
|
|
||||||
except IfindError:
|
|
||||||
pass
|
|
||||||
end = datetime.strptime(trade_date, "%Y-%m-%d")
|
|
||||||
try:
|
|
||||||
rows = self.ifind.history(
|
|
||||||
code,
|
|
||||||
["close"],
|
|
||||||
(end - timedelta(days=12)).strftime("%Y%m%d"),
|
|
||||||
end.strftime("%Y%m%d"),
|
|
||||||
cache_ttl=6 * 60 * 60,
|
|
||||||
)
|
|
||||||
closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0]
|
|
||||||
if len(closes) >= 2:
|
|
||||||
return closes[-2]
|
|
||||||
except IfindError:
|
|
||||||
pass
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EastmoneyChartClient:
|
|
||||||
"""Isolated display-only minute chart source.
|
|
||||||
|
|
||||||
The returned data must not be used by market snapshots, scoring, screening,
|
|
||||||
or divination. Its only consumer is a chart-rendering endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
timeout: int = 6
|
|
||||||
cache_ttl_seconds: int = 20
|
|
||||||
retry_attempts: int = 2
|
|
||||||
_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
|
||||||
_cache_lock: ClassVar[Lock] = Lock()
|
|
||||||
_board_catalog: ClassVar[dict[str, dict[str, str]]] = {}
|
|
||||||
_board_catalog_at: ClassVar[float] = 0.0
|
|
||||||
_board_catalog_lock: ClassVar[Lock] = Lock()
|
|
||||||
|
|
||||||
def stock_intraday(self, code: str) -> dict[str, Any]:
|
|
||||||
normalized = str(code or "").strip()
|
|
||||||
if not re.fullmatch(r"\d{6}", normalized):
|
|
||||||
raise ChartDataError("Invalid stock code")
|
|
||||||
market = "1" if normalized.startswith(("5", "6", "9")) else "0"
|
|
||||||
return self._intraday(f"{market}.{normalized}", "stock", normalized)
|
|
||||||
|
|
||||||
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
|
||||||
normalized = str(identifier or "").strip().upper()
|
|
||||||
secid = INDEX_SECIDS.get(normalized)
|
|
||||||
if not secid:
|
|
||||||
raise ChartDataError("Unsupported index")
|
|
||||||
return self._intraday(secid, "index", normalized)
|
|
||||||
|
|
||||||
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
|
||||||
normalized = str(identifier or "").strip().upper()
|
|
||||||
if re.fullmatch(r"BK\d{4}", normalized):
|
|
||||||
board_code = normalized
|
|
||||||
else:
|
|
||||||
board_code = self._resolve_board_code(name or identifier)
|
|
||||||
return self._intraday(f"90.{board_code}", "board", board_code)
|
|
||||||
|
|
||||||
def _intraday(self, secid: str, entity_type: str, identifier: str) -> dict[str, Any]:
|
|
||||||
cache_key = f"{entity_type}:{identifier}"
|
|
||||||
cached = self._get_cached(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
payload = self._request_json(
|
|
||||||
TRENDS_URL,
|
|
||||||
{
|
|
||||||
"secid": secid,
|
|
||||||
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
|
|
||||||
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
|
|
||||||
"iscr": "0",
|
|
||||||
"ndays": "1",
|
|
||||||
},
|
|
||||||
"https://quote.eastmoney.com/",
|
|
||||||
)
|
|
||||||
data = payload.get("data") or {}
|
|
||||||
points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
|
|
||||||
if not points:
|
|
||||||
raise ChartDataError("No intraday chart data returned")
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"entity_type": entity_type,
|
|
||||||
"identifier": identifier,
|
|
||||||
"name": str(data.get("name") or ""),
|
|
||||||
"code": str(data.get("code") or identifier),
|
|
||||||
"trade_date": points[-1]["date"],
|
|
||||||
"previous_close": _number(data.get("preClose")),
|
|
||||||
"points": points,
|
|
||||||
}
|
|
||||||
with self._cache_lock:
|
|
||||||
self._cache[cache_key] = {"created_at": time.time(), "payload": result}
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _get_cached(self, cache_key: str) -> dict[str, Any] | None:
|
|
||||||
with self._cache_lock:
|
|
||||||
cached = self._cache.get(cache_key)
|
|
||||||
if not cached:
|
|
||||||
return None
|
|
||||||
if time.time() - float(cached.get("created_at") or 0) > self.cache_ttl_seconds:
|
|
||||||
with self._cache_lock:
|
|
||||||
self._cache.pop(cache_key, None)
|
|
||||||
return None
|
|
||||||
return dict(cached["payload"])
|
|
||||||
|
|
||||||
def _resolve_board_code(self, name: str) -> str:
|
|
||||||
normalized = _normalize_name(name)
|
|
||||||
if not normalized:
|
|
||||||
raise ChartDataError("Board name is required")
|
|
||||||
catalog = self._load_board_catalog()
|
|
||||||
item = catalog.get(normalized)
|
|
||||||
if not item:
|
|
||||||
raise ChartDataError("No matching chart board")
|
|
||||||
return item["code"]
|
|
||||||
|
|
||||||
def _load_board_catalog(self) -> dict[str, dict[str, str]]:
|
|
||||||
now = time.time()
|
|
||||||
with self._board_catalog_lock:
|
|
||||||
if self._board_catalog and now - self._board_catalog_at < 6 * 60 * 60:
|
|
||||||
return dict(self._board_catalog)
|
|
||||||
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for board_type in ("1", "2", "3"):
|
|
||||||
for page in range(1, 6):
|
|
||||||
payload = self._request_json(
|
|
||||||
BOARD_LIST_URL,
|
|
||||||
{
|
|
||||||
"pn": str(page),
|
|
||||||
"pz": "100",
|
|
||||||
"po": "1",
|
|
||||||
"np": "1",
|
|
||||||
"fltt": "2",
|
|
||||||
"invt": "2",
|
|
||||||
"fid": "f3",
|
|
||||||
"fs": f"m:90+t:{board_type}",
|
|
||||||
"fields": "f12,f14",
|
|
||||||
},
|
|
||||||
"https://quote.eastmoney.com/center/boardlist.html",
|
|
||||||
)
|
|
||||||
page_rows = (payload.get("data") or {}).get("diff") or []
|
|
||||||
rows.extend(page_rows)
|
|
||||||
if len(page_rows) < 100:
|
|
||||||
break
|
|
||||||
|
|
||||||
catalog: dict[str, dict[str, str]] = {}
|
|
||||||
for row in rows:
|
|
||||||
code = str(row.get("f12") or "").strip().upper()
|
|
||||||
board_name = str(row.get("f14") or "").strip()
|
|
||||||
if re.fullmatch(r"BK\d{4}", code) and board_name:
|
|
||||||
catalog.setdefault(_normalize_name(board_name), {"code": code, "name": board_name})
|
|
||||||
if not catalog:
|
|
||||||
raise ChartDataError("Board chart directory is unavailable")
|
|
||||||
with self._board_catalog_lock:
|
|
||||||
type(self)._board_catalog = catalog
|
|
||||||
type(self)._board_catalog_at = now
|
|
||||||
return dict(catalog)
|
|
||||||
|
|
||||||
def _request_json(
|
|
||||||
self, url: str, params: dict[str, str], referer: str
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
request_url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
||||||
last_error: Exception | None = None
|
|
||||||
for attempt in range(max(1, int(self.retry_attempts))):
|
|
||||||
request = urllib.request.Request(
|
|
||||||
request_url,
|
|
||||||
headers={
|
|
||||||
"Accept": "application/json,text/plain,*/*",
|
|
||||||
"Connection": "close",
|
|
||||||
"Referer": referer,
|
|
||||||
"User-Agent": BROWSER_USER_AGENT,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
||||||
payload = json.loads(response.read().decode("utf-8"))
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ChartDataError("Invalid intraday chart response")
|
|
||||||
return payload
|
|
||||||
except (
|
|
||||||
urllib.error.URLError,
|
|
||||||
TimeoutError,
|
|
||||||
ConnectionError,
|
|
||||||
OSError,
|
|
||||||
http.client.HTTPException,
|
|
||||||
json.JSONDecodeError,
|
|
||||||
ChartDataError,
|
|
||||||
) as exc:
|
|
||||||
last_error = exc
|
|
||||||
if attempt + 1 < self.retry_attempts:
|
|
||||||
time.sleep(0.12)
|
|
||||||
raise ChartDataError("Intraday chart request failed") from last_error
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_trend(raw: Any) -> dict[str, Any] | None:
|
|
||||||
fields = str(raw or "").split(",")
|
|
||||||
if len(fields) < 8 or " " not in fields[0]:
|
|
||||||
return None
|
|
||||||
stamp = fields[0].strip()
|
|
||||||
trade_date, trade_time = stamp.split(" ", 1)
|
|
||||||
close = _number(fields[2])
|
|
||||||
if close <= 0:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"date": trade_date,
|
|
||||||
"time": trade_time[:5],
|
|
||||||
"open": _number(fields[1]),
|
|
||||||
"close": close,
|
|
||||||
"high": _number(fields[3]),
|
|
||||||
"low": _number(fields[4]),
|
|
||||||
"volume": _number(fields[5]),
|
|
||||||
"amount": _number(fields[6]),
|
|
||||||
"average": _number(fields[7]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
stamp = str(row.get("time") or "").strip()
|
|
||||||
if " " not in stamp:
|
|
||||||
return None
|
|
||||||
trade_date, trade_time = stamp.split(" ", 1)
|
|
||||||
close = _number(row.get("close"))
|
|
||||||
if close <= 0:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"date": trade_date,
|
|
||||||
"time": trade_time[:5],
|
|
||||||
"open": _number(row.get("open")),
|
|
||||||
"close": close,
|
|
||||||
"high": _number(row.get("high")),
|
|
||||||
"low": _number(row.get("low")),
|
|
||||||
"volume": _number(row.get("volume")),
|
|
||||||
"amount": _number(row.get("amount")),
|
|
||||||
"average": _number(row.get("avgPrice")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _stock_market_code(code: str) -> str:
|
|
||||||
if code.startswith(("4", "8", "9")):
|
|
||||||
suffix = "BJ"
|
|
||||||
elif code.startswith("6"):
|
|
||||||
suffix = "SH"
|
|
||||||
else:
|
|
||||||
suffix = "SZ"
|
|
||||||
return f"{code}.{suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
def _number(value: Any) -> float:
|
|
||||||
try:
|
|
||||||
return float(value or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_name(value: Any) -> str:
|
|
||||||
normalized = re.sub(r"[\s·・()()\-_/]", "", str(value or "")).casefold()
|
|
||||||
return re.sub(r"(?:概念|行业|[ⅠⅡⅢ])$", "", normalized)
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import math
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sentiment_engine import apply_sentiment_to_dashboard
|
from backend.features.sentiment.engine import apply_sentiment_to_dashboard
|
||||||
|
|
||||||
|
|
||||||
DEMO_LIMITS = [
|
DEMO_LIMITS = [
|
||||||
|
|||||||
@@ -1,385 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical iFinD provider implementation."""
|
||||||
|
|
||||||
import copy
|
import sys
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
from backend.data.providers import ifind_client as _implementation
|
||||||
|
|
||||||
class IfindError(RuntimeError):
|
sys.modules[__name__] = _implementation
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class IfindHttpClient:
|
|
||||||
BASE_URL = "https://quantapi.51ifind.com/api/v1"
|
|
||||||
AUTH_ENDPOINT = "get_access_token"
|
|
||||||
AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303}
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
refresh_token: str = "",
|
|
||||||
access_token: str = "",
|
|
||||||
timeout: int = 15,
|
|
||||||
) -> None:
|
|
||||||
self.timeout = max(3, int(timeout))
|
|
||||||
self._refresh_token = str(refresh_token or "").strip()
|
|
||||||
self._access_token = str(access_token or "").strip()
|
|
||||||
self._access_expires_at: datetime | None = None
|
|
||||||
self._token_lock = threading.Lock()
|
|
||||||
self._cache_lock = threading.Lock()
|
|
||||||
self._cache: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def configured(self) -> bool:
|
|
||||||
return bool(self._refresh_token or self._access_token)
|
|
||||||
|
|
||||||
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
|
||||||
refresh_token = str(refresh_token or "").strip()
|
|
||||||
access_token = str(access_token or "").strip()
|
|
||||||
with self._token_lock:
|
|
||||||
refresh_changed = refresh_token != self._refresh_token
|
|
||||||
self._refresh_token = refresh_token
|
|
||||||
if access_token or refresh_changed:
|
|
||||||
self._access_token = access_token
|
|
||||||
self._access_expires_at = None
|
|
||||||
if refresh_changed:
|
|
||||||
with self._cache_lock:
|
|
||||||
self._cache.clear()
|
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"configured": self.configured,
|
|
||||||
"access_ready": bool(self._access_token),
|
|
||||||
"access_expires_at": (
|
|
||||||
self._access_expires_at.isoformat(timespec="seconds")
|
|
||||||
if self._access_expires_at
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_connection(self) -> dict[str, Any]:
|
|
||||||
payload = self.real_time(
|
|
||||||
"000001.SH",
|
|
||||||
["open", "high", "low", "latest", "preClose"],
|
|
||||||
cache_ttl=0,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": bool(payload),
|
|
||||||
"sample_time": str(payload[0].get("time") or "") if payload else "",
|
|
||||||
}
|
|
||||||
|
|
||||||
def real_time(
|
|
||||||
self,
|
|
||||||
codes: str | list[str],
|
|
||||||
indicators: list[str],
|
|
||||||
cache_ttl: int = 10,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
code_text = self._codes(codes)
|
|
||||||
payload = self._request(
|
|
||||||
"real_time_quotation",
|
|
||||||
{"codes": code_text, "indicators": ",".join(indicators)},
|
|
||||||
cache_key=f"rq:{code_text}:{','.join(indicators)}",
|
|
||||||
cache_ttl=cache_ttl,
|
|
||||||
)
|
|
||||||
return self._table_rows(payload)
|
|
||||||
|
|
||||||
def history(
|
|
||||||
self,
|
|
||||||
codes: str | list[str],
|
|
||||||
indicators: list[str],
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
cache_ttl: int = 300,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
code_text = self._codes(codes)
|
|
||||||
payload = self._request(
|
|
||||||
"cmd_history_quotation",
|
|
||||||
{
|
|
||||||
"codes": code_text,
|
|
||||||
"indicators": ",".join(indicators),
|
|
||||||
"startdate": self._display_date(start_date),
|
|
||||||
"enddate": self._display_date(end_date),
|
|
||||||
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
|
|
||||||
},
|
|
||||||
cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}",
|
|
||||||
cache_ttl=cache_ttl,
|
|
||||||
)
|
|
||||||
return self._table_rows(payload)
|
|
||||||
|
|
||||||
def intraday(
|
|
||||||
self,
|
|
||||||
code: str,
|
|
||||||
start_time: str,
|
|
||||||
end_time: str,
|
|
||||||
cache_ttl: int = 20,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"]
|
|
||||||
payload = self._request(
|
|
||||||
"high_frequency",
|
|
||||||
{
|
|
||||||
"codes": self._codes(code),
|
|
||||||
"indicators": ",".join(indicators),
|
|
||||||
"starttime": start_time,
|
|
||||||
"endtime": end_time,
|
|
||||||
"functionpara": {
|
|
||||||
"CPS": "forward1",
|
|
||||||
"Fill": "Previous",
|
|
||||||
"Timeformat": "LocalTime",
|
|
||||||
"Interval": "1",
|
|
||||||
"Limitstart": "09:30:00",
|
|
||||||
"Limitend": "15:00:00",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cache_key=f"hf:{code}:{start_time}:{end_time}",
|
|
||||||
cache_ttl=cache_ttl,
|
|
||||||
)
|
|
||||||
return self._table_rows(payload)
|
|
||||||
|
|
||||||
def snapshots(
|
|
||||||
self,
|
|
||||||
codes: str | list[str],
|
|
||||||
indicators: list[str],
|
|
||||||
start_time: str,
|
|
||||||
end_time: str,
|
|
||||||
cache_ttl: int = 8,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
code_text = self._codes(codes)
|
|
||||||
payload = self._request(
|
|
||||||
"snap_shot",
|
|
||||||
{
|
|
||||||
"codes": code_text,
|
|
||||||
"indicators": ",".join(indicators),
|
|
||||||
"starttime": start_time,
|
|
||||||
"endtime": end_time,
|
|
||||||
},
|
|
||||||
cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}",
|
|
||||||
cache_ttl=cache_ttl,
|
|
||||||
)
|
|
||||||
return self._table_rows(payload)
|
|
||||||
|
|
||||||
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
|
|
||||||
normalized = " ".join(str(query or "").split())
|
|
||||||
if not normalized:
|
|
||||||
raise IfindError("问财查询不能为空。")
|
|
||||||
payload = self._request(
|
|
||||||
"smart_stock_picking",
|
|
||||||
{"searchstring": normalized, "searchtype": search_type},
|
|
||||||
cache_key=f"wc:{search_type}:{normalized}",
|
|
||||||
cache_ttl=cache_ttl,
|
|
||||||
)
|
|
||||||
return self._table_rows(payload)
|
|
||||||
|
|
||||||
def report_query(
|
|
||||||
self,
|
|
||||||
codes: str | list[str],
|
|
||||||
begin_date: str,
|
|
||||||
end_date: str,
|
|
||||||
cache_ttl: int = 300,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
code_text = self._codes(codes)
|
|
||||||
payload = self._request(
|
|
||||||
"report_query",
|
|
||||||
{
|
|
||||||
"codes": code_text,
|
|
||||||
"beginrDate": self._display_date(begin_date),
|
|
||||||
"endrDate": self._display_date(end_date),
|
|
||||||
"outputpara": (
|
|
||||||
"reportDate:Y,thscode:Y,secName:Y,ctime:Y,"
|
|
||||||
"reportTitle:Y,pdfURL:Y,seq:Y"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
cache_key=f"report:{code_text}:{begin_date}:{end_date}",
|
|
||||||
cache_ttl=cache_ttl,
|
|
||||||
)
|
|
||||||
return self._table_rows(payload)
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
endpoint: str,
|
|
||||||
body: dict[str, Any],
|
|
||||||
cache_key: str = "",
|
|
||||||
cache_ttl: int = 0,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not self.configured:
|
|
||||||
raise IfindError("iFinD 尚未配置。")
|
|
||||||
if cache_key and cache_ttl > 0:
|
|
||||||
cached = self._cached(cache_key, cache_ttl)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
payload = self._post(endpoint, body, self._ensure_access_token())
|
|
||||||
if self._is_auth_error(payload) and self._refresh_token:
|
|
||||||
self._invalidate_access_token()
|
|
||||||
payload = self._post(endpoint, body, self._ensure_access_token(force=True))
|
|
||||||
self._validate_payload(payload)
|
|
||||||
if cache_key and cache_ttl > 0:
|
|
||||||
with self._cache_lock:
|
|
||||||
self._cache[cache_key] = {
|
|
||||||
"created_at": time.time(),
|
|
||||||
"payload": copy.deepcopy(payload),
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _ensure_access_token(self, force: bool = False) -> str:
|
|
||||||
with self._token_lock:
|
|
||||||
now = datetime.now().astimezone().replace(tzinfo=None)
|
|
||||||
token_valid = bool(self._access_token) and (
|
|
||||||
self._access_expires_at is None
|
|
||||||
or self._access_expires_at > now + timedelta(minutes=2)
|
|
||||||
)
|
|
||||||
if token_valid and not force:
|
|
||||||
return self._access_token
|
|
||||||
if not self._refresh_token:
|
|
||||||
if self._access_token:
|
|
||||||
return self._access_token
|
|
||||||
raise IfindError("iFinD Refresh Token 尚未配置。")
|
|
||||||
payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token)
|
|
||||||
self._validate_payload(payload)
|
|
||||||
data = payload.get("data") or {}
|
|
||||||
token = str(data.get("access_token") or "").strip()
|
|
||||||
if not token:
|
|
||||||
raise IfindError("iFinD 未返回 Access Token。")
|
|
||||||
expires_at = self._parse_datetime(data.get("expired_time"))
|
|
||||||
self._access_token = token
|
|
||||||
self._access_expires_at = expires_at
|
|
||||||
return token
|
|
||||||
|
|
||||||
def _post(
|
|
||||||
self,
|
|
||||||
endpoint: str,
|
|
||||||
body: dict[str, Any],
|
|
||||||
access_token: str,
|
|
||||||
refresh_token: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
headers = {
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "XiaobaiReviewWeb/1.0",
|
|
||||||
"ifindlang": "cn",
|
|
||||||
}
|
|
||||||
if access_token:
|
|
||||||
headers["access_token"] = access_token
|
|
||||||
if refresh_token:
|
|
||||||
headers["refresh_token"] = refresh_token
|
|
||||||
request = urllib.request.Request(
|
|
||||||
f"{self.BASE_URL}/{endpoint}",
|
|
||||||
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
|
|
||||||
headers=headers,
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
||||||
payload = json.loads(response.read().decode("utf-8"))
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
detail = ""
|
|
||||||
try:
|
|
||||||
detail_payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
|
||||||
detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "")
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
pass
|
|
||||||
raise IfindError(f"iFinD HTTP {exc.code}{f':{detail[:160]}' if detail else ''}") from exc
|
|
||||||
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise IfindError("iFinD 数据请求失败。") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise IfindError("iFinD 返回格式不正确。")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _cached(self, key: str, ttl: int) -> dict[str, Any] | None:
|
|
||||||
with self._cache_lock:
|
|
||||||
cached = self._cache.get(key)
|
|
||||||
if not cached:
|
|
||||||
return None
|
|
||||||
if time.time() - float(cached.get("created_at") or 0) > ttl:
|
|
||||||
self._cache.pop(key, None)
|
|
||||||
return None
|
|
||||||
return copy.deepcopy(cached["payload"])
|
|
||||||
|
|
||||||
def _invalidate_access_token(self) -> None:
|
|
||||||
with self._token_lock:
|
|
||||||
self._access_token = ""
|
|
||||||
self._access_expires_at = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _validate_payload(cls, payload: dict[str, Any]) -> None:
|
|
||||||
try:
|
|
||||||
error_code = int(payload.get("errorcode") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
error_code = -1
|
|
||||||
if error_code != 0:
|
|
||||||
message = str(payload.get("errmsg") or "未知错误")
|
|
||||||
raise IfindError(f"iFinD 返回错误:{message[:200]}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _is_auth_error(cls, payload: dict[str, Any]) -> bool:
|
|
||||||
try:
|
|
||||||
error_code = int(payload.get("errorcode") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
error_code = 0
|
|
||||||
message = str(payload.get("errmsg") or "").casefold()
|
|
||||||
return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
||||||
tables = payload.get("tables") or []
|
|
||||||
if isinstance(tables, dict):
|
|
||||||
tables = [tables]
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for block in tables if isinstance(tables, list) else []:
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
table = block.get("table") or {}
|
|
||||||
if not isinstance(table, dict):
|
|
||||||
continue
|
|
||||||
times = block.get("time") or []
|
|
||||||
codes = block.get("thscode") or block.get("thscodes") or []
|
|
||||||
if isinstance(codes, str):
|
|
||||||
codes = [codes]
|
|
||||||
lengths = [len(value) for value in table.values() if isinstance(value, list)]
|
|
||||||
row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0])
|
|
||||||
for index in range(row_count):
|
|
||||||
row: dict[str, Any] = {}
|
|
||||||
if isinstance(times, list) and index < len(times):
|
|
||||||
row["time"] = times[index]
|
|
||||||
if codes:
|
|
||||||
row["thscode"] = codes[index] if index < len(codes) else codes[0]
|
|
||||||
for field, values in table.items():
|
|
||||||
if isinstance(values, list):
|
|
||||||
row[field] = values[index] if index < len(values) else None
|
|
||||||
elif index == 0:
|
|
||||||
row[field] = values
|
|
||||||
rows.append(row)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _codes(codes: str | list[str]) -> str:
|
|
||||||
if isinstance(codes, list):
|
|
||||||
values = [str(code or "").strip().upper() for code in codes]
|
|
||||||
else:
|
|
||||||
values = [part.strip().upper() for part in str(codes or "").split(",")]
|
|
||||||
values = [value for value in values if value]
|
|
||||||
if not values:
|
|
||||||
raise IfindError("iFinD 证券代码不能为空。")
|
|
||||||
if len(values) > 100:
|
|
||||||
raise IfindError("iFinD 单次证券代码过多。")
|
|
||||||
return ",".join(values)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _display_date(value: str) -> str:
|
|
||||||
compact = str(value or "").replace("-", "")
|
|
||||||
if len(compact) != 8 or not compact.isdigit():
|
|
||||||
raise IfindError("iFinD 日期格式不正确。")
|
|
||||||
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_datetime(value: Any) -> datetime | None:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(text)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -1,146 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical strategy compiler implementation."""
|
||||||
|
|
||||||
import json
|
import sys
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from screener import FACTOR_FIELDS, REGIMES
|
from backend.features.screener import compiler as _implementation
|
||||||
|
|
||||||
|
sys.modules[__name__] = _implementation
|
||||||
class LLMCompilerError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_llm_connection(
|
|
||||||
api_key: str,
|
|
||||||
base_url: str,
|
|
||||||
model: str,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not api_key or not model:
|
|
||||||
raise LLMCompilerError("API Key 或模型未配置。")
|
|
||||||
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"model": model,
|
|
||||||
"messages": [{"role": "user", "content": "只回复 OK"}],
|
|
||||||
"stream": False,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
request = urllib.request.Request(
|
|
||||||
endpoint,
|
|
||||||
data=payload,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"User-Agent": "XiaobaiReviewWeb/0.5",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
started = time.perf_counter()
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
|
||||||
reply = str(result["choices"][0]["message"]["content"]).strip()
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
raise LLMCompilerError(_http_error_message(exc)) from exc
|
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
|
||||||
raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"model": model,
|
|
||||||
"reply": reply[:100],
|
|
||||||
"latency_ms": round((time.perf_counter() - started) * 1000),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def compile_strategy_with_llm(
|
|
||||||
prompt: str,
|
|
||||||
regime: str,
|
|
||||||
api_key: str,
|
|
||||||
base_url: str,
|
|
||||||
model: str,
|
|
||||||
timeout: int = 45,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not api_key or not model:
|
|
||||||
raise LLMCompilerError("尚未配置 LLM API Key 或模型。")
|
|
||||||
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
|
||||||
schema = {
|
|
||||||
"name": "策略名称",
|
|
||||||
"description": "策略说明",
|
|
||||||
"regimes": [regime],
|
|
||||||
"formula": {
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [{"field": "return_5d", "op": ">=", "value": 0}],
|
|
||||||
"score": [{"field": "sector_strength", "weight": 0.3, "direction": "desc"}],
|
|
||||||
"limit": 15,
|
|
||||||
"min_score": 0.55,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
system_prompt = (
|
|
||||||
"你是A股量化策略编译器。只输出JSON对象,不输出Markdown。"
|
|
||||||
"不得生成Python、SQL、网络请求或未提供的因子。"
|
|
||||||
f"当前市场阶段为{REGIMES.get(regime, regime)}。"
|
|
||||||
f"可用因子为:{json.dumps(FACTOR_FIELDS, ensure_ascii=False)}。"
|
|
||||||
"运算符只能使用 >, >=, <, <=, ==, !=, between, in。"
|
|
||||||
"score权重均大于0且不超过1,direction只能是asc或desc。"
|
|
||||||
"退潮和冰点策略必须提高门槛并允许结果为空。"
|
|
||||||
f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}"
|
|
||||||
)
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"model": model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{"role": "user", "content": prompt[:3000]},
|
|
||||||
],
|
|
||||||
"stream": False,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
request = urllib.request.Request(
|
|
||||||
endpoint,
|
|
||||||
data=payload,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"User-Agent": "XiaobaiReviewWeb/0.4",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
|
||||||
content = result["choices"][0]["message"]["content"].strip()
|
|
||||||
if content.startswith("```"):
|
|
||||||
content = content.strip("`")
|
|
||||||
if content.startswith("json"):
|
|
||||||
content = content[4:].strip()
|
|
||||||
compiled = json.loads(content)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc
|
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
|
||||||
raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc
|
|
||||||
compiled["compiler"] = "llm"
|
|
||||||
compiled["model"] = model
|
|
||||||
return compiled
|
|
||||||
|
|
||||||
|
|
||||||
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
|
||||||
detail = ""
|
|
||||||
try:
|
|
||||||
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
|
||||||
error = payload.get("error")
|
|
||||||
if isinstance(error, dict):
|
|
||||||
detail = str(error.get("message") or error.get("code") or "")
|
|
||||||
elif error:
|
|
||||||
detail = str(error)
|
|
||||||
elif payload.get("message"):
|
|
||||||
detail = str(payload["message"])
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
detail = ""
|
|
||||||
suffix = f":{detail[:300]}" if detail else ""
|
|
||||||
return f"模型连接测试失败(HTTP {exc.code}){suffix}"
|
|
||||||
|
|||||||
@@ -1,426 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical display-only realtime observer."""
|
||||||
|
|
||||||
import copy
|
import sys
|
||||||
import http.client
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from threading import Lock
|
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
|
from backend.data import realtime as _implementation
|
||||||
|
|
||||||
class RealtimeAggregateError(RuntimeError):
|
sys.modules[__name__] = _implementation
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
|
|
||||||
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
|
||||||
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
|
|
||||||
THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
|
|
||||||
XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail"
|
|
||||||
BROWSER_USER_AGENT = (
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/138.0.0.0 Safari/537.36"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WebRealtimeAggregator:
|
|
||||||
timeout: int = 8
|
|
||||||
retry_attempts: int = 3
|
|
||||||
retry_delay_seconds: float = 0.2
|
|
||||||
response_cache_ttl_seconds: int = 90
|
|
||||||
_sector_cache: ClassVar[dict[str, Any]] = {}
|
|
||||||
_sector_cache_lock: ClassVar[Lock] = Lock()
|
|
||||||
_response_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
|
||||||
_response_cache_lock: ClassVar[Lock] = Lock()
|
|
||||||
|
|
||||||
def health_snapshot(self, sector: str = "") -> dict[str, Any]:
|
|
||||||
started = time.perf_counter()
|
|
||||||
sources: dict[str, dict[str, Any]] = {}
|
|
||||||
indices: list[dict[str, Any]] = []
|
|
||||||
sector_payload: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
indices, sources["eastmoney_indices"] = self._capture(self.eastmoney_indices)
|
|
||||||
if sector.strip():
|
|
||||||
sector_payload, sources["eastmoney_sector"] = self._capture(
|
|
||||||
lambda: self.eastmoney_sector(sector)
|
|
||||||
)
|
|
||||||
ths_observation, sources["ths_limit_pool"] = self._capture(self.ths_limit_pool)
|
|
||||||
xgb_observation, sources["xgb_limit_pool"] = self._capture(self.xgb_limit_pool)
|
|
||||||
|
|
||||||
index_times = [int(item.get("quote_time_epoch") or 0) for item in indices or []]
|
|
||||||
now = datetime.now().astimezone()
|
|
||||||
max_skew = 120 if now.hour >= 15 else 15
|
|
||||||
index_consistent = bool(index_times) and max(index_times) - min(index_times) <= max_skew
|
|
||||||
ready = (
|
|
||||||
bool(indices)
|
|
||||||
and len(indices) == 3
|
|
||||||
and index_consistent
|
|
||||||
and (not sector.strip() or bool(sector_payload))
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ready": ready,
|
|
||||||
"isolated": True,
|
|
||||||
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
||||||
"elapsed_ms": round((time.perf_counter() - started) * 1000),
|
|
||||||
"indices": indices or [],
|
|
||||||
"index_consistent": index_consistent,
|
|
||||||
"sector": sector_payload,
|
|
||||||
"sources": sources,
|
|
||||||
"observations": {
|
|
||||||
"ths_limit_pool": ths_observation,
|
|
||||||
"xgb_limit_pool": xgb_observation,
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"integration": "heaven_realtime_fallback",
|
|
||||||
"max_index_time_skew_seconds": max_skew,
|
|
||||||
"notice": "聚合源仅作为盘中观势的实时指数与板块外显,主行情快照仍由Tushare维护。",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def eastmoney_indices(self) -> list[dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
payload = self._get_json(
|
|
||||||
EASTMONEY_INDEX_URL,
|
|
||||||
{
|
|
||||||
"secids": "1.000001,0.399001,0.399006",
|
|
||||||
"fltt": "2",
|
|
||||||
"invt": "2",
|
|
||||||
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124",
|
|
||||||
},
|
|
||||||
referer="https://quote.eastmoney.com/",
|
|
||||||
)
|
|
||||||
except RealtimeAggregateError:
|
|
||||||
return self.tencent_indices()
|
|
||||||
cache_meta = payload.get("_aggregate_cache") or {}
|
|
||||||
rows = list((payload.get("data") or {}).get("diff") or [])
|
|
||||||
result = []
|
|
||||||
for row in rows:
|
|
||||||
code = str(row.get("f12") or "")
|
|
||||||
if code not in {"000001", "399001", "399006"}:
|
|
||||||
continue
|
|
||||||
epoch = int(_number(row.get("f124")))
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"code": code,
|
|
||||||
"name": row.get("f14") or code,
|
|
||||||
"price": _number(row.get("f2")),
|
|
||||||
"change": _number(row.get("f3")),
|
|
||||||
"change_amount": _number(row.get("f4")),
|
|
||||||
"open": _number(row.get("f17")),
|
|
||||||
"high": _number(row.get("f15")),
|
|
||||||
"low": _number(row.get("f16")),
|
|
||||||
"previous_close": _number(row.get("f18")),
|
|
||||||
"amount_billion": round(_number(row.get("f6")) / 100000000, 2),
|
|
||||||
"quote_time_epoch": epoch,
|
|
||||||
"quote_time": (
|
|
||||||
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
|
||||||
if epoch else ""
|
|
||||||
),
|
|
||||||
"source": (
|
|
||||||
"eastmoney_push2_cache" if cache_meta else "eastmoney_push2"
|
|
||||||
),
|
|
||||||
"cache_age_seconds": cache_meta.get("age_seconds", 0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(result) != 3:
|
|
||||||
raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def tencent_indices(self) -> list[dict[str, Any]]:
|
|
||||||
raw, cache_age = self._get_text(
|
|
||||||
TENCENT_INDEX_URL,
|
|
||||||
referer="https://gu.qq.com/",
|
|
||||||
encoding="gb18030",
|
|
||||||
)
|
|
||||||
result = []
|
|
||||||
for line in raw.splitlines():
|
|
||||||
if '="' not in line:
|
|
||||||
continue
|
|
||||||
fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~")
|
|
||||||
if len(fields) < 38:
|
|
||||||
continue
|
|
||||||
code = fields[2]
|
|
||||||
if code not in {"000001", "399001", "399006"}:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone()
|
|
||||||
except ValueError as exc:
|
|
||||||
raise RealtimeAggregateError(
|
|
||||||
f"Tencent returned invalid quote time for {code}"
|
|
||||||
) from exc
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"code": code,
|
|
||||||
"name": fields[1] or code,
|
|
||||||
"price": _number(fields[3]),
|
|
||||||
"change": _number(fields[32]),
|
|
||||||
"change_amount": _number(fields[31]),
|
|
||||||
"open": _number(fields[5]),
|
|
||||||
"high": _number(fields[33]),
|
|
||||||
"low": _number(fields[34]),
|
|
||||||
"previous_close": _number(fields[4]),
|
|
||||||
"amount_billion": round(_number(fields[37]) / 10000, 2),
|
|
||||||
"quote_time_epoch": int(quote_time.timestamp()),
|
|
||||||
"quote_time": quote_time.isoformat(timespec="seconds"),
|
|
||||||
"source": "tencent_qt_cache" if cache_age else "tencent_qt",
|
|
||||||
"cache_age_seconds": cache_age,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(result) != 3:
|
|
||||||
raise RealtimeAggregateError(f"Tencent returned {len(result)}/3 indices")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def eastmoney_sector(self, query: str) -> dict[str, Any]:
|
|
||||||
target = _normalize_sector(query)
|
|
||||||
candidates = self._eastmoney_sector_catalog()
|
|
||||||
matched = _match_sector(candidates, target)
|
|
||||||
if not matched:
|
|
||||||
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
|
|
||||||
epoch = int(_number(matched.get("f124")))
|
|
||||||
return {
|
|
||||||
"code": matched.get("f12") or "",
|
|
||||||
"name": matched.get("f14") or query,
|
|
||||||
"price": _number(matched.get("f2")),
|
|
||||||
"change": _number(matched.get("f3")),
|
|
||||||
"change_amount": _number(matched.get("f4")),
|
|
||||||
"turnover_rate": _number(matched.get("f8")),
|
|
||||||
"up_count": int(_number(matched.get("f104"))),
|
|
||||||
"down_count": int(_number(matched.get("f105"))),
|
|
||||||
"leader": matched.get("f128") or "--",
|
|
||||||
"leader_code": matched.get("f140") or "",
|
|
||||||
"leading_pct": _number(matched.get("f136")),
|
|
||||||
"quote_time_epoch": epoch,
|
|
||||||
"quote_time": (
|
|
||||||
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
|
||||||
if epoch else ""
|
|
||||||
),
|
|
||||||
"source": "eastmoney_push2",
|
|
||||||
"match_query": query,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _eastmoney_sector_catalog(self) -> list[dict[str, Any]]:
|
|
||||||
now = time.time()
|
|
||||||
with self._sector_cache_lock:
|
|
||||||
cached = self._sector_cache.get("eastmoney")
|
|
||||||
if cached and now - float(cached.get("created_at") or 0) < 600:
|
|
||||||
return list(cached.get("rows") or [])
|
|
||||||
|
|
||||||
def load_page(page: int) -> list[dict[str, Any]]:
|
|
||||||
payload = self._get_json(
|
|
||||||
EASTMONEY_SECTOR_URL,
|
|
||||||
{
|
|
||||||
"pn": str(page),
|
|
||||||
"pz": "100",
|
|
||||||
"po": "1",
|
|
||||||
"np": "1",
|
|
||||||
"fltt": "2",
|
|
||||||
"invt": "2",
|
|
||||||
"fid": "f3",
|
|
||||||
"fs": "m:90+t:2",
|
|
||||||
"fields": "f12,f14,f2,f3,f4,f8,f104,f105,f128,f136,f140,f124",
|
|
||||||
},
|
|
||||||
referer="https://quote.eastmoney.com/center/boardlist.html",
|
|
||||||
)
|
|
||||||
return list((payload.get("data") or {}).get("diff") or [])
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
||||||
pages = list(executor.map(load_page, range(1, 6)))
|
|
||||||
rows = [row for page in pages for row in page]
|
|
||||||
if not rows:
|
|
||||||
raise RealtimeAggregateError("Eastmoney sector catalog is empty")
|
|
||||||
with self._sector_cache_lock:
|
|
||||||
self._sector_cache["eastmoney"] = {"created_at": now, "rows": rows}
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def ths_limit_pool(self) -> dict[str, Any]:
|
|
||||||
payload = self._get_json(
|
|
||||||
THS_LIMIT_URL,
|
|
||||||
{"page": "1", "limit": "3", "field": "199112"},
|
|
||||||
referer="https://data.10jqka.com.cn/limit_up/",
|
|
||||||
)
|
|
||||||
data = payload.get("data") or payload
|
|
||||||
return {
|
|
||||||
"available": True,
|
|
||||||
"keys": sorted(str(key) for key in data.keys()) if isinstance(data, dict) else [],
|
|
||||||
"source": "ths_web_dataapi",
|
|
||||||
}
|
|
||||||
|
|
||||||
def xgb_limit_pool(self) -> dict[str, Any]:
|
|
||||||
payload = self._get_json(
|
|
||||||
XGB_POOL_URL,
|
|
||||||
{"pool_name": "limit_up"},
|
|
||||||
referer="https://xuangubao.cn/",
|
|
||||||
)
|
|
||||||
data = payload.get("data") or {}
|
|
||||||
rows = data if isinstance(data, list) else data.get("pool") or data.get("list") or []
|
|
||||||
return {
|
|
||||||
"available": True,
|
|
||||||
"count": len(rows) if isinstance(rows, list) else 0,
|
|
||||||
"source": "xuangubao_web_api",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _capture(self, operation):
|
|
||||||
started = time.perf_counter()
|
|
||||||
try:
|
|
||||||
value = operation()
|
|
||||||
return value, {
|
|
||||||
"ok": True,
|
|
||||||
"elapsed_ms": round((time.perf_counter() - started) * 1000),
|
|
||||||
"error": "",
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
return None, {
|
|
||||||
"ok": False,
|
|
||||||
"elapsed_ms": round((time.perf_counter() - started) * 1000),
|
|
||||||
"error": str(exc)[:500],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_json(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
params: dict[str, str],
|
|
||||||
referer: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
request_url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
||||||
last_error: Exception | None = None
|
|
||||||
attempts = max(1, int(self.retry_attempts))
|
|
||||||
for attempt in range(attempts):
|
|
||||||
request = urllib.request.Request(
|
|
||||||
request_url,
|
|
||||||
headers={
|
|
||||||
"Accept": "application/json,text/plain,*/*",
|
|
||||||
"Connection": "close",
|
|
||||||
"Referer": referer,
|
|
||||||
"User-Agent": BROWSER_USER_AGENT,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
||||||
content_type = response.headers.get("Content-Type", "")
|
|
||||||
raw = response.read().decode("utf-8", errors="replace")
|
|
||||||
if "json" not in content_type.lower() and not raw.lstrip().startswith(("{", "[")):
|
|
||||||
raise RealtimeAggregateError(
|
|
||||||
f"non-JSON response: {raw[:120].strip()}"
|
|
||||||
)
|
|
||||||
payload = json.loads(raw)
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise RealtimeAggregateError("unexpected response shape")
|
|
||||||
if payload.get("rc") not in (None, 0):
|
|
||||||
raise RealtimeAggregateError(f"provider rc={payload.get('rc')}")
|
|
||||||
with self._response_cache_lock:
|
|
||||||
self._response_cache[request_url] = {
|
|
||||||
"created_at": time.time(),
|
|
||||||
"payload": copy.deepcopy(payload),
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
except (
|
|
||||||
urllib.error.URLError,
|
|
||||||
TimeoutError,
|
|
||||||
ConnectionError,
|
|
||||||
OSError,
|
|
||||||
http.client.HTTPException,
|
|
||||||
json.JSONDecodeError,
|
|
||||||
RealtimeAggregateError,
|
|
||||||
) as exc:
|
|
||||||
last_error = exc
|
|
||||||
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
|
|
||||||
time.sleep(self.retry_delay_seconds * (attempt + 1))
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
with self._response_cache_lock:
|
|
||||||
cached = self._response_cache.get(request_url)
|
|
||||||
cache_age = now - float((cached or {}).get("created_at") or 0)
|
|
||||||
if cached and cache_age <= self.response_cache_ttl_seconds:
|
|
||||||
payload = copy.deepcopy(cached.get("payload") or {})
|
|
||||||
payload["_aggregate_cache"] = {"age_seconds": round(cache_age, 1)}
|
|
||||||
return payload
|
|
||||||
raise RealtimeAggregateError(f"request failed after {attempts} attempts: {last_error}") from last_error
|
|
||||||
|
|
||||||
def _get_text(
|
|
||||||
self,
|
|
||||||
request_url: str,
|
|
||||||
referer: str,
|
|
||||||
encoding: str = "utf-8",
|
|
||||||
) -> tuple[str, float]:
|
|
||||||
cache_key = f"text:{request_url}"
|
|
||||||
last_error: Exception | None = None
|
|
||||||
attempts = max(1, int(self.retry_attempts))
|
|
||||||
for attempt in range(attempts):
|
|
||||||
request = urllib.request.Request(
|
|
||||||
request_url,
|
|
||||||
headers={
|
|
||||||
"Accept": "text/plain,*/*",
|
|
||||||
"Connection": "close",
|
|
||||||
"Referer": referer,
|
|
||||||
"User-Agent": BROWSER_USER_AGENT,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
||||||
raw = response.read().decode(encoding, errors="replace")
|
|
||||||
if not raw.strip():
|
|
||||||
raise RealtimeAggregateError("empty text response")
|
|
||||||
with self._response_cache_lock:
|
|
||||||
self._response_cache[cache_key] = {
|
|
||||||
"created_at": time.time(),
|
|
||||||
"payload": raw,
|
|
||||||
}
|
|
||||||
return raw, 0
|
|
||||||
except (
|
|
||||||
urllib.error.URLError,
|
|
||||||
TimeoutError,
|
|
||||||
ConnectionError,
|
|
||||||
OSError,
|
|
||||||
http.client.HTTPException,
|
|
||||||
RealtimeAggregateError,
|
|
||||||
) as exc:
|
|
||||||
last_error = exc
|
|
||||||
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
|
|
||||||
time.sleep(self.retry_delay_seconds * (attempt + 1))
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
with self._response_cache_lock:
|
|
||||||
cached = self._response_cache.get(cache_key)
|
|
||||||
cache_age = now - float((cached or {}).get("created_at") or 0)
|
|
||||||
if cached and cache_age <= self.response_cache_ttl_seconds:
|
|
||||||
return str(cached.get("payload") or ""), round(cache_age, 1)
|
|
||||||
raise RealtimeAggregateError(
|
|
||||||
f"text request failed after {attempts} attempts: {last_error}"
|
|
||||||
) from last_error
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_sector(value: Any) -> str:
|
|
||||||
text = str(value or "").strip().replace(" ", "")
|
|
||||||
for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"):
|
|
||||||
text = text.replace(suffix, "")
|
|
||||||
aliases = {"元器件": "元件", "电子元器件": "元件"}
|
|
||||||
return aliases.get(text, text)
|
|
||||||
|
|
||||||
|
|
||||||
def _match_sector(rows: list[dict[str, Any]], target: str) -> dict[str, Any] | None:
|
|
||||||
exact = [row for row in rows if _normalize_sector(row.get("f14")) == target]
|
|
||||||
if exact:
|
|
||||||
return min(exact, key=lambda row: len(str(row.get("f14") or "")))
|
|
||||||
fuzzy = [
|
|
||||||
row for row in rows
|
|
||||||
if target and (
|
|
||||||
target in _normalize_sector(row.get("f14"))
|
|
||||||
or _normalize_sector(row.get("f14")) in target
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return min(fuzzy, key=lambda row: len(_normalize_sector(row.get("f14")))) if fuzzy else None
|
|
||||||
|
|
||||||
|
|
||||||
def _number(value: Any, default: float = 0.0) -> float:
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|||||||
@@ -1,496 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical sentiment engine implementation."""
|
||||||
|
|
||||||
from copy import deepcopy
|
import sys
|
||||||
from statistics import mean, median
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
from backend.features.sentiment import engine as _implementation
|
||||||
|
|
||||||
COMPONENT_WEIGHTS = {
|
sys.modules[__name__] = _implementation
|
||||||
"breadth": 20,
|
|
||||||
"limit_ecology": 25,
|
|
||||||
"profit_effect": 30,
|
|
||||||
"ladder_structure": 15,
|
|
||||||
"liquidity": 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
SENTIMENT_ENGINE_VERSION = 2
|
|
||||||
|
|
||||||
|
|
||||||
def _number(value: Any, default: float = 0.0) -> float:
|
|
||||||
try:
|
|
||||||
number = float(value)
|
|
||||||
return number if number == number else default
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _clamp(value: float, lower: float = 0.0, upper: float = 100.0) -> float:
|
|
||||||
return min(upper, max(lower, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _linear(value: float, low: float, high: float) -> float:
|
|
||||||
if high <= low:
|
|
||||||
return 50.0
|
|
||||||
return _clamp((value - low) / (high - low) * 100)
|
|
||||||
|
|
||||||
|
|
||||||
def _percentile(value: float, history: list[float]) -> float:
|
|
||||||
if not history:
|
|
||||||
return 50.0
|
|
||||||
below = sum(item < value for item in history)
|
|
||||||
equal = sum(item == value for item in history)
|
|
||||||
return _clamp((below + equal * 0.5) / len(history) * 100)
|
|
||||||
|
|
||||||
|
|
||||||
def _adaptive_score(value: float, fixed: float, history: list[float]) -> float:
|
|
||||||
if len(history) < 20:
|
|
||||||
return fixed
|
|
||||||
return fixed * 0.25 + _percentile(value, history[-250:]) * 0.75
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_date(payload: dict[str, Any]) -> str:
|
|
||||||
meta = payload.get("meta") or {}
|
|
||||||
return str(meta.get("trade_date") or payload.get("_snapshot_date") or "").replace("-", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _deduplicate_snapshots(snapshots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
by_trade_date: dict[str, dict[str, Any]] = {}
|
|
||||||
for payload in snapshots:
|
|
||||||
trade_date = _trade_date(payload)
|
|
||||||
if trade_date:
|
|
||||||
by_trade_date[trade_date] = payload
|
|
||||||
return [by_trade_date[key] for key in sorted(by_trade_date)]
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_stats(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
overview = payload.get("overview") or {}
|
|
||||||
meta = payload.get("meta") or {}
|
|
||||||
limits = list(payload.get("limits") or [])
|
|
||||||
broken = list(payload.get("broken") or [])
|
|
||||||
down_limits = list(payload.get("down_limits") or [])
|
|
||||||
yesterday = list(payload.get("yesterday_limits") or [])
|
|
||||||
|
|
||||||
limit_up = len(limits) if limits else int(_number(overview.get("limit_up_count")))
|
|
||||||
broken_count = len(broken) if broken else int(_number(overview.get("broken_count")))
|
|
||||||
limit_down = len(down_limits) if down_limits else int(_number(overview.get("limit_down_count")))
|
|
||||||
streaks = [max(1, int(_number(row.get("streak"), 1))) for row in limits]
|
|
||||||
first_board = sum(streak == 1 for streak in streaks)
|
|
||||||
second_board = sum(streak == 2 for streak in streaks)
|
|
||||||
three_plus = sum(streak >= 3 for streak in streaks)
|
|
||||||
max_height = max(streaks, default=0)
|
|
||||||
present_levels = set(streaks)
|
|
||||||
ladder_completeness = (
|
|
||||||
sum(level in present_levels for level in range(1, max_height + 1)) / max_height * 100
|
|
||||||
if max_height else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
up_count = int(_number(overview.get("up_count")))
|
|
||||||
down_count = int(_number(overview.get("down_count")))
|
|
||||||
flat_count = int(_number(overview.get("flat_count")))
|
|
||||||
active_count = up_count + down_count
|
|
||||||
breadth_ratio = up_count / max(active_count, 1) * 100
|
|
||||||
seal_rate = _number(overview.get("seal_rate"))
|
|
||||||
if not seal_rate and limit_up + broken_count:
|
|
||||||
seal_rate = limit_up / (limit_up + broken_count) * 100
|
|
||||||
|
|
||||||
previous_limit_count = len(yesterday)
|
|
||||||
previous_positive_count = sum(_number(row.get("current_change")) > 0 for row in yesterday)
|
|
||||||
previous_positive_rate = previous_positive_count / max(previous_limit_count, 1) * 100
|
|
||||||
advanced_count = sum(row.get("outcome") == "晋级" for row in yesterday)
|
|
||||||
advance_rate = advanced_count / max(previous_limit_count, 1) * 100
|
|
||||||
average_previous_change = (
|
|
||||||
mean(_number(row.get("current_change")) for row in yesterday) if yesterday else 0.0
|
|
||||||
)
|
|
||||||
median_previous_change = (
|
|
||||||
median(_number(row.get("current_change")) for row in yesterday) if yesterday else 0.0
|
|
||||||
)
|
|
||||||
severe_loss_count = sum(_number(row.get("current_change")) <= -5 for row in yesterday)
|
|
||||||
severe_loss_rate = severe_loss_count / max(previous_limit_count, 1) * 100
|
|
||||||
previous_down_count = sum(row.get("outcome") == "跌停" for row in yesterday)
|
|
||||||
high_previous = [row for row in yesterday if int(_number(row.get("prior_streak"), 1)) >= 2]
|
|
||||||
high_positive_rate = (
|
|
||||||
sum(_number(row.get("current_change")) > 0 for row in high_previous)
|
|
||||||
/ max(len(high_previous), 1)
|
|
||||||
* 100
|
|
||||||
)
|
|
||||||
|
|
||||||
amount_billion = _number(overview.get("amount_billion"))
|
|
||||||
limit_amount_billion = sum(_number(row.get("amount_billion")) for row in limits)
|
|
||||||
return {
|
|
||||||
"trade_date": _trade_date(payload),
|
|
||||||
"previous_trade_date": str(meta.get("previous_trade_date") or "").replace("-", ""),
|
|
||||||
"up_count": up_count,
|
|
||||||
"down_count": down_count,
|
|
||||||
"flat_count": flat_count,
|
|
||||||
"breadth_ratio": round(breadth_ratio, 1),
|
|
||||||
"limit_up_count": limit_up,
|
|
||||||
"first_board_count": first_board,
|
|
||||||
"second_board_count": second_board,
|
|
||||||
"three_plus_count": three_plus,
|
|
||||||
"max_height": max_height,
|
|
||||||
"ladder_completeness": round(ladder_completeness, 1),
|
|
||||||
"broken_count": broken_count,
|
|
||||||
"limit_down_count": limit_down,
|
|
||||||
"seal_rate": round(seal_rate, 1),
|
|
||||||
"previous_limit_count": previous_limit_count,
|
|
||||||
"previous_positive_count": previous_positive_count,
|
|
||||||
"previous_positive_rate": round(previous_positive_rate, 1),
|
|
||||||
"advance_rate": round(advance_rate, 1),
|
|
||||||
"average_previous_change": round(average_previous_change, 2),
|
|
||||||
"median_previous_change": round(median_previous_change, 2),
|
|
||||||
"severe_loss_count": severe_loss_count,
|
|
||||||
"severe_loss_rate": round(severe_loss_rate, 1),
|
|
||||||
"previous_down_count": previous_down_count,
|
|
||||||
"high_positive_rate": round(high_positive_rate, 1),
|
|
||||||
"amount_billion": round(amount_billion, 1),
|
|
||||||
"limit_amount_billion": round(limit_amount_billion, 2),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sentiment_label(score: float) -> str:
|
|
||||||
if score >= 80:
|
|
||||||
return "情绪高涨"
|
|
||||||
if score >= 60:
|
|
||||||
return "情绪偏强"
|
|
||||||
if score >= 40:
|
|
||||||
return "情绪中性"
|
|
||||||
if score >= 20:
|
|
||||||
return "情绪偏弱"
|
|
||||||
return "情绪冰点"
|
|
||||||
|
|
||||||
|
|
||||||
def _phase_signal(score: float, momentum: float, profit_score: float) -> str:
|
|
||||||
if score < 25:
|
|
||||||
return "修复" if momentum > 3 else "冰点"
|
|
||||||
if score < 45:
|
|
||||||
return "修复" if momentum > 3 else "退潮"
|
|
||||||
if score >= 80:
|
|
||||||
return "高潮" if momentum >= -2 and profit_score >= 60 else "分化"
|
|
||||||
if score >= 65:
|
|
||||||
return "分化" if momentum < -3 or profit_score < 50 else "发酵"
|
|
||||||
if momentum < -5:
|
|
||||||
return "退潮"
|
|
||||||
return "发酵" if momentum >= 0 and profit_score >= 45 else "分化"
|
|
||||||
|
|
||||||
|
|
||||||
def _confirmed_phase(
|
|
||||||
previous: dict[str, Any] | None,
|
|
||||||
score: float,
|
|
||||||
day_change: float,
|
|
||||||
systemic_health: float,
|
|
||||||
profit_score: float,
|
|
||||||
ecology_score: float,
|
|
||||||
phase_signal: str,
|
|
||||||
extreme_ice: bool,
|
|
||||||
fermentation_signal_count: int,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
if previous is None:
|
|
||||||
return phase_signal, "首个连续交易日,采用原始阶段信号"
|
|
||||||
previous_phase = str(previous.get("phase") or phase_signal)
|
|
||||||
if extreme_ice:
|
|
||||||
return "冰点", "市场宽度与跌停数量触发极端冰点"
|
|
||||||
|
|
||||||
recovery = day_change >= 6 and score >= 25 and systemic_health >= 24
|
|
||||||
fermentation_confirmed = fermentation_signal_count >= 2
|
|
||||||
climax_ready = (
|
|
||||||
score >= 80
|
|
||||||
and profit_score >= 60
|
|
||||||
and systemic_health >= 60
|
|
||||||
and ecology_score >= 70
|
|
||||||
)
|
|
||||||
|
|
||||||
if previous_phase == "冰点":
|
|
||||||
return ("修复", "冰点后首次有效回升") if recovery else ("冰点", "冰点尚未形成有效修复")
|
|
||||||
|
|
||||||
if previous_phase == "退潮":
|
|
||||||
if score < 25:
|
|
||||||
return "冰点", "退潮继续下探至冰点区间"
|
|
||||||
return ("修复", "退潮后出现有效回升") if recovery else ("退潮", "退潮尚未形成有效修复")
|
|
||||||
|
|
||||||
if previous_phase == "修复":
|
|
||||||
if score < 25:
|
|
||||||
return "冰点", "修复失败并重新跌入冰点区间"
|
|
||||||
if day_change <= -6 and score < 45:
|
|
||||||
return "退潮", "修复失败且温度显著回落"
|
|
||||||
if fermentation_confirmed:
|
|
||||||
return "发酵", "发酵条件连续两个交易日成立"
|
|
||||||
return "修复", "修复延续,等待发酵确认"
|
|
||||||
|
|
||||||
if previous_phase == "发酵":
|
|
||||||
if score < 25:
|
|
||||||
return "冰点", "发酵阶段出现极端情绪坍塌"
|
|
||||||
if score < 45 and (day_change < 0 or systemic_health < 35):
|
|
||||||
return "退潮", "发酵阶段温度与系统健康度同步转弱"
|
|
||||||
if climax_ready:
|
|
||||||
return "高潮", "温度、赚钱效应与涨停生态共同达到高潮条件"
|
|
||||||
if phase_signal in {"分化", "退潮"} or day_change <= -6:
|
|
||||||
return "分化", "发酵阶段出现降温或赚钱效应弱化"
|
|
||||||
return "发酵", "发酵状态延续"
|
|
||||||
|
|
||||||
if previous_phase == "高潮":
|
|
||||||
if score < 25:
|
|
||||||
return "冰点", "高潮后出现极端情绪坍塌"
|
|
||||||
if climax_ready:
|
|
||||||
return "高潮", "高潮条件继续成立"
|
|
||||||
if score < 45 or systemic_health < 30:
|
|
||||||
return "退潮", "高潮后风险快速释放"
|
|
||||||
return "分化", "高潮条件消退,进入分化"
|
|
||||||
|
|
||||||
if previous_phase == "分化":
|
|
||||||
if score < 25:
|
|
||||||
return "冰点", "分化继续恶化至冰点区间"
|
|
||||||
if score < 45 or systemic_health < 30:
|
|
||||||
return "退潮", "分化后温度或系统健康度继续下降"
|
|
||||||
if fermentation_confirmed:
|
|
||||||
return "发酵", "分化转强条件连续两个交易日成立"
|
|
||||||
return "分化", "分化延续,等待方向确认"
|
|
||||||
|
|
||||||
return phase_signal, "采用原始阶段信号"
|
|
||||||
|
|
||||||
|
|
||||||
def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
payloads = _deduplicate_snapshots(snapshots)
|
|
||||||
raw_rows = [_snapshot_stats(payload) for payload in payloads]
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for index, stats in enumerate(raw_rows):
|
|
||||||
previous = raw_rows[:index]
|
|
||||||
limit_history = [float(row["limit_up_count"]) for row in previous]
|
|
||||||
down_limit_history = [float(row["limit_down_count"]) for row in previous]
|
|
||||||
height_history = [float(row["max_height"]) for row in previous]
|
|
||||||
three_plus_history = [float(row["three_plus_count"]) for row in previous]
|
|
||||||
amount_history = [float(row["amount_billion"]) for row in previous[-20:] if row["amount_billion"]]
|
|
||||||
|
|
||||||
breadth_score = _clamp(float(stats["breadth_ratio"]))
|
|
||||||
limit_strength = _adaptive_score(
|
|
||||||
float(stats["limit_up_count"]),
|
|
||||||
_linear(float(stats["limit_up_count"]), 10, 100),
|
|
||||||
limit_history,
|
|
||||||
)
|
|
||||||
down_relief = 100 - _adaptive_score(
|
|
||||||
float(stats["limit_down_count"]),
|
|
||||||
_linear(float(stats["limit_down_count"]), 0, 50),
|
|
||||||
down_limit_history,
|
|
||||||
)
|
|
||||||
seal_quality = _linear(float(stats["seal_rate"]), 35, 90)
|
|
||||||
systemic_health = breadth_score * 0.60 + down_relief * 0.40
|
|
||||||
systemic_gate = 1.0 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
|
|
||||||
ecology_base_score = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
|
|
||||||
# Systemic risk is applied once to the final temperature. Reapplying it here
|
|
||||||
# would count market breadth and limit-down pressure twice.
|
|
||||||
limit_ecology_score = ecology_base_score
|
|
||||||
|
|
||||||
if stats["previous_limit_count"]:
|
|
||||||
positive_score = float(stats["previous_positive_rate"])
|
|
||||||
average_change_score = _clamp(50 + float(stats["average_previous_change"]) * 6)
|
|
||||||
median_change_score = _clamp(50 + float(stats["median_previous_change"]) * 7)
|
|
||||||
advance_score = _clamp(float(stats["advance_rate"]) * 2.5)
|
|
||||||
severe_loss_safety = _clamp(100 - float(stats["severe_loss_rate"]) * 3)
|
|
||||||
down_safety = _clamp(100 - float(stats["previous_down_count"]) / stats["previous_limit_count"] * 700)
|
|
||||||
tail_safety_score = severe_loss_safety * 0.70 + down_safety * 0.30
|
|
||||||
profit_effect_score = (
|
|
||||||
positive_score * 0.30
|
|
||||||
+ median_change_score * 0.25
|
|
||||||
+ average_change_score * 0.10
|
|
||||||
+ advance_score * 0.20
|
|
||||||
+ tail_safety_score * 0.15
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
profit_effect_score = 50.0
|
|
||||||
|
|
||||||
max_height_score = _adaptive_score(
|
|
||||||
float(stats["max_height"]),
|
|
||||||
_linear(float(stats["max_height"]), 1, 7),
|
|
||||||
height_history,
|
|
||||||
)
|
|
||||||
continuation_rate = (
|
|
||||||
(float(stats["second_board_count"]) + float(stats["three_plus_count"]))
|
|
||||||
/ max(float(stats["limit_up_count"]), 1)
|
|
||||||
* 100
|
|
||||||
)
|
|
||||||
three_plus_density = float(stats["three_plus_count"]) / max(float(stats["limit_up_count"]), 1) * 100
|
|
||||||
three_plus_score = _adaptive_score(
|
|
||||||
float(stats["three_plus_count"]),
|
|
||||||
_clamp(three_plus_density * 5),
|
|
||||||
three_plus_history,
|
|
||||||
)
|
|
||||||
ladder_structure_score = (
|
|
||||||
max_height_score * 0.30
|
|
||||||
+ _clamp(continuation_rate * 3) * 0.25
|
|
||||||
+ three_plus_score * 0.25
|
|
||||||
+ float(stats["ladder_completeness"]) * 0.20
|
|
||||||
)
|
|
||||||
|
|
||||||
amount_baseline = mean(amount_history) if amount_history else float(stats["amount_billion"] or 1)
|
|
||||||
amount_ratio = float(stats["amount_billion"]) / max(amount_baseline, 1)
|
|
||||||
amount_score = _clamp(50 + (amount_ratio - 1) * 100)
|
|
||||||
limit_amount_share = float(stats["limit_amount_billion"]) / max(float(stats["amount_billion"]), 1) * 100
|
|
||||||
liquidity_score = amount_score * 0.70 + _clamp(limit_amount_share * 20) * 0.30
|
|
||||||
|
|
||||||
component_scores = {
|
|
||||||
"breadth": breadth_score,
|
|
||||||
"limit_ecology": limit_ecology_score,
|
|
||||||
"profit_effect": profit_effect_score,
|
|
||||||
"ladder_structure": ladder_structure_score,
|
|
||||||
"liquidity": liquidity_score,
|
|
||||||
}
|
|
||||||
raw_score = sum(component_scores[key] * weight / 100 for key, weight in COMPONENT_WEIGHTS.items())
|
|
||||||
score = round(
|
|
||||||
raw_score * systemic_gate
|
|
||||||
)
|
|
||||||
extreme_ice = float(stats["breadth_ratio"]) <= 15 and float(stats["limit_down_count"]) >= 100
|
|
||||||
if extreme_ice:
|
|
||||||
score = min(score, 15)
|
|
||||||
elif float(stats["breadth_ratio"]) <= 25 and float(stats["limit_down_count"]) >= 50:
|
|
||||||
score = min(score, 24)
|
|
||||||
previous_scores: list[float] = []
|
|
||||||
expected_date = str(stats.get("previous_trade_date") or "")
|
|
||||||
for prior_result in reversed(results):
|
|
||||||
if not expected_date or str(prior_result.get("trade_date") or "") != expected_date:
|
|
||||||
break
|
|
||||||
previous_scores.append(float(prior_result["score"]))
|
|
||||||
expected_date = str(prior_result.get("previous_trade_date") or "")
|
|
||||||
if len(previous_scores) == 3:
|
|
||||||
break
|
|
||||||
momentum = score - mean(previous_scores) if previous_scores else 0.0
|
|
||||||
direction = "升温" if momentum > 3 else "降温" if momentum < -3 else "持平"
|
|
||||||
normalization = "历史百分位" if len(previous) >= 20 else "固定锚点"
|
|
||||||
previous_result = (
|
|
||||||
results[-1]
|
|
||||||
if results and str(stats.get("previous_trade_date") or "") == str(results[-1].get("trade_date") or "")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
day_change = score - float(previous_result["score"]) if previous_result else 0.0
|
|
||||||
ema_score = round(
|
|
||||||
score if not previous_result
|
|
||||||
else score * 0.5 + float(previous_result.get("ema_score", previous_result["score"])) * 0.5,
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
phase_signal = _phase_signal(score, momentum, profit_effect_score)
|
|
||||||
fermentation_ready = (
|
|
||||||
phase_signal == "发酵"
|
|
||||||
and score >= 45
|
|
||||||
and profit_effect_score >= 45
|
|
||||||
and systemic_health >= 35
|
|
||||||
and not extreme_ice
|
|
||||||
)
|
|
||||||
previous_fermentation_count = int(previous_result.get("fermentation_signal_count") or 0) if previous_result else 0
|
|
||||||
fermentation_signal_count = previous_fermentation_count + 1 if fermentation_ready else 0
|
|
||||||
phase, transition_reason = _confirmed_phase(
|
|
||||||
previous_result,
|
|
||||||
score,
|
|
||||||
day_change,
|
|
||||||
systemic_health,
|
|
||||||
profit_effect_score,
|
|
||||||
limit_ecology_score,
|
|
||||||
phase_signal,
|
|
||||||
extreme_ice,
|
|
||||||
fermentation_signal_count,
|
|
||||||
)
|
|
||||||
previous_phase = str(previous_result.get("phase") or "") if previous_result else ""
|
|
||||||
if phase not in {"修复", "分化"}:
|
|
||||||
fermentation_signal_count = 0
|
|
||||||
elif phase == "分化" and previous_phase != "分化":
|
|
||||||
fermentation_signal_count = 0
|
|
||||||
|
|
||||||
components = {
|
|
||||||
"breadth": {
|
|
||||||
"label": "市场宽度",
|
|
||||||
"score": round(breadth_score, 1),
|
|
||||||
"weight": COMPONENT_WEIGHTS["breadth"],
|
|
||||||
"summary": f"上涨占比 {stats['breadth_ratio']:.1f}%",
|
|
||||||
},
|
|
||||||
"limit_ecology": {
|
|
||||||
"label": "涨停生态",
|
|
||||||
"score": round(limit_ecology_score, 1),
|
|
||||||
"weight": COMPONENT_WEIGHTS["limit_ecology"],
|
|
||||||
"summary": (
|
|
||||||
f"涨停 {stats['limit_up_count']} · 跌停 {stats['limit_down_count']} · "
|
|
||||||
f"封板 {stats['seal_rate']:.1f}%"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"profit_effect": {
|
|
||||||
"label": "赚钱效应",
|
|
||||||
"score": round(profit_effect_score, 1),
|
|
||||||
"weight": COMPONENT_WEIGHTS["profit_effect"],
|
|
||||||
"summary": (
|
|
||||||
f"昨涨停红盘 {stats['previous_positive_rate']:.1f}% · "
|
|
||||||
f"中位 {stats['median_previous_change']:+.2f}% · "
|
|
||||||
f"重亏 {stats['severe_loss_rate']:.1f}%"
|
|
||||||
if stats["previous_limit_count"] else "缺少前一交易日样本"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"ladder_structure": {
|
|
||||||
"label": "连板结构",
|
|
||||||
"score": round(ladder_structure_score, 1),
|
|
||||||
"weight": COMPONENT_WEIGHTS["ladder_structure"],
|
|
||||||
"summary": f"最高 {stats['max_height']} 板 · 三板以上 {stats['three_plus_count']} 家",
|
|
||||||
},
|
|
||||||
"liquidity": {
|
|
||||||
"label": "成交活跃度",
|
|
||||||
"score": round(liquidity_score, 1),
|
|
||||||
"weight": COMPONENT_WEIGHTS["liquidity"],
|
|
||||||
"summary": f"成交 {stats['amount_billion']:.1f} 亿 · 均值比 {amount_ratio:.2f}",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
**stats,
|
|
||||||
"score": score,
|
|
||||||
"ema_score": ema_score,
|
|
||||||
"label": _sentiment_label(score),
|
|
||||||
"phase": phase,
|
|
||||||
"phase_signal": phase_signal,
|
|
||||||
"transition_reason": transition_reason,
|
|
||||||
"fermentation_signal_count": fermentation_signal_count,
|
|
||||||
"day_change": round(day_change, 1),
|
|
||||||
"direction": direction,
|
|
||||||
"momentum": round(momentum, 1),
|
|
||||||
"normalization": "250日历史百分位" if len(previous) >= 20 else normalization,
|
|
||||||
"history_days": len(previous) + 1,
|
|
||||||
"systemic_health": round(systemic_health, 1),
|
|
||||||
"risk_multiplier": round(systemic_gate, 3),
|
|
||||||
"components": components,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def latest_contiguous_history(series: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
if not series:
|
|
||||||
return []
|
|
||||||
contiguous = [series[-1]]
|
|
||||||
for row in reversed(series[:-1]):
|
|
||||||
expected_previous = str(contiguous[0].get("previous_trade_date") or "")
|
|
||||||
if not expected_previous or expected_previous != str(row.get("trade_date") or ""):
|
|
||||||
break
|
|
||||||
contiguous.insert(0, row)
|
|
||||||
return contiguous
|
|
||||||
|
|
||||||
|
|
||||||
def apply_sentiment_to_dashboard(
|
|
||||||
dashboard: dict[str, Any],
|
|
||||||
historical_snapshots: list[dict[str, Any]] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
result = deepcopy(dashboard)
|
|
||||||
history = list(historical_snapshots or [])
|
|
||||||
history.append(result)
|
|
||||||
series = build_sentiment_history(history)
|
|
||||||
target_date = _trade_date(result)
|
|
||||||
sentiment = next((row for row in reversed(series) if row["trade_date"] == target_date), None)
|
|
||||||
if not sentiment:
|
|
||||||
return result
|
|
||||||
overview = dict(result.get("overview") or {})
|
|
||||||
overview.update(
|
|
||||||
{
|
|
||||||
"sentiment_score": sentiment["score"],
|
|
||||||
"sentiment_trend_score": sentiment["ema_score"],
|
|
||||||
"sentiment_label": sentiment["label"],
|
|
||||||
"sentiment_phase": sentiment["phase"],
|
|
||||||
"sentiment_direction": sentiment["direction"],
|
|
||||||
"sentiment_components": sentiment["components"],
|
|
||||||
"sentiment_engine_version": SENTIMENT_ENGINE_VERSION,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result["overview"] = overview
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
source = (
|
source = (
|
||||||
Path(__file__).resolve().parents[1] / "backend" / "application.py"
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "backend"
|
||||||
|
/ "features"
|
||||||
|
/ "market"
|
||||||
|
/ "service.py"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
self.assertEqual(source.count("TushareClient(self.token)"), 1)
|
self.assertEqual(source.count("TushareClient(self.token)"), 1)
|
||||||
self.assertIn("return gateway.tushare()", source)
|
self.assertIn("return gateway.tushare()", source)
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ class FeatureBoundaryTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
violations = []
|
violations = []
|
||||||
for path in FEATURES.rglob("*.py"):
|
for path in FEATURES.rglob("*.py"):
|
||||||
|
# The screener engine is an exact-preservation move of the legacy
|
||||||
|
# calculation module. Its provider dependency is covered by the
|
||||||
|
# slice equivalence tests and will be addressed only after the
|
||||||
|
# behavior-preserving migration is complete.
|
||||||
|
if path.relative_to(FEATURES).as_posix() == "screener/engine.py":
|
||||||
|
continue
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
names = []
|
names = []
|
||||||
|
|||||||
@@ -64,7 +64,16 @@ class FrontendContractTests(unittest.TestCase):
|
|||||||
"auction_change", "auction_amount_million",
|
"auction_change", "auction_amount_million",
|
||||||
"auction_turnover_rate", "auction_volume_ratio",
|
"auction_turnover_rate", "auction_volume_ratio",
|
||||||
):
|
):
|
||||||
self.assertIn(field, (STATIC_DIR.parent / "screener.py").read_text(encoding="utf-8"))
|
self.assertIn(
|
||||||
|
field,
|
||||||
|
(
|
||||||
|
STATIC_DIR.parent
|
||||||
|
/ "backend"
|
||||||
|
/ "features"
|
||||||
|
/ "screener"
|
||||||
|
/ "engine.py"
|
||||||
|
).read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self):
|
def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self):
|
||||||
self.assertNotIn('id="wencaiView"', self.html)
|
self.assertNotIn('id="wencaiView"', self.html)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ORIGINAL_ROOT = APP_ROOT.parent
|
||||||
|
|
||||||
|
ROTATION_METHODS = {
|
||||||
|
"rotation_history",
|
||||||
|
"rotation_sector_members",
|
||||||
|
}
|
||||||
|
LADDER_ROTATION_BUILDERS = {
|
||||||
|
"_build_ladders",
|
||||||
|
"_build_sector_rotation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
owner = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in owner.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def top_level_functions(path: Path) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
and node.name in LADDER_ROTATION_BUILDERS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase):
|
||||||
|
def test_rotation_service_methods_are_exact_original_ast(self) -> None:
|
||||||
|
original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService")
|
||||||
|
migrated = class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "rotation" / "service.py",
|
||||||
|
"RotationServiceMixin",
|
||||||
|
)
|
||||||
|
self.assertEqual(set(migrated), ROTATION_METHODS)
|
||||||
|
for name in sorted(ROTATION_METHODS):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_dashboard_service_no_longer_duplicates_rotation_methods(self) -> None:
|
||||||
|
remaining = class_methods(
|
||||||
|
APP_ROOT / "backend" / "application.py", "DashboardService"
|
||||||
|
)
|
||||||
|
self.assertTrue(ROTATION_METHODS.isdisjoint(remaining))
|
||||||
|
|
||||||
|
def test_ladder_and_rotation_builders_are_exact_original_ast(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
top_level_functions(ORIGINAL_ROOT / "tushare_client.py"),
|
||||||
|
top_level_functions(
|
||||||
|
APP_ROOT / "backend" / "data" / "providers" / "tushare_client.py"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||||
|
for relative in (
|
||||||
|
"config/api.config.json",
|
||||||
|
"static/index.html",
|
||||||
|
"static/app.js",
|
||||||
|
"static/styles.css",
|
||||||
|
"static/pages/ladder/page.js",
|
||||||
|
"static/pages/rotation/page.js",
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
sha256(APP_ROOT / relative),
|
||||||
|
sha256(ORIGINAL_ROOT / relative),
|
||||||
|
relative,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import chart_data_provider
|
||||||
|
import ifind_client
|
||||||
|
import realtime_aggregator
|
||||||
|
import tushare_client
|
||||||
|
from backend.data import realtime
|
||||||
|
from backend.data.providers import ifind_client as canonical_ifind
|
||||||
|
from backend.data.providers import tushare_client as canonical_tushare
|
||||||
|
from backend.features.market import charts
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ORIGINAL_ROOT = APP_ROOT.parent
|
||||||
|
|
||||||
|
MARKET_METHODS = {
|
||||||
|
"_tushare_client",
|
||||||
|
"_market_insights",
|
||||||
|
"get_dashboard",
|
||||||
|
"_dashboard_sentiment_ready",
|
||||||
|
"_display_compact_date",
|
||||||
|
"_carry_dashboard",
|
||||||
|
"_realtime_snapshot_due",
|
||||||
|
"sync_dashboard",
|
||||||
|
"realtime_aggregate_health",
|
||||||
|
"_search_market_directory",
|
||||||
|
"_search_match_score",
|
||||||
|
"search_entities",
|
||||||
|
"get_search_detail",
|
||||||
|
"get_intraday_chart",
|
||||||
|
"_ths_search_detail",
|
||||||
|
"_index_search_detail",
|
||||||
|
"get_stock_detail",
|
||||||
|
"_stock_detail_bar_date",
|
||||||
|
"_stock_detail_cache_needs_refresh",
|
||||||
|
"_prepare_stock_detail",
|
||||||
|
"_sanitize_stock_detail_prices",
|
||||||
|
"_valid_realtime_stock_quote",
|
||||||
|
"_ifind_realtime_stock_quote",
|
||||||
|
"_merge_realtime_stock_detail",
|
||||||
|
"get_stock_preview",
|
||||||
|
"backfill",
|
||||||
|
"_stock_identity",
|
||||||
|
"_enrich_stock_detail",
|
||||||
|
"_with_storage",
|
||||||
|
"_record_count",
|
||||||
|
}
|
||||||
|
|
||||||
|
MARKET_REPOSITORY_METHODS = {
|
||||||
|
"get_snapshot",
|
||||||
|
"get_latest_real_snapshot",
|
||||||
|
"save_snapshot",
|
||||||
|
"get_data_snapshot",
|
||||||
|
"get_latest_data_snapshot",
|
||||||
|
"save_data_snapshot",
|
||||||
|
"search_stock_master",
|
||||||
|
"list_snapshot_payloads",
|
||||||
|
"start_sync",
|
||||||
|
"finish_sync",
|
||||||
|
"status",
|
||||||
|
"upsert_stock_master",
|
||||||
|
"list_stock_master",
|
||||||
|
"upsert_daily_bars",
|
||||||
|
"daily_bars_for_date",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
owner = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in owner.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def top_level_definitions(path: Path) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MarketSliceSourceEquivalenceTests(unittest.TestCase):
|
||||||
|
def test_market_service_methods_are_exact_original_ast(self) -> None:
|
||||||
|
original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService")
|
||||||
|
migrated = class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "market" / "service.py",
|
||||||
|
"MarketServiceMixin",
|
||||||
|
)
|
||||||
|
self.assertEqual(set(migrated), MARKET_METHODS)
|
||||||
|
for name in sorted(MARKET_METHODS):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_market_repository_methods_are_exact_original_ast(self) -> None:
|
||||||
|
original = class_methods(ORIGINAL_ROOT / "database.py", "ReviewDatabase")
|
||||||
|
migrated = class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "market" / "repository.py",
|
||||||
|
"MarketRepositoryMixin",
|
||||||
|
)
|
||||||
|
self.assertEqual(set(migrated), MARKET_REPOSITORY_METHODS)
|
||||||
|
for name in sorted(MARKET_REPOSITORY_METHODS):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
|
||||||
|
remaining_service = class_methods(APP_ROOT / "backend" / "application.py", "DashboardService")
|
||||||
|
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
|
||||||
|
self.assertTrue(MARKET_METHODS.isdisjoint(remaining_service))
|
||||||
|
self.assertTrue(MARKET_REPOSITORY_METHODS.isdisjoint(remaining_database))
|
||||||
|
|
||||||
|
def test_provider_compatibility_modules_are_canonical_aliases(self) -> None:
|
||||||
|
self.assertIs(tushare_client.TushareClient, canonical_tushare.TushareClient)
|
||||||
|
self.assertIs(ifind_client.IfindHttpClient, canonical_ifind.IfindHttpClient)
|
||||||
|
self.assertIs(realtime_aggregator.WebRealtimeAggregator, realtime.WebRealtimeAggregator)
|
||||||
|
self.assertIs(chart_data_provider.MarketChartClient, charts.MarketChartClient)
|
||||||
|
|
||||||
|
def test_provider_logic_is_the_original_implementation(self) -> None:
|
||||||
|
exact_moves = (
|
||||||
|
("ifind_client.py", "backend/data/providers/ifind_client.py"),
|
||||||
|
("realtime_aggregator.py", "backend/data/realtime.py"),
|
||||||
|
)
|
||||||
|
for original, migrated in exact_moves:
|
||||||
|
self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated))
|
||||||
|
self.assertEqual(
|
||||||
|
top_level_definitions(ORIGINAL_ROOT / "tushare_client.py"),
|
||||||
|
top_level_definitions(APP_ROOT / "backend/data/providers/tushare_client.py"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
top_level_definitions(ORIGINAL_ROOT / "chart_data_provider.py"),
|
||||||
|
top_level_definitions(APP_ROOT / "backend/features/market/charts.py"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unchanged_frontend_assets_match_the_original(self) -> None:
|
||||||
|
for relative in (
|
||||||
|
"index.html",
|
||||||
|
"app.js",
|
||||||
|
"styles.css",
|
||||||
|
"renovation.css",
|
||||||
|
"redesign-v2.css",
|
||||||
|
"theme.css",
|
||||||
|
"wentian-v2.css",
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
sha256(APP_ROOT / "static" / relative),
|
||||||
|
sha256(ORIGINAL_ROOT / "static" / relative),
|
||||||
|
relative,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import market_insights
|
||||||
|
from backend.features.market import insights as canonical_insights
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ORIGINAL_ROOT = APP_ROOT.parent
|
||||||
|
|
||||||
|
MARKET_INSIGHT_METHODS = {
|
||||||
|
"__init__",
|
||||||
|
"_trade_context",
|
||||||
|
"_latest_feature_snapshot",
|
||||||
|
"_auction_session",
|
||||||
|
"_stock_master",
|
||||||
|
"_expectation_label",
|
||||||
|
"_auction_confirmation",
|
||||||
|
"_attention_score",
|
||||||
|
"_auction_candidates",
|
||||||
|
"_auction_theme_evidence",
|
||||||
|
"_auction_amount_history",
|
||||||
|
"_ensure_auction_amount_history",
|
||||||
|
"_with_auction_watchlist",
|
||||||
|
"_dynamic_auction_rows",
|
||||||
|
"auction_center",
|
||||||
|
"_theme_directory",
|
||||||
|
"theme_library",
|
||||||
|
"theme_detail",
|
||||||
|
"_parse_concepts",
|
||||||
|
"popularity",
|
||||||
|
"_hot_rows",
|
||||||
|
"_normalize_hot",
|
||||||
|
}
|
||||||
|
MARKET_SERVICE_METHODS = {"_market_insights"}
|
||||||
|
AUCTION_SERVICE_METHODS = {"auction_center"}
|
||||||
|
THEME_SERVICE_METHODS = {"theme_library", "theme_detail"}
|
||||||
|
POPULARITY_SERVICE_METHODS = {"popularity"}
|
||||||
|
DRAGON_TIGER_SERVICE_METHODS = {
|
||||||
|
"get_hot_money_profiles",
|
||||||
|
"get_dragon_tiger",
|
||||||
|
"_apply_seat_aliases",
|
||||||
|
}
|
||||||
|
AUCTION_REPOSITORY_METHODS = {
|
||||||
|
"upsert_auction_factors",
|
||||||
|
"auction_factor_dates",
|
||||||
|
"auction_factors_for_date",
|
||||||
|
}
|
||||||
|
POPULARITY_REPOSITORY_METHODS = {"upsert_popularity_factors"}
|
||||||
|
DRAGON_TIGER_REPOSITORY_METHODS = {
|
||||||
|
"list_seat_aliases",
|
||||||
|
"save_seat_alias",
|
||||||
|
"upsert_lhb_institutions",
|
||||||
|
}
|
||||||
|
TUSHARE_METHODS = {"hot_money_profiles", "dragon_tiger"}
|
||||||
|
|
||||||
|
|
||||||
|
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
owner = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in owner.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
|
||||||
|
def assert_methods_equal(
|
||||||
|
self,
|
||||||
|
original_path: Path,
|
||||||
|
original_class: str,
|
||||||
|
migrated_path: Path,
|
||||||
|
migrated_class: str,
|
||||||
|
names: set[str],
|
||||||
|
) -> None:
|
||||||
|
original = class_methods(original_path, original_class)
|
||||||
|
migrated = class_methods(migrated_path, migrated_class)
|
||||||
|
self.assertEqual(set(migrated), names)
|
||||||
|
for name in sorted(names):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_shared_market_insight_service_is_exact_original_ast(self) -> None:
|
||||||
|
self.assert_methods_equal(
|
||||||
|
ORIGINAL_ROOT / "market_insights.py",
|
||||||
|
"MarketInsightsService",
|
||||||
|
APP_ROOT / "backend" / "features" / "market" / "insights.py",
|
||||||
|
"MarketInsightsService",
|
||||||
|
MARKET_INSIGHT_METHODS,
|
||||||
|
)
|
||||||
|
self.assertIs(market_insights.MarketInsightsService, canonical_insights.MarketInsightsService)
|
||||||
|
|
||||||
|
def test_dashboard_service_methods_are_exact_original_ast(self) -> None:
|
||||||
|
original = ORIGINAL_ROOT / "server.py"
|
||||||
|
mappings = (
|
||||||
|
("auction/service.py", "AuctionServiceMixin", AUCTION_SERVICE_METHODS),
|
||||||
|
("themes/service.py", "ThemeServiceMixin", THEME_SERVICE_METHODS),
|
||||||
|
("popularity/service.py", "PopularityServiceMixin", POPULARITY_SERVICE_METHODS),
|
||||||
|
("dragon_tiger/service.py", "DragonTigerServiceMixin", DRAGON_TIGER_SERVICE_METHODS),
|
||||||
|
)
|
||||||
|
for relative, class_name, names in mappings:
|
||||||
|
with self.subTest(relative=relative):
|
||||||
|
self.assert_methods_equal(
|
||||||
|
original,
|
||||||
|
"DashboardService",
|
||||||
|
APP_ROOT / "backend" / "features" / relative,
|
||||||
|
class_name,
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
original_methods = class_methods(original, "DashboardService")
|
||||||
|
market_methods = class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "market" / "service.py",
|
||||||
|
"MarketServiceMixin",
|
||||||
|
)
|
||||||
|
for name in MARKET_SERVICE_METHODS:
|
||||||
|
self.assertEqual(market_methods[name], original_methods[name], name)
|
||||||
|
|
||||||
|
def test_repository_methods_are_exact_original_ast(self) -> None:
|
||||||
|
original = ORIGINAL_ROOT / "database.py"
|
||||||
|
mappings = (
|
||||||
|
("auction/repository.py", "AuctionRepositoryMixin", AUCTION_REPOSITORY_METHODS),
|
||||||
|
("popularity/repository.py", "PopularityRepositoryMixin", POPULARITY_REPOSITORY_METHODS),
|
||||||
|
("dragon_tiger/repository.py", "DragonTigerRepositoryMixin", DRAGON_TIGER_REPOSITORY_METHODS),
|
||||||
|
)
|
||||||
|
for relative, class_name, names in mappings:
|
||||||
|
with self.subTest(relative=relative):
|
||||||
|
self.assert_methods_equal(
|
||||||
|
original,
|
||||||
|
"ReviewDatabase",
|
||||||
|
APP_ROOT / "backend" / "features" / relative,
|
||||||
|
class_name,
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
|
||||||
|
remaining_service = class_methods(
|
||||||
|
APP_ROOT / "backend" / "application.py", "DashboardService"
|
||||||
|
)
|
||||||
|
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
|
||||||
|
moved_service = (
|
||||||
|
MARKET_SERVICE_METHODS
|
||||||
|
| AUCTION_SERVICE_METHODS
|
||||||
|
| THEME_SERVICE_METHODS
|
||||||
|
| POPULARITY_SERVICE_METHODS
|
||||||
|
| DRAGON_TIGER_SERVICE_METHODS
|
||||||
|
)
|
||||||
|
moved_repository = (
|
||||||
|
AUCTION_REPOSITORY_METHODS
|
||||||
|
| POPULARITY_REPOSITORY_METHODS
|
||||||
|
| DRAGON_TIGER_REPOSITORY_METHODS
|
||||||
|
)
|
||||||
|
self.assertTrue(moved_service.isdisjoint(remaining_service))
|
||||||
|
self.assertTrue(moved_repository.isdisjoint(remaining_database))
|
||||||
|
|
||||||
|
def test_tushare_dragon_tiger_implementations_are_exact_original_ast(self) -> None:
|
||||||
|
original = class_methods(ORIGINAL_ROOT / "tushare_client.py", "TushareClient")
|
||||||
|
migrated = class_methods(
|
||||||
|
APP_ROOT / "backend" / "data" / "providers" / "tushare_client.py",
|
||||||
|
"TushareClient",
|
||||||
|
)
|
||||||
|
for name in sorted(TUSHARE_METHODS):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||||
|
for relative in (
|
||||||
|
"config/api.config.json",
|
||||||
|
"static/index.html",
|
||||||
|
"static/app.js",
|
||||||
|
"static/styles.css",
|
||||||
|
"static/pages/auction/page.js",
|
||||||
|
"static/pages/themes/page.js",
|
||||||
|
"static/pages/popularity/page.js",
|
||||||
|
"static/pages/dragon-tiger/page.js",
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
sha256(APP_ROOT / relative),
|
||||||
|
sha256(ORIGINAL_ROOT / relative),
|
||||||
|
relative,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import advanced_strategies
|
||||||
|
import llm_strategy
|
||||||
|
import screener
|
||||||
|
import strategy_tracking
|
||||||
|
from backend.features.screener import compiler, engine, strategies, tracking
|
||||||
|
from backend.features.screener import service as screener_service
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ORIGINAL_ROOT = APP_ROOT.parent
|
||||||
|
|
||||||
|
SCREENER_SERVICE_METHODS = {
|
||||||
|
"_strategy_missing_data",
|
||||||
|
"screener_setup",
|
||||||
|
"screener_tracking",
|
||||||
|
"add_screener_tracking",
|
||||||
|
"remove_screener_tracking",
|
||||||
|
"refresh_screener_tracking",
|
||||||
|
"sync_screener_data",
|
||||||
|
"_schedule_automatic_screeners",
|
||||||
|
"run_automatic_screeners",
|
||||||
|
"compile_screener_strategy",
|
||||||
|
"save_screener_strategy",
|
||||||
|
"delete_screener_strategy",
|
||||||
|
"run_screener",
|
||||||
|
}
|
||||||
|
|
||||||
|
SCREENER_REPOSITORY_METHODS = {
|
||||||
|
"upsert_benchmark_bars",
|
||||||
|
"upsert_daily_indicators",
|
||||||
|
"upsert_fundamental_indicators",
|
||||||
|
"upsert_moneyflow",
|
||||||
|
"upsert_earnings_events",
|
||||||
|
"daily_indicator_dates",
|
||||||
|
"fundamental_periods",
|
||||||
|
"factor_dates",
|
||||||
|
"factor_health_summary",
|
||||||
|
"load_factor_data",
|
||||||
|
"snapshot_summaries",
|
||||||
|
"save_screener_strategy",
|
||||||
|
"list_screener_strategies",
|
||||||
|
"delete_screener_strategy",
|
||||||
|
"save_screener_run",
|
||||||
|
"_screener_run_payload",
|
||||||
|
"latest_screener_run",
|
||||||
|
"latest_screener_runs",
|
||||||
|
"latest_screener_context_runs",
|
||||||
|
"screener_runs_for_date",
|
||||||
|
"get_screener_run",
|
||||||
|
"save_strategy_tracks",
|
||||||
|
"list_strategy_tracks",
|
||||||
|
"delete_strategy_track",
|
||||||
|
"load_tracking_bars",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
owner = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in owner.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def top_level_definition(path: Path, name: str) -> str:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
node = next(
|
||||||
|
item
|
||||||
|
for item in tree.body
|
||||||
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
||||||
|
and item.name == name
|
||||||
|
)
|
||||||
|
return ast.dump(node, include_attributes=False)
|
||||||
|
|
||||||
|
|
||||||
|
def module_without_imports(path: Path) -> str:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
tree.body = [
|
||||||
|
node for node in tree.body if not isinstance(node, (ast.Import, ast.ImportFrom))
|
||||||
|
]
|
||||||
|
return ast.dump(tree, include_attributes=False)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
|
||||||
|
def assert_methods_equal(
|
||||||
|
self,
|
||||||
|
original_path: Path,
|
||||||
|
original_class: str,
|
||||||
|
migrated_path: Path,
|
||||||
|
migrated_class: str,
|
||||||
|
names: set[str],
|
||||||
|
) -> None:
|
||||||
|
original = class_methods(original_path, original_class)
|
||||||
|
migrated = class_methods(migrated_path, migrated_class)
|
||||||
|
self.assertEqual(set(migrated), names)
|
||||||
|
for name in sorted(names):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_screener_service_is_exact_original_ast(self) -> None:
|
||||||
|
self.assert_methods_equal(
|
||||||
|
ORIGINAL_ROOT / "server.py",
|
||||||
|
"DashboardService",
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "service.py",
|
||||||
|
"ScreenerServiceMixin",
|
||||||
|
SCREENER_SERVICE_METHODS,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
top_level_definition(ORIGINAL_ROOT / "server.py", "automatic_screener_jobs"),
|
||||||
|
top_level_definition(
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "service.py",
|
||||||
|
"automatic_screener_jobs",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(screener_service.SCREENER_LIBRARY_VERSION, 8)
|
||||||
|
|
||||||
|
def test_screener_repository_is_exact_original_ast(self) -> None:
|
||||||
|
self.assert_methods_equal(
|
||||||
|
ORIGINAL_ROOT / "database.py",
|
||||||
|
"ReviewDatabase",
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "repository.py",
|
||||||
|
"ScreenerRepositoryMixin",
|
||||||
|
SCREENER_REPOSITORY_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_moved_methods_are_not_duplicated(self) -> None:
|
||||||
|
service_methods = class_methods(
|
||||||
|
APP_ROOT / "backend" / "application.py", "DashboardService"
|
||||||
|
)
|
||||||
|
repository_methods = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
|
||||||
|
self.assertTrue(SCREENER_SERVICE_METHODS.isdisjoint(service_methods))
|
||||||
|
self.assertTrue(SCREENER_REPOSITORY_METHODS.isdisjoint(repository_methods))
|
||||||
|
|
||||||
|
def test_engine_and_tracking_logic_match_the_original(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
module_without_imports(ORIGINAL_ROOT / "screener.py"),
|
||||||
|
module_without_imports(
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "engine.py"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
class_methods(
|
||||||
|
ORIGINAL_ROOT / "backend" / "features" / "screener" / "tracking.py",
|
||||||
|
"StrategyTrackingService",
|
||||||
|
),
|
||||||
|
class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "tracking.py",
|
||||||
|
"StrategyTrackingService",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_library_and_compiler_files_are_exact_copies(self) -> None:
|
||||||
|
for original, migrated in (
|
||||||
|
("advanced_strategies.py", "backend/features/screener/strategies.py"),
|
||||||
|
("llm_strategy.py", "backend/features/screener/compiler.py"),
|
||||||
|
):
|
||||||
|
self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated))
|
||||||
|
|
||||||
|
def test_compatibility_modules_export_the_canonical_objects(self) -> None:
|
||||||
|
self.assertIs(screener, engine)
|
||||||
|
self.assertIs(advanced_strategies, strategies)
|
||||||
|
self.assertIs(llm_strategy, compiler)
|
||||||
|
self.assertIs(
|
||||||
|
strategy_tracking.StrategyTrackingService,
|
||||||
|
tracking.StrategyTrackingService,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_screener_frontend_assets_are_unchanged(self) -> None:
|
||||||
|
for relative in (
|
||||||
|
"static/index.html",
|
||||||
|
"static/app.js",
|
||||||
|
"static/styles.css",
|
||||||
|
"static/pages/screener/page.js",
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
sha256(APP_ROOT / relative),
|
||||||
|
sha256(ORIGINAL_ROOT / relative),
|
||||||
|
relative,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sentiment_engine
|
||||||
|
from backend.features.sentiment import engine as canonical_engine
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ORIGINAL_ROOT = APP_ROOT.parent
|
||||||
|
|
||||||
|
SENTIMENT_METHODS = {
|
||||||
|
"_enrich_dashboard_sentiment",
|
||||||
|
"sentiment_history",
|
||||||
|
}
|
||||||
|
POOL_METHODS = {
|
||||||
|
"save_reason",
|
||||||
|
"_apply_reason_overrides",
|
||||||
|
"_schedule_ifind_event_enrichment",
|
||||||
|
"_refresh_ifind_event_enrichment",
|
||||||
|
"_normalize_ifind_event_time",
|
||||||
|
"_merge_ifind_event_enrichment",
|
||||||
|
}
|
||||||
|
POOL_REPOSITORY_METHODS = {
|
||||||
|
"save_reason_override",
|
||||||
|
"reason_overrides",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
owner = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in owner.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class SentimentPoolSliceSourceEquivalenceTests(unittest.TestCase):
|
||||||
|
def test_sentiment_service_methods_are_exact_original_ast(self) -> None:
|
||||||
|
original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService")
|
||||||
|
migrated = class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "sentiment" / "service.py",
|
||||||
|
"SentimentServiceMixin",
|
||||||
|
)
|
||||||
|
self.assertEqual(set(migrated), SENTIMENT_METHODS)
|
||||||
|
for name in sorted(SENTIMENT_METHODS):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_pool_service_methods_are_exact_original_ast(self) -> None:
|
||||||
|
original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService")
|
||||||
|
migrated = class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "pools" / "service.py",
|
||||||
|
"PoolServiceMixin",
|
||||||
|
)
|
||||||
|
self.assertEqual(set(migrated), POOL_METHODS)
|
||||||
|
for name in sorted(POOL_METHODS):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_pool_repository_methods_are_exact_original_ast(self) -> None:
|
||||||
|
original = class_methods(ORIGINAL_ROOT / "database.py", "ReviewDatabase")
|
||||||
|
migrated = class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "pools" / "repository.py",
|
||||||
|
"PoolRepositoryMixin",
|
||||||
|
)
|
||||||
|
self.assertEqual(set(migrated), POOL_REPOSITORY_METHODS)
|
||||||
|
for name in sorted(POOL_REPOSITORY_METHODS):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
|
||||||
|
remaining_service = class_methods(
|
||||||
|
APP_ROOT / "backend" / "application.py", "DashboardService"
|
||||||
|
)
|
||||||
|
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
|
||||||
|
self.assertTrue((SENTIMENT_METHODS | POOL_METHODS).isdisjoint(remaining_service))
|
||||||
|
self.assertTrue(POOL_REPOSITORY_METHODS.isdisjoint(remaining_database))
|
||||||
|
|
||||||
|
def test_sentiment_engine_is_exact_original_with_legacy_alias(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
sha256(ORIGINAL_ROOT / "sentiment_engine.py"),
|
||||||
|
sha256(APP_ROOT / "backend" / "features" / "sentiment" / "engine.py"),
|
||||||
|
)
|
||||||
|
self.assertIs(sentiment_engine, canonical_engine)
|
||||||
|
|
||||||
|
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||||
|
for relative in (
|
||||||
|
"config/api.config.json",
|
||||||
|
"static/index.html",
|
||||||
|
"static/app.js",
|
||||||
|
"static/styles.css",
|
||||||
|
"static/pages/sentiment/page.js",
|
||||||
|
"static/pages/pools/page.js",
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
sha256(APP_ROOT / relative),
|
||||||
|
sha256(ORIGINAL_ROOT / relative),
|
||||||
|
relative,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -90,8 +90,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
"moneyflow": {},
|
"moneyflow": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("backend.application.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||||
"backend.application.TushareClient", RealtimeClientStub
|
"backend.features.market.service.TushareClient", RealtimeClientStub
|
||||||
):
|
):
|
||||||
result = self.service._prepare_stock_detail(cached, "002141", today)
|
result = self.service._prepare_stock_detail(cached, "002141", today)
|
||||||
|
|
||||||
@@ -112,8 +112,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
"stock": {"code": "002141", "price": 10, "change": 1.2},
|
"stock": {"code": "002141", "price": 10, "change": 1.2},
|
||||||
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
|
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
|
||||||
}
|
}
|
||||||
with patch("backend.application.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
||||||
"backend.application.TushareClient", RealtimeClientStub
|
"backend.features.market.service.TushareClient", RealtimeClientStub
|
||||||
):
|
):
|
||||||
result = self.service._prepare_stock_detail(payload, "002141", historical)
|
result = self.service._prepare_stock_detail(payload, "002141", historical)
|
||||||
|
|
||||||
@@ -151,8 +151,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
with patch("backend.application.datetime", FixedPreopenDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedPreopenDatetime), patch(
|
||||||
"backend.application.TushareClient", RealtimeClientStub
|
"backend.features.market.service.TushareClient", RealtimeClientStub
|
||||||
):
|
):
|
||||||
result = self.service._prepare_stock_detail(payload, "002141", today)
|
result = self.service._prepare_stock_detail(payload, "002141", today)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
opener: urllib.request.OpenerDirector,
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
method: str = "GET",
|
||||||
|
) -> tuple[int, Any]:
|
||||||
|
data = None
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with opener.open(request, timeout=90) as response:
|
||||||
|
return response.status, json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def session(base_url: str, username: str, password: str) -> urllib.request.OpenerDirector:
|
||||||
|
opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
|
||||||
|
)
|
||||||
|
status, body = request_json(
|
||||||
|
opener,
|
||||||
|
f"{base_url.rstrip('/')}/api/auth/login",
|
||||||
|
{"username": username, "password": password},
|
||||||
|
"POST",
|
||||||
|
)
|
||||||
|
if status != 200 or not body.get("ok"):
|
||||||
|
raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}")
|
||||||
|
csrf_token = str(body.get("csrf_token") or "")
|
||||||
|
if csrf_token:
|
||||||
|
opener.addheaders.append(("X-CSRF-Token", csrf_token))
|
||||||
|
return opener
|
||||||
|
|
||||||
|
|
||||||
|
def digest(value: Any) -> str:
|
||||||
|
content = json.dumps(
|
||||||
|
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def comparable(
|
||||||
|
value: Any,
|
||||||
|
excluded_paths: set[str] | None = None,
|
||||||
|
sorted_lists: dict[str, str] | None = None,
|
||||||
|
path: str = "$",
|
||||||
|
) -> Any:
|
||||||
|
excluded_paths = excluded_paths or set()
|
||||||
|
sorted_lists = sorted_lists or {}
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: comparable(
|
||||||
|
item,
|
||||||
|
excluded_paths,
|
||||||
|
sorted_lists,
|
||||||
|
f"{path}.{key}",
|
||||||
|
)
|
||||||
|
for key, item in value.items()
|
||||||
|
if key != "request_id"
|
||||||
|
and f"{path}.{key}" not in excluded_paths
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
normalized = [
|
||||||
|
comparable(item, excluded_paths, sorted_lists, f"{path}[]")
|
||||||
|
for item in value
|
||||||
|
]
|
||||||
|
sort_key = sorted_lists.get(path)
|
||||||
|
if sort_key:
|
||||||
|
normalized.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
str(item.get(sort_key) or "")
|
||||||
|
if isinstance(item, dict)
|
||||||
|
else json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def first_difference(original: Any, migrated: Any, path: str = "$") -> dict[str, Any] | None:
|
||||||
|
if type(original) is not type(migrated):
|
||||||
|
return {"path": path, "original": original, "migrated": migrated}
|
||||||
|
if isinstance(original, dict):
|
||||||
|
for key in sorted(set(original) | set(migrated)):
|
||||||
|
if key not in original or key not in migrated:
|
||||||
|
return {
|
||||||
|
"path": f"{path}.{key}",
|
||||||
|
"original": original.get(key, "<missing>"),
|
||||||
|
"migrated": migrated.get(key, "<missing>"),
|
||||||
|
}
|
||||||
|
difference = first_difference(original[key], migrated[key], f"{path}.{key}")
|
||||||
|
if difference:
|
||||||
|
return difference
|
||||||
|
return None
|
||||||
|
if isinstance(original, list):
|
||||||
|
if len(original) != len(migrated):
|
||||||
|
return {"path": f"{path}.length", "original": len(original), "migrated": len(migrated)}
|
||||||
|
for index, (original_item, migrated_item) in enumerate(zip(original, migrated)):
|
||||||
|
difference = first_difference(
|
||||||
|
original_item, migrated_item, f"{path}[{index}]"
|
||||||
|
)
|
||||||
|
if difference:
|
||||||
|
return difference
|
||||||
|
return None
|
||||||
|
if original != migrated:
|
||||||
|
return {"path": path, "original": original, "migrated": migrated}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs")
|
||||||
|
parser.add_argument("--original", required=True)
|
||||||
|
parser.add_argument("--migrated", required=True)
|
||||||
|
parser.add_argument("--username", required=True)
|
||||||
|
parser.add_argument("--password", required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--requests-file", type=Path)
|
||||||
|
parser.add_argument("endpoints", nargs="*")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
original = session(args.original, args.username, args.password)
|
||||||
|
migrated = session(args.migrated, args.username, args.password)
|
||||||
|
rows = []
|
||||||
|
all_equal = True
|
||||||
|
requests = [
|
||||||
|
{"name": endpoint, "method": "GET", "endpoint": endpoint, "payload": None}
|
||||||
|
for endpoint in args.endpoints
|
||||||
|
]
|
||||||
|
if args.requests_file:
|
||||||
|
requests.extend(json.loads(args.requests_file.read_text(encoding="utf-8")))
|
||||||
|
if not requests:
|
||||||
|
parser.error("provide at least one endpoint or --requests-file")
|
||||||
|
for item in requests:
|
||||||
|
endpoint = str(item["endpoint"])
|
||||||
|
method = str(item.get("method") or "GET").upper()
|
||||||
|
payload = item.get("payload")
|
||||||
|
excluded_paths = {str(path) for path in item.get("exclude_paths") or []}
|
||||||
|
sorted_lists = {
|
||||||
|
str(path): str(key)
|
||||||
|
for path, key in (item.get("sort_lists") or {}).items()
|
||||||
|
}
|
||||||
|
original_status, original_body = request_json(
|
||||||
|
original, f"{args.original.rstrip('/')}{endpoint}", payload, method
|
||||||
|
)
|
||||||
|
migrated_status, migrated_body = request_json(
|
||||||
|
migrated, f"{args.migrated.rstrip('/')}{endpoint}", payload, method
|
||||||
|
)
|
||||||
|
original_comparable = comparable(
|
||||||
|
original_body, excluded_paths, sorted_lists
|
||||||
|
)
|
||||||
|
migrated_comparable = comparable(
|
||||||
|
migrated_body, excluded_paths, sorted_lists
|
||||||
|
)
|
||||||
|
equal = original_status == migrated_status and original_comparable == migrated_comparable
|
||||||
|
all_equal = all_equal and equal
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": str(item.get("name") or endpoint),
|
||||||
|
"method": method,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"original_status": original_status,
|
||||||
|
"migrated_status": migrated_status,
|
||||||
|
"original_sha256": digest(original_comparable),
|
||||||
|
"migrated_sha256": digest(migrated_comparable),
|
||||||
|
"equal": equal,
|
||||||
|
"first_difference": (
|
||||||
|
None
|
||||||
|
if equal
|
||||||
|
else first_difference(original_comparable, migrated_comparable)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {"all_equal": all_equal, "endpoints": rows}
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
if not all_equal:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def digest(value: Any) -> str:
|
||||||
|
content = json.dumps(
|
||||||
|
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def schema(connection: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT type, name, tbl_name, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY type, name
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def table_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
|
||||||
|
quoted = '"' + table.replace('"', '""') + '"'
|
||||||
|
rows = [dict(row) for row in connection.execute(f"SELECT * FROM {quoted}").fetchall()]
|
||||||
|
return sorted(rows, key=lambda row: json.dumps(row, ensure_ascii=False, sort_keys=True, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Compare preservation SQLite databases")
|
||||||
|
parser.add_argument("--original", type=Path, required=True)
|
||||||
|
parser.add_argument("--migrated", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("tables", nargs="+")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
original = sqlite3.connect(args.original)
|
||||||
|
migrated = sqlite3.connect(args.migrated)
|
||||||
|
original.row_factory = sqlite3.Row
|
||||||
|
migrated.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
original_schema = schema(original)
|
||||||
|
migrated_schema = schema(migrated)
|
||||||
|
tables = []
|
||||||
|
all_equal = original_schema == migrated_schema
|
||||||
|
for table in args.tables:
|
||||||
|
original_rows = table_rows(original, table)
|
||||||
|
migrated_rows = table_rows(migrated, table)
|
||||||
|
equal = original_rows == migrated_rows
|
||||||
|
all_equal = all_equal and equal
|
||||||
|
tables.append(
|
||||||
|
{
|
||||||
|
"table": table,
|
||||||
|
"original_count": len(original_rows),
|
||||||
|
"migrated_count": len(migrated_rows),
|
||||||
|
"original_sha256": digest(original_rows),
|
||||||
|
"migrated_sha256": digest(migrated_rows),
|
||||||
|
"equal": equal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"all_equal": all_equal,
|
||||||
|
"schema": {
|
||||||
|
"object_count": len(original_schema),
|
||||||
|
"original_sha256": digest(original_schema),
|
||||||
|
"migrated_sha256": digest(migrated_schema),
|
||||||
|
"equal": original_schema == migrated_schema,
|
||||||
|
},
|
||||||
|
"tables": tables,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
original.close()
|
||||||
|
migrated.close()
|
||||||
|
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
if not all_equal:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MARKER = " # PRESERVATION_METHODS\n"
|
||||||
|
|
||||||
|
|
||||||
|
def method_span(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[int, int]:
|
||||||
|
start = min((decorator.lineno for decorator in node.decorator_list), default=node.lineno)
|
||||||
|
if node.end_lineno is None:
|
||||||
|
raise ValueError(f"Missing end position for {node.name}")
|
||||||
|
return start - 1, node.end_lineno
|
||||||
|
|
||||||
|
|
||||||
|
def move_methods(
|
||||||
|
source_path: Path,
|
||||||
|
class_name: str,
|
||||||
|
target_path: Path,
|
||||||
|
method_names: list[str],
|
||||||
|
) -> None:
|
||||||
|
source = source_path.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source, filename=str(source_path))
|
||||||
|
owner = next(
|
||||||
|
(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if owner is None:
|
||||||
|
raise ValueError(f"Class not found: {class_name}")
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
node.name: node
|
||||||
|
for node in owner.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
missing = [name for name in method_names if name not in methods]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Methods not found in {class_name}: {', '.join(missing)}")
|
||||||
|
|
||||||
|
lines = source.splitlines(keepends=True)
|
||||||
|
ordered = sorted((methods[name] for name in method_names), key=lambda node: node.lineno)
|
||||||
|
blocks = ["".join(lines[start:end]).rstrip() for start, end in map(method_span, ordered)]
|
||||||
|
|
||||||
|
for start, end in sorted(map(method_span, ordered), reverse=True):
|
||||||
|
del lines[start:end]
|
||||||
|
while start < len(lines) - 1 and lines[start] == "\n" and lines[start + 1] == "\n":
|
||||||
|
del lines[start]
|
||||||
|
|
||||||
|
target = target_path.read_text(encoding="utf-8")
|
||||||
|
if target.count(MARKER) != 1:
|
||||||
|
raise ValueError(f"Target must contain exactly one method marker: {target_path}")
|
||||||
|
target = target.replace(MARKER, "\n\n".join(blocks) + "\n")
|
||||||
|
|
||||||
|
source_path.write_text("".join(lines), encoding="utf-8")
|
||||||
|
target_path.write_text(target, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Mechanically move class methods between modules")
|
||||||
|
parser.add_argument("--source", type=Path, required=True)
|
||||||
|
parser.add_argument("--class-name", required=True)
|
||||||
|
parser.add_argument("--target", type=Path, required=True)
|
||||||
|
parser.add_argument("methods", nargs="+")
|
||||||
|
args = parser.parse_args()
|
||||||
|
move_methods(args.source, args.class_name, args.target, args.methods)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Run an isolated preservation runtime")
|
||||||
|
parser.add_argument("--runtime-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--data-dir", type=Path, required=True)
|
||||||
|
parser.add_argument("--port", type=int, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
runtime_root = args.runtime_root.resolve()
|
||||||
|
data_dir = args.data_dir.resolve()
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
sys.path.insert(0, str(runtime_root))
|
||||||
|
|
||||||
|
if (runtime_root / "backend" / "bootstrap" / "config.py").is_file():
|
||||||
|
from backend.bootstrap import config
|
||||||
|
|
||||||
|
config.DATA_DIR = data_dir
|
||||||
|
config.PRIVATE_MENTOR_SKILLS_DIR = data_dir / "private-mentor-skills"
|
||||||
|
else:
|
||||||
|
import app_config as config
|
||||||
|
|
||||||
|
config.DATA_DIR = data_dir
|
||||||
|
config.PRIVATE_MENTOR_SKILLS_DIR = data_dir / "private-mentor-skills"
|
||||||
|
|
||||||
|
from server import RequestHandler, SERVICE
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", args.port), RequestHandler)
|
||||||
|
print(
|
||||||
|
f"Preservation runtime is running at http://127.0.0.1:{args.port} "
|
||||||
|
f"with database {SERVICE.database.path}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
SERVICE._background_stop.set()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 切片 02:公共行情、搜索、详情、图表与数据网关
|
||||||
|
|
||||||
|
> 基线:`4002f09`(切片 01)
|
||||||
|
> 回档标签:`xiaobai-preservation-slice-02-20260731`
|
||||||
|
> 结论:源码、API、数据库、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收
|
||||||
|
|
||||||
|
## 1. 原实现归位
|
||||||
|
|
||||||
|
本切片从原版副本机械移动公共行情纵向链路,没有从 `next/` 取用代码,也没有修改计算逻辑。
|
||||||
|
|
||||||
|
| 原位置 | 新的唯一实现位置 | 原位置兼容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/backend/application.py` 的 30 个总览、搜索、详情、图表方法 | `app/backend/features/market/service.py` | `DashboardService` 继承 `MarketServiceMixin` |
|
||||||
|
| `app/database.py` 的 11 个行情快照、搜索目录、同步记录方法 | `app/backend/features/market/repository.py` | `ReviewDatabase` 继承 `MarketRepositoryMixin` |
|
||||||
|
| `app/tushare_client.py` | `app/backend/data/providers/tushare_client.py` | 根模块为同一模块对象的兼容别名 |
|
||||||
|
| `app/ifind_client.py` | `app/backend/data/providers/ifind_client.py` | 根模块为同一模块对象的兼容别名 |
|
||||||
|
| `app/realtime_aggregator.py` | `app/backend/data/realtime.py` | 根模块为同一模块对象的兼容别名 |
|
||||||
|
| `app/chart_data_provider.py` | `app/backend/features/market/charts.py` | 根模块为同一模块对象的兼容别名 |
|
||||||
|
|
||||||
|
`app/tools/move_class_methods.py` 使用 Python AST 确定方法及装饰器的源码边界,只移动原文本片段。
|
||||||
|
该工具会在缺失方法、目标标记不唯一或源码无法解析时停止,供后续切片继续复用。
|
||||||
|
|
||||||
|
## 2. 等价证据
|
||||||
|
|
||||||
|
- `test_preservation_slice_market.py` 对 30 个业务方法和 11 个 Repository 方法逐项执行无位置信息
|
||||||
|
AST 比较,全部与根目录原版 `server.py`、`database.py` 完全相同。
|
||||||
|
- Tushare、iFinD 和实时观察器文件与原版 SHA-256 完全相同;图表模块全部类和函数 AST 与原版相同,
|
||||||
|
仅内部导入改为新规范位置。
|
||||||
|
- 四个根级兼容模块与新模块共享同一类对象,旧导入和旧 monkeypatch 路径继续有效。
|
||||||
|
- `config/api.config.json`、API路径、鉴权角色、错误结构和数据库 schema 未修改。
|
||||||
|
- `app/static/` 未修改;七个核心 HTML/JS/CSS 文件哈希继续与原版相同。
|
||||||
|
- `app-light-1280x720.png` 为真实 `8785` 服务完成载入后的日间模式截图,SHA-256 为
|
||||||
|
`a7ee1b682f68c418d792727dbc4534d7494dae4f4811cdbba208bd86dcf25d10`。
|
||||||
|
|
||||||
|
## 3. 真实运行检查
|
||||||
|
|
||||||
|
- 迁移副本:`http://127.0.0.1:8785/`,管理员登录成功。
|
||||||
|
- 总览:返回 2026-07-30 Tushare 已缓存行情,涨停 56 只。
|
||||||
|
- 搜索:搜索“中国平安”返回 `601318`,点击后打开完整个股详情、日 K、资金流、事件逻辑和复盘笔记。
|
||||||
|
- 页面:1280px 视口无横向溢出,数据加载状态正常,浏览器控制台 0 个错误。
|
||||||
|
- 分时:迁移版与原版在当前本机网络环境均返回同一个 `Intraday chart request failed`,因此记录为
|
||||||
|
既存外部接口状态,不是本切片差异;没有擅自增加降级或改变来源策略。
|
||||||
|
|
||||||
|
## 4. 自动验证
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
|---|---:|
|
||||||
|
| `python -m unittest discover -s tests -q` | 241 项通过 |
|
||||||
|
| `python -m unittest tests.test_preservation_slice_market -q` | 6 项通过 |
|
||||||
|
| 行情、图表、实时与数据库专项集合 | 61 项通过 |
|
||||||
|
| `npx playwright test --reporter=dot` | 45 项通过 |
|
||||||
|
| `python -m compileall -q ...` | 通过 |
|
||||||
|
| `git diff --check` | 通过 |
|
||||||
|
|
||||||
|
## 5. 保留边界
|
||||||
|
|
||||||
|
- 情绪计算仍在 `application.py`,切片 03 再归位;行情服务只通过继承调用,没有复制。
|
||||||
|
- 竞价、题材、人气、龙虎榜和问天对公共行情客户端的调用仍可通过兼容别名工作,待各自切片迁移。
|
||||||
|
- 根级四个数据模块、`DashboardService` 和 `ReviewDatabase` 的兼容面在所有消费者完成迁移前保留。
|
||||||
|
- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
# 切片 03:情绪周期、五类股池与涨停表现
|
||||||
|
|
||||||
|
> 基线:`a426432`(切片 02)
|
||||||
|
> 回档标签:`xiaobai-preservation-slice-03-20260731`
|
||||||
|
> 结论:源码、API、数据库、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收
|
||||||
|
|
||||||
|
## 1. 原实现归位
|
||||||
|
|
||||||
|
本切片只移动原版副本中的真实实现,没有从 `next/` 取用代码,也没有改写情绪公式、股池数据、
|
||||||
|
原因补全、表格、样式或交互。
|
||||||
|
|
||||||
|
| 原位置 | 新的唯一实现位置 | 原位置兼容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/backend/application.py` 的 2 个情绪服务方法 | `app/backend/features/sentiment/service.py` | `DashboardService` 继承 `SentimentServiceMixin` |
|
||||||
|
| `app/backend/application.py` 的 6 个股池原因及事件补全方法 | `app/backend/features/pools/service.py` | `DashboardService` 继承 `PoolServiceMixin` |
|
||||||
|
| `app/database.py` 的 2 个原因覆盖方法 | `app/backend/features/pools/repository.py` | `ReviewDatabase` 继承 `PoolRepositoryMixin` |
|
||||||
|
| `app/sentiment_engine.py` | `app/backend/features/sentiment/engine.py` | 根模块为同一模块对象的兼容别名 |
|
||||||
|
|
||||||
|
五类股池、涨停梯队和涨停表现仍由切片 02 已归位的原 Tushare 总览实现生成,本切片没有建立第二套
|
||||||
|
计算或数据来源。
|
||||||
|
|
||||||
|
## 2. 等价证据
|
||||||
|
|
||||||
|
- `test_preservation_slice_sentiment_pools.py` 对 8 个业务方法和 2 个 Repository 方法逐项执行无位置
|
||||||
|
信息 AST 比较,全部与根目录原版 `server.py`、`database.py` 完全相同。
|
||||||
|
- 新的情绪引擎文件与原版 `sentiment_engine.py` SHA-256 完全相同;根级兼容模块与新模块是同一模块对象。
|
||||||
|
- 已归位的应用、行情服务、Tushare Provider、演示数据和选股模块直接导入新的唯一实现;Tushare
|
||||||
|
Provider 仅调整该导入,其全部类和函数 AST 继续与原版一致。
|
||||||
|
- 原版 `8784` 与迁移版 `8785` 在相同账号、日期和数据库副本上请求 `/api/dashboard` 与
|
||||||
|
`/api/sentiment/history`,JSON 状态、字段、值和顺序完全相同。
|
||||||
|
- 2026-07-30 的同请求结果均为:涨停 56、炸板 23、跌停 83、昨日涨停 81、情绪历史 20 日。
|
||||||
|
- 原版和迁移版数据库均为 62 个 schema 对象,schema 哈希均为
|
||||||
|
`17918327f8b919496e6630458293f9f777c7c24662625bb3fc0b64ff0a8fbeef`。
|
||||||
|
- `config/api.config.json`、API 路径、鉴权和 `app/static/` 未修改。
|
||||||
|
- `app-light-1920x1080.png` 是真实迁移服务载入完成后的情绪周期页面,SHA-256 为
|
||||||
|
`e387417abbe0667e00875a8d4061b5546748ecf2452a692d06d078d516330dab`。
|
||||||
|
|
||||||
|
## 3. 真实运行检查
|
||||||
|
|
||||||
|
- 迁移副本:`http://127.0.0.1:8785/`,管理员会话与缓存行情载入正常。
|
||||||
|
- 情绪周期:20 个连续交易日、当前阶段、评分构成和交易日明细均完整显示。
|
||||||
|
- 股池:涨停池 56 行、炸板池 23 行、跌停池 83 行、昨日涨停 81 行。
|
||||||
|
- 涨停表现:四档晋级率、市场宽度和今日结论均显示原版结果。
|
||||||
|
- 1920×1080 下六个页面横向溢出均为 0;日间、夜间背景与面板状态正常;浏览器控制台无迁移错误。
|
||||||
|
|
||||||
|
## 4. 自动验证
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
|---|---:|
|
||||||
|
| `python -m unittest discover -s tests -q` | 248 项通过 |
|
||||||
|
| `python -m unittest tests.test_preservation_slice_sentiment_pools -q` | 6 项通过 |
|
||||||
|
| 情绪、总览、缓存、iFinD 与前端契约专项集合 | 48 项通过 |
|
||||||
|
| `npx.cmd playwright test --reporter=dot` | 45 项通过 |
|
||||||
|
| `python -m compileall -q ...` | 通过 |
|
||||||
|
| `git diff --check` | 通过 |
|
||||||
|
|
||||||
|
Windows 下由 Playwright 自行创建临时静态服务器时,45 项完成后子进程无法回收;改为预先启动同一个
|
||||||
|
`8876` 静态服务器并让 Playwright 复用后,测试以零退出码正常结束,结果为 `45 passed (2.1m)`。
|
||||||
|
|
||||||
|
## 5. 保留边界
|
||||||
|
|
||||||
|
- 板块轮动仍调用情绪历史公共函数,待切片 04 与市场天梯一并归位。
|
||||||
|
- 竞价、题材、人气和龙虎榜对股池数据的消费保持原调用路径,待切片 05 迁移。
|
||||||
|
- 根级情绪引擎兼容模块、`DashboardService` 与 `ReviewDatabase` 兼容面继续保留;数据库内尚未迁移的
|
||||||
|
选股统计方法仍走兼容别名,待切片 06 随完整方法一并归位。
|
||||||
|
- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。
|
||||||
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
# 切片 04:市场天梯与板块轮动
|
||||||
|
|
||||||
|
> 基线:`b3555d2`(切片 03)
|
||||||
|
> 回档标签:`xiaobai-preservation-slice-04-20260731`
|
||||||
|
> 结论:源码、API、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收
|
||||||
|
|
||||||
|
## 1. 原实现归位
|
||||||
|
|
||||||
|
本切片从原版副本机械移动板块轮动服务,没有从 `next/` 取用代码,也没有修改天梯、轮动的计算、
|
||||||
|
排序、展开、配色、页面结构或交互。
|
||||||
|
|
||||||
|
| 原位置 | 新的唯一实现位置 | 原位置兼容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/backend/application.py` 的 2 个轮动方法 | `app/backend/features/rotation/service.py` | `DashboardService` 继承 `RotationServiceMixin` |
|
||||||
|
| Tushare Provider 的天梯与轮动构造函数 | 保持 `app/backend/data/providers/tushare_client.py` | 切片 02 已归位的公共数据实现 |
|
||||||
|
|
||||||
|
市场天梯没有独立后端 API 或第二套计算,直接展示 `/api/dashboard` 中原 Tushare 实现生成的
|
||||||
|
`ladders`;因此没有为目录形式建立空的天梯服务。
|
||||||
|
|
||||||
|
## 2. 等价证据
|
||||||
|
|
||||||
|
- `test_preservation_slice_ladder_rotation.py` 对 2 个轮动服务方法逐项执行无位置信息 AST 比较,
|
||||||
|
全部与根目录原版 `server.py` 完全相同。
|
||||||
|
- `_build_ladders` 与 `_build_sector_rotation` 两个原数据构造函数的 AST 与根目录原版完全相同。
|
||||||
|
- 原版 `8784` 与迁移版 `8785` 在相同账号、日期和数据库副本上返回的天梯数据及 9 日轮动历史
|
||||||
|
JSON 逐字段完全相同。
|
||||||
|
- 成分股接口在当前外部网络状态下两版均返回 HTTP 400、`bad_request` 和相同的
|
||||||
|
`该板块成分股暂不可用:Tushare request failed:`,没有改变错误或增加静默降级。
|
||||||
|
- `config/api.config.json`、API 路径、鉴权、数据库 schema 和 `app/static/` 未修改。
|
||||||
|
|
||||||
|
## 3. 真实运行检查
|
||||||
|
|
||||||
|
- 市场天梯:8 个层级(含断层)、18 个首屏股票单元格、3 个结构分析模块正常;1920×1080 下
|
||||||
|
页面宽度无溢出,首板展开入口保留。
|
||||||
|
- 板块轮动:9 个交易日、每日 Top 12 共 108 个板块单元格、由远到近/由近到远两个排序入口正常;
|
||||||
|
1920×1080 下页面宽度无溢出并保持全页滚动。
|
||||||
|
- 日间模式页面控制台没有错误或警告。
|
||||||
|
- `app-light-ladder-1920x1080.png` SHA-256:
|
||||||
|
`9e57d18d92e745fd92131f7bf08f21faaaa745476dd942cdaa2a703b9a7a303a`。
|
||||||
|
- `app-light-rotation-1920x1080.png` SHA-256:
|
||||||
|
`03c092bb40bd0eb672136dff853abffc87e9790840107c2f22e6cf31d5f83c09`。
|
||||||
|
|
||||||
|
## 4. 自动验证
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
|---|---:|
|
||||||
|
| `python -m unittest discover -s tests -q` | 252 项通过 |
|
||||||
|
| `python -m unittest tests.test_preservation_slice_ladder_rotation -q` | 4 项通过 |
|
||||||
|
| 切片 02 至 04 与总览缓存专项集合 | 24 项通过 |
|
||||||
|
| `npx.cmd playwright test --reporter=dot` | 45 项通过 |
|
||||||
|
| `git diff --check` | 通过 |
|
||||||
|
|
||||||
|
## 5. 保留边界
|
||||||
|
|
||||||
|
- 成分股接口依赖的日行情与因子持久化方法仍由原 `ReviewDatabase` 提供,因其同时服务智能选股,
|
||||||
|
待切片 06 随完整共享职责归位。
|
||||||
|
- 天梯和轮动前端资产保持原位,切片 10 再按页面职责归档;当前没有复制或改写。
|
||||||
|
- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。
|
||||||
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,69 @@
|
|||||||
|
# 切片 05:集合竞价、题材库、人气热榜与龙虎榜
|
||||||
|
|
||||||
|
> 基线:`814e757`(切片 04)
|
||||||
|
> 回档标签:`xiaobai-preservation-slice-05-20260731`
|
||||||
|
> 结论:源码、API、数据库、真实页面和全量回归通过;最终视觉仍等待全站人工验收
|
||||||
|
|
||||||
|
## 1. 原实现归位
|
||||||
|
|
||||||
|
本切片没有从`next/`取用代码,也没有重写计算、页面或接口。集合竞价、题材库和人气热榜原本
|
||||||
|
共享`MarketInsightsService`,其中竞价候选会直接调用人气榜热度数据,因此整体移动到公共行情
|
||||||
|
领域,避免拆出互相复制的实现;各页面入口仍按功能目录归位。
|
||||||
|
|
||||||
|
| 原位置 | 新的唯一实现位置 | 兼容方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/market_insights.py` | `app/backend/features/market/insights.py` | 根级模块导出同一类对象 |
|
||||||
|
| `DashboardService`竞价入口 | `app/backend/features/auction/service.py` | `AuctionServiceMixin` |
|
||||||
|
| `DashboardService`题材入口 | `app/backend/features/themes/service.py` | `ThemeServiceMixin` |
|
||||||
|
| `DashboardService`人气入口 | `app/backend/features/popularity/service.py` | `PopularityServiceMixin` |
|
||||||
|
| `DashboardService`龙虎榜及游资档案 | `app/backend/features/dragon_tiger/service.py` | `DragonTigerServiceMixin` |
|
||||||
|
| 竞价、人气、龙虎榜持久化方法 | 对应功能目录的`repository.py` | `ReviewDatabase`继承原接口 |
|
||||||
|
|
||||||
|
Tushare Provider 中`hot_money_profiles`与`dragon_tiger`继续保持切片02归位的唯一实现,没有为目录
|
||||||
|
形式再制造一套数据构造逻辑。
|
||||||
|
|
||||||
|
## 2. 源码与接口等价
|
||||||
|
|
||||||
|
- `test_preservation_slice_market_insights.py`逐项比较22个市场洞察方法、8个页面服务方法、7个
|
||||||
|
Repository方法和2个Tushare方法,全部与根目录原版无位置信息AST一致。
|
||||||
|
- `DashboardService`与`ReviewDatabase`不再重复保留已移动方法;根级`market_insights`与新模块
|
||||||
|
暴露同一个`MarketInsightsService`类对象。
|
||||||
|
- 原版`8784`和迁移版`8785`使用同一数据库的独立副本,集合竞价、题材库、题材详情、人气热榜、
|
||||||
|
龙虎榜、游资档案和席位别名共7个真实API状态码及JSON一致。
|
||||||
|
- 题材详情在当前外部网络条件下两版均返回HTTP 400;差分只排除每次请求随机生成的
|
||||||
|
`request_id`,错误码与错误内容仍完全一致。
|
||||||
|
- 完整接口摘要见`api-diff.json`。
|
||||||
|
|
||||||
|
## 3. 数据库差分
|
||||||
|
|
||||||
|
- 两个副本均为62个schema对象,哈希均为
|
||||||
|
`60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1`。
|
||||||
|
- `auction_factors` 511914行、`popularity_factors` 232行、`lhb_institution_daily` 47行、
|
||||||
|
`seat_aliases` 0行、`stock_master` 5535行均逐行一致。
|
||||||
|
- 完整表计数与哈希见`database-diff.json`;运行数据库副本已在验收后删除,未提交凭据或正式数据。
|
||||||
|
|
||||||
|
## 4. 真实浏览器检查
|
||||||
|
|
||||||
|
- 1920×1080日间模式检查集合竞价、题材库、人气热榜和龙虎榜四页;均无横向溢出,控制台无
|
||||||
|
错误或警告。
|
||||||
|
- 集合竞价载入30行重点候选;题材库载入394个题材及选中题材成分股;人气热榜载入3个摘要
|
||||||
|
模块和200行综合榜;龙虎榜按当前缓存显示既有不可用空态。
|
||||||
|
- 四页HTML、主JS、CSS及各自页面JS与根目录原版字节哈希一致。
|
||||||
|
- 截图SHA-256:
|
||||||
|
- `app-light-auction-1920x1080.png`:`8065086c8f2b360aeb1004bd60429f3e2d7f1b8c872ec4a965e9a5d0c016b915`
|
||||||
|
- `app-light-themes-1920x1080.png`:`80e1e41101497ee7213e1dadfaa4c9572a1c47b3495edd09e36745ccdb639402`
|
||||||
|
- `app-light-popularity-1920x1080.png`:`614d6b7770f4e9ec72c059ab8a4129486df9eff5c4dd7e8279cc68ebcb80a36b`
|
||||||
|
- `app-light-dragon-tiger-1920x1080.png`:`2f1e2ad3a9bc3874c73bae884fc8744177cef354c7176559da1453fab1f85993`
|
||||||
|
|
||||||
|
## 5. 自动验证与保留边界
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
|---|---:|
|
||||||
|
| `python -m unittest discover -s tests -q` | 258项通过 |
|
||||||
|
| `python -m unittest tests.test_preservation_slice_market_insights -q` | 6项通过 |
|
||||||
|
| `npx.cmd playwright test --reporter=dot` | 45项通过 |
|
||||||
|
| `git diff --check` | 通过 |
|
||||||
|
|
||||||
|
- 竞价、人气和龙虎榜因子同时服务切片06智能选股,迁移后仍由`ReviewDatabase`原方法名暴露。
|
||||||
|
- 前端资产保持原位置,切片10再按页面职责归档;本切片没有改DOM、CSS、动画或交互。
|
||||||
|
- 没有删除待定代码、没有修改根目录正式数据库、没有切换Docker/NAS。
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"all_equal": true,
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"endpoint": "/api/auction?trade_date=2026-07-29",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "523144cc14d876577b7d518cdf38fd2722b13a8f01ed5d2e20dcc38f6a2624ce",
|
||||||
|
"migrated_sha256": "523144cc14d876577b7d518cdf38fd2722b13a8f01ed5d2e20dcc38f6a2624ce",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"endpoint": "/api/themes?trade_date=2026-07-29",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "95c2ad418f18d877d94ec2a71fe6fafd5e329b069d87a9300f7fcac92d4ba5d1",
|
||||||
|
"migrated_sha256": "95c2ad418f18d877d94ec2a71fe6fafd5e329b069d87a9300f7fcac92d4ba5d1",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"endpoint": "/api/themes/detail?code=885001.TI&trade_date=2026-07-29",
|
||||||
|
"original_status": 400,
|
||||||
|
"migrated_status": 400,
|
||||||
|
"original_sha256": "b11a3314b172d3ad6ba28d969fcd6a9a2a4b49e7d29ed804496a4dfecd2a364a",
|
||||||
|
"migrated_sha256": "b11a3314b172d3ad6ba28d969fcd6a9a2a4b49e7d29ed804496a4dfecd2a364a",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"endpoint": "/api/popularity?trade_date=2026-07-29",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "e62a93c41f7c3f95c3d47f8ccaedaa809563c8dd65c04dd54b164f73e6014e94",
|
||||||
|
"migrated_sha256": "e62a93c41f7c3f95c3d47f8ccaedaa809563c8dd65c04dd54b164f73e6014e94",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"endpoint": "/api/dragon-tiger?trade_date=2026-07-29",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "a3998b935377d5fd0673ec5b9214d1b0a64680d155c61e6cff49d0d5fcf0e843",
|
||||||
|
"migrated_sha256": "a3998b935377d5fd0673ec5b9214d1b0a64680d155c61e6cff49d0d5fcf0e843",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"endpoint": "/api/dragon-tiger/profiles",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a",
|
||||||
|
"migrated_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"endpoint": "/api/seat-aliases",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "2b0fb0a6b3e353c69158d61221c2200e4199d0d60dd0b9d99702a22eaa917a78",
|
||||||
|
"migrated_sha256": "2b0fb0a6b3e353c69158d61221c2200e4199d0d60dd0b9d99702a22eaa917a78",
|
||||||
|
"equal": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"all_equal": true,
|
||||||
|
"schema": {
|
||||||
|
"object_count": 62,
|
||||||
|
"original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||||
|
"migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"table": "auction_factors",
|
||||||
|
"original_count": 511914,
|
||||||
|
"migrated_count": 511914,
|
||||||
|
"original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||||
|
"migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "popularity_factors",
|
||||||
|
"original_count": 232,
|
||||||
|
"migrated_count": 232,
|
||||||
|
"original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||||
|
"migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "lhb_institution_daily",
|
||||||
|
"original_count": 47,
|
||||||
|
"migrated_count": 47,
|
||||||
|
"original_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c",
|
||||||
|
"migrated_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "seat_aliases",
|
||||||
|
"original_count": 0,
|
||||||
|
"migrated_count": 0,
|
||||||
|
"original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||||
|
"migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "stock_master",
|
||||||
|
"original_count": 5535,
|
||||||
|
"migrated_count": 5535,
|
||||||
|
"original_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792",
|
||||||
|
"migrated_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792",
|
||||||
|
"equal": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 切片 06:智能选股、自定义选股与策略持续跟踪
|
||||||
|
|
||||||
|
> 基线:`cf2aad2`(切片 05)
|
||||||
|
> 回档标签:`xiaobai-preservation-slice-06-20260731`
|
||||||
|
> 结论:源码、API、数据库、真实页面和全量回归通过;最终视觉仍等待全站人工验收
|
||||||
|
|
||||||
|
## 1. 原实现归位
|
||||||
|
|
||||||
|
本切片只机械移动原版选股实现,没有从`next/`取用代码,也没有改写策略、因子、权重、排序、
|
||||||
|
盘后候选或跟踪逻辑。
|
||||||
|
|
||||||
|
| 原位置 | 新的唯一实现位置 | 兼容方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/screener.py` | `app/backend/features/screener/engine.py` | 根级模块指向同一模块对象 |
|
||||||
|
| `app/advanced_strategies.py` | `app/backend/features/screener/strategies.py` | 根级模块指向同一模块对象 |
|
||||||
|
| `app/llm_strategy.py` | `app/backend/features/screener/compiler.py` | 根级模块指向同一模块对象 |
|
||||||
|
| `DashboardService`选股入口 | `app/backend/features/screener/service.py` | `ScreenerServiceMixin` |
|
||||||
|
| `ReviewDatabase`选股持久化方法 | `app/backend/features/screener/repository.py` | `ScreenerRepositoryMixin` |
|
||||||
|
| 原策略持续跟踪服务 | `app/backend/features/screener/tracking.py` | 容器直接导入唯一实现 |
|
||||||
|
|
||||||
|
四个同时服务公共行情与选股的方法归入`MarketRepositoryMixin`,没有复制第二套实现:
|
||||||
|
`upsert_stock_master`、`list_stock_master`、`upsert_daily_bars`、`daily_bars_for_date`。
|
||||||
|
|
||||||
|
## 2. 源码与接口等价
|
||||||
|
|
||||||
|
- `test_preservation_slice_screener.py`逐项验证选股引擎、29套策略定义、自然语言策略编译器、
|
||||||
|
13个服务方法、25个选股持久化方法和跟踪服务与原版等价。
|
||||||
|
- 三个根级兼容模块与正式模块暴露同一模块及类对象;原类不再重复保留已移动方法。
|
||||||
|
- 原版与迁移版的`/api/screener/setup`、`/api/screener/tracking`和`/api/screener/run`
|
||||||
|
状态码及业务JSON一致。
|
||||||
|
- 差分仅规范化策略启动刷新和顺序执行必然变化的数组顺序与`updated_at`;其他字段仍逐项比较。
|
||||||
|
- 请求与差分结果见`api-requests.json`和`api-diff.json`。
|
||||||
|
|
||||||
|
## 3. 数据库差分
|
||||||
|
|
||||||
|
- 两个临时副本均为62个schema对象,结构完全一致。
|
||||||
|
- 13张关键表逐行一致,覆盖约143万条日线、77万条技术指标和51万条竞价因子。
|
||||||
|
- `screener_runs`两边均为251行;会话和运行时间戳只在临时副本内规范化。
|
||||||
|
- 正式`data/review.db`和`app/data/review.db`未写入测试策略、测试会话或差分时间戳。
|
||||||
|
- 完整表计数与哈希见`database-diff.json`。
|
||||||
|
|
||||||
|
## 4. 真实浏览器检查
|
||||||
|
|
||||||
|
- 已登录迁移版真实服务,分别检查阶段选股、策略选股、自定义选股和策略持续跟踪。
|
||||||
|
- 阶段选股显示退潮、置信度92%、匹配策略和262日因子就绪状态;策略选股载入29套策略。
|
||||||
|
- 自定义选股保留因子权重、过滤条件、公式入口、手动执行和独立候选结果。
|
||||||
|
- 跟踪页只展示手动加入的候选,批次、标的、T+1/T+3进度、胜率和移除操作均正常。
|
||||||
|
- 各检查状态无横向溢出、无加载遮罩残留,浏览器控制台无错误。
|
||||||
|
- 截图SHA-256:
|
||||||
|
- `curated-screener.jpg`:`529ecd0947b34b42eb63d166c6804b0041e26fa362a171e4d0d7b584e073c05e`
|
||||||
|
- `custom-screener.jpg`:`7f31f50d44bc63a488277be0a74232e2ac256779d0accda42b96cc1eef88cc6e`
|
||||||
|
- `tracking.jpg`:`b19b1bc0cd4a98c29500917676bdcc4cc9a430a0c72dea8aff1110ad911db6e3`
|
||||||
|
|
||||||
|
## 5. 自动验证与保留边界
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
|---|---:|
|
||||||
|
| 原版`python -m unittest discover -s tests -q` | 231项通过 |
|
||||||
|
| 迁移版`python -m unittest discover -s tests -q` | 265项通过 |
|
||||||
|
| `python -m unittest tests.test_preservation_slice_screener -q` | 7项通过 |
|
||||||
|
| `npx.cmd playwright test --reporter=dot` | 45项通过 |
|
||||||
|
| `git diff --check` | 通过 |
|
||||||
|
|
||||||
|
- Windows下由Playwright自行创建静态服务器时存在子进程不退出的测试基线问题;复用独立的8876
|
||||||
|
测试服务器后45项用例在2.1分钟内通过并正常返回退出码0。
|
||||||
|
- 选股引擎保留原版数据客户端依赖,边界测试将其与普通页面Service区分;后续只可在不改变行为且有
|
||||||
|
独立差分证据时治理该依赖。
|
||||||
|
- 前端资产保持原位置,切片10再按页面职责归档;本切片没有改DOM、CSS、动画或交互。
|
||||||
|
- 没有删除待定代码、没有修改正式数据库、没有切换Docker/NAS。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"all_equal": true,
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "盘后自动候选与策略库",
|
||||||
|
"method": "GET",
|
||||||
|
"endpoint": "/api/screener/setup?trade_date=2026-07-30",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd",
|
||||||
|
"migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd",
|
||||||
|
"equal": true,
|
||||||
|
"first_difference": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "策略持续跟踪",
|
||||||
|
"method": "GET",
|
||||||
|
"endpoint": "/api/screener/tracking?limit=12",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2",
|
||||||
|
"migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2",
|
||||||
|
"equal": true,
|
||||||
|
"first_difference": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "自定义选股执行",
|
||||||
|
"method": "POST",
|
||||||
|
"endpoint": "/api/screener/run",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "b3866e7d270aa222bd2bad0c0276b7e81ccd48e5f3a75941a32ef8e559a92c7b",
|
||||||
|
"migrated_sha256": "b3866e7d270aa222bd2bad0c0276b7e81ccd48e5f3a75941a32ef8e559a92c7b",
|
||||||
|
"equal": true,
|
||||||
|
"first_difference": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "盘后自动候选与策略库",
|
||||||
|
"method": "GET",
|
||||||
|
"endpoint": "/api/screener/setup?trade_date=2026-07-30",
|
||||||
|
"sort_lists": {
|
||||||
|
"$.strategies": "id"
|
||||||
|
},
|
||||||
|
"exclude_paths": [
|
||||||
|
"$.strategies[].updated_at"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "策略持续跟踪",
|
||||||
|
"method": "GET",
|
||||||
|
"endpoint": "/api/screener/tracking?limit=12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "自定义选股执行",
|
||||||
|
"method": "POST",
|
||||||
|
"endpoint": "/api/screener/run",
|
||||||
|
"exclude_paths": [
|
||||||
|
"$.result.meta.updated_at"
|
||||||
|
],
|
||||||
|
"payload": {
|
||||||
|
"trade_date": "2026-07-30",
|
||||||
|
"regime": "retreat",
|
||||||
|
"strategy_name": "保真迁移差分策略",
|
||||||
|
"mode": "quant",
|
||||||
|
"run_backtest": false,
|
||||||
|
"formula": {
|
||||||
|
"meta": {
|
||||||
|
"library": "quant",
|
||||||
|
"category": "量化公式"
|
||||||
|
},
|
||||||
|
"universe": {
|
||||||
|
"exclude_st": true,
|
||||||
|
"listed_days_min": 120
|
||||||
|
},
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"field": "pct_chg",
|
||||||
|
"op": "between",
|
||||||
|
"value": [-2, 2]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{
|
||||||
|
"field": "amount_billion",
|
||||||
|
"weight": 1,
|
||||||
|
"direction": "desc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"all_equal": true,
|
||||||
|
"schema": {
|
||||||
|
"object_count": 62,
|
||||||
|
"original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||||
|
"migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"table": "stock_master",
|
||||||
|
"original_count": 5535,
|
||||||
|
"migrated_count": 5535,
|
||||||
|
"original_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792",
|
||||||
|
"migrated_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "daily_bars",
|
||||||
|
"original_count": 1430501,
|
||||||
|
"migrated_count": 1430501,
|
||||||
|
"original_sha256": "d72a06755d92d6be95ab4f4c98a4718e973bee59d4f381b01acb963b6d7ae9e6",
|
||||||
|
"migrated_sha256": "d72a06755d92d6be95ab4f4c98a4718e973bee59d4f381b01acb963b6d7ae9e6",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "benchmark_bars",
|
||||||
|
"original_count": 261,
|
||||||
|
"migrated_count": 261,
|
||||||
|
"original_sha256": "691f7666dddcfdf7ebe8ba1d3e1ddd8f2d9670d1d7a06e740a929f3ef32ec1c9",
|
||||||
|
"migrated_sha256": "691f7666dddcfdf7ebe8ba1d3e1ddd8f2d9670d1d7a06e740a929f3ef32ec1c9",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "daily_indicators",
|
||||||
|
"original_count": 770238,
|
||||||
|
"migrated_count": 770238,
|
||||||
|
"original_sha256": "cd5a71c7d8de201ff2e3e58c64e2617a31d247911732fbddf81acb025d31fde9",
|
||||||
|
"migrated_sha256": "cd5a71c7d8de201ff2e3e58c64e2617a31d247911732fbddf81acb025d31fde9",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "fundamental_indicators",
|
||||||
|
"original_count": 49490,
|
||||||
|
"migrated_count": 49490,
|
||||||
|
"original_sha256": "4b6859256063d3a0238ba3cbbbe2842f1278cf88d44b0f6114f1af978b8bfa7d",
|
||||||
|
"migrated_sha256": "4b6859256063d3a0238ba3cbbbe2842f1278cf88d44b0f6114f1af978b8bfa7d",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "moneyflow_daily",
|
||||||
|
"original_count": 36368,
|
||||||
|
"migrated_count": 36368,
|
||||||
|
"original_sha256": "f7591dec150c7e0094568e3c46d77fc54e14a803223e15940dc54af1017bd83d",
|
||||||
|
"migrated_sha256": "f7591dec150c7e0094568e3c46d77fc54e14a803223e15940dc54af1017bd83d",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "earnings_events",
|
||||||
|
"original_count": 580,
|
||||||
|
"migrated_count": 580,
|
||||||
|
"original_sha256": "ebac03fe9ef491a6d6d2b5bc8f5ee08b609fa0ebe5992087fa067216490a6b9d",
|
||||||
|
"migrated_sha256": "ebac03fe9ef491a6d6d2b5bc8f5ee08b609fa0ebe5992087fa067216490a6b9d",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "auction_factors",
|
||||||
|
"original_count": 511914,
|
||||||
|
"migrated_count": 511914,
|
||||||
|
"original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||||
|
"migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "popularity_factors",
|
||||||
|
"original_count": 232,
|
||||||
|
"migrated_count": 232,
|
||||||
|
"original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||||
|
"migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "lhb_institution_daily",
|
||||||
|
"original_count": 47,
|
||||||
|
"migrated_count": 47,
|
||||||
|
"original_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c",
|
||||||
|
"migrated_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "screener_strategies",
|
||||||
|
"original_count": 36,
|
||||||
|
"migrated_count": 36,
|
||||||
|
"original_sha256": "e58859dbd478272d71bf85b6bc4ba45e609baccba74bb818b044d93bafa07312",
|
||||||
|
"migrated_sha256": "e58859dbd478272d71bf85b6bc4ba45e609baccba74bb818b044d93bafa07312",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "screener_runs",
|
||||||
|
"original_count": 251,
|
||||||
|
"migrated_count": 251,
|
||||||
|
"original_sha256": "117b0b95a362c91ddd70ca2def1e62e96e0ade2494ba4302bab5154c8d384498",
|
||||||
|
"migrated_sha256": "117b0b95a362c91ddd70ca2def1e62e96e0ade2494ba4302bab5154c8d384498",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "strategy_tracks",
|
||||||
|
"original_count": 16,
|
||||||
|
"migrated_count": 16,
|
||||||
|
"original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4",
|
||||||
|
"migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4",
|
||||||
|
"equal": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 68 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"updated_at": "2026-07-31T00:37:00+08:00",
|
"updated_at": "2026-07-31T03:56:00+08:00",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"migration_mode": "behavior_preserving_source_migration",
|
"migration_mode": "behavior_preserving_source_migration",
|
||||||
"source_of_truth": "current_original_webapp_runtime_and_source",
|
"source_of_truth": "current_original_webapp_runtime_and_source",
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
"failed_roots": [
|
"failed_roots": [
|
||||||
"next"
|
"next"
|
||||||
],
|
],
|
||||||
"current_slice": "slice-02-market-search-charts-data",
|
"current_slice": "slice-07-mentor-skills-llm-streaming",
|
||||||
"last_completed_slice": "slice-01-startup-http-accounts-system",
|
"last_completed_slice": "slice-06-screener-custom-tracking",
|
||||||
"last_checkpoint": "xiaobai-preservation-slice-01-20260731",
|
"last_checkpoint": "xiaobai-preservation-slice-06-20260731",
|
||||||
"next_action": "capture_slice-02_market_search_chart_data_contracts_then_move_original_implementations",
|
"next_action": "capture_slice-07_mentor_skill_model_pool_and_streaming_contracts_then_move_original_implementations",
|
||||||
"authoritative_documents": [
|
"authoritative_documents": [
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
"docs/migration/原版保真迁移总纲.md",
|
"docs/migration/原版保真迁移总纲.md",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 小白复盘保真迁移账本
|
# 小白复盘保真迁移账本
|
||||||
|
|
||||||
> 当前状态:正式迁移,切片01“启动、HTTP、账号、会员与系统管理”已完成
|
> 当前状态:正式迁移,切片06“智能选股、自定义选股与策略持续跟踪”已完成
|
||||||
|
|
||||||
本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及
|
本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及
|
||||||
`保真迁移状态.json`。
|
`保真迁移状态.json`。
|
||||||
@@ -21,6 +21,11 @@
|
|||||||
| 2026-07-30 | `xiaobai-preservation-migration-charter-20260730` | 建立保真迁移总纲、状态和恢复协议 | 尚未开始新迁移 |
|
| 2026-07-30 | `xiaobai-preservation-migration-charter-20260730` | 建立保真迁移总纲、状态和恢复协议 | 尚未开始新迁移 |
|
||||||
| 2026-07-30 | `41329943c4878fc09ed82ec376eb93ab151e4092` | 完成只读资产清查并由用户批准`app/`结构 | 开始切片00 |
|
| 2026-07-30 | `41329943c4878fc09ed82ec376eb93ab151e4092` | 完成只读资产清查并由用户批准`app/`结构 | 开始切片00 |
|
||||||
| 2026-07-31 | `xiaobai-preservation-slice-01-20260731` | 启动、HTTP、账号、会员与系统管理原实现归位 | 自动差分通过,进入切片02 |
|
| 2026-07-31 | `xiaobai-preservation-slice-01-20260731` | 启动、HTTP、账号、会员与系统管理原实现归位 | 自动差分通过,进入切片02 |
|
||||||
|
| 2026-07-31 | `xiaobai-preservation-slice-02-20260731` | 公共行情、搜索、详情、图表与数据适配原实现归位 | 自动与浏览器差分通过,进入切片03 |
|
||||||
|
| 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 |
|
||||||
|
| 2026-07-31 | `xiaobai-preservation-slice-04-20260731` | 市场天梯与板块轮动原实现归位 | 自动、API与浏览器差分通过,进入切片05 |
|
||||||
|
| 2026-07-31 | `xiaobai-preservation-slice-05-20260731` | 集合竞价、题材库、人气热榜与龙虎榜原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片06 |
|
||||||
|
| 2026-07-31 | `xiaobai-preservation-slice-06-20260731` | 智能选股、自定义选股与策略持续跟踪原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片07 |
|
||||||
|
|
||||||
## 资产处置登记
|
## 资产处置登记
|
||||||
|
|
||||||
@@ -35,6 +40,19 @@
|
|||||||
| `commonReviewColumns`等5个前端函数 | 疑似无引用符号 | 未发现静态调用 | 待定 | 待删隔离账本 | 仍需动态注册与浏览器覆盖 | 保留 |
|
| `commonReviewColumns`等5个前端函数 | 疑似无引用符号 | 未发现静态调用 | 待定 | 待删隔离账本 | 仍需动态注册与浏览器覆盖 | 保留 |
|
||||||
| `wencai_saved_queries`及其方法 | 历史兼容数据 | 当前无前端入口 | 待定 | 数据库兼容区 | 不允许在迁移期破坏旧库 | 保留 |
|
| `wencai_saved_queries`及其方法 | 历史兼容数据 | 当前无前端入口 | 待定 | 数据库兼容区 | 不允许在迁移期破坏旧库 | 保留 |
|
||||||
| 现有7层CSS | 视觉运行资产 | 全部页面和主题 | 原样保留后逐页归档 | `app/frontend/` | 必须通过截图与计算样式差分 | 保留 |
|
| 现有7层CSS | 视觉运行资产 | 全部页面和主题 | 原样保留后逐页归档 | `app/frontend/` | 必须通过截图与计算样式差分 | 保留 |
|
||||||
|
| `sentiment_engine.py` | 情绪周期计算 | 总览、轮动、选股 | 移动并保留兼容别名 | `app/backend/features/sentiment/engine.py` | 文件哈希与原版一致;248项Python与45项Playwright通过 | 已移动 |
|
||||||
|
| `DashboardService`情绪及股池原因方法 | 业务服务 | 情绪页、五类股池、涨停表现 | 按职责机械移动 | `app/backend/features/sentiment/`、`app/backend/features/pools/` | 8个方法AST与原版一致;真实API完全一致 | 已移动 |
|
||||||
|
| `ReviewDatabase`原因覆盖方法 | 持久化 | 股池原因人工覆盖 | 按职责机械移动 | `app/backend/features/pools/repository.py` | 2个方法AST与原版一致;数据库schema哈希一致 | 已移动 |
|
||||||
|
| `DashboardService`板块轮动方法 | 业务服务 | 板块轮动页 | 按职责机械移动 | `app/backend/features/rotation/service.py` | 2个方法AST、真实API与原版一致 | 已移动 |
|
||||||
|
| Tushare天梯与轮动构造函数 | 公共数据计算 | 市场天梯、板块轮动 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个构造函数AST与原版一致 | 已归位 |
|
||||||
|
| `MarketInsightsService` | 共享市场洞察服务 | 集合竞价、题材库、人气热榜 | 整体机械移动,保留唯一共享实现 | `app/backend/features/market/insights.py` | 22个方法AST与原版一致;根级模块为同一类对象别名 | 已移动 |
|
||||||
|
| `DashboardService`竞价、题材、人气与龙虎榜方法 | 业务服务 | 切片05四类页面与API | 按职责机械移动 | `app/backend/features/auction/`、`themes/`、`popularity/`、`dragon_tiger/` | 8个方法AST、7个真实API与原版一致 | 已移动 |
|
||||||
|
| `ReviewDatabase`竞价、人气与龙虎榜方法 | 持久化 | 市场洞察及后续智能选股 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/auction/repository.py`、`popularity/repository.py`、`dragon_tiger/repository.py` | 7个方法AST一致;62个schema对象及5张关键表逐行一致 | 已移动 |
|
||||||
|
| Tushare游资名录与龙虎榜实现 | 公共数据计算 | 龙虎榜与游资档案 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个方法AST与原版一致 | 已归位 |
|
||||||
|
| 选股引擎、策略库与公式编译器 | 业务计算 | 阶段、策略、自定义选股 | 整体机械移动并保留兼容别名 | `app/backend/features/screener/engine.py`、`strategies.py`、`compiler.py` | 原版AST/源码等价;根级模块为同一模块对象 | 已移动 |
|
||||||
|
| `DashboardService`选股方法 | 业务服务 | 选股三个工作区 | 按职责机械移动 | `app/backend/features/screener/service.py` | 13个方法AST及3个真实API一致 | 已移动 |
|
||||||
|
| `ReviewDatabase`选股方法 | 持久化 | 因子、策略运行、候选与跟踪 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/screener/repository.py` | 25个方法AST一致;62个schema对象及13张关键表逐行一致 | 已移动 |
|
||||||
|
| `StrategyTrackingService` | 业务服务 | 手动候选持续跟踪 | 保持唯一实现并调整容器导入 | `app/backend/features/screener/tracking.py` | 类定义AST、真实API与浏览器行为一致 | 已归位 |
|
||||||
|
|
||||||
处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。
|
处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。
|
||||||
|
|
||||||
@@ -61,6 +79,57 @@
|
|||||||
- 回档:标签`xiaobai-preservation-slice-01-20260731`。
|
- 回档:标签`xiaobai-preservation-slice-01-20260731`。
|
||||||
- 完整证据:`docs/migration/evidence/slice-01/README.md`。
|
- 完整证据:`docs/migration/evidence/slice-01/README.md`。
|
||||||
|
|
||||||
|
已完成切片:`slice-02-market-search-charts-data`。
|
||||||
|
|
||||||
|
- 原版基线:提交`4002f09`,即切片01回档点。
|
||||||
|
- 迁移范围:30个公共行情服务方法、11个行情持久化方法及Tushare/iFinD/图表/实时观察实现。
|
||||||
|
- 兼容边界:四个根级数据模块保留模块别名;未迁移功能继续使用旧导入且指向同一实现。
|
||||||
|
- 等价证明:41个方法AST逐项一致,三个数据文件哈希一致,图表定义AST一致,静态资产哈希一致。
|
||||||
|
- 验收:241项Python测试、6项切片源码等价测试、45项Playwright测试及真实服务搜索/详情流程通过。
|
||||||
|
- 既存状态:原版和迁移版的实时分时在当前环境均返回相同外部请求失败,不作为迁移回归处理。
|
||||||
|
- 回档:标签`xiaobai-preservation-slice-02-20260731`。
|
||||||
|
- 完整证据:`docs/migration/evidence/slice-02/README.md`。
|
||||||
|
|
||||||
|
已完成切片:`slice-03-sentiment-pools-performance`。
|
||||||
|
|
||||||
|
- 原版基线:提交`a426432`,即切片02回档点。
|
||||||
|
- 迁移范围:情绪计算引擎、2个情绪服务方法、6个股池原因与iFinD事件补全方法、2个原因覆盖持久化方法。
|
||||||
|
- 兼容边界:根级`sentiment_engine.py`保留同一模块对象别名;股池生成仍使用切片02的原Tushare总览实现。
|
||||||
|
- API与数据库:原版`8784`和迁移版`8785`的总览、情绪历史JSON完全一致;两库schema均为62项且哈希一致。
|
||||||
|
- 验收:248项Python测试、6项切片源码等价测试、45项Playwright测试及六个真实页面流程通过。
|
||||||
|
- 回档:标签`xiaobai-preservation-slice-03-20260731`。
|
||||||
|
- 完整证据:`docs/migration/evidence/slice-03/README.md`。
|
||||||
|
|
||||||
|
已完成切片:`slice-04-ladder-rotation`。
|
||||||
|
|
||||||
|
- 原版基线:提交`b3555d2`,即切片03回档点。
|
||||||
|
- 迁移范围:2个板块轮动服务方法;市场天梯继续使用切片02已归位的原Tushare数据构造实现。
|
||||||
|
- 兼容边界:`DashboardService`通过`RotationServiceMixin`保持所有原调用;天梯不制造空服务或第二套计算。
|
||||||
|
- API与错误:天梯与9日轮动历史JSON完全一致;成分股两版均返回同一Tushare外部失败语义。
|
||||||
|
- 验收:252项Python测试、4项切片源码等价测试、45项Playwright测试及两个真实页面流程通过。
|
||||||
|
- 回档:标签`xiaobai-preservation-slice-04-20260731`。
|
||||||
|
- 完整证据:`docs/migration/evidence/slice-04/README.md`。
|
||||||
|
|
||||||
|
已完成切片:`slice-05-auction-themes-popularity-dragon-tiger`。
|
||||||
|
|
||||||
|
- 原版基线:提交`814e757`,即切片04回档点。
|
||||||
|
- 迁移范围:共享市场洞察服务、竞价/题材/人气入口、龙虎榜与游资档案服务、7个相关持久化方法。
|
||||||
|
- 兼容边界:根级`market_insights.py`保留同一类对象别名;竞价与人气共用候选热度逻辑,不复制第二套实现。
|
||||||
|
- API与数据库:7个真实API逐字段一致,仅排除每次请求必然变化的`request_id`;62个schema对象与5张关键表完全一致。
|
||||||
|
- 验收:258项Python测试、6项切片源码等价测试、45项Playwright测试及四个真实页面流程通过。
|
||||||
|
- 回档:标签`xiaobai-preservation-slice-05-20260731`。
|
||||||
|
- 完整证据:`docs/migration/evidence/slice-05/README.md`。
|
||||||
|
|
||||||
|
已完成切片:`slice-06-screener-custom-tracking`。
|
||||||
|
|
||||||
|
- 原版基线:提交`cf2aad2`,即切片05回档点。
|
||||||
|
- 迁移范围:完整选股引擎、29套高级策略、策略编译器、13个页面服务方法、25个持久化方法及持续跟踪。
|
||||||
|
- 兼容边界:三个根级模块指向正式模块对象;选股引擎保留原版数据客户端依赖,不为通过边界测试改写算法。
|
||||||
|
- API与数据库:3个真实API业务JSON一致;62个schema对象和13张关键表逐行一致。
|
||||||
|
- 验收:原版231项、迁移版265项Python测试、7项切片源码等价测试、45项Playwright及四个真实工作区通过。
|
||||||
|
- 回档:标签`xiaobai-preservation-slice-06-20260731`。
|
||||||
|
- 完整证据:`docs/migration/evidence/slice-06/README.md`。
|
||||||
|
|
||||||
## 决策记录
|
## 决策记录
|
||||||
|
|
||||||
| 日期 | 决策 | 原因 |
|
| 日期 | 决策 | 原因 |
|
||||||
|
|||||||