From 0030bb8cc18c5aa7107dd8fd866355a6507bd6da Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 29 Jul 2026 16:50:40 +0800 Subject: [PATCH] feat: complete strategy and market data improvements --- advanced_strategies.py | 153 ++++++++++ chart_data_provider.py | 49 +++- database.py | 201 ++++++++++++- market_insights.py | 29 +- screener.py | 420 ++++++++++++++++++++++++++++ server.py | 101 ++++++- static/app.js | 225 ++++++++++++--- static/design-system.css | 10 +- static/index.html | 39 +-- static/redesign-v2.css | 36 +-- static/styles.css | 10 +- static/theme.css | 20 +- tests/e2e/app-shell.spec.js | 157 +++++++++-- tests/test_curated_screener.py | 191 ++++++++++++- tests/test_frontend_contract.py | 2 +- tests/test_ifind_features.py | 48 ++++ tests/test_market_insights.py | 42 +++ tests/test_stock_detail_realtime.py | 51 +++- 18 files changed, 1622 insertions(+), 162 deletions(-) diff --git a/advanced_strategies.py b/advanced_strategies.py index 752643e..8ddfc1d 100644 --- a/advanced_strategies.py +++ b/advanced_strategies.py @@ -278,6 +278,159 @@ ADVANCED_CURATED_STRATEGIES.extend( ] ) +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( [ { diff --git a/chart_data_provider.py b/chart_data_provider.py index 7ce259d..1259cc5 100644 --- a/chart_data_provider.py +++ b/chart_data_provider.py @@ -8,7 +8,7 @@ import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta from threading import Lock from typing import Any, ClassVar @@ -168,8 +168,24 @@ class MarketChartClient: previous = normalized[index - 1]["close"] if index > 0 else 0 row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0 - today = datetime.now().astimezone().strftime("%Y%m%d") - if compact_end == today: + 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, @@ -179,16 +195,31 @@ class MarketChartClient: quote = quote_rows[0] if quote_rows else {} latest = _number(quote.get("latest")) previous = _number(quote.get("preClose")) - if latest > 0: + 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": _number(quote.get("open")) or latest, - "high": _number(quote.get("high")) or latest, - "low": _number(quote.get("low")) or latest, + "open": open_price, + "high": high, + "low": low, "close": latest, "change": round((latest / previous - 1) * 100, 4) if previous else 0.0, - "volume": _number(quote.get("volume")), - "amount_billion": _number(quote.get("amount")) / 100_000_000, + "volume": volume, + "amount_billion": amount / 100_000_000, "realtime": True, } if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]: diff --git a/database.py b/database.py index e45d0c0..61a5b6c 100644 --- a/database.py +++ b/database.py @@ -276,6 +276,49 @@ class ReviewDatabase: CREATE INDEX IF NOT EXISTS idx_auction_factors_code_date ON auction_factors(ts_code, trade_date DESC); + CREATE TABLE IF NOT EXISTS earnings_events ( + end_date TEXT NOT NULL, + ann_date TEXT NOT NULL, + ts_code TEXT NOT NULL, + forecast_profit REAL, + actual_profit REAL, + surprise_pct REAL, + revenue_yoy REAL, + netprofit_yoy REAL, + source TEXT NOT NULL DEFAULT '', + PRIMARY KEY (end_date, ann_date, ts_code) + ); + + CREATE INDEX IF NOT EXISTS idx_earnings_events_code_announcement + ON earnings_events(ts_code, ann_date DESC, end_date DESC); + + CREATE TABLE IF NOT EXISTS popularity_factors ( + trade_date TEXT NOT NULL, + ts_code TEXT NOT NULL, + ths_rank INTEGER, + dc_rank INTEGER, + combined_score REAL NOT NULL DEFAULT 0, + rank_change INTEGER, + dual_source INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trade_date, ts_code) + ); + + CREATE INDEX IF NOT EXISTS idx_popularity_factors_code_date + ON popularity_factors(ts_code, trade_date DESC); + + CREATE TABLE IF NOT EXISTS lhb_institution_daily ( + trade_date TEXT NOT NULL, + ts_code TEXT NOT NULL, + net_buy_amount REAL NOT NULL DEFAULT 0, + buy_amount REAL NOT NULL DEFAULT 0, + sell_amount REAL NOT NULL DEFAULT 0, + seat_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trade_date, ts_code) + ); + + CREATE INDEX IF NOT EXISTS idx_lhb_institution_code_date + ON lhb_institution_daily(ts_code, trade_date DESC); + CREATE TABLE IF NOT EXISTS screener_strategies ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, @@ -1446,6 +1489,109 @@ class ReviewDatabase: ) 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 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) + + 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) + 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,) @@ -1566,6 +1712,21 @@ class ReviewDatabase: """, (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), @@ -1579,6 +1740,12 @@ class ReviewDatabase: "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]: @@ -1588,7 +1755,8 @@ class ReviewDatabase: "dates": [], "bars": [], "master": [], "indicators": [], "indicator_history": [], "indicator_series": [], "fundamentals": [], "moneyflow": [], "moneyflow_history": [], "auction": [], - "benchmarks": [], + "benchmarks": [], "fundamental_history": [], + "earnings_events": [], "popularity": [], "institutions": [], } placeholders = ",".join("?" for _ in dates) with self.connect() as connection: @@ -1623,7 +1791,8 @@ class ReviewDatabase: ).fetchall() indicator_series = connection.execute( f""" - SELECT trade_date, ts_code, turnover_rate, volume_ratio + 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 @@ -1644,6 +1813,14 @@ class ReviewDatabase: """, (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 @@ -1680,6 +1857,22 @@ class ReviewDatabase: """, 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], @@ -1688,10 +1881,14 @@ class ReviewDatabase: "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]]: diff --git a/market_insights.py b/market_insights.py index 8d2483e..8ee5dad 100644 --- a/market_insights.py +++ b/market_insights.py @@ -668,8 +668,8 @@ class MarketInsightsService: start_stamp = f"{display_date} 09:15:00" snapshot_rows: list[dict[str, Any]] = [] ordered_codes = sorted(selected_codes) - try: - for index in range(0, len(ordered_codes), 80): + for index in range(0, len(ordered_codes), 80): + try: snapshot_rows.extend( self.ifind.snapshots( ordered_codes[index:index + 80], @@ -682,13 +682,18 @@ class MarketInsightsService: cache_ttl=8, ) ) - except IfindError: - return [] + except IfindError: + continue latest: dict[str, dict[str, Any]] = {} for row in snapshot_rows: ts_code = str(row.get("thscode") or "") - if ts_code and _number(row.get("latest")) > 0: + previous = latest.get(ts_code) or {} + if ( + ts_code + and _number(row.get("latest")) > 0 + and str(row.get("time") or "") >= str(previous.get("time") or "") + ): latest[ts_code] = row prior_factors = { str(item.get("ts_code") or ""): item @@ -735,11 +740,13 @@ class MarketInsightsService: trade_date, previous_date = self._trade_context(requested_date) session = self._auction_session(requested_date, trade_date) phase = str(session["phase"]) - dynamic = phase == "observing" and bool(self.ifind and self.ifind.configured) - data_date = previous_date if phase == "pending" or (phase == "observing" and not dynamic) else trade_date + ifind_ready = bool(self.ifind and self.ifind.configured) + live_dynamic = phase == "observing" and ifind_ready + use_ifind_snapshot = phase in {"observing", "selection", "finalized"} and ifind_ready + data_date = previous_date if phase == "pending" or (phase == "observing" and not live_dynamic) else trade_date carried_forward = data_date != trade_date cache_key = data_date - if not force and not dynamic: + if not force and not live_dynamic: cached = self.database.get_data_snapshot("auction_center_v6", cache_key) if cached: result = copy.deepcopy(cached) @@ -754,9 +761,11 @@ class MarketInsightsService: } return self._with_auction_watchlist(result, data_date, user_id) - if dynamic: + if use_ifind_snapshot: rows = self._dynamic_auction_rows(data_date, previous_date, user_id) else: + rows = [] + if not rows and not live_dynamic: try: rows = self.client.query("stk_auction", {"trade_date": data_date}) except TushareError: @@ -927,7 +936,7 @@ class MarketInsightsService: "one_price_rows": one_price_rows, "rows": candidates, } - if not dynamic: + if not live_dynamic: self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result) return self._with_auction_watchlist(result, data_date, user_id) diff --git a/screener.py b/screener.py index dbdd71b..1ee940e 100644 --- a/screener.py +++ b/screener.py @@ -63,6 +63,10 @@ FACTOR_FIELDS = { "sector_stock_momentum_rank": "行业内个股动量排名", "sector_net_flow_5d_million": "行业5日主力净流入", "sector_flow_rank": "行业资金流排名", + "sector_prosperity_rank": "行业景气度排名", + "sector_trend_rank": "行业趋势排名", + "sector_crowding_rank": "行业拥挤度排名", + "sector_composite_score": "行业三维综合分", "sector_limit_count": "板块涨停数", "sector_up_count": "板块强势股数", "relative_strength": "相对强度", @@ -84,6 +88,23 @@ FACTOR_FIELDS = { "netprofit_yoy": "净利润同比", "revenue_yoy": "营业收入同比", "ocf_to_opincome": "经营现金流质量", + "earnings_surprise_pct": "业绩超预期幅度", + "earnings_days_since_announce": "业绩公告后天数", + "earnings_event_quality": "业绩事件质量", + "popularity_score": "人气榜热度", + "popularity_rank_change": "人气排名跃升", + "popularity_dual_source": "双榜共识", + "institution_net_buy_million": "机构席位净买入", + "institution_seat_count": "机构席位数", + "style_size_fit": "大小盘风格匹配", + "style_growth_fit": "成长价值风格匹配", + "style_fit_score": "当前风格匹配度", + "factor_value_score": "价值因子分", + "factor_growth_score": "成长因子分", + "factor_quality_score": "质量因子分", + "factor_momentum_score": "动量因子分", + "factor_sentiment_score": "交易情绪因子分", + "multi_factor_composite": "动态多因子综合分", "relative_position_60": "60日相对位置", "max_abs_change_15d": "15日最大波动", "close_to_high_15d": "距15日高点", @@ -133,6 +154,8 @@ FACTOR_GROUPS = { "板块结构": [ "sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank", "sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_rank", + "sector_prosperity_rank", "sector_trend_rank", "sector_crowding_rank", + "sector_composite_score", "sector_limit_count", "sector_up_count", "sector_breadth_ma20", "limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal", "is_limit_up_today", "is_limit_down_today", @@ -151,6 +174,14 @@ FACTOR_GROUPS = { "财务质量": [ "roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome", "financial_risk", + "earnings_surprise_pct", "earnings_days_since_announce", "earnings_event_quality", + ], + "特色数据": [ + "popularity_score", "popularity_rank_change", "popularity_dual_source", + "institution_net_buy_million", "institution_seat_count", + "style_size_fit", "style_growth_fit", "style_fit_score", + "factor_value_score", "factor_growth_score", "factor_quality_score", + "factor_momentum_score", "factor_sentiment_score", "multi_factor_composite", ], } @@ -649,6 +680,30 @@ STRATEGY_ENVIRONMENT_NOTES = { "板块轮动初期、资金先于价格形成连续净流入的阶段", "资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势", ), + "景气-趋势-拥挤三维行业打分": ( + "行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市", + "财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢", + ), + "大小盘/成长价值风格切换(元策略)": ( + "大小盘或成长价值风格形成持续相对强弱的阶段", + "风格快速往返切换时,近20日相对表现容易产生滞后信号", + ), + "业绩超预期漂移(SUE/PEAD)": ( + "业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时", + "预告与快报口径可能不同,公告后高开兑现会削弱漂移效应", + ), + "多因子综合打分(IC动态加权)": ( + "因子表现具备一定延续性、市场并非由单一极端主题主导时", + "近期有效因子可能快速失效,动态权重不能消除风格突变风险", + ), + "热度突增潜伏(另类数据)": ( + "人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期", + "榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高", + ), + "机构榜溢价": ( + "机构专用席位在相对低位形成明确净买入、且成交承载正常时", + "高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓", + ), } for strategy in CURATED_STRATEGIES: @@ -679,6 +734,106 @@ def _quarter_periods(trade_date: str, count: int) -> list[str]: return sorted(periods) +def _earnings_event_rows( + forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], trade_date: str, +) -> list[dict[str, Any]]: + forecast_map: dict[tuple[str, str], dict[str, Any]] = {} + for row in forecasts: + key = (str(row.get("ts_code") or ""), str(row.get("end_date") or "")) + ann_date = str(row.get("ann_date") or "") + if not all(key) or not ann_date or ann_date > trade_date: + continue + previous = forecast_map.get(key) + if previous is None or ann_date > str(previous.get("ann_date") or ""): + forecast_map[key] = row + result = [] + for row in expresses: + ts_code = str(row.get("ts_code") or "") + end_date = str(row.get("end_date") or "") + ann_date = str(row.get("ann_date") or "") + forecast = forecast_map.get((ts_code, end_date)) + if not forecast or not ts_code or not end_date or not ann_date or ann_date > trade_date: + continue + lower = _optional_number(forecast.get("net_profit_min")) + upper = _optional_number(forecast.get("net_profit_max")) + forecast_profit = statistics.fmean( + value for value in (lower, upper) if value is not None + ) if lower is not None or upper is not None else None + actual_profit = _optional_number(row.get("n_income")) + if forecast_profit in (None, 0) or actual_profit is None: + continue + # forecast is reported in ten-thousand yuan while express uses yuan. + if abs(actual_profit) > max(abs(forecast_profit), 1) * 100: + actual_profit /= 10000 + surprise_pct = (actual_profit / forecast_profit - 1) * 100 + result.append( + { + "end_date": end_date, + "ann_date": ann_date, + "ts_code": ts_code, + "forecast_profit": forecast_profit, + "actual_profit": actual_profit, + "surprise_pct": surprise_pct, + "revenue_yoy": _optional_number(row.get("yoy_sales")), + "netprofit_yoy": _optional_number(row.get("yoy_net_profit")), + "source": "forecast+express", + } + ) + return result + + +def _popularity_factor_rows( + trade_date: str, + ths_rows: list[dict[str, Any]], + dc_rows: list[dict[str, Any]], + previous_ths: list[dict[str, Any]], + previous_dc: list[dict[str, Any]], +) -> list[dict[str, Any]]: + def ranks(rows: list[dict[str, Any]], data_type: str) -> dict[str, int]: + result = {} + for row in rows: + if data_type and str(row.get("data_type") or "") != data_type: + continue + ts_code = str(row.get("ts_code") or "") + rank = int(_number(row.get("rank"))) + if ts_code and rank > 0: + result[ts_code] = rank + return result + + ths = ranks(ths_rows, "热股") + dc = ranks(dc_rows, "A股市场") + previous_ths_map = ranks(previous_ths, "热股") + previous_dc_map = ranks(previous_dc, "A股市场") + result = [] + for ts_code in set(ths) | set(dc): + ths_rank = ths.get(ts_code) + dc_rank = dc.get(ts_code) + current_best = min(value for value in (ths_rank, dc_rank) if value is not None) + previous_candidates = [ + value for value in (previous_ths_map.get(ts_code), previous_dc_map.get(ts_code)) + if value is not None + ] + previous_best = min(previous_candidates) if previous_candidates else None + score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25 + result.append( + { + "trade_date": trade_date, + "ts_code": ts_code, + "ths_rank": ths_rank, + "dc_rank": dc_rank, + "combined_score": round(score, 2), + "rank_change": ( + previous_best - current_best + if previous_best is not None + else min(30, max(0, 31 - current_best)) + if previous_ths_map or previous_dc_map else 0 + ), + "dual_source": bool(ths_rank and dc_rank), + } + ) + return result + + class FactorDataService: def __init__(self, database: ReviewDatabase, client: TushareClient) -> None: self.database = database @@ -714,12 +869,15 @@ class FactorDataService: "cal_date,is_open", ) last_open_by_year: dict[str, str] = {} + last_open_by_month: dict[str, str] = {} for row in long_calendar: if row.get("is_open") == 1 and row.get("cal_date"): value = str(row["cal_date"]) last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value) + last_open_by_month[value[:6]] = max(last_open_by_month.get(value[:6], ""), value) valuation_dates = set(dates[-min(80, len(dates)):]) valuation_dates.update(last_open_by_year.values()) + valuation_dates.update(last_open_by_month.values()) existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500)) indicator_dates_to_fetch = sorted( value for value in valuation_dates if value not in existing_indicators or value == trade_date @@ -814,6 +972,63 @@ class FactorDataService: notices.append(f"资金流接口不可用:{exc}") break + earnings_count = 0 + forecasts: list[dict[str, Any]] = [] + expresses: list[dict[str, Any]] = [] + for period in _quarter_periods(trade_date, 5): + try: + forecast_rows = self.client.query( + "forecast_vip", + {"period": period}, + "ts_code,ann_date,end_date,net_profit_min,net_profit_max,last_parent_net,p_change_min,p_change_max", + ) + express_rows = self.client.query( + "express_vip", + {"period": period}, + "ts_code,ann_date,end_date,n_income,yoy_net_profit,yoy_sales", + ) + except TushareError as exc: + notices.append(f"业绩事件接口不可用:{exc}") + break + forecasts.extend(forecast_rows) + expresses.extend(express_rows) + if forecasts and expresses: + earnings_count = self.database.upsert_earnings_events( + _earnings_event_rows(forecasts, expresses, trade_date) + ) + + popularity_count = 0 + previous_trade_date = dates[-2] if len(dates) >= 2 else "" + try: + ths_rows = self.client.query("ths_hot", {"trade_date": trade_date}) + dc_rows = self.client.query("dc_hot", {"trade_date": trade_date}) + previous_ths = ( + self.client.query("ths_hot", {"trade_date": previous_trade_date}) + if previous_trade_date else [] + ) + previous_dc = ( + self.client.query("dc_hot", {"trade_date": previous_trade_date}) + if previous_trade_date else [] + ) + popularity_count = self.database.upsert_popularity_factors( + _popularity_factor_rows( + trade_date, ths_rows, dc_rows, previous_ths, previous_dc + ) + ) + except TushareError as exc: + notices.append(f"人气榜因子不可用:{exc}") + + institution_count = 0 + try: + institution_rows = self.client.query( + "top_inst", + {"trade_date": trade_date}, + "trade_date,ts_code,exalter,buy,sell,net_buy,side,reason", + ) + institution_count = self.database.upsert_lhb_institutions(institution_rows) + except TushareError as exc: + notices.append(f"机构席位明细不可用:{exc}") + return { "trade_date": trade_date, "calendar_dates": len(dates), @@ -828,6 +1043,9 @@ class FactorDataService: "moneyflow_dates": moneyflow_dates, "auction_rows": auction_count, "auction_dates": auction_dates, + "earnings_events": earnings_count, + "popularity_rows": popularity_count, + "institution_rows": institution_count, "notice": ";".join(notices), } @@ -1061,6 +1279,23 @@ class ScreenerEngine: for row in data.get("auction", []) if str(row.get("trade_date") or "") == actual_date } + earnings_events: dict[str, dict[str, Any]] = {} + for row in data.get("earnings_events", []): + ts_code = str(row.get("ts_code") or "") + ann_date = str(row.get("ann_date") or "") + if ann_date <= actual_date and ( + ts_code not in earnings_events + or ann_date > str(earnings_events[ts_code].get("ann_date") or "") + ): + earnings_events[ts_code] = row + popularity = { + str(row.get("ts_code") or ""): row + for row in data.get("popularity", []) + } + institutions = { + str(row.get("ts_code") or ""): row + for row in data.get("institutions", []) + } grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in data["bars"]: if row["trade_date"] <= history_date: @@ -1206,6 +1441,33 @@ class ScreenerEngine: vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0 broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name) netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy")) + earnings_event = earnings_events.get(ts_code, {}) + announcement_date = str(earnings_event.get("ann_date") or "") + earnings_days = ( + sum(1 for value in dates if announcement_date < value <= actual_date) + if announcement_date and announcement_date <= actual_date + else None + ) + announcement_bar = next( + (item for item in shape_rows if str(item.get("trade_date") or "") == announcement_date), + None, + ) + announcement_bad = False + if announcement_bar is not None: + bar_index = shape_rows.index(announcement_bar) + prior_volumes = [ + _number(item.get("vol")) for item in shape_rows[max(0, bar_index - 5):bar_index] + if _number(item.get("vol")) > 0 + ] + volume_baseline = statistics.fmean(prior_volumes) if prior_volumes else 0 + announcement_bad = ( + _number(announcement_bar.get("close")) < _number(announcement_bar.get("open")) + and _number(announcement_bar.get("pct_chg")) < 0 + and volume_baseline > 0 + and _number(announcement_bar.get("vol")) / volume_baseline >= 1.8 + ) + popularity_row = popularity.get(ts_code) + institution_row = institutions.get(ts_code) factors.append( { "code": code, @@ -1263,6 +1525,26 @@ class ScreenerEngine: "netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2), "revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2), "ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2), + "earnings_surprise_pct": _rounded_optional(earnings_event.get("surprise_pct"), 2), + "earnings_days_since_announce": earnings_days, + "earnings_event_quality": int(not announcement_bad) if earnings_days is not None else None, + "popularity_score": _rounded_optional( + popularity_row.get("combined_score") if popularity_row else None, 2 + ), + "popularity_rank_change": ( + int(popularity_row["rank_change"]) + if popularity_row and popularity_row.get("rank_change") is not None else None + ), + "popularity_dual_source": ( + int(bool(popularity_row.get("dual_source"))) if popularity_row else None + ), + "institution_net_buy_million": ( + round(_number(institution_row.get("net_buy_amount")) / 1_000_000, 2) + if institution_row else None + ), + "institution_seat_count": ( + int(institution_row.get("seat_count") or 0) if institution_row else None + ), "net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2), "large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2), "net_flow_5d_million": round( @@ -1322,6 +1604,7 @@ class ScreenerEngine: for row in factors: sectors[row["sector"]].append(row) sector_metrics = [] + market_amount = sum(max(0.0, row["amount_billion"]) for row in factors) for sector_name, sector_rows in sectors.items(): average_return = statistics.fmean(row["return_5d"] for row in sector_rows) average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows) @@ -1329,12 +1612,31 @@ class ScreenerEngine: limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows) up_count = sum(row["pct_chg"] >= 5 for row in sector_rows) breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100 + sector_growth = [ + statistics.fmean(values) + for row in sector_rows + if (values := [ + value for value in (row.get("revenue_yoy"), row.get("netprofit_yoy")) + if value is not None + ]) + ] + prosperity_raw = statistics.median(sector_growth) if sector_growth else -100.0 + average_turnover = statistics.fmean(row["turnover_rate"] for row in sector_rows) + amount_share = ( + sum(max(0.0, row["amount_billion"]) for row in sector_rows) / market_amount * 100 + if market_amount else 0.0 + ) + crowding_raw = average_turnover + amount_share + trend_raw = average_return_20d + breadth_ma20 / 10 strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6)) sector_metrics.append( { "ts_code": sector_name, "sector_return_20d": average_return_20d, "sector_net_flow_5d_million": sector_net_flow, + "sector_prosperity_raw": prosperity_raw, + "sector_trend_raw": trend_raw, + "sector_crowding_raw": crowding_raw, } ) stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc") @@ -1356,7 +1658,22 @@ class ScreenerEngine: sector_flow_ranks = _percentile_map( sector_metrics, "sector_net_flow_5d_million", "desc" ) + sector_prosperity_ranks = _percentile_map( + sector_metrics, "sector_prosperity_raw", "desc" + ) + sector_trend_ranks = _percentile_map( + sector_metrics, "sector_trend_raw", "desc" + ) + sector_crowding_ranks = _percentile_map( + sector_metrics, "sector_crowding_raw", "desc" + ) for sector_name, sector_rows in sectors.items(): + prosperity_rank = sector_prosperity_ranks.get(sector_name, 0.0) + trend_rank = sector_trend_ranks.get(sector_name, 0.0) + crowding_rank = sector_crowding_ranks.get(sector_name, 0.0) + composite_score = ( + prosperity_rank * 0.40 + trend_rank * 0.30 + (1 - crowding_rank) * 0.30 + ) for row in sector_rows: row["sector_momentum_rank"] = round( sector_momentum_ranks.get(sector_name, 0.0), 4 @@ -1364,6 +1681,75 @@ class ScreenerEngine: row["sector_flow_rank"] = round( sector_flow_ranks.get(sector_name, 0.0), 4 ) + row["sector_prosperity_rank"] = round(prosperity_rank, 4) + row["sector_trend_rank"] = round(trend_rank, 4) + row["sector_crowding_rank"] = round(crowding_rank, 4) + row["sector_composite_score"] = round(composite_score, 4) + + factor_specs = { + "factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")), + "factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")), + "factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")), + "factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")), + "factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")), + } + for output_field, specs in factor_specs.items(): + maps = [_available_percentile_map(factors, field, direction) for field, direction in specs] + for row in factors: + values = [mapping.get(row["ts_code"]) for mapping in maps] + available = [value for value in values if value is not None] + row[output_field] = round(statistics.fmean(available), 4) if available else None + + return_rank_map = _available_percentile_map(factors, "return_20d", "desc") + factor_weights = {} + for output_field in factor_specs: + pairs = [ + (row.get(output_field), return_rank_map.get(row["ts_code"])) + for row in factors + if row.get(output_field) is not None and return_rank_map.get(row["ts_code"]) is not None + ] + correlation = _pearson([pair[0] for pair in pairs], [pair[1] for pair in pairs]) + factor_weights[output_field] = max(0.05, correlation) + factor_weight_total = sum(factor_weights.values()) or 1 + for row in factors: + weighted = [ + (row.get(field), weight) + for field, weight in factor_weights.items() + if row.get(field) is not None + ] + row["multi_factor_composite"] = round( + sum(value * weight for value, weight in weighted) + / (sum(weight for _, weight in weighted) or factor_weight_total), + 4, + ) if weighted else None + + size_ranks = _available_percentile_map(factors, "total_mv_billion", "desc") + large_rows = [row for row in factors if (size_ranks.get(row["ts_code"]) or 0) >= 0.70] + small_rows = [ + row for row in factors + if size_ranks.get(row["ts_code"]) is not None + and size_ranks[row["ts_code"]] <= 0.30 + ] + large_return = statistics.fmean(row["return_20d"] for row in large_rows) if large_rows else 0 + small_return = statistics.fmean(row["return_20d"] for row in small_rows) if small_rows else 0 + prefer_large = large_return >= small_return + growth_rows = [row for row in factors if (row.get("factor_growth_score") or 0) >= 0.70] + value_rows = [row for row in factors if (row.get("factor_value_score") or 0) >= 0.70] + growth_return = statistics.fmean(row["return_20d"] for row in growth_rows) if growth_rows else 0 + value_return = statistics.fmean(row["return_20d"] for row in value_rows) if value_rows else 0 + prefer_growth = growth_return >= value_return + for row in factors: + size_rank = size_ranks.get(row["ts_code"]) + row["style_size_fit"] = round( + size_rank if prefer_large else 1 - size_rank, 4 + ) if size_rank is not None else None + style_factor = "factor_growth_score" if prefer_growth else "factor_value_score" + row["style_growth_fit"] = row.get(style_factor) + style_values = [ + value for value in (row.get("style_size_fit"), row.get("style_growth_fit")) + if value is not None + ] + row["style_fit_score"] = round(statistics.fmean(style_values), 4) if style_values else None momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc") return_ranks = _percentile_map(factors, "return_5d", "desc") market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0) @@ -1753,6 +2139,40 @@ def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> d return result +def _available_percentile_map( + rows: list[dict[str, Any]], field: str, direction: str, +) -> dict[str, float | None]: + available = [row for row in rows if row.get(field) is not None] + result: dict[str, float | None] = { + str(row.get("ts_code") or ""): None for row in rows + } + if not available: + return result + ordered = sorted(available, key=lambda item: _number(item.get(field))) + denominator = max(1, len(ordered) - 1) + for index, row in enumerate(ordered): + percentile = 0.5 if len(ordered) == 1 else index / denominator + result[str(row.get("ts_code") or "")] = ( + 1 - percentile if direction == "asc" else percentile + ) + return result + + +def _pearson(first: list[float], second: list[float]) -> float: + if len(first) != len(second) or len(first) < 20: + return 0.0 + first_mean = statistics.fmean(first) + second_mean = statistics.fmean(second) + numerator = sum( + (left - first_mean) * (right - second_mean) + for left, right in zip(first, second) + ) + left_sum = sum((value - first_mean) ** 2 for value in first) + right_sum = sum((value - second_mean) ** 2 for value in second) + denominator = math.sqrt(left_sum * right_sum) + return numerator / denominator if denominator else 0.0 + + def _risk_flags( row: dict[str, Any], regime: str, include_regime_risk: bool = True ) -> list[str]: diff --git a/server.py b/server.py index a09fced..88b116d 100644 --- a/server.py +++ b/server.py @@ -76,7 +76,7 @@ from trade_journal import TradeJournalService from tushare_client import TushareClient, TushareError, _sector_coverage_issue -SCREENER_LIBRARY_VERSION = 7 +SCREENER_LIBRARY_VERSION = 8 def automatic_screener_jobs( @@ -1385,6 +1385,10 @@ class DashboardService: 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"]: @@ -1393,7 +1397,13 @@ class DashboardService: missing.append("沪深300基准") if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"): missing.append("近5日资金流") - return missing + 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) @@ -4272,27 +4282,29 @@ class DashboardService: 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:]}", } - now = datetime.now().astimezone() today = now.strftime("%Y%m%d") should_merge = ( requested_date == today and actual_date <= today - and now.time().replace(tzinfo=None) >= dt_time(9, 15) + 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: + 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 = TushareClient(self.token) @@ -4300,11 +4312,87 @@ class DashboardService: resolved_date, _ = client.resolve_trade_context(requested_date) if resolved_date == today: quote = client.realtime_stock_quote(tushare_code(code), requested_date) - self._merge_realtime_stock_detail(result, quote, 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: @@ -4339,6 +4427,7 @@ class DashboardService: "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 diff --git a/static/app.js b/static/app.js index 14edc7e..b81e1d9 100644 --- a/static/app.js +++ b/static/app.js @@ -41,7 +41,7 @@ const state = { yesterdayQuery: "", yesterdaySortKey: "", yesterdaySortDirection: "desc", - activeView: "limitPool", + activeView: "sentimentCycleView", dragonTiger: null, dragonViewMode: "daily", dragonFilter: "all", @@ -89,6 +89,8 @@ const state = { entityDetailIntraday: null, entityDetailRequestSequence: 0, stockPreviewCode: "", + stockPreviewType: "stock", + stockPreviewItem: null, stockPreviewPayload: null, stockPreviewChart: "daily", stockPreviewFallback: null, @@ -195,7 +197,6 @@ const elements = { globalSearchResults: document.querySelector("#globalSearchResults"), entityDetailDialog: document.querySelector("#entityDetailDialog"), entityDetailChart: document.querySelector("#entityDetailChart"), - themeDetailChart: document.querySelector("#themeDetailChart"), settingsDialog: document.querySelector("#settingsDialog"), adminDialog: document.querySelector("#adminDialog"), priceChart: document.querySelector("#priceChart"), @@ -366,9 +367,6 @@ function redrawThemeSensitiveVisuals() { if (state.activeView === "sentimentCycleView" && state.sentimentHistory) { drawSentimentTrendChart(state.sentimentHistory.rows || []); } - if (state.activeView === "themeLibraryView" && state.themeDetail?.series) { - drawEntityDetailChart(state.themeDetail.series, elements.themeDetailChart); - } if (state.activeView === "heavenView") { if (state.heavenPanel === "fortune" && state.heavenSetup?.field) { renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }); @@ -430,11 +428,12 @@ async function initialize() { syncThemeControl(); refreshIcons(); initializeApplicationShell(); - const searchParams = new URLSearchParams(window.location.search); - const requestedDate = searchParams.get("date"); - elements.tradeDate.value = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate || "") && requestedDate <= todayString() - ? requestedDate - : todayString(); + elements.tradeDate.value = todayString(); + const initialUrl = new URL(window.location.href); + if (initialUrl.searchParams.has("date")) { + initialUrl.searchParams.delete("date"); + history.replaceState(null, "", initialUrl); + } elements.tradeDate.max = todayString(); document.querySelector("#journalDate").value = elements.tradeDate.value; document.querySelector("#journalDate").max = todayString(); @@ -581,7 +580,6 @@ function bindEvents() { state.heavenRequestSequence += 1; state.heavenManualData = null; document.querySelector("#qiObservationDate").value = elements.tradeDate.value; - setDateInUrl(elements.tradeDate.value); loadDashboard(); }); document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1)); @@ -673,6 +671,7 @@ function bindEvents() { if (window.innerWidth > 720) toggleMentorDirectory(false); if (!elements.stockPreview.hidden) closeStockPreview(); updateSidebarControl(); + syncNavigationState(state.activeView); if (state.activeView === "dragonView") layoutDragonCards(); }); document.querySelectorAll("[data-open-view]").forEach((button) => { @@ -1273,14 +1272,6 @@ function renderSentimentHistory() { setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`); const marker = document.querySelector("#sentimentCycleScoreMarker"); marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`; - document.querySelectorAll("[data-sentiment-stage]").forEach((item) => { - const current = item.dataset.sentimentStage === latest.phase; - item.classList.toggle("current", current); - item.hidden = !current; - const label = item.querySelector("strong"); - if (label) label.textContent = `${item.dataset.sentimentStage}${current ? "(当前)" : ""}`; - }); - document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
@@ -2330,7 +2321,7 @@ function scheduleAuctionTransition(meta) { clearAuctionTimer(); if (state.activeView !== "auctionView") return; let delay = 0; - if (meta.phase === "selection" && !meta.available) { + if (["selection", "finalized"].includes(meta.phase) && !meta.available) { delay = 10_000; } else if (meta.next_transition_at) { const transitionAt = new Date(meta.next_transition_at).getTime(); @@ -2518,7 +2509,7 @@ function renderThemeDirectory() { return ` `; }).join("") || '
没有匹配的题材
'; @@ -2567,7 +2558,6 @@ function renderThemeDetail() { ${row.has_quote ? signed(row.change) : ""} ${row.has_quote ? formatNumber(row.price, 2) : ""}${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}`).join(""); bindStockRows(body); - requestAnimationFrame(() => drawEntityDetailChart(payload.series || [], elements.themeDetailChart)); renderThemeDirectory(); } @@ -3748,15 +3738,24 @@ function curatedStrategySchool(strategy) { if (["行业轮动", "形态突破", "趋势追踪"].includes(category)) return "趋势"; if (["短线竞价", "连板接力", "低吸反核"].includes(category)) return "短线"; if (["动量反转"].includes(category)) return "动量"; + if (["元策略", "多因子"].includes(category)) return "量化"; + if (["业绩事件", "热度观察"].includes(category)) return "事件"; + if (["资金席位"].includes(category)) return "资金"; if (/红利|价值|质量|成长|财务|现金流/.test(category)) return "基本面"; if (/趋势|轮动|突破/.test(category)) return "趋势"; if (/竞价|连板|龙头|反核|首阴|反包|打板/.test(category)) return "短线"; if (/动量|反转/.test(category)) return "动量"; + if (/因子|量化|元策略/.test(category)) return "量化"; + if (/事件|热度|公告|业绩/.test(category)) return "事件"; + if (/席位|资金/.test(category)) return "资金"; return "其他"; } function curatedSchoolIcon(school) { - return { 基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap", 动量: "refresh-cw", 其他: "boxes" }[school] || "boxes"; + return { + 基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap", + 动量: "refresh-cw", 量化: "binary", 事件: "calendar-clock", 资金: "landmark", 其他: "boxes", + }[school] || "boxes"; } function curatedStrategyRunState(strategy, result) { @@ -3774,7 +3773,7 @@ function renderCuratedStrategyLibrary() { if (!state.screenerSetup) return; const strategies = curatedStrategies(); const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))]; - const schools = ["全部", "基本面", "趋势", "短线", "动量"]; + const schools = ["全部", "基本面", "趋势", "短线", "动量", "量化", "事件", "资金"]; if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部"; if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部"; setText("curatedStrategyCount", `${strategies.length} 套`); @@ -7315,6 +7314,37 @@ function stockCodeFromTrigger(trigger) { return matched ? matched[1] : ""; } +function marketPreviewTargetFromTrigger(trigger) { + if (trigger?.classList?.contains("market-preview-trigger")) { + const type = String(trigger.dataset.marketPreviewType || "").trim().toLowerCase(); + const id = String(trigger.dataset.marketPreviewId || "").trim().toUpperCase(); + if (type === "theme" && id) { + const item = (state.themeLibrary?.items || []).find((row) => String(row.code) === id) || {}; + return { + type, + id, + code: id, + name: item.name || trigger.textContent?.trim() || "--", + type_label: "题材", + change: item.change, + value: item.close, + }; + } + } + const code = stockCodeFromTrigger(trigger); + return code ? { type: "stock", id: code, code } : null; +} + +function previewTriggerFromEvent(event) { + return event.target.closest?.(".stock-preview-trigger, .market-preview-trigger"); +} + +function showMarketPreview(target, trigger) { + if (!target) return; + if (target.type === "stock") showStockPreview(target.id, trigger); + else showEntityPreview(target, trigger); +} + function findStockFallback(code) { const dashboardRows = [ ...(state.dashboard?.limits || []), @@ -7346,19 +7376,19 @@ function supportsStockPreviewHover() { function handleStockPreviewPointerOver(event) { if (!supportsStockPreviewHover()) return; - const trigger = event.target.closest?.(".stock-preview-trigger"); - if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return; - const code = stockCodeFromTrigger(trigger); - if (!code) return; + const trigger = previewTriggerFromEvent(event); + if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return; + const target = marketPreviewTargetFromTrigger(trigger); + if (!target) return; cancelStockPreviewClose(); clearTimeout(stockPreviewOpenTimer); - stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), STOCK_PREVIEW_DELAY); + stockPreviewOpenTimer = setTimeout(() => showMarketPreview(target, trigger), STOCK_PREVIEW_DELAY); } function handleStockPreviewPointerOut(event) { if (!supportsStockPreviewHover()) return; - const trigger = event.target.closest?.(".stock-preview-trigger"); - if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return; + const trigger = previewTriggerFromEvent(event); + if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return; clearTimeout(stockPreviewOpenTimer); if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return; scheduleStockPreviewClose(); @@ -7424,6 +7454,8 @@ async function showStockPreview(code, trigger) { if (!/^\d{6}$/.test(String(code))) return; stockPreviewAnchor = trigger; state.stockPreviewCode = String(code); + state.stockPreviewType = "stock"; + state.stockPreviewItem = null; state.stockPreviewFallback = findStockFallback(code); state.stockPreviewPayload = null; state.stockPreviewChart = "daily"; @@ -7461,8 +7493,80 @@ async function showStockPreview(code, trigger) { } } +async function showEntityPreview(item, trigger) { + const type = String(item?.type || "").trim().toLowerCase(); + const id = String(item?.id || item?.code || "").trim().toUpperCase(); + if (type !== "theme" || !id) return; + clearTimeout(stockPreviewOpenTimer); + cancelStockPreviewClose(); + stockPreviewAnchor = trigger; + state.stockPreviewCode = id; + state.stockPreviewType = type; + state.stockPreviewItem = { ...item, id, code: item.code || id, type, type_label: item.type_label || "题材" }; + state.stockPreviewFallback = { + code: item.code || id, + name: item.name || "--", + sector: item.type_label || "题材", + price: item.value, + change: item.change, + }; + state.stockPreviewPayload = null; + state.stockPreviewChart = "daily"; + renderStockPreviewLoading(); + elements.stockPreview.hidden = false; + const mobile = window.innerWidth <= 720; + elements.stockPreviewBackdrop.hidden = !mobile; + document.body.classList.toggle("stock-preview-open", mobile); + requestAnimationFrame(repositionStockPreview); + + const cacheKey = `${type}:${id}:latest`; + const cached = stockPreviewCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + renderStockPreview(cached.payload); + return; + } + if (cached) stockPreviewCache.delete(cacheKey); + stockPreviewAbortController?.abort(); + stockPreviewAbortController = new AbortController(); + try { + const params = new URLSearchParams({ type, id, trade_date: todayString() }); + const detail = await apiRequest( + `/api/search/detail?${params}`, + "GET", + null, + { signal: stockPreviewAbortController.signal }, + ); + if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return; + const entity = detail.entity || {}; + const payload = { + stock: { + code: entity.code || id, + name: entity.name || item.name || "--", + industry: entity.type_label || item.type_label || "题材", + price: entity.value, + change: entity.change, + }, + prices: detail.series || [], + intraday: [], + meta: { + trade_date: detail.meta?.trade_date || "", + realtime: Boolean(detail.meta?.realtime), + intraday_status: "idle", + intraday_notice: "", + }, + }; + stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + STOCK_PREVIEW_CACHE_MS }); + while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value); + renderStockPreview(payload); + } catch (error) { + if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return; + renderStockPreviewError(error.message || "题材行情预览加载失败"); + } +} + function renderStockPreviewLoading() { const fallback = state.stockPreviewFallback || {}; + selectStockPreviewChart("daily"); setText("stockPreviewCode", state.stockPreviewCode || "--"); setText("stockPreviewName", fallback.name || "正在加载"); setText("stockPreviewSector", fallback.sector || "--"); @@ -7510,6 +7614,16 @@ function selectStockPreviewChart(chart) { const payload = state.stockPreviewPayload; if (!payload) return; if (state.stockPreviewChart === "intraday") { + if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "idle") { + payload.meta.intraday_status = "loading"; + setText("stockPreviewDate", "正在加载分时"); + setText("stockPreviewSource", "正在读取最新分时"); + setText("stockPreviewSummary", "等待分时行情数据"); + clearStockPreviewChart(""); + loadEntityPreviewIntraday(); + return; + } + if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "loading") return; setText("stockPreviewDate", payload.meta?.intraday_trade_date || payload.meta?.trade_date || "最新行情"); setText( "stockPreviewSource", @@ -7538,6 +7652,36 @@ function selectStockPreviewChart(chart) { } } +async function loadEntityPreviewIntraday() { + const type = state.stockPreviewType; + const id = state.stockPreviewCode; + const payload = state.stockPreviewPayload; + if (type === "stock" || !id || !payload) return; + stockPreviewAbortController?.abort(); + stockPreviewAbortController = new AbortController(); + try { + const params = new URLSearchParams({ type, id }); + const intraday = await apiRequest( + `/api/chart/intraday?${params}`, + "GET", + null, + { signal: stockPreviewAbortController.signal }, + ); + if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return; + payload.intraday = intraday.points || []; + payload.meta.intraday_status = payload.intraday.length ? "available" : "empty"; + payload.meta.intraday_trade_date = intraday.meta?.trade_date || ""; + payload.meta.intraday_previous_close = intraday.meta?.previous_close || 0; + payload.meta.intraday_notice = payload.intraday.length ? "" : "该题材暂无可用分时数据。"; + if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday"); + } catch (error) { + if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return; + payload.meta.intraday_status = "unavailable"; + payload.meta.intraday_notice = error.message || "题材分时行情暂不可用。"; + if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday"); + } +} + function closeStockPreview() { clearTimeout(stockPreviewOpenTimer); clearTimeout(stockPreviewCloseTimer); @@ -7548,14 +7692,19 @@ function closeStockPreview() { document.body.classList.remove("stock-preview-open"); state.stockPreviewPayload = null; state.stockPreviewCode = ""; + state.stockPreviewType = "stock"; + state.stockPreviewItem = null; } function openStockDetailFromPreview() { const code = state.stockPreviewCode; const fallback = state.stockPreviewFallback; + const type = state.stockPreviewType; + const item = state.stockPreviewItem; if (!code) return; closeStockPreview(); - openStock(code, fallback); + if (type === "stock") openStock(code, fallback); + else if (item) openEntityDetail(item); } function repositionStockPreview() { @@ -8484,16 +8633,9 @@ function shiftDate(delta) { elements.tradeDate.value = next; state.heavenManualData = null; document.querySelector("#qiObservationDate").value = next; - setDateInUrl(next); loadDashboard(); } -function setDateInUrl(value) { - const url = new URL(window.location.href); - url.searchParams.set("date", value); - history.replaceState(null, "", url); -} - function updateDateButtons() { document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString(); } @@ -9198,7 +9340,10 @@ function syncNavigationState(viewId) { button.dataset.view === viewId || (viewId === "screenerTrackingView" && button.dataset.view === "screenerView"), ); - button.classList.remove("mobile-active"); + button.classList.toggle( + "mobile-active", + window.innerWidth <= 720 && marketView && button.dataset.view === "limitPool" && viewId !== "limitPool", + ); }); const selector = document.querySelector("#mobileMarketSelector"); const select = document.querySelector("#mobileMarketViewSelect"); diff --git a/static/design-system.css b/static/design-system.css index 40348fc..2770c65 100644 --- a/static/design-system.css +++ b/static/design-system.css @@ -888,7 +888,7 @@ tbody tr.clickable{cursor:pointer} #themeLibraryView.active-view{overflow:hidden} #themeLibraryView .theme-library-workspace-v2{height:100%;grid-template-rows:minmax(0,1fr);align-items:stretch;overflow:hidden} - #themeLibraryView .theme-detail-stack-v2{grid-template-rows:minmax(0,1.35fr) minmax(0,.85fr)} + #themeLibraryView .theme-detail-stack-v2{grid-template-rows:auto minmax(0,1fr)} #themeLibraryView .theme-directory-card-v2, #themeLibraryView .theme-detail-column-v2, #themeLibraryView .theme-detail-stack-v2{height:100%;min-height:0;overflow:hidden} @@ -926,7 +926,9 @@ tbody tr.clickable{cursor:pointer} :root body:is( [data-active-view="sentimentCycleView"], [data-active-view="rotationView"], - [data-active-view="screenerView"] + [data-active-view="screenerView"], + [data-active-view="ladderView"], + [data-active-view="reviewWorkspaceView"] ) .app-main{ height:var(--workspace-height); min-height:0; @@ -937,7 +939,9 @@ tbody tr.clickable{cursor:pointer} :root #sentimentCycleView.active-view, :root #rotationView.active-view, - :root #screenerView.active-view{ + :root #screenerView.active-view, + :root #ladderView.active-view, + :root #reviewWorkspaceView.active-view{ height:auto; min-height:0; display:block; diff --git a/static/index.html b/static/index.html index e6d89a9..4912074 100644 --- a/static/index.html +++ b/static/index.html @@ -96,8 +96,8 @@
-
+

涨停池

@@ -419,7 +419,7 @@
-
+

情绪周期

@@ -445,18 +445,6 @@
-
-

判定口径

温度 + 结构共同判定
- -
-
冰点涨停稀少、跌停成堆、高度显著压缩低于 25抗跌先手,允许无结果
-
修复风险收敛、温度从低位有效回升25+ 且回升修复先锋,小仓试错
-
发酵主线清晰、梯队成型、连续转强45+ 且连续确认主线跟随
-
高潮温度、赚钱效应与涨停生态共振80+ 且生态达标核心去后排
-
分化高低切换、炸板增多、主线内部分歧45+ 且结构转弱承接回流
-
退潮温度或系统健康度继续走弱低于 45 且走弱防守观察
-
-