feat: complete strategy and market data improvements

This commit is contained in:
leefer
2026-07-29 16:50:40 +08:00
parent c30d2107b3
commit 0030bb8cc1
18 changed files with 1622 additions and 162 deletions
+153
View File
@@ -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( ADVANCED_CURATED_STRATEGIES.extend(
[ [
{ {
+40 -9
View File
@@ -8,7 +8,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, time as dt_time, timedelta
from threading import Lock from threading import Lock
from typing import Any, ClassVar from typing import Any, ClassVar
@@ -168,8 +168,24 @@ class MarketChartClient:
previous = normalized[index - 1]["close"] if index > 0 else 0 previous = normalized[index - 1]["close"] if index > 0 else 0
row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0 row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0
today = datetime.now().astimezone().strftime("%Y%m%d") market_now = datetime.now().astimezone()
if compact_end == today: 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: try:
quote_rows = self.ifind.real_time( quote_rows = self.ifind.real_time(
ifind_code, ifind_code,
@@ -179,16 +195,31 @@ class MarketChartClient:
quote = quote_rows[0] if quote_rows else {} quote = quote_rows[0] if quote_rows else {}
latest = _number(quote.get("latest")) latest = _number(quote.get("latest"))
previous = _number(quote.get("preClose")) 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 = { realtime = {
"trade_date": end.strftime("%Y-%m-%d"), "trade_date": end.strftime("%Y-%m-%d"),
"open": _number(quote.get("open")) or latest, "open": open_price,
"high": _number(quote.get("high")) or latest, "high": high,
"low": _number(quote.get("low")) or latest, "low": low,
"close": latest, "close": latest,
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0, "change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
"volume": _number(quote.get("volume")), "volume": volume,
"amount_billion": _number(quote.get("amount")) / 100_000_000, "amount_billion": amount / 100_000_000,
"realtime": True, "realtime": True,
} }
if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]: if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
+199 -2
View File
@@ -276,6 +276,49 @@ class ReviewDatabase:
CREATE INDEX IF NOT EXISTS idx_auction_factors_code_date CREATE INDEX IF NOT EXISTS idx_auction_factors_code_date
ON auction_factors(ts_code, trade_date DESC); 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 ( CREATE TABLE IF NOT EXISTS screener_strategies (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, user_id INTEGER,
@@ -1446,6 +1489,109 @@ class ReviewDatabase:
) )
return len(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 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]: def auction_factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
where = "WHERE trade_date <= ?" if end_date else "" where = "WHERE trade_date <= ?" if end_date else ""
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
@@ -1566,6 +1712,21 @@ class ReviewDatabase:
""", """,
(end_date,), (end_date,),
).fetchone()[0] ).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 { return {
"market": bool(market), "market": bool(market),
"auction": bool(auction), "auction": bool(auction),
@@ -1579,6 +1740,12 @@ class ReviewDatabase:
"dividend_years": int(dividend_years or 0), "dividend_years": int(dividend_years or 0),
"moneyflow_history": int(moneyflow_dates or 0) >= 5, "moneyflow_history": int(moneyflow_dates or 0) >= 5,
"moneyflow_dates": int(moneyflow_dates or 0), "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]: 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": [], "dates": [], "bars": [], "master": [], "indicators": [],
"indicator_history": [], "indicator_series": [], "fundamentals": [], "indicator_history": [], "indicator_series": [], "fundamentals": [],
"moneyflow": [], "moneyflow_history": [], "auction": [], "moneyflow": [], "moneyflow_history": [], "auction": [],
"benchmarks": [], "benchmarks": [], "fundamental_history": [],
"earnings_events": [], "popularity": [], "institutions": [],
} }
placeholders = ",".join("?" for _ in dates) placeholders = ",".join("?" for _ in dates)
with self.connect() as connection: with self.connect() as connection:
@@ -1623,7 +1791,8 @@ class ReviewDatabase:
).fetchall() ).fetchall()
indicator_series = connection.execute( indicator_series = connection.execute(
f""" 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 FROM daily_indicators
WHERE trade_date IN ({placeholders}) WHERE trade_date IN ({placeholders})
ORDER BY trade_date, ts_code ORDER BY trade_date, ts_code
@@ -1644,6 +1813,14 @@ class ReviewDatabase:
""", """,
(end_date,), (end_date,),
).fetchall() ).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( moneyflow = connection.execute(
""" """
SELECT * FROM moneyflow_daily SELECT * FROM moneyflow_daily
@@ -1680,6 +1857,22 @@ class ReviewDatabase:
""", """,
dates, dates,
).fetchall() ).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 { return {
"dates": dates, "dates": dates,
"bars": [dict(row) for row in bars], "bars": [dict(row) for row in bars],
@@ -1688,10 +1881,14 @@ class ReviewDatabase:
"indicator_history": [dict(row) for row in indicator_history], "indicator_history": [dict(row) for row in indicator_history],
"indicator_series": [dict(row) for row in indicator_series], "indicator_series": [dict(row) for row in indicator_series],
"fundamentals": [dict(row) for row in fundamentals], "fundamentals": [dict(row) for row in fundamentals],
"fundamental_history": [dict(row) for row in fundamental_history],
"moneyflow": [dict(row) for row in moneyflow], "moneyflow": [dict(row) for row in moneyflow],
"moneyflow_history": [dict(row) for row in moneyflow_history], "moneyflow_history": [dict(row) for row in moneyflow_history],
"auction": [dict(row) for row in auction], "auction": [dict(row) for row in auction],
"benchmarks": [dict(row) for row in benchmarks], "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]]: def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
+19 -10
View File
@@ -668,8 +668,8 @@ class MarketInsightsService:
start_stamp = f"{display_date} 09:15:00" start_stamp = f"{display_date} 09:15:00"
snapshot_rows: list[dict[str, Any]] = [] snapshot_rows: list[dict[str, Any]] = []
ordered_codes = sorted(selected_codes) 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( snapshot_rows.extend(
self.ifind.snapshots( self.ifind.snapshots(
ordered_codes[index:index + 80], ordered_codes[index:index + 80],
@@ -682,13 +682,18 @@ class MarketInsightsService:
cache_ttl=8, cache_ttl=8,
) )
) )
except IfindError: except IfindError:
return [] continue
latest: dict[str, dict[str, Any]] = {} latest: dict[str, dict[str, Any]] = {}
for row in snapshot_rows: for row in snapshot_rows:
ts_code = str(row.get("thscode") or "") 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 latest[ts_code] = row
prior_factors = { prior_factors = {
str(item.get("ts_code") or ""): item str(item.get("ts_code") or ""): item
@@ -735,11 +740,13 @@ class MarketInsightsService:
trade_date, previous_date = self._trade_context(requested_date) trade_date, previous_date = self._trade_context(requested_date)
session = self._auction_session(requested_date, trade_date) session = self._auction_session(requested_date, trade_date)
phase = str(session["phase"]) phase = str(session["phase"])
dynamic = phase == "observing" and bool(self.ifind and self.ifind.configured) ifind_ready = bool(self.ifind and self.ifind.configured)
data_date = previous_date if phase == "pending" or (phase == "observing" and not dynamic) else trade_date 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 carried_forward = data_date != trade_date
cache_key = data_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) cached = self.database.get_data_snapshot("auction_center_v6", cache_key)
if cached: if cached:
result = copy.deepcopy(cached) result = copy.deepcopy(cached)
@@ -754,9 +761,11 @@ class MarketInsightsService:
} }
return self._with_auction_watchlist(result, data_date, user_id) 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) rows = self._dynamic_auction_rows(data_date, previous_date, user_id)
else: else:
rows = []
if not rows and not live_dynamic:
try: try:
rows = self.client.query("stk_auction", {"trade_date": data_date}) rows = self.client.query("stk_auction", {"trade_date": data_date})
except TushareError: except TushareError:
@@ -927,7 +936,7 @@ class MarketInsightsService:
"one_price_rows": one_price_rows, "one_price_rows": one_price_rows,
"rows": candidates, "rows": candidates,
} }
if not dynamic: if not live_dynamic:
self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result) self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result)
return self._with_auction_watchlist(result, data_date, user_id) return self._with_auction_watchlist(result, data_date, user_id)
+420
View File
@@ -63,6 +63,10 @@ FACTOR_FIELDS = {
"sector_stock_momentum_rank": "行业内个股动量排名", "sector_stock_momentum_rank": "行业内个股动量排名",
"sector_net_flow_5d_million": "行业5日主力净流入", "sector_net_flow_5d_million": "行业5日主力净流入",
"sector_flow_rank": "行业资金流排名", "sector_flow_rank": "行业资金流排名",
"sector_prosperity_rank": "行业景气度排名",
"sector_trend_rank": "行业趋势排名",
"sector_crowding_rank": "行业拥挤度排名",
"sector_composite_score": "行业三维综合分",
"sector_limit_count": "板块涨停数", "sector_limit_count": "板块涨停数",
"sector_up_count": "板块强势股数", "sector_up_count": "板块强势股数",
"relative_strength": "相对强度", "relative_strength": "相对强度",
@@ -84,6 +88,23 @@ FACTOR_FIELDS = {
"netprofit_yoy": "净利润同比", "netprofit_yoy": "净利润同比",
"revenue_yoy": "营业收入同比", "revenue_yoy": "营业收入同比",
"ocf_to_opincome": "经营现金流质量", "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日相对位置", "relative_position_60": "60日相对位置",
"max_abs_change_15d": "15日最大波动", "max_abs_change_15d": "15日最大波动",
"close_to_high_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_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank",
"sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_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", "sector_limit_count", "sector_up_count", "sector_breadth_ma20",
"limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal", "limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal",
"is_limit_up_today", "is_limit_down_today", "is_limit_up_today", "is_limit_down_today",
@@ -151,6 +174,14 @@ FACTOR_GROUPS = {
"财务质量": [ "财务质量": [
"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
"ocf_to_opincome", "financial_risk", "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: for strategy in CURATED_STRATEGIES:
@@ -679,6 +734,106 @@ def _quarter_periods(trade_date: str, count: int) -> list[str]:
return sorted(periods) 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: class FactorDataService:
def __init__(self, database: ReviewDatabase, client: TushareClient) -> None: def __init__(self, database: ReviewDatabase, client: TushareClient) -> None:
self.database = database self.database = database
@@ -714,12 +869,15 @@ class FactorDataService:
"cal_date,is_open", "cal_date,is_open",
) )
last_open_by_year: dict[str, str] = {} last_open_by_year: dict[str, str] = {}
last_open_by_month: dict[str, str] = {}
for row in long_calendar: for row in long_calendar:
if row.get("is_open") == 1 and row.get("cal_date"): if row.get("is_open") == 1 and row.get("cal_date"):
value = str(row["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_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 = set(dates[-min(80, len(dates)):])
valuation_dates.update(last_open_by_year.values()) 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)) existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
indicator_dates_to_fetch = sorted( indicator_dates_to_fetch = sorted(
value for value in valuation_dates if value not in existing_indicators or value == trade_date 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}") notices.append(f"资金流接口不可用:{exc}")
break 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 { return {
"trade_date": trade_date, "trade_date": trade_date,
"calendar_dates": len(dates), "calendar_dates": len(dates),
@@ -828,6 +1043,9 @@ class FactorDataService:
"moneyflow_dates": moneyflow_dates, "moneyflow_dates": moneyflow_dates,
"auction_rows": auction_count, "auction_rows": auction_count,
"auction_dates": auction_dates, "auction_dates": auction_dates,
"earnings_events": earnings_count,
"popularity_rows": popularity_count,
"institution_rows": institution_count,
"notice": "".join(notices), "notice": "".join(notices),
} }
@@ -1061,6 +1279,23 @@ class ScreenerEngine:
for row in data.get("auction", []) for row in data.get("auction", [])
if str(row.get("trade_date") or "") == actual_date 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) grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data["bars"]: for row in data["bars"]:
if row["trade_date"] <= history_date: 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 vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0
broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name) broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name)
netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy")) 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( factors.append(
{ {
"code": code, "code": code,
@@ -1263,6 +1525,26 @@ class ScreenerEngine:
"netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2), "netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2),
"revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2), "revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2),
"ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 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), "net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2), "large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
"net_flow_5d_million": round( "net_flow_5d_million": round(
@@ -1322,6 +1604,7 @@ class ScreenerEngine:
for row in factors: for row in factors:
sectors[row["sector"]].append(row) sectors[row["sector"]].append(row)
sector_metrics = [] sector_metrics = []
market_amount = sum(max(0.0, row["amount_billion"]) for row in factors)
for sector_name, sector_rows in sectors.items(): for sector_name, sector_rows in sectors.items():
average_return = statistics.fmean(row["return_5d"] for row in sector_rows) 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) 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) 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) 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 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)) strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
sector_metrics.append( sector_metrics.append(
{ {
"ts_code": sector_name, "ts_code": sector_name,
"sector_return_20d": average_return_20d, "sector_return_20d": average_return_20d,
"sector_net_flow_5d_million": sector_net_flow, "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") stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc")
@@ -1356,7 +1658,22 @@ class ScreenerEngine:
sector_flow_ranks = _percentile_map( sector_flow_ranks = _percentile_map(
sector_metrics, "sector_net_flow_5d_million", "desc" 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(): 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: for row in sector_rows:
row["sector_momentum_rank"] = round( row["sector_momentum_rank"] = round(
sector_momentum_ranks.get(sector_name, 0.0), 4 sector_momentum_ranks.get(sector_name, 0.0), 4
@@ -1364,6 +1681,75 @@ class ScreenerEngine:
row["sector_flow_rank"] = round( row["sector_flow_rank"] = round(
sector_flow_ranks.get(sector_name, 0.0), 4 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") momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc")
return_ranks = _percentile_map(factors, "return_5d", "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) 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 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( def _risk_flags(
row: dict[str, Any], regime: str, include_regime_risk: bool = True row: dict[str, Any], regime: str, include_regime_risk: bool = True
) -> list[str]: ) -> list[str]:
+95 -6
View File
@@ -76,7 +76,7 @@ from trade_journal import TradeJournalService
from tushare_client import TushareClient, TushareError, _sector_coverage_issue from tushare_client import TushareClient, TushareError, _sector_coverage_issue
SCREENER_LIBRARY_VERSION = 7 SCREENER_LIBRARY_VERSION = 8
def automatic_screener_jobs( def automatic_screener_jobs(
@@ -1385,6 +1385,10 @@ class DashboardService:
missing.append("估值数据") missing.append("估值数据")
if used_fields & fundamental_fields and not factor_health["fundamental"]: if used_fields & fundamental_fields and not factor_health["fundamental"]:
missing.append("财务质量") 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"]: if "dividend_years" in used_fields and not factor_health["dividend_history"]:
missing.append("历年分红") missing.append("历年分红")
if used_fields & auction_fields and not factor_health["auction"]: if used_fields & auction_fields and not factor_health["auction"]:
@@ -1393,7 +1397,13 @@ class DashboardService:
missing.append("沪深300基准") missing.append("沪深300基准")
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"): if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
missing.append("近5日资金流") 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]: def screener_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date) normalized_date = normalize_date(trade_date)
@@ -4272,27 +4282,29 @@ class DashboardService:
self, payload: dict[str, Any], code: str, requested_date: str self, payload: dict[str, Any], code: str, requested_date: str
) -> dict[str, Any]: ) -> dict[str, Any]:
result = copy.deepcopy(payload) result = copy.deepcopy(payload)
now = datetime.now().astimezone()
try: try:
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90) result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"} result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
except (AttributeError, ChartDataError): except (AttributeError, ChartDataError):
pass pass
result = self._sanitize_stock_detail_prices(result, now)
actual_date = self._stock_detail_bar_date(result) actual_date = self._stock_detail_bar_date(result)
if actual_date: if actual_date:
result["meta"] = { result["meta"] = {
**(result.get("meta") or {}), **(result.get("meta") or {}),
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}", "trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
} }
now = datetime.now().astimezone()
today = now.strftime("%Y%m%d") today = now.strftime("%Y%m%d")
should_merge = ( should_merge = (
requested_date == today requested_date == today
and actual_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: if should_merge:
quote = self._ifind_realtime_stock_quote(code) 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) self._merge_realtime_stock_detail(result, quote, requested_date)
elif self.configured and actual_date < today: elif self.configured and actual_date < today:
client = TushareClient(self.token) client = TushareClient(self.token)
@@ -4300,11 +4312,87 @@ class DashboardService:
resolved_date, _ = client.resolve_trade_context(requested_date) resolved_date, _ = client.resolve_trade_context(requested_date)
if resolved_date == today: if resolved_date == today:
quote = client.realtime_stock_quote(tushare_code(code), requested_date) 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: except TushareError:
pass pass
return self._enrich_stock_detail(result) 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: def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None:
ifind = getattr(self, "ifind", None) ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured: if not ifind or not ifind.configured:
@@ -4339,6 +4427,7 @@ class DashboardService:
"volume_unit": "lots", "volume_unit": "lots",
"amount_billion": float(row.get("amount") or 0) / 100_000_000, "amount_billion": float(row.get("amount") or 0) / 100_000_000,
"turnover_rate": float(row.get("turnoverRatio") or 0), "turnover_rate": float(row.get("turnoverRatio") or 0),
"quote_time": str(row.get("time") or ""),
} }
@staticmethod @staticmethod
+185 -40
View File
@@ -41,7 +41,7 @@ const state = {
yesterdayQuery: "", yesterdayQuery: "",
yesterdaySortKey: "", yesterdaySortKey: "",
yesterdaySortDirection: "desc", yesterdaySortDirection: "desc",
activeView: "limitPool", activeView: "sentimentCycleView",
dragonTiger: null, dragonTiger: null,
dragonViewMode: "daily", dragonViewMode: "daily",
dragonFilter: "all", dragonFilter: "all",
@@ -89,6 +89,8 @@ const state = {
entityDetailIntraday: null, entityDetailIntraday: null,
entityDetailRequestSequence: 0, entityDetailRequestSequence: 0,
stockPreviewCode: "", stockPreviewCode: "",
stockPreviewType: "stock",
stockPreviewItem: null,
stockPreviewPayload: null, stockPreviewPayload: null,
stockPreviewChart: "daily", stockPreviewChart: "daily",
stockPreviewFallback: null, stockPreviewFallback: null,
@@ -195,7 +197,6 @@ const elements = {
globalSearchResults: document.querySelector("#globalSearchResults"), globalSearchResults: document.querySelector("#globalSearchResults"),
entityDetailDialog: document.querySelector("#entityDetailDialog"), entityDetailDialog: document.querySelector("#entityDetailDialog"),
entityDetailChart: document.querySelector("#entityDetailChart"), entityDetailChart: document.querySelector("#entityDetailChart"),
themeDetailChart: document.querySelector("#themeDetailChart"),
settingsDialog: document.querySelector("#settingsDialog"), settingsDialog: document.querySelector("#settingsDialog"),
adminDialog: document.querySelector("#adminDialog"), adminDialog: document.querySelector("#adminDialog"),
priceChart: document.querySelector("#priceChart"), priceChart: document.querySelector("#priceChart"),
@@ -366,9 +367,6 @@ function redrawThemeSensitiveVisuals() {
if (state.activeView === "sentimentCycleView" && state.sentimentHistory) { if (state.activeView === "sentimentCycleView" && state.sentimentHistory) {
drawSentimentTrendChart(state.sentimentHistory.rows || []); drawSentimentTrendChart(state.sentimentHistory.rows || []);
} }
if (state.activeView === "themeLibraryView" && state.themeDetail?.series) {
drawEntityDetailChart(state.themeDetail.series, elements.themeDetailChart);
}
if (state.activeView === "heavenView") { if (state.activeView === "heavenView") {
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) { if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }); renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
@@ -430,11 +428,12 @@ async function initialize() {
syncThemeControl(); syncThemeControl();
refreshIcons(); refreshIcons();
initializeApplicationShell(); initializeApplicationShell();
const searchParams = new URLSearchParams(window.location.search); elements.tradeDate.value = todayString();
const requestedDate = searchParams.get("date"); const initialUrl = new URL(window.location.href);
elements.tradeDate.value = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate || "") && requestedDate <= todayString() if (initialUrl.searchParams.has("date")) {
? requestedDate initialUrl.searchParams.delete("date");
: todayString(); history.replaceState(null, "", initialUrl);
}
elements.tradeDate.max = todayString(); elements.tradeDate.max = todayString();
document.querySelector("#journalDate").value = elements.tradeDate.value; document.querySelector("#journalDate").value = elements.tradeDate.value;
document.querySelector("#journalDate").max = todayString(); document.querySelector("#journalDate").max = todayString();
@@ -581,7 +580,6 @@ function bindEvents() {
state.heavenRequestSequence += 1; state.heavenRequestSequence += 1;
state.heavenManualData = null; state.heavenManualData = null;
document.querySelector("#qiObservationDate").value = elements.tradeDate.value; document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
setDateInUrl(elements.tradeDate.value);
loadDashboard(); loadDashboard();
}); });
document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1)); document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1));
@@ -673,6 +671,7 @@ function bindEvents() {
if (window.innerWidth > 720) toggleMentorDirectory(false); if (window.innerWidth > 720) toggleMentorDirectory(false);
if (!elements.stockPreview.hidden) closeStockPreview(); if (!elements.stockPreview.hidden) closeStockPreview();
updateSidebarControl(); updateSidebarControl();
syncNavigationState(state.activeView);
if (state.activeView === "dragonView") layoutDragonCards(); if (state.activeView === "dragonView") layoutDragonCards();
}); });
document.querySelectorAll("[data-open-view]").forEach((button) => { document.querySelectorAll("[data-open-view]").forEach((button) => {
@@ -1273,14 +1272,6 @@ function renderSentimentHistory() {
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length}`); setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length}`);
const marker = document.querySelector("#sentimentCycleScoreMarker"); const marker = document.querySelector("#sentimentCycleScoreMarker");
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`; 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) => ` document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
<article class="sentiment-component-item"> <article class="sentiment-component-item">
<div class="sentiment-component-main"> <div class="sentiment-component-main">
@@ -2330,7 +2321,7 @@ function scheduleAuctionTransition(meta) {
clearAuctionTimer(); clearAuctionTimer();
if (state.activeView !== "auctionView") return; if (state.activeView !== "auctionView") return;
let delay = 0; let delay = 0;
if (meta.phase === "selection" && !meta.available) { if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
delay = 10_000; delay = 10_000;
} else if (meta.next_transition_at) { } else if (meta.next_transition_at) {
const transitionAt = new Date(meta.next_transition_at).getTime(); const transitionAt = new Date(meta.next_transition_at).getTime();
@@ -2518,7 +2509,7 @@ function renderThemeDirectory() {
return ` return `
<button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}"> <button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}">
<span class="theme-rank-v2">${index + 1}</span> <span class="theme-rank-v2">${index + 1}</span>
<span class="theme-directory-copy-v2"><strong>${escapeHtml(item.name)}</strong><small>${number(item.member_count)} ${item.hot_rank ? ` · ${number(item.hot_rank)}` : ""}</small></span> <span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} ${item.hot_rank ? ` · ${number(item.hot_rank)}` : ""}</small></span>
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b> <b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
</button>`; </button>`;
}).join("") || '<div class="empty-state">没有匹配的题材</div>'; }).join("") || '<div class="empty-state">没有匹配的题材</div>';
@@ -2567,7 +2558,6 @@ function renderThemeDetail() {
<td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td> <td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td>
<td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join(""); <td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join("");
bindStockRows(body); bindStockRows(body);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || [], elements.themeDetailChart));
renderThemeDirectory(); 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 (["元策略", "多因子"].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 "动量"; if (/动量|反转/.test(category)) return "动量";
if (/因子|量化|元策略/.test(category)) return "量化";
if (/事件|热度|公告|业绩/.test(category)) return "事件";
if (/席位|资金/.test(category)) return "资金";
return "其他"; return "其他";
} }
function curatedSchoolIcon(school) { 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) { function curatedStrategyRunState(strategy, result) {
@@ -3774,7 +3773,7 @@ function renderCuratedStrategyLibrary() {
if (!state.screenerSetup) return; if (!state.screenerSetup) return;
const strategies = curatedStrategies(); const strategies = curatedStrategies();
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))]; const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
const schools = ["全部", "基本面", "趋势", "短线", "动量"]; const schools = ["全部", "基本面", "趋势", "短线", "动量", "量化", "事件", "资金"];
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部"; if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部"; if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部";
setText("curatedStrategyCount", `${strategies.length}`); setText("curatedStrategyCount", `${strategies.length}`);
@@ -7315,6 +7314,37 @@ function stockCodeFromTrigger(trigger) {
return matched ? matched[1] : ""; 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) { function findStockFallback(code) {
const dashboardRows = [ const dashboardRows = [
...(state.dashboard?.limits || []), ...(state.dashboard?.limits || []),
@@ -7346,19 +7376,19 @@ function supportsStockPreviewHover() {
function handleStockPreviewPointerOver(event) { function handleStockPreviewPointerOver(event) {
if (!supportsStockPreviewHover()) return; if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger"); const trigger = previewTriggerFromEvent(event);
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return; if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
const code = stockCodeFromTrigger(trigger); const target = marketPreviewTargetFromTrigger(trigger);
if (!code) return; if (!target) return;
cancelStockPreviewClose(); cancelStockPreviewClose();
clearTimeout(stockPreviewOpenTimer); clearTimeout(stockPreviewOpenTimer);
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), STOCK_PREVIEW_DELAY); stockPreviewOpenTimer = setTimeout(() => showMarketPreview(target, trigger), STOCK_PREVIEW_DELAY);
} }
function handleStockPreviewPointerOut(event) { function handleStockPreviewPointerOut(event) {
if (!supportsStockPreviewHover()) return; if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger"); const trigger = previewTriggerFromEvent(event);
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return; if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
clearTimeout(stockPreviewOpenTimer); clearTimeout(stockPreviewOpenTimer);
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return; if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
scheduleStockPreviewClose(); scheduleStockPreviewClose();
@@ -7424,6 +7454,8 @@ async function showStockPreview(code, trigger) {
if (!/^\d{6}$/.test(String(code))) return; if (!/^\d{6}$/.test(String(code))) return;
stockPreviewAnchor = trigger; stockPreviewAnchor = trigger;
state.stockPreviewCode = String(code); state.stockPreviewCode = String(code);
state.stockPreviewType = "stock";
state.stockPreviewItem = null;
state.stockPreviewFallback = findStockFallback(code); state.stockPreviewFallback = findStockFallback(code);
state.stockPreviewPayload = null; state.stockPreviewPayload = null;
state.stockPreviewChart = "daily"; 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() { function renderStockPreviewLoading() {
const fallback = state.stockPreviewFallback || {}; const fallback = state.stockPreviewFallback || {};
selectStockPreviewChart("daily");
setText("stockPreviewCode", state.stockPreviewCode || "--"); setText("stockPreviewCode", state.stockPreviewCode || "--");
setText("stockPreviewName", fallback.name || "正在加载"); setText("stockPreviewName", fallback.name || "正在加载");
setText("stockPreviewSector", fallback.sector || "--"); setText("stockPreviewSector", fallback.sector || "--");
@@ -7510,6 +7614,16 @@ function selectStockPreviewChart(chart) {
const payload = state.stockPreviewPayload; const payload = state.stockPreviewPayload;
if (!payload) return; if (!payload) return;
if (state.stockPreviewChart === "intraday") { 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("stockPreviewDate", payload.meta?.intraday_trade_date || payload.meta?.trade_date || "最新行情");
setText( setText(
"stockPreviewSource", "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() { function closeStockPreview() {
clearTimeout(stockPreviewOpenTimer); clearTimeout(stockPreviewOpenTimer);
clearTimeout(stockPreviewCloseTimer); clearTimeout(stockPreviewCloseTimer);
@@ -7548,14 +7692,19 @@ function closeStockPreview() {
document.body.classList.remove("stock-preview-open"); document.body.classList.remove("stock-preview-open");
state.stockPreviewPayload = null; state.stockPreviewPayload = null;
state.stockPreviewCode = ""; state.stockPreviewCode = "";
state.stockPreviewType = "stock";
state.stockPreviewItem = null;
} }
function openStockDetailFromPreview() { function openStockDetailFromPreview() {
const code = state.stockPreviewCode; const code = state.stockPreviewCode;
const fallback = state.stockPreviewFallback; const fallback = state.stockPreviewFallback;
const type = state.stockPreviewType;
const item = state.stockPreviewItem;
if (!code) return; if (!code) return;
closeStockPreview(); closeStockPreview();
openStock(code, fallback); if (type === "stock") openStock(code, fallback);
else if (item) openEntityDetail(item);
} }
function repositionStockPreview() { function repositionStockPreview() {
@@ -8484,16 +8633,9 @@ function shiftDate(delta) {
elements.tradeDate.value = next; elements.tradeDate.value = next;
state.heavenManualData = null; state.heavenManualData = null;
document.querySelector("#qiObservationDate").value = next; document.querySelector("#qiObservationDate").value = next;
setDateInUrl(next);
loadDashboard(); loadDashboard();
} }
function setDateInUrl(value) {
const url = new URL(window.location.href);
url.searchParams.set("date", value);
history.replaceState(null, "", url);
}
function updateDateButtons() { function updateDateButtons() {
document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString(); document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString();
} }
@@ -9198,7 +9340,10 @@ function syncNavigationState(viewId) {
button.dataset.view === viewId button.dataset.view === viewId
|| (viewId === "screenerTrackingView" && button.dataset.view === "screenerView"), || (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 selector = document.querySelector("#mobileMarketSelector");
const select = document.querySelector("#mobileMarketViewSelect"); const select = document.querySelector("#mobileMarketViewSelect");
+7 -3
View File
@@ -888,7 +888,7 @@ tbody tr.clickable{cursor:pointer}
#themeLibraryView.active-view{overflow:hidden} #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-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-directory-card-v2,
#themeLibraryView .theme-detail-column-v2, #themeLibraryView .theme-detail-column-v2,
#themeLibraryView .theme-detail-stack-v2{height:100%;min-height:0;overflow:hidden} #themeLibraryView .theme-detail-stack-v2{height:100%;min-height:0;overflow:hidden}
@@ -926,7 +926,9 @@ tbody tr.clickable{cursor:pointer}
:root body:is( :root body:is(
[data-active-view="sentimentCycleView"], [data-active-view="sentimentCycleView"],
[data-active-view="rotationView"], [data-active-view="rotationView"],
[data-active-view="screenerView"] [data-active-view="screenerView"],
[data-active-view="ladderView"],
[data-active-view="reviewWorkspaceView"]
) .app-main{ ) .app-main{
height:var(--workspace-height); height:var(--workspace-height);
min-height:0; min-height:0;
@@ -937,7 +939,9 @@ tbody tr.clickable{cursor:pointer}
:root #sentimentCycleView.active-view, :root #sentimentCycleView.active-view,
:root #rotationView.active-view, :root #rotationView.active-view,
:root #screenerView.active-view{ :root #screenerView.active-view,
:root #ladderView.active-view,
:root #reviewWorkspaceView.active-view{
height:auto; height:auto;
min-height:0; min-height:0;
display:block; display:block;
+11 -28
View File
@@ -96,8 +96,8 @@
</div> </div>
<div class="nav-group market-nav-group"> <div class="nav-group market-nav-group">
<div class="nav-group-label">市场复盘</div> <div class="nav-group-label">市场复盘</div>
<button class="module-tab market-sub-tab" type="button" data-view="sentimentCycleView" title="情绪周期"><i data-lucide="activity"></i><span>情绪周期</span></button> <button class="module-tab market-sub-tab active" type="button" data-view="sentimentCycleView" title="情绪周期"><i data-lucide="activity"></i><span>情绪周期</span></button>
<button class="module-tab active mobile-primary-tab" type="button" data-view="limitPool" title="涨停池"><i data-lucide="flame"></i><span class="nav-label-desktop">涨停池</span><span class="nav-label-mobile">行情</span></button> <button class="module-tab mobile-primary-tab" type="button" data-view="limitPool" title="涨停池"><i data-lucide="flame"></i><span class="nav-label-desktop">涨停池</span><span class="nav-label-mobile">行情</span></button>
<button class="module-tab market-sub-tab" type="button" data-view="brokenView" title="炸板池"><i data-lucide="bomb"></i><span>炸板池</span></button> <button class="module-tab market-sub-tab" type="button" data-view="brokenView" title="炸板池"><i data-lucide="bomb"></i><span>炸板池</span></button>
<button class="module-tab market-sub-tab" type="button" data-view="downView" title="跌停板"><i data-lucide="trending-down"></i><span>跌停板</span></button> <button class="module-tab market-sub-tab" type="button" data-view="downView" title="跌停板"><i data-lucide="trending-down"></i><span>跌停板</span></button>
<button class="module-tab market-sub-tab" type="button" data-view="yesterdayView" title="昨日涨停"><i data-lucide="history"></i><span>昨日涨停</span></button> <button class="module-tab market-sub-tab" type="button" data-view="yesterdayView" title="昨日涨停"><i data-lucide="history"></i><span>昨日涨停</span></button>
@@ -182,7 +182,7 @@
</div> </div>
</section> </section>
<section id="limitPool" class="workspace-view page active-view redesigned-pool-view"> <section id="limitPool" class="workspace-view page redesigned-pool-view">
<div class="section-toolbar lad-head redesigned-page-head pool-page-head"> <div class="section-toolbar lad-head redesigned-page-head pool-page-head">
<div class="section-title-group"> <div class="section-title-group">
<h2>涨停池</h2> <h2>涨停池</h2>
@@ -419,7 +419,7 @@
</div> </div>
</section> </section>
<section id="sentimentCycleView" class="workspace-view page redesigned-sentiment-view"> <section id="sentimentCycleView" class="workspace-view page active-view redesigned-sentiment-view">
<div class="section-toolbar lad-head sentiment-cycle-toolbar redesigned-page-head"> <div class="section-toolbar lad-head sentiment-cycle-toolbar redesigned-page-head">
<div class="section-title-group"> <div class="section-title-group">
<h2>情绪周期</h2> <h2>情绪周期</h2>
@@ -445,18 +445,6 @@
<div id="sentimentChartTooltip" class="sentiment-chart-tooltip" hidden></div> <div id="sentimentChartTooltip" class="sentiment-chart-tooltip" hidden></div>
</div> </div>
</section> </section>
<section class="sentiment-stage-guide card redesigned-card" aria-labelledby="sentimentStageGuideTitle">
<div class="workspace-heading card-h redesigned-card-head"><h3 id="sentimentStageGuideTitle">判定口径</h3><span>温度 + 结构共同判定</span></div>
<div class="sentiment-stage-guide-head" aria-hidden="true"><span>阶段</span><span>典型特征</span><span>温度区间</span><span>策略取向</span></div>
<div class="sentiment-stage-guide-grid">
<article data-sentiment-stage="冰点"><strong>冰点</strong><span>涨停稀少、跌停成堆、高度显著压缩</span><span class="stage-range">低于 25</span><small>抗跌先手,允许无结果</small></article>
<article data-sentiment-stage="修复"><strong>修复</strong><span>风险收敛、温度从低位有效回升</span><span class="stage-range">25+ 且回升</span><small>修复先锋,小仓试错</small></article>
<article data-sentiment-stage="发酵"><strong>发酵</strong><span>主线清晰、梯队成型、连续转强</span><span class="stage-range">45+ 且连续确认</span><small>主线跟随</small></article>
<article data-sentiment-stage="高潮"><strong>高潮</strong><span>温度、赚钱效应与涨停生态共振</span><span class="stage-range">80+ 且生态达标</span><small>核心去后排</small></article>
<article data-sentiment-stage="分化"><strong>分化</strong><span>高低切换、炸板增多、主线内部分歧</span><span class="stage-range">45+ 且结构转弱</span><small>承接回流</small></article>
<article data-sentiment-stage="退潮"><strong>退潮</strong><span>温度或系统健康度继续走弱</span><span class="stage-range">低于 45 且走弱</span><small>防守观察</small></article>
</div>
</section>
</div> </div>
<aside class="sentiment-analysis-rail"> <aside class="sentiment-analysis-rail">
<section class="sentiment-cycle-summary card redesigned-card" aria-label="最新情绪状态"> <section class="sentiment-cycle-summary card redesigned-card" aria-label="最新情绪状态">
@@ -861,7 +849,7 @@
<div class="curated-workspace"> <div class="curated-workspace">
<aside class="curated-library-pane" aria-label="精选策略库"> <aside class="curated-library-pane" aria-label="精选策略库">
<div class="curated-library-heading"> <div class="curated-library-heading">
<div><span>策略库</span><h3>盘后自动候选池</h3></div><strong id="curatedStrategyCount">10</strong> <div><span>策略库</span><h3>盘后自动候选池</h3></div><strong id="curatedStrategyCount">29</strong>
</div> </div>
<div class="curated-library-controls"> <div class="curated-library-controls">
<label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索策略" autocomplete="off"></label> <label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索策略" autocomplete="off"></label>
@@ -1216,7 +1204,7 @@
<section id="themeLibraryView" class="workspace-view page redesigned-theme-view"> <section id="themeLibraryView" class="workspace-view page redesigned-theme-view">
<header class="theme-page-head-v2 lad-head"> <header class="theme-page-head-v2 lad-head">
<div class="theme-title-v2"> <div class="theme-title-v2">
<div><h2>题材库</h2><span>题材走势与成分股</span></div> <div><h2>题材库</h2><span>题材行情与成分股</span></div>
<span id="themeDateLabel" class="theme-date-v2">--</span> <span id="themeDateLabel" class="theme-date-v2">--</span>
</div> </div>
<div class="theme-head-actions-v2"> <div class="theme-head-actions-v2">
@@ -1242,7 +1230,7 @@
</aside> </aside>
<section class="theme-detail-column-v2" aria-live="polite"> <section class="theme-detail-column-v2" aria-live="polite">
<div id="themeDetailEmpty" class="empty-state theme-detail-empty-v2">选择题材查看走势与成分股</div> <div id="themeDetailEmpty" class="empty-state theme-detail-empty-v2">选择题材查看行情与成分股</div>
<div id="themeDetailContent" class="theme-detail-stack-v2" hidden> <div id="themeDetailContent" class="theme-detail-stack-v2" hidden>
<section class="theme-market-card-v2 card"> <section class="theme-market-card-v2 card">
<header class="theme-detail-heading-v2"> <header class="theme-detail-heading-v2">
@@ -1253,11 +1241,6 @@
<div class="theme-change-v2"><span>当日涨幅</span><strong id="themeDetailChange">--</strong></div> <div class="theme-change-v2"><span>当日涨幅</span><strong id="themeDetailChange">--</strong></div>
</header> </header>
<div id="themeDetailMetrics" class="theme-detail-metrics-v2"></div> <div id="themeDetailMetrics" class="theme-detail-metrics-v2"></div>
<div class="theme-chart-heading-v2">
<div><h4>题材走势</h4><span>日 K · 最近 90 个交易日</span></div>
<div class="theme-chart-legend-v2" aria-label="日K图例"><span><i class="up"></i>上涨</span><span><i class="down"></i>下跌</span><span><i class="volume"></i>成交量</span></div>
</div>
<div class="theme-chart-shell-v2"><canvas id="themeDetailChart" aria-label="题材日K图"></canvas></div>
</section> </section>
<section class="theme-members-card-v2 card"> <section class="theme-members-card-v2 card">
@@ -1543,7 +1526,7 @@
</dialog> </dialog>
<div id="stockPreviewBackdrop" class="stock-preview-backdrop" hidden></div> <div id="stockPreviewBackdrop" class="stock-preview-backdrop" hidden></div>
<aside id="stockPreview" class="stock-preview" aria-label="个股行情预览" aria-live="polite" hidden> <aside id="stockPreview" class="stock-preview" aria-label="行情预览" aria-live="polite" hidden>
<header class="stock-preview-header"> <header class="stock-preview-header">
<div class="stock-preview-identity"> <div class="stock-preview-identity">
<span id="stockPreviewCode">--</span> <span id="stockPreviewCode">--</span>
@@ -1557,12 +1540,12 @@
<button id="closeStockPreview" class="icon-button stock-preview-close" type="button" title="关闭预览" aria-label="关闭预览"><i data-lucide="x"></i></button> <button id="closeStockPreview" class="icon-button stock-preview-close" type="button" title="关闭预览" aria-label="关闭预览"><i data-lucide="x"></i></button>
</header> </header>
<div class="stock-preview-tabs" role="tablist" aria-label="行情图表"> <div class="stock-preview-tabs" role="tablist" aria-label="行情图表">
<button class="stock-preview-tab active" type="button" role="tab" aria-selected="true" data-preview-chart="intraday">分时</button> <button class="stock-preview-tab" type="button" role="tab" aria-selected="false" data-preview-chart="intraday">分时</button>
<button class="stock-preview-tab" type="button" role="tab" aria-selected="false" data-preview-chart="daily">日K</button> <button class="stock-preview-tab active" type="button" role="tab" aria-selected="true" data-preview-chart="daily">日K</button>
<span id="stockPreviewDate">--</span> <span id="stockPreviewDate">--</span>
</div> </div>
<div class="stock-preview-chart-shell"> <div class="stock-preview-chart-shell">
<canvas id="stockPreviewChart" width="488" height="232" aria-label="个股行情图" aria-describedby="stockPreviewSummary"></canvas> <canvas id="stockPreviewChart" width="488" height="232" aria-label="行情图" aria-describedby="stockPreviewSummary"></canvas>
<div id="stockPreviewLoading" class="stock-preview-loading"> <div id="stockPreviewLoading" class="stock-preview-loading">
<span class="spinner" aria-hidden="true"></span> <span class="spinner" aria-hidden="true"></span>
<span>正在读取行情</span> <span>正在读取行情</span>
+5 -31
View File
@@ -3501,8 +3501,6 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.theme-head-actions-v2, .theme-head-actions-v2,
.theme-title-v2 > div, .theme-title-v2 > div,
.theme-detail-name-line-v2, .theme-detail-name-line-v2,
.theme-chart-heading-v2,
.theme-chart-legend-v2,
.theme-members-heading-v2, .theme-members-heading-v2,
.theme-members-heading-v2 > div { .theme-members-heading-v2 > div {
display: flex; display: flex;
@@ -3627,8 +3625,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
} }
.theme-card-head-v2 h3, .theme-card-head-v2 h3,
.theme-members-heading-v2 h3, .theme-members-heading-v2 h3 { margin: 0; color: var(--r2-ink); }
.theme-chart-heading-v2 h4 { margin: 0; color: var(--r2-ink); }
.theme-card-head-v2 h3 { font-size: 14px; font-weight: 700; } .theme-card-head-v2 h3 { font-size: 14px; font-weight: 700; }
.theme-card-head-v2 span { display: block; margin-top: 2px; color: var(--r2-faint); font-size: 10.5px; } .theme-card-head-v2 span { display: block; margin-top: 2px; color: var(--r2-faint); font-size: 10.5px; }
.theme-card-head-v2 > strong { .theme-card-head-v2 > strong {
@@ -3717,7 +3714,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.theme-detail-stack-v2:not([hidden]) { .theme-detail-stack-v2:not([hidden]) {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-rows: minmax(348px, 1.35fr) minmax(190px, .85fr); grid-template-rows: auto minmax(0, 1fr);
gap: 12px; gap: 12px;
} }
@@ -3773,25 +3770,6 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.theme-detail-metrics-v2 strong.up { color: var(--r2-up); } .theme-detail-metrics-v2 strong.up { color: var(--r2-up); }
.theme-detail-metrics-v2 strong.down { color: var(--r2-down); } .theme-detail-metrics-v2 strong.down { color: var(--r2-down); }
.theme-chart-heading-v2 {
min-height: 39px;
justify-content: space-between;
gap: 12px;
padding: 6px 14px 4px;
}
.theme-chart-heading-v2 h4 { display: inline; font-size: 12px; font-weight: 700; }
.theme-chart-heading-v2 > div:first-child > span { margin-left: 6px; color: var(--r2-faint); font-size: 10px; }
.theme-chart-legend-v2 { gap: 12px; color: var(--r2-faint); font-size: 9.5px; }
.theme-chart-legend-v2 span { display: inline-flex; align-items: center; gap: 4px; }
.theme-chart-legend-v2 i { width: 8px; height: 8px; display: inline-block; border-radius: 2px; }
.theme-chart-legend-v2 i.up { background: var(--r2-up); }
.theme-chart-legend-v2 i.down { background: var(--r2-down); }
.theme-chart-legend-v2 i.volume { height: 5px; background: #a8b2c1; }
.theme-chart-shell-v2 { min-height: 220px; flex: 1 1 auto; padding: 0 10px 8px; }
.theme-chart-shell-v2 canvas { width: 100%; height: 100%; display: block; }
.theme-members-heading-v2 { .theme-members-heading-v2 {
min-height: 50px; min-height: 50px;
justify-content: space-between; justify-content: space-between;
@@ -3858,7 +3836,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.theme-directory-card-v2 { max-height: 330px; } .theme-directory-card-v2 { max-height: 330px; }
.theme-directory-v2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .theme-directory-v2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.theme-directory-item-v2:nth-child(odd) { border-right: 1px solid var(--r2-line-soft); } .theme-directory-item-v2:nth-child(odd) { border-right: 1px solid var(--r2-line-soft); }
.theme-detail-stack-v2:not([hidden]) { grid-template-rows: 430px 360px; } .theme-detail-stack-v2:not([hidden]) { grid-template-rows: auto 420px; }
} }
@media (min-width: 721px) and (max-height: 900px) { @media (min-width: 721px) and (max-height: 900px) {
@@ -3869,11 +3847,9 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.theme-card-head-v2 { min-height: 50px; } .theme-card-head-v2 { min-height: 50px; }
.theme-directory-labels-v2 { min-height: 25px; } .theme-directory-labels-v2 { min-height: 25px; }
.theme-directory-item-v2 { min-height: 52px; } .theme-directory-item-v2 { min-height: 52px; }
.theme-detail-stack-v2:not([hidden]) { grid-template-rows: minmax(330px, 1.35fr) minmax(180px, .85fr); gap: 9px; } .theme-detail-stack-v2:not([hidden]) { grid-template-rows: auto minmax(260px, 1fr); gap: 9px; }
.theme-detail-heading-v2 { min-height: 58px; } .theme-detail-heading-v2 { min-height: 58px; }
.theme-detail-metrics-v2 { min-height: 47px; } .theme-detail-metrics-v2 { min-height: 47px; }
.theme-chart-heading-v2 { min-height: 34px; }
.theme-chart-shell-v2 { min-height: 185px; }
.theme-members-heading-v2 { min-height: 44px; } .theme-members-heading-v2 { min-height: 44px; }
.theme-members-table-v2 tbody td { height: 35px; padding-top: 5px; padding-bottom: 5px; } .theme-members-table-v2 tbody td { height: 35px; padding-top: 5px; padding-bottom: 5px; }
} }
@@ -3892,15 +3868,13 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.theme-library-workspace-v2 { grid-template-columns: minmax(0, 1fr); } .theme-library-workspace-v2 { grid-template-columns: minmax(0, 1fr); }
.theme-directory-card-v2 { max-height: 360px; } .theme-directory-card-v2 { max-height: 360px; }
.theme-detail-stack-v2:not([hidden]) { display: flex; flex-direction: column; gap: 10px; } .theme-detail-stack-v2:not([hidden]) { display: flex; flex-direction: column; gap: 10px; }
.theme-market-card-v2 { min-height: 430px; } .theme-market-card-v2 { min-height: 0; }
.theme-detail-heading-v2 { align-items: flex-end; } .theme-detail-heading-v2 { align-items: flex-end; }
.theme-detail-name-line-v2 { align-items: flex-start; flex-direction: column; gap: 2px; } .theme-detail-name-line-v2 { align-items: flex-start; flex-direction: column; gap: 2px; }
.theme-detail-metrics-v2 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .theme-detail-metrics-v2 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.theme-detail-metrics-v2 > div { border-bottom: 1px solid var(--r2-line-soft); } .theme-detail-metrics-v2 > div { border-bottom: 1px solid var(--r2-line-soft); }
.theme-detail-metrics-v2 > div:nth-child(3) { border-right: 0; } .theme-detail-metrics-v2 > div:nth-child(3) { border-right: 0; }
.theme-detail-metrics-v2 > div:nth-child(n + 4) { border-bottom: 0; } .theme-detail-metrics-v2 > div:nth-child(n + 4) { border-bottom: 0; }
.theme-chart-heading-v2 { align-items: flex-start; flex-direction: column; gap: 4px; }
.theme-chart-shell-v2 { min-height: 245px; }
.theme-members-card-v2 { min-height: 420px; } .theme-members-card-v2 { min-height: 420px; }
.theme-members-heading-v2 > div { align-items: flex-start; flex-direction: column; gap: 2px; } .theme-members-heading-v2 > div { align-items: flex-start; flex-direction: column; gap: 2px; }
} }
+6 -4
View File
@@ -5688,7 +5688,8 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
} }
/* Phase 2 stock preview */ /* Phase 2 stock preview */
.stock-preview-trigger { .stock-preview-trigger,
.market-preview-trigger {
cursor: pointer; cursor: pointer;
text-decoration: underline; text-decoration: underline;
text-decoration-color: transparent; text-decoration-color: transparent;
@@ -5697,7 +5698,8 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
} }
.stock-preview-trigger:hover, .stock-preview-trigger:hover,
.stock-preview-trigger:focus-visible { .stock-preview-trigger:focus-visible,
.market-preview-trigger:hover {
color: var(--action); color: var(--action);
text-decoration-color: currentColor; text-decoration-color: currentColor;
} }
@@ -5846,7 +5848,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
min-width: 0; min-width: 0;
position: relative; position: relative;
padding: 6px 10px 2px; padding: 6px 10px 2px;
background: var(--surface); background: var(--chart-background);
} }
.stock-preview-chart-shell canvas { .stock-preview-chart-shell canvas {
@@ -5862,7 +5864,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 9px; gap: 9px;
background: rgba(255, 255, 255, 0.94); background: var(--chart-background);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px; font-size: 12px;
} }
+18 -2
View File
@@ -528,7 +528,7 @@
} }
/* Theme library, popularity and dragon-tiger. */ /* Theme library, popularity and dragon-tiger. */
:root[data-theme="dark"] :is(.theme-summary-v2, .theme-directory-labels-v2, .theme-detail-metrics-v2, .theme-chart-heading-v2) { :root[data-theme="dark"] :is(.theme-summary-v2, .theme-directory-labels-v2, .theme-detail-metrics-v2) {
border-color: var(--line-soft); border-color: var(--line-soft);
background: var(--surface-muted); background: var(--surface-muted);
} }
@@ -659,7 +659,7 @@
color: var(--heaven-ink); color: var(--heaven-ink);
} }
:root[data-theme="dark"] :is(.price-chart, .stock-preview-chart, .entity-detail-chart, .theme-chart-shell-v2) { :root[data-theme="dark"] :is(.price-chart, .stock-preview-chart, .entity-detail-chart) {
border-color: var(--border); border-color: var(--border);
background: var(--chart-background); background: var(--chart-background);
} }
@@ -1236,6 +1236,22 @@
background: var(--action-soft); background: var(--action-soft);
} }
:root[data-theme="dark"] #mentorView :is(
.mentor-message-content,
.mentor-answer-heading,
.loading-message p
) {
color: var(--text-primary);
}
:root[data-theme="dark"] #mentorView .mentor-answer-quote {
color: var(--text-secondary);
}
:root[data-theme="dark"] #mentorView :is(.mentor-message-label, .mentor-message small) {
color: var(--text-tertiary);
}
:root[data-theme="dark"] #reviewWorkspaceView { :root[data-theme="dark"] #reviewWorkspaceView {
--review-blue: var(--action); --review-blue: var(--action);
--review-blue-dark: var(--action-hover); --review-blue-dark: var(--action-hover);
+136 -21
View File
@@ -135,12 +135,20 @@ async function mockApplication(page, authSession = session(), options = {}) {
if (url.pathname === "/api/auth/me") payload = authSession; if (url.pathname === "/api/auth/me") payload = authSession;
else if (url.pathname === "/api/dashboard") { else if (url.pathname === "/api/dashboard") {
options.dashboardRequests = (options.dashboardRequests || 0) + 1; options.dashboardRequests = (options.dashboardRequests || 0) + 1;
options.dashboardTradeDates ||= [];
options.dashboardTradeDates.push(url.searchParams.get("trade_date"));
if (options.dashboardDelay) { if (options.dashboardDelay) {
await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay)); await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay));
} }
payload = dashboard; if (options.echoDashboardDate) {
const requestedDate = url.searchParams.get("trade_date");
payload = { ...dashboard, meta: { ...dashboard.meta, trade_date: requestedDate, requested_date: requestedDate } };
} else payload = dashboard;
} }
else if (url.pathname === "/api/stock/002141/preview") { else if (url.pathname === "/api/stock/002141/preview") {
if (options.previewDelay) {
await new Promise((resolve) => setTimeout(resolve, options.previewDelay));
}
payload = { payload = {
meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, intraday_notice: "" }, meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, intraday_notice: "" },
stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 }, stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 },
@@ -168,7 +176,16 @@ async function mockApplication(page, authSession = session(), options = {}) {
notes: [], notes: [],
}; };
} else if (url.pathname === "/api/search/detail") { } else if (url.pathname === "/api/search/detail") {
payload = { const theme = url.searchParams.get("type") === "theme";
payload = theme ? {
meta: { trade_date: "2026-07-23", realtime: false },
entity: { id: "885728.TI", code: "885728.TI", name: "人工智能", type: "theme", type_label: "题材", value: 1280, change: 2.2 },
series: [
{ trade_date: "2026-07-22", open: 1220, high: 1260, low: 1210, close: 1250, volume: 1000 },
{ trade_date: "2026-07-23", open: 1255, high: 1290, low: 1248, close: 1280, volume: 1200 },
],
metrics: [],
} : {
meta: { trade_date: "2026-07-23", realtime: false }, meta: { trade_date: "2026-07-23", realtime: false },
entity: { id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数", value: 3800, change: 0.5 }, entity: { id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数", value: 3800, change: 0.5 },
series: [ series: [
@@ -486,6 +503,27 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#globalSearchInput")).toBeFocused(); await expect(page.locator("#globalSearchInput")).toBeFocused();
}); });
test("fresh visits default to the latest date and sentiment cycle", async ({ page }) => {
const options = { echoDashboardDate: true };
await mockApplication(page, session("admin", true), options);
await page.goto("/index.html?date=2026-07-28");
const today = await page.evaluate(() => todayString());
await expect(page.locator("#tradeDate")).toHaveValue(today);
await expect(page.locator("#sentimentCycleView")).toHaveClass(/active-view/);
await expect(page.locator('[data-view="sentimentCycleView"]')).toHaveClass(/active/);
expect(options.dashboardTradeDates.at(-1)).toBe(today);
expect(new URL(page.url()).searchParams.has("date")).toBe(false);
await page.evaluate(() => {
const input = document.querySelector("#tradeDate");
input.value = "2026-07-28";
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await expect.poll(() => options.dashboardTradeDates.at(-1)).toBe("2026-07-28");
expect(new URL(page.url()).searchParams.has("date")).toBe(false);
});
test("every primary workspace shares the canonical desktop shell geometry", async ({ page }) => { test("every primary workspace shares the canonical desktop shell geometry", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true)); await mockApplication(page, session("admin", true));
@@ -564,7 +602,7 @@ test("night mode covers the application shell and persists across reloads", asyn
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式"); await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式");
}); });
test("collapsed overview and sentiment decision layout keep a single current reading", async ({ page }) => { test("collapsed overview and sentiment layout keep a single current reading", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); await page.goto("/index.html");
@@ -591,49 +629,37 @@ test("collapsed overview and sentiment decision layout keep a single current rea
}); });
await page.locator('[data-view="sentimentCycleView"]').first().click(); await page.locator('[data-view="sentimentCycleView"]').first().click();
await expect(page.locator("#sentimentStageGuideTitle")).toHaveText("判定口径"); await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
await expect(page.locator('[data-sentiment-stage]:visible')).toHaveCount(1);
await expect(page.locator('[data-sentiment-stage="退潮"]')).toBeVisible();
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。"); await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
const alignment = await page.evaluate(() => { const alignment = await page.evaluate(() => {
const guide = document.querySelector(".sentiment-stage-guide").getBoundingClientRect();
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect(); const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
const currentGuide = document.querySelector("[data-sentiment-stage].current");
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect(); const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
const label = document.querySelector(".sentiment-block .metric-label"); const label = document.querySelector(".sentiment-block .metric-label");
const status = document.querySelector(".sentiment-block .sentiment-text"); const status = document.querySelector(".sentiment-block .sentiment-text");
const rangeHeader = document.querySelector(".sentiment-stage-guide-head > span:nth-child(3)").getBoundingClientRect();
const rangeValue = currentGuide.querySelector(".stage-range").getBoundingClientRect();
const labelStyle = getComputedStyle(document.querySelector(".sentiment-block .metric-label")); const labelStyle = getComputedStyle(document.querySelector(".sentiment-block .metric-label"));
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text")); const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
return { return {
mainAligned: Math.abs(guide.x - trend.x) < 1 && Math.abs(guide.width - trend.width) < 1 && guide.top > trend.bottom, columnsAligned: Math.abs(trend.top - summary.top) < 1,
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom, railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
detailVisible: detail.top < innerHeight, detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
chartHeight: chart.height, chartHeight: chart.height,
guideRowHeight: currentGuide.getBoundingClientRect().height,
guideIsWhite: getComputedStyle(currentGuide).backgroundColor === "rgb(255, 255, 255)",
sameType: labelStyle.fontSize === statusStyle.fontSize sameType: labelStyle.fontSize === statusStyle.fontSize
&& labelStyle.fontWeight === statusStyle.fontWeight && labelStyle.fontWeight === statusStyle.fontWeight
&& labelStyle.lineHeight === statusStyle.lineHeight, && labelStyle.lineHeight === statusStyle.lineHeight,
sameBaseline: Math.abs(label.getBoundingClientRect().y - status.getBoundingClientRect().y) < 0.1, sameBaseline: Math.abs(label.getBoundingClientRect().y - status.getBoundingClientRect().y) < 0.1,
noStatusOffset: statusStyle.marginTop === "0px", noStatusOffset: statusStyle.marginTop === "0px",
rangeAligned: Math.abs(rangeHeader.right - rangeValue.right) < 1,
}; };
}); });
expect(alignment.mainAligned).toBe(true); expect(alignment.columnsAligned).toBe(true);
expect(alignment.railAligned).toBe(true); expect(alignment.railAligned).toBe(true);
expect(alignment.detailVisible).toBe(true); expect(alignment.detailAfterAnalysis).toBe(true);
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340); expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
expect(alignment.guideRowHeight).toBeLessThanOrEqual(52);
expect(alignment.guideIsWhite).toBe(true);
expect(alignment.sameType).toBe(true); expect(alignment.sameType).toBe(true);
expect(alignment.sameBaseline).toBe(true); expect(alignment.sameBaseline).toBe(true);
expect(alignment.noStatusOffset).toBe(true); expect(alignment.noStatusOffset).toBe(true);
expect(alignment.rangeAligned).toBe(true);
const pageFrames = {}; const pageFrames = {};
for (const [view, headSelector] of [ for (const [view, headSelector] of [
@@ -946,6 +972,29 @@ test("market ladder transfers tier bands, sorting and structural insights", asyn
await page.locator('[data-ladder-level="2"]').click(); await page.locator('[data-ladder-level="2"]').click();
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(8); await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(8);
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("展开剩余 1 只"); await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("展开剩余 1 只");
const ladderOverflow = await page.evaluate(() => {
const group = state.dashboard.ladders.find((item) => item.level === 2);
group.stocks = Array.from({ length: 48 }, (_, index) => ({
code: `001${String(index).padStart(3, "0")}`,
name: `二板扩展${index + 1}`,
sector: "电网设备",
first_time: "09:30:00",
open_times: index % 4,
amount_billion: 1.2,
}));
group.count = group.stocks.length;
state.expandedLadderLevels.add(2);
renderLadderBoard(state.dashboard.ladders);
const main = document.querySelector(".app-main");
return {
clientHeight: main.clientHeight,
scrollHeight: main.scrollHeight,
overflowY: getComputedStyle(main).overflowY,
};
});
expect(ladderOverflow.overflowY).toBe("auto");
expect(ladderOverflow.scrollHeight).toBeGreaterThan(ladderOverflow.clientHeight);
}); });
test("sector rotation transfers the nine-day matrix, tracking and sortable detail", async ({ page }) => { test("sector rotation transfers the nine-day matrix, tracking and sortable detail", async ({ page }) => {
@@ -1159,7 +1208,20 @@ test("theme library preserves the full master-detail workflow in its redesigned
await expect(page.locator("#themeDetailMetrics > div")).toHaveCount(5); await expect(page.locator("#themeDetailMetrics > div")).toHaveCount(5);
await expect(page.locator("#themeMemberCount")).toHaveText("有行情 1 / 1"); await expect(page.locator("#themeMemberCount")).toHaveText("有行情 1 / 1");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveAttribute("aria-pressed", "true");
expect(await page.locator("#themeDetailChart").evaluate((canvas) => canvas.toDataURL().length)).toBeGreaterThan(100); await expect(page.locator("#themeDetailChart")).toHaveCount(0);
const themePreviewRequest = page.waitForRequest((request) => request.url().includes("/api/search/detail?") && request.url().includes("type=theme"));
await page.locator(".market-preview-trigger").hover();
await themePreviewRequest;
await expect(page.locator("#stockPreview")).toBeVisible();
await expect(page.locator("#stockPreviewName")).toHaveText("人工智能");
await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日");
const intradayRequest = page.waitForRequest((request) => request.url().includes("/api/chart/intraday?") && request.url().includes("type=theme"));
await page.locator('[data-preview-chart="intraday"]').click();
const requestedIntraday = new URL((await intradayRequest).url());
expect(requestedIntraday.searchParams.get("id")).toBe("885728.TI");
await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟");
await page.locator("#closeStockPreview").click();
await page.locator("#themeSearch").fill("不存在的题材"); await page.locator("#themeSearch").fill("不存在的题材");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(0); await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(0);
@@ -1507,6 +1569,28 @@ test("stock hover preview ignores the selected historical date", async ({ page }
expect(canvasColors).toBeGreaterThan(4); expect(canvasColors).toBeGreaterThan(4);
}); });
test("stock hover preview loading state follows the dark chart theme", async ({ page }) => {
await mockApplication(page, session("user", true), { previewDelay: 500 });
await page.goto("/index.html");
await page.evaluate(() => {
document.documentElement.dataset.theme = "dark";
showStockPreview("002141", document.querySelector("#globalSearchButton"));
});
const loading = page.locator("#stockPreviewLoading");
await expect(loading).toBeVisible();
await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/);
const colors = await page.evaluate(() => ({
overlay: getComputedStyle(document.querySelector("#stockPreviewLoading")).backgroundColor,
chart: getComputedStyle(document.documentElement).getPropertyValue("--chart-background").trim(),
pixel: Array.from(
document.querySelector("#stockPreviewChart").getContext("2d").getImageData(10, 10, 1, 1).data,
),
}));
expect(colors.overlay).not.toBe("rgb(255, 255, 255)");
expect(colors.chart).toBe("#181b1e");
expect(colors.pixel.slice(0, 3)).toEqual([24, 27, 30]);
});
test("rising candle body stays hollow and its wick stops at both edges", async ({ page }) => { test("rising candle body stays hollow and its wick stops at both edges", async ({ page }) => {
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); await page.goto("/index.html");
@@ -1726,6 +1810,7 @@ test("mobile shell stays within the viewport", async ({ page }) => {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1); expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#globalSearchButton")).toBeVisible(); await expect(page.locator("#globalSearchButton")).toBeVisible();
await expect(page.locator('[data-view="limitPool"]')).toHaveClass(/mobile-active/);
const mobileShell = await page.evaluate(() => { const mobileShell = await page.evaluate(() => {
const header = document.querySelector(".topbar").getBoundingClientRect(); const header = document.querySelector(".topbar").getBoundingClientRect();
const main = document.querySelector(".app-main").getBoundingClientRect(); const main = document.querySelector(".app-main").getBoundingClientRect();
@@ -1848,6 +1933,24 @@ test("new review workflows render account-scoped records", async ({ page }) => {
await page.locator("#reviewHistoryToggle").click(); await page.locator("#reviewHistoryToggle").click();
await expect(page.locator("#reviewHistoryPanel")).toBeVisible(); await expect(page.locator("#reviewHistoryPanel")).toBeVisible();
await expect(page.locator("#reviewHistoryToggle")).toHaveAttribute("aria-expanded", "true"); await expect(page.locator("#reviewHistoryToggle")).toHaveAttribute("aria-expanded", "true");
const reviewHistoryOverflow = await page.evaluate(() => {
const history = document.querySelector("#notesHistory");
const seed = history.querySelector(".note-row");
for (let index = 0; index < 18; index += 1) history.appendChild(seed.cloneNode(true));
const main = document.querySelector(".app-main");
return {
mainClientHeight: main.clientHeight,
mainScrollHeight: main.scrollHeight,
mainOverflowY: getComputedStyle(main).overflowY,
historyClientHeight: history.clientHeight,
historyScrollHeight: history.scrollHeight,
historyOverflowY: getComputedStyle(history).overflowY,
};
});
expect(reviewHistoryOverflow.mainOverflowY).toBe("auto");
expect(reviewHistoryOverflow.mainScrollHeight).toBeGreaterThan(reviewHistoryOverflow.mainClientHeight);
expect(reviewHistoryOverflow.historyOverflowY).toBe("auto");
expect(reviewHistoryOverflow.historyScrollHeight).toBeGreaterThan(reviewHistoryOverflow.historyClientHeight);
await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1); await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1);
const tradeScroll = await page.evaluate(() => { const tradeScroll = await page.evaluate(() => {
const seed = state.tradeEntries[0]; const seed = state.tradeEntries[0];
@@ -2447,15 +2550,27 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await page.locator("#themeToggle").click(); await page.locator("#themeToggle").click();
const darkMessageStyle = await answer.evaluate((element) => { const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
const content = element.querySelector(".mentor-message-content");
const heading = element.querySelector(".mentor-answer-heading");
const label = element.querySelector(".mentor-message-label");
const meta = element.querySelector("small");
return { return {
background: style.backgroundColor, background: style.backgroundColor,
border: style.borderTopColor, border: style.borderTopColor,
shadow: style.boxShadow, shadow: style.boxShadow,
contentColor: getComputedStyle(content).color,
headingColor: getComputedStyle(heading).color,
labelColor: getComputedStyle(label).color,
metaColor: getComputedStyle(meta).color,
}; };
}); });
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)"); expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)"); expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.shadow).toBe("none"); expect(darkMessageStyle.shadow).toBe("none");
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)");
expect(darkMessageStyle.metaColor).toBe("rgb(127, 137, 147)");
}); });
test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => { test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => {
+187 -4
View File
@@ -12,18 +12,20 @@ from screener import (
FACTOR_GROUPS, FACTOR_GROUPS,
ScreenerEngine, ScreenerEngine,
_broken_reversal_metrics, _broken_reversal_metrics,
_earnings_event_rows,
_popularity_factor_rows,
_risk_flags, _risk_flags,
_rsi, _rsi,
_quarter_periods, _quarter_periods,
) )
from server import automatic_screener_jobs from server import DashboardService, automatic_screener_jobs
class CuratedScreenerTests(unittest.TestCase): class CuratedScreenerTests(unittest.TestCase):
def test_curated_library_contains_original_and_advanced_strategies(self): def test_curated_library_contains_original_and_advanced_strategies(self):
self.assertEqual(13, len(ADVANCED_CURATED_STRATEGIES)) self.assertEqual(19, len(ADVANCED_CURATED_STRATEGIES))
self.assertEqual(23, len(CURATED_STRATEGIES)) self.assertEqual(29, len(CURATED_STRATEGIES))
self.assertEqual(23, len({item["name"] for item in CURATED_STRATEGIES})) self.assertEqual(29, len({item["name"] for item in CURATED_STRATEGIES}))
self.assertTrue( self.assertTrue(
{"行业动量轮动", "主力资金行业流入"}.issubset( {"行业动量轮动", "主力资金行业流入"}.issubset(
{item["name"] for item in CURATED_STRATEGIES} {item["name"] for item in CURATED_STRATEGIES}
@@ -32,6 +34,16 @@ class CuratedScreenerTests(unittest.TestCase):
self.assertTrue( self.assertTrue(
all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES) all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES)
) )
self.assertTrue(
{
"景气-趋势-拥挤三维行业打分",
"大小盘/成长价值风格切换(元策略)",
"业绩超预期漂移(SUE/PEAD)",
"多因子综合打分(IC动态加权)",
"热度突增潜伏(另类数据)",
"机构榜溢价",
}.issubset({item["name"] for item in CURATED_STRATEGIES})
)
def test_every_curated_strategy_explains_environment_and_failure_risk(self): def test_every_curated_strategy_explains_environment_and_failure_risk(self):
for strategy in CURATED_STRATEGIES: for strategy in CURATED_STRATEGIES:
@@ -85,6 +97,51 @@ class CuratedScreenerTests(unittest.TestCase):
} }
self.assertTrue(fields.issubset(FACTOR_FIELDS), strategy["name"]) self.assertTrue(fields.issubset(FACTOR_FIELDS), strategy["name"])
def test_server_gate_blocks_specialized_strategies_until_sources_are_ready(self):
factor_dates = [f"2026{index + 1:04d}" for index in range(260)]
health = {
"market": True,
"auction": True,
"benchmark": True,
"valuation": True,
"fundamental": True,
"dividend_history": True,
"moneyflow_history": True,
"earnings_events": False,
"popularity": False,
"institutions": False,
}
expected = {
"业绩超预期漂移(SUE/PEAD)": "业绩预告与快报",
"热度突增潜伏(另类数据)": "当日人气榜",
"机构榜溢价": "龙虎榜机构席位",
}
by_name = {strategy["name"]: strategy for strategy in CURATED_STRATEGIES}
for name, missing_label in expected.items():
self.assertEqual(
[missing_label],
DashboardService._strategy_missing_data(
by_name[name], factor_dates, health
),
name,
)
ready_health = {
**health,
"earnings_events": True,
"popularity": True,
"institutions": True,
}
for name in expected:
self.assertEqual(
[],
DashboardService._strategy_missing_data(
by_name[name], factor_dates, ready_health
),
name,
)
def test_factor_groups_cover_every_quant_factor(self): def test_factor_groups_cover_every_quant_factor(self):
grouped = [field for fields in FACTOR_GROUPS.values() for field in fields] grouped = [field for fields in FACTOR_GROUPS.values() for field in fields]
self.assertEqual(set(FACTOR_FIELDS), set(grouped)) self.assertEqual(set(FACTOR_FIELDS), set(grouped))
@@ -109,6 +166,9 @@ class CuratedScreenerTests(unittest.TestCase):
self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns)) self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns))
self.assertIn("fundamental_indicators", tables) self.assertIn("fundamental_indicators", tables)
self.assertIn("benchmark_bars", tables) self.assertIn("benchmark_bars", tables)
self.assertIn("earnings_events", tables)
self.assertIn("popularity_factors", tables)
self.assertIn("lhb_institution_daily", tables)
def test_advanced_strategies_declare_history_and_backtest_contracts(self): def test_advanced_strategies_declare_history_and_backtest_contracts(self):
for strategy in ADVANCED_CURATED_STRATEGIES: for strategy in ADVANCED_CURATED_STRATEGIES:
@@ -327,6 +387,129 @@ class CuratedScreenerTests(unittest.TestCase):
self.assertLess(laggard["net_flow_5d_million"], 0) self.assertLess(laggard["net_flow_5d_million"], 0)
self.assertEqual(leader["sector_flow_rank"], 1) self.assertEqual(leader["sector_flow_rank"], 1)
def test_stage_three_event_and_composite_factors_are_date_scoped(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
stocks = [
("600001.SH", "成长样本", "电子", 0.08),
("600002.SH", "价值样本", "银行", 0.02),
]
database.upsert_stock_master([
{
"ts_code": code, "name": name, "industry": industry,
"market": "主板", "list_date": "20000101",
}
for code, name, industry, _ in stocks
])
dates = []
cursor = datetime(2026, 3, 1)
while len(dates) < 80:
if cursor.weekday() < 5:
dates.append(cursor.strftime("%Y%m%d"))
cursor += timedelta(days=1)
database.upsert_daily_bars([
{
"trade_date": trade_date, "ts_code": code,
"open": 10 + index * slope - 0.02,
"high": 10 + index * slope + 0.08,
"low": 10 + index * slope - 0.08,
"close": 10 + index * slope,
"pct_chg": slope, "vol": 1000 + index, "amount": 300000,
}
for index, trade_date in enumerate(dates)
for code, _, _, slope in stocks
])
database.upsert_daily_indicators([
{
"trade_date": dates[-1], "ts_code": "600001.SH",
"turnover_rate": 3, "volume_ratio": 1.4, "total_mv": 900000,
"circ_mv": 700000, "pe_ttm": 25, "pb": 3, "ps_ttm": 4,
},
{
"trade_date": dates[-1], "ts_code": "600002.SH",
"turnover_rate": 1, "volume_ratio": 0.9, "total_mv": 5000000,
"circ_mv": 4000000, "pe_ttm": 8, "pb": 0.8, "ps_ttm": 1,
},
])
database.upsert_fundamental_indicators([
{
"end_date": "20260331", "ann_date": dates[-10],
"ts_code": "600001.SH", "roe": 16, "roic": 13,
"grossprofit_margin": 35, "netprofit_yoy": 45, "or_yoy": 30,
},
{
"end_date": "20260331", "ann_date": dates[-10],
"ts_code": "600002.SH", "roe": 9, "roic": 7,
"grossprofit_margin": 18, "netprofit_yoy": 5, "or_yoy": 3,
},
])
database.upsert_earnings_events([{
"end_date": "20260331", "ann_date": dates[-3],
"ts_code": "600001.SH", "forecast_profit": 100,
"actual_profit": 125, "surprise_pct": 25,
"revenue_yoy": 30, "netprofit_yoy": 45,
"source": "forecast+express",
}])
database.upsert_popularity_factors([{
"trade_date": dates[-1], "ts_code": "600001.SH",
"ths_rank": 5, "dc_rank": 8, "combined_score": 75,
"rank_change": 12, "dual_source": True,
}])
database.upsert_lhb_institutions([{
"trade_date": dates[-1], "ts_code": "600001.SH",
"exalter": "机构专用", "buy": 80_000_000,
"sell": 20_000_000, "net_buy": 60_000_000,
}])
factors, actual_date = ScreenerEngine(database).build_factors(
dates[-1], history_days=80
)
by_code = {item["ts_code"]: item for item in factors}
factor = by_code["600001.SH"]
self.assertEqual(actual_date, dates[-1])
self.assertEqual(factor["earnings_days_since_announce"], 2)
self.assertEqual(factor["earnings_surprise_pct"], 25)
self.assertEqual(factor["popularity_score"], 75)
self.assertEqual(factor["popularity_dual_source"], 1)
self.assertEqual(factor["institution_net_buy_million"], 60)
self.assertEqual(factor["institution_seat_count"], 1)
self.assertIsNotNone(factor["sector_composite_score"])
self.assertIsNotNone(factor["style_fit_score"])
self.assertIsNotNone(factor["multi_factor_composite"])
health = database.factor_health_summary(dates[-1])
self.assertTrue(health["earnings_events"])
self.assertTrue(health["popularity"])
self.assertTrue(health["institutions"])
def test_stage_three_sources_normalize_units_and_rank_changes(self):
earnings = _earnings_event_rows(
[{
"ts_code": "600001.SH", "ann_date": "20260401",
"end_date": "20260331", "net_profit_min": 10000,
"net_profit_max": 12000,
}],
[{
"ts_code": "600001.SH", "ann_date": "20260420",
"end_date": "20260331", "n_income": 132_000_000,
"yoy_net_profit": 30, "yoy_sales": 18,
}],
"20260420",
)
self.assertEqual(len(earnings), 1)
self.assertEqual(round(earnings[0]["actual_profit"]), 13200)
self.assertEqual(round(earnings[0]["surprise_pct"]), 20)
popularity = _popularity_factor_rows(
"20260420",
[{"data_type": "热股", "ts_code": "600001.SH", "rank": 5}],
[{"data_type": "A股市场", "ts_code": "600001.SH", "rank": 8}],
[{"data_type": "热股", "ts_code": "600001.SH", "rank": 20}],
[{"data_type": "A股市场", "ts_code": "600001.SH", "rank": 30}],
)
self.assertEqual(len(popularity), 1)
self.assertEqual(popularity[0]["rank_change"], 15)
self.assertTrue(popularity[0]["dual_source"])
def test_screen_reports_signal_health(self): def test_screen_reports_signal_health(self):
with tempfile.TemporaryDirectory() as root: with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db") database = ReviewDatabase(Path(root) / "review.db")
+1 -1
View File
@@ -194,7 +194,7 @@ class FrontendContractTests(unittest.TestCase):
def test_stock_hover_preview_always_uses_latest_market_context(self): def test_stock_hover_preview_always_uses_latest_market_context(self):
start = self.script.index("async function showStockPreview") start = self.script.index("async function showStockPreview")
end = self.script.index("function renderStockPreviewLoading", start) end = self.script.index("async function showEntityPreview", start)
preview_loader = self.script[start:end] preview_loader = self.script[start:end]
self.assertIn('const cacheKey = `${code}:latest`;', preview_loader) self.assertIn('const cacheKey = `${code}:latest`;', preview_loader)
self.assertIn('/preview`', preview_loader) self.assertIn('/preview`', preview_loader)
+48
View File
@@ -4,6 +4,7 @@ import tempfile
import unittest import unittest
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from chart_data_provider import EastmoneyChartClient, MarketChartClient from chart_data_provider import EastmoneyChartClient, MarketChartClient
from database import ReviewDatabase from database import ReviewDatabase
@@ -42,6 +43,45 @@ class FakeIfind:
return [] return []
class FakeIfindStalePreopen(FakeIfind):
def history(self, codes, indicators, start_date, end_date, cache_ttl=0):
return [
*super().history(codes, indicators, start_date, end_date, cache_ttl),
{
"time": "2026-07-29",
"thscode": "000001.SZ",
"open": 10.5,
"high": 10.5,
"low": 10.5,
"close": 10.5,
"volume": 0,
"amount": 0,
},
]
def real_time(self, codes, indicators, cache_ttl=0):
return [
{
"time": "2026-07-28 15:00:00",
"open": 10.2,
"high": 10.8,
"low": 10.1,
"latest": 10.5,
"preClose": 10.2,
"volume": 120,
"amount": 1_200_000,
}
]
class FixedPreopenDatetime(datetime):
fixed_now = datetime(2026, 7, 29, 8, 45, tzinfo=timezone(timedelta(hours=8)))
@classmethod
def now(cls, tz=None):
return cls.fixed_now
class FakeIfindSnapshots: class FakeIfindSnapshots:
configured = True configured = True
@@ -93,6 +133,14 @@ class IfindFeatureTests(unittest.TestCase):
self.assertEqual(rows[-1]["trade_date"], "2026-07-28") self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4) self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4)
def test_ifind_daily_chart_keeps_last_traded_bar_before_market_open(self):
client = MarketChartClient(FakeIfindStalePreopen(), EastmoneyChartClient())
with patch("chart_data_provider.datetime", FixedPreopenDatetime):
rows = client.stock_daily("000001", "20260729")
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
self.assertFalse(rows[-1].get("realtime", False))
def test_event_enrichment_keeps_blank_broken_reason_blank(self): def test_event_enrichment_keeps_blank_broken_reason_blank(self):
dashboard = {"broken": [{"code": "000001", "reason": "原原因"}]} dashboard = {"broken": [{"code": "000001", "reason": "原原因"}]}
DashboardService._merge_ifind_event_enrichment( DashboardService._merge_ifind_event_enrichment(
+42
View File
@@ -76,6 +76,10 @@ class FakeMarketClient:
return [] return []
class ConfiguredIfind:
configured = True
class MarketInsightsTests(unittest.TestCase): class MarketInsightsTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temp = tempfile.TemporaryDirectory() self.temp = tempfile.TemporaryDirectory()
@@ -170,6 +174,44 @@ class MarketInsightsTests(unittest.TestCase):
self.assertFalse(payload["meta"]["carried_forward"]) self.assertFalse(payload["meta"]["carried_forward"])
self.assertEqual(payload["rows"], []) self.assertEqual(payload["rows"], [])
def test_finalized_window_uses_and_persists_ifind_closing_snapshot(self):
service = MarketInsightsService(
self.database,
FakeMarketClient(),
now_provider=lambda: datetime(
2026, 7, 24, 9, 31, tzinfo=timezone(timedelta(hours=8))
),
ifind=ConfiguredIfind(),
)
calls = []
service._dynamic_auction_rows = lambda trade_date, baseline_date, user_id: (
calls.append((trade_date, baseline_date, user_id))
or [
{
"ts_code": "000001.SZ",
"trade_date": trade_date,
"price": 10.5,
"pre_close": 10,
"vol": 20_000,
"amount": 5_000_000,
"turnover_rate": 0.12,
"volume_ratio": 1.8,
"dynamic": True,
}
]
)
payload = service.auction_center("20260724")
cached = service.auction_center("20260724")
self.assertEqual(payload["meta"]["phase"], "finalized")
self.assertTrue(payload["meta"]["available"])
self.assertEqual(payload["summary"]["stock_count"], 1)
self.assertEqual(payload["rows"][0]["code"], "000001")
self.assertEqual(len(calls), 1)
self.assertTrue(cached["meta"]["cached"])
self.assertEqual(cached["summary"]["stock_count"], 1)
def test_theme_library_detail_and_popularity(self): def test_theme_library_detail_and_popularity(self):
library = self.service.theme_library("20260724") library = self.service.theme_library("20260724")
self.assertEqual(library["meta"]["trade_date"], "2026-07-23") self.assertEqual(library["meta"]["trade_date"], "2026-07-23")
+50 -1
View File
@@ -53,6 +53,14 @@ class FixedMarketDatetime(datetime):
return cls.fixed_now return cls.fixed_now
class FixedPreopenDatetime(datetime):
fixed_now = datetime.now().astimezone().replace(hour=8, minute=45, second=0, microsecond=0)
@classmethod
def now(cls, tz=None):
return cls.fixed_now
class StockDetailRealtimeTests(unittest.TestCase): class StockDetailRealtimeTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.service = DashboardService.__new__(DashboardService) self.service = DashboardService.__new__(DashboardService)
@@ -110,7 +118,48 @@ class StockDetailRealtimeTests(unittest.TestCase):
result = self.service._prepare_stock_detail(payload, "002141", historical) result = self.service._prepare_stock_detail(payload, "002141", historical)
self.assertEqual(result["stock"]["change"], 1.2) self.assertEqual(result["stock"]["change"], 1.2)
self.assertNotIn("realtime", result["meta"]) self.assertFalse(result["meta"].get("realtime", False))
self.assertEqual(RealtimeClientStub.quote_calls, 0)
def test_today_detail_keeps_last_traded_bar_before_market_open(self):
today = FixedPreopenDatetime.fixed_now.strftime("%Y%m%d")
today_display = FixedPreopenDatetime.fixed_now.strftime("%Y-%m-%d")
yesterday = (FixedPreopenDatetime.fixed_now - timedelta(days=1)).strftime("%Y-%m-%d")
payload = {
"meta": {"trade_date": yesterday, "source": "tushare"},
"stock": {"code": "002141", "price": 10, "change": 0},
"prices": [
{
"trade_date": yesterday,
"open": 9.8,
"high": 10.1,
"low": 9.7,
"close": 10,
"change": 1.2,
"volume": 100,
},
{
"trade_date": today_display,
"open": 10,
"high": 10,
"low": 10,
"close": 10,
"change": 0,
"volume": 0,
"amount_billion": 0,
"realtime": True,
},
],
}
with patch("server.datetime", FixedPreopenDatetime), patch(
"server.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(payload, "002141", today)
self.assertEqual(result["meta"]["trade_date"], yesterday)
self.assertFalse(result["meta"].get("realtime", False))
self.assertEqual(result["prices"][-1]["trade_date"], yesterday)
self.assertEqual(result["stock"]["change"], 1.2)
self.assertEqual(RealtimeClientStub.quote_calls, 0) self.assertEqual(RealtimeClientStub.quote_calls, 0)