feat: complete strategy and market data improvements
This commit is contained in:
@@ -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(
|
||||
[
|
||||
{
|
||||
|
||||
+40
-9
@@ -8,7 +8,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, time as dt_time, timedelta
|
||||
from threading import Lock
|
||||
from typing import Any, ClassVar
|
||||
|
||||
@@ -168,8 +168,24 @@ class MarketChartClient:
|
||||
previous = normalized[index - 1]["close"] if index > 0 else 0
|
||||
row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0
|
||||
|
||||
today = datetime.now().astimezone().strftime("%Y%m%d")
|
||||
if compact_end == today:
|
||||
market_now = datetime.now().astimezone()
|
||||
today = market_now.strftime("%Y%m%d")
|
||||
market_open = (
|
||||
market_now.weekday() < 5
|
||||
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
||||
)
|
||||
today_display = market_now.date().isoformat()
|
||||
if normalized and normalized[-1]["trade_date"] == today_display:
|
||||
current_bar = normalized[-1]
|
||||
current_bar_is_valid = (
|
||||
current_bar["open"] > 0
|
||||
and current_bar["high"] >= max(current_bar["open"], current_bar["close"])
|
||||
and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"])
|
||||
and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0)
|
||||
)
|
||||
if not market_open or not current_bar_is_valid:
|
||||
normalized.pop()
|
||||
if compact_end == today and market_open:
|
||||
try:
|
||||
quote_rows = self.ifind.real_time(
|
||||
ifind_code,
|
||||
@@ -179,16 +195,31 @@ class MarketChartClient:
|
||||
quote = quote_rows[0] if quote_rows else {}
|
||||
latest = _number(quote.get("latest"))
|
||||
previous = _number(quote.get("preClose"))
|
||||
if latest > 0:
|
||||
open_price = _number(quote.get("open"))
|
||||
high = _number(quote.get("high"))
|
||||
low = _number(quote.get("low"))
|
||||
volume = _number(quote.get("volume"))
|
||||
amount = _number(quote.get("amount"))
|
||||
quote_date = str(quote.get("time") or "")[:10].replace("-", "")
|
||||
quote_is_current = not quote_date or quote_date == today
|
||||
has_market_activity = volume > 0 or amount > 0
|
||||
if (
|
||||
latest > 0
|
||||
and open_price > 0
|
||||
and high >= max(open_price, latest)
|
||||
and 0 < low <= min(open_price, latest)
|
||||
and has_market_activity
|
||||
and quote_is_current
|
||||
):
|
||||
realtime = {
|
||||
"trade_date": end.strftime("%Y-%m-%d"),
|
||||
"open": _number(quote.get("open")) or latest,
|
||||
"high": _number(quote.get("high")) or latest,
|
||||
"low": _number(quote.get("low")) or latest,
|
||||
"open": open_price,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"close": latest,
|
||||
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
|
||||
"volume": _number(quote.get("volume")),
|
||||
"amount_billion": _number(quote.get("amount")) / 100_000_000,
|
||||
"volume": volume,
|
||||
"amount_billion": amount / 100_000_000,
|
||||
"realtime": True,
|
||||
}
|
||||
if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
|
||||
|
||||
+199
-2
@@ -276,6 +276,49 @@ class ReviewDatabase:
|
||||
CREATE INDEX IF NOT EXISTS idx_auction_factors_code_date
|
||||
ON auction_factors(ts_code, trade_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS earnings_events (
|
||||
end_date TEXT NOT NULL,
|
||||
ann_date TEXT NOT NULL,
|
||||
ts_code TEXT NOT NULL,
|
||||
forecast_profit REAL,
|
||||
actual_profit REAL,
|
||||
surprise_pct REAL,
|
||||
revenue_yoy REAL,
|
||||
netprofit_yoy REAL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (end_date, ann_date, ts_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_earnings_events_code_announcement
|
||||
ON earnings_events(ts_code, ann_date DESC, end_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS popularity_factors (
|
||||
trade_date TEXT NOT NULL,
|
||||
ts_code TEXT NOT NULL,
|
||||
ths_rank INTEGER,
|
||||
dc_rank INTEGER,
|
||||
combined_score REAL NOT NULL DEFAULT 0,
|
||||
rank_change INTEGER,
|
||||
dual_source INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (trade_date, ts_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_popularity_factors_code_date
|
||||
ON popularity_factors(ts_code, trade_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lhb_institution_daily (
|
||||
trade_date TEXT NOT NULL,
|
||||
ts_code TEXT NOT NULL,
|
||||
net_buy_amount REAL NOT NULL DEFAULT 0,
|
||||
buy_amount REAL NOT NULL DEFAULT 0,
|
||||
sell_amount REAL NOT NULL DEFAULT 0,
|
||||
seat_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (trade_date, ts_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lhb_institution_code_date
|
||||
ON lhb_institution_daily(ts_code, trade_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS screener_strategies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
@@ -1446,6 +1489,109 @@ class ReviewDatabase:
|
||||
)
|
||||
return len(values)
|
||||
|
||||
def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int:
|
||||
values = [
|
||||
(
|
||||
str(row.get("end_date") or ""),
|
||||
str(row.get("ann_date") or ""),
|
||||
str(row.get("ts_code") or ""),
|
||||
_optional_float(row.get("forecast_profit")),
|
||||
_optional_float(row.get("actual_profit")),
|
||||
_optional_float(row.get("surprise_pct")),
|
||||
_optional_float(row.get("revenue_yoy")),
|
||||
_optional_float(row.get("netprofit_yoy")),
|
||||
str(row.get("source") or ""),
|
||||
)
|
||||
for row in rows
|
||||
if row.get("end_date") and row.get("ann_date") and row.get("ts_code")
|
||||
]
|
||||
with self.connect() as connection:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO earnings_events
|
||||
(end_date, ann_date, ts_code, forecast_profit, actual_profit,
|
||||
surprise_pct, revenue_yoy, netprofit_yoy, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET
|
||||
forecast_profit=excluded.forecast_profit,
|
||||
actual_profit=excluded.actual_profit,
|
||||
surprise_pct=excluded.surprise_pct,
|
||||
revenue_yoy=excluded.revenue_yoy,
|
||||
netprofit_yoy=excluded.netprofit_yoy,
|
||||
source=excluded.source
|
||||
""",
|
||||
values,
|
||||
)
|
||||
return len(values)
|
||||
|
||||
def upsert_popularity_factors(self, rows: list[dict[str, Any]]) -> int:
|
||||
values = [
|
||||
(
|
||||
str(row.get("trade_date") or ""),
|
||||
str(row.get("ts_code") or ""),
|
||||
int(row["ths_rank"]) if row.get("ths_rank") not in (None, "") else None,
|
||||
int(row["dc_rank"]) if row.get("dc_rank") not in (None, "") else None,
|
||||
float(row.get("combined_score") or 0),
|
||||
int(row["rank_change"]) if row.get("rank_change") not in (None, "") else None,
|
||||
int(bool(row.get("dual_source"))),
|
||||
)
|
||||
for row in rows
|
||||
if row.get("trade_date") and row.get("ts_code")
|
||||
]
|
||||
with self.connect() as connection:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO popularity_factors
|
||||
(trade_date, ts_code, ths_rank, dc_rank, combined_score,
|
||||
rank_change, dual_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||
ths_rank=excluded.ths_rank,
|
||||
dc_rank=excluded.dc_rank,
|
||||
combined_score=excluded.combined_score,
|
||||
rank_change=excluded.rank_change,
|
||||
dual_source=excluded.dual_source
|
||||
""",
|
||||
values,
|
||||
)
|
||||
return len(values)
|
||||
|
||||
def upsert_lhb_institutions(self, rows: list[dict[str, Any]]) -> int:
|
||||
grouped: dict[tuple[str, str], dict[str, float | int]] = {}
|
||||
for row in rows:
|
||||
trade_date = str(row.get("trade_date") or "")
|
||||
ts_code = str(row.get("ts_code") or "")
|
||||
seat_name = str(row.get("exalter") or row.get("seat_name") or "")
|
||||
if not trade_date or not ts_code or "机构专用" not in seat_name:
|
||||
continue
|
||||
group = grouped.setdefault(
|
||||
(trade_date, ts_code),
|
||||
{"net": 0.0, "buy": 0.0, "sell": 0.0, "seats": 0},
|
||||
)
|
||||
group["net"] = float(group["net"]) + float(row.get("net_buy") or row.get("net_amount") or 0)
|
||||
group["buy"] = float(group["buy"]) + float(row.get("buy") or row.get("buy_amount") or 0)
|
||||
group["sell"] = float(group["sell"]) + float(row.get("sell") or row.get("sell_amount") or 0)
|
||||
group["seats"] = int(group["seats"]) + 1
|
||||
values = [
|
||||
(trade_date, ts_code, item["net"], item["buy"], item["sell"], item["seats"])
|
||||
for (trade_date, ts_code), item in grouped.items()
|
||||
]
|
||||
with self.connect() as connection:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO lhb_institution_daily
|
||||
(trade_date, ts_code, net_buy_amount, buy_amount, sell_amount, seat_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||
net_buy_amount=excluded.net_buy_amount,
|
||||
buy_amount=excluded.buy_amount,
|
||||
sell_amount=excluded.sell_amount,
|
||||
seat_count=excluded.seat_count
|
||||
""",
|
||||
values,
|
||||
)
|
||||
return len(values)
|
||||
|
||||
def auction_factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
|
||||
where = "WHERE trade_date <= ?" if end_date else ""
|
||||
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
||||
@@ -1566,6 +1712,21 @@ class ReviewDatabase:
|
||||
""",
|
||||
(end_date,),
|
||||
).fetchone()[0]
|
||||
earnings_rows = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM earnings_events
|
||||
WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '')
|
||||
""",
|
||||
(end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"),
|
||||
).fetchone()[0]
|
||||
popularity_rows = connection.execute(
|
||||
"SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?",
|
||||
(end_date,),
|
||||
).fetchone()[0]
|
||||
institution_rows = connection.execute(
|
||||
"SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?",
|
||||
(end_date,),
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"market": bool(market),
|
||||
"auction": bool(auction),
|
||||
@@ -1579,6 +1740,12 @@ class ReviewDatabase:
|
||||
"dividend_years": int(dividend_years or 0),
|
||||
"moneyflow_history": int(moneyflow_dates or 0) >= 5,
|
||||
"moneyflow_dates": int(moneyflow_dates or 0),
|
||||
"earnings_events": int(earnings_rows or 0) > 0,
|
||||
"earnings_event_rows": int(earnings_rows or 0),
|
||||
"popularity": int(popularity_rows or 0) > 0,
|
||||
"popularity_rows": int(popularity_rows or 0),
|
||||
"institutions": int(institution_rows or 0) > 0,
|
||||
"institution_rows": int(institution_rows or 0),
|
||||
}
|
||||
|
||||
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
|
||||
@@ -1588,7 +1755,8 @@ class ReviewDatabase:
|
||||
"dates": [], "bars": [], "master": [], "indicators": [],
|
||||
"indicator_history": [], "indicator_series": [], "fundamentals": [],
|
||||
"moneyflow": [], "moneyflow_history": [], "auction": [],
|
||||
"benchmarks": [],
|
||||
"benchmarks": [], "fundamental_history": [],
|
||||
"earnings_events": [], "popularity": [], "institutions": [],
|
||||
}
|
||||
placeholders = ",".join("?" for _ in dates)
|
||||
with self.connect() as connection:
|
||||
@@ -1623,7 +1791,8 @@ class ReviewDatabase:
|
||||
).fetchall()
|
||||
indicator_series = connection.execute(
|
||||
f"""
|
||||
SELECT trade_date, ts_code, turnover_rate, volume_ratio
|
||||
SELECT trade_date, ts_code, turnover_rate, volume_ratio,
|
||||
total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm
|
||||
FROM daily_indicators
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date, ts_code
|
||||
@@ -1644,6 +1813,14 @@ class ReviewDatabase:
|
||||
""",
|
||||
(end_date,),
|
||||
).fetchall()
|
||||
fundamental_history = connection.execute(
|
||||
"""
|
||||
SELECT * FROM fundamental_indicators
|
||||
WHERE ann_date = '' OR ann_date <= ?
|
||||
ORDER BY ann_date, end_date, ts_code
|
||||
""",
|
||||
(end_date,),
|
||||
).fetchall()
|
||||
moneyflow = connection.execute(
|
||||
"""
|
||||
SELECT * FROM moneyflow_daily
|
||||
@@ -1680,6 +1857,22 @@ class ReviewDatabase:
|
||||
""",
|
||||
dates,
|
||||
).fetchall()
|
||||
earnings_events = connection.execute(
|
||||
"""
|
||||
SELECT * FROM earnings_events
|
||||
WHERE ann_date <= ?
|
||||
ORDER BY ann_date, end_date, ts_code
|
||||
""",
|
||||
(end_date,),
|
||||
).fetchall()
|
||||
popularity = connection.execute(
|
||||
"SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code",
|
||||
(end_date,),
|
||||
).fetchall()
|
||||
institutions = connection.execute(
|
||||
"SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code",
|
||||
(end_date,),
|
||||
).fetchall()
|
||||
return {
|
||||
"dates": dates,
|
||||
"bars": [dict(row) for row in bars],
|
||||
@@ -1688,10 +1881,14 @@ class ReviewDatabase:
|
||||
"indicator_history": [dict(row) for row in indicator_history],
|
||||
"indicator_series": [dict(row) for row in indicator_series],
|
||||
"fundamentals": [dict(row) for row in fundamentals],
|
||||
"fundamental_history": [dict(row) for row in fundamental_history],
|
||||
"moneyflow": [dict(row) for row in moneyflow],
|
||||
"moneyflow_history": [dict(row) for row in moneyflow_history],
|
||||
"auction": [dict(row) for row in auction],
|
||||
"benchmarks": [dict(row) for row in benchmarks],
|
||||
"earnings_events": [dict(row) for row in earnings_events],
|
||||
"popularity": [dict(row) for row in popularity],
|
||||
"institutions": [dict(row) for row in institutions],
|
||||
}
|
||||
|
||||
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
|
||||
+19
-10
@@ -668,8 +668,8 @@ class MarketInsightsService:
|
||||
start_stamp = f"{display_date} 09:15:00"
|
||||
snapshot_rows: list[dict[str, Any]] = []
|
||||
ordered_codes = sorted(selected_codes)
|
||||
try:
|
||||
for index in range(0, len(ordered_codes), 80):
|
||||
for index in range(0, len(ordered_codes), 80):
|
||||
try:
|
||||
snapshot_rows.extend(
|
||||
self.ifind.snapshots(
|
||||
ordered_codes[index:index + 80],
|
||||
@@ -682,13 +682,18 @@ class MarketInsightsService:
|
||||
cache_ttl=8,
|
||||
)
|
||||
)
|
||||
except IfindError:
|
||||
return []
|
||||
except IfindError:
|
||||
continue
|
||||
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for row in snapshot_rows:
|
||||
ts_code = str(row.get("thscode") or "")
|
||||
if ts_code and _number(row.get("latest")) > 0:
|
||||
previous = latest.get(ts_code) or {}
|
||||
if (
|
||||
ts_code
|
||||
and _number(row.get("latest")) > 0
|
||||
and str(row.get("time") or "") >= str(previous.get("time") or "")
|
||||
):
|
||||
latest[ts_code] = row
|
||||
prior_factors = {
|
||||
str(item.get("ts_code") or ""): item
|
||||
@@ -735,11 +740,13 @@ class MarketInsightsService:
|
||||
trade_date, previous_date = self._trade_context(requested_date)
|
||||
session = self._auction_session(requested_date, trade_date)
|
||||
phase = str(session["phase"])
|
||||
dynamic = phase == "observing" and bool(self.ifind and self.ifind.configured)
|
||||
data_date = previous_date if phase == "pending" or (phase == "observing" and not dynamic) else trade_date
|
||||
ifind_ready = bool(self.ifind and self.ifind.configured)
|
||||
live_dynamic = phase == "observing" and ifind_ready
|
||||
use_ifind_snapshot = phase in {"observing", "selection", "finalized"} and ifind_ready
|
||||
data_date = previous_date if phase == "pending" or (phase == "observing" and not live_dynamic) else trade_date
|
||||
carried_forward = data_date != trade_date
|
||||
cache_key = data_date
|
||||
if not force and not dynamic:
|
||||
if not force and not live_dynamic:
|
||||
cached = self.database.get_data_snapshot("auction_center_v6", cache_key)
|
||||
if cached:
|
||||
result = copy.deepcopy(cached)
|
||||
@@ -754,9 +761,11 @@ class MarketInsightsService:
|
||||
}
|
||||
return self._with_auction_watchlist(result, data_date, user_id)
|
||||
|
||||
if dynamic:
|
||||
if use_ifind_snapshot:
|
||||
rows = self._dynamic_auction_rows(data_date, previous_date, user_id)
|
||||
else:
|
||||
rows = []
|
||||
if not rows and not live_dynamic:
|
||||
try:
|
||||
rows = self.client.query("stk_auction", {"trade_date": data_date})
|
||||
except TushareError:
|
||||
@@ -927,7 +936,7 @@ class MarketInsightsService:
|
||||
"one_price_rows": one_price_rows,
|
||||
"rows": candidates,
|
||||
}
|
||||
if not dynamic:
|
||||
if not live_dynamic:
|
||||
self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result)
|
||||
return self._with_auction_watchlist(result, data_date, user_id)
|
||||
|
||||
|
||||
+420
@@ -63,6 +63,10 @@ FACTOR_FIELDS = {
|
||||
"sector_stock_momentum_rank": "行业内个股动量排名",
|
||||
"sector_net_flow_5d_million": "行业5日主力净流入",
|
||||
"sector_flow_rank": "行业资金流排名",
|
||||
"sector_prosperity_rank": "行业景气度排名",
|
||||
"sector_trend_rank": "行业趋势排名",
|
||||
"sector_crowding_rank": "行业拥挤度排名",
|
||||
"sector_composite_score": "行业三维综合分",
|
||||
"sector_limit_count": "板块涨停数",
|
||||
"sector_up_count": "板块强势股数",
|
||||
"relative_strength": "相对强度",
|
||||
@@ -84,6 +88,23 @@ FACTOR_FIELDS = {
|
||||
"netprofit_yoy": "净利润同比",
|
||||
"revenue_yoy": "营业收入同比",
|
||||
"ocf_to_opincome": "经营现金流质量",
|
||||
"earnings_surprise_pct": "业绩超预期幅度",
|
||||
"earnings_days_since_announce": "业绩公告后天数",
|
||||
"earnings_event_quality": "业绩事件质量",
|
||||
"popularity_score": "人气榜热度",
|
||||
"popularity_rank_change": "人气排名跃升",
|
||||
"popularity_dual_source": "双榜共识",
|
||||
"institution_net_buy_million": "机构席位净买入",
|
||||
"institution_seat_count": "机构席位数",
|
||||
"style_size_fit": "大小盘风格匹配",
|
||||
"style_growth_fit": "成长价值风格匹配",
|
||||
"style_fit_score": "当前风格匹配度",
|
||||
"factor_value_score": "价值因子分",
|
||||
"factor_growth_score": "成长因子分",
|
||||
"factor_quality_score": "质量因子分",
|
||||
"factor_momentum_score": "动量因子分",
|
||||
"factor_sentiment_score": "交易情绪因子分",
|
||||
"multi_factor_composite": "动态多因子综合分",
|
||||
"relative_position_60": "60日相对位置",
|
||||
"max_abs_change_15d": "15日最大波动",
|
||||
"close_to_high_15d": "距15日高点",
|
||||
@@ -133,6 +154,8 @@ FACTOR_GROUPS = {
|
||||
"板块结构": [
|
||||
"sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank",
|
||||
"sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_rank",
|
||||
"sector_prosperity_rank", "sector_trend_rank", "sector_crowding_rank",
|
||||
"sector_composite_score",
|
||||
"sector_limit_count", "sector_up_count", "sector_breadth_ma20",
|
||||
"limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal",
|
||||
"is_limit_up_today", "is_limit_down_today",
|
||||
@@ -151,6 +174,14 @@ FACTOR_GROUPS = {
|
||||
"财务质量": [
|
||||
"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
|
||||
"ocf_to_opincome", "financial_risk",
|
||||
"earnings_surprise_pct", "earnings_days_since_announce", "earnings_event_quality",
|
||||
],
|
||||
"特色数据": [
|
||||
"popularity_score", "popularity_rank_change", "popularity_dual_source",
|
||||
"institution_net_buy_million", "institution_seat_count",
|
||||
"style_size_fit", "style_growth_fit", "style_fit_score",
|
||||
"factor_value_score", "factor_growth_score", "factor_quality_score",
|
||||
"factor_momentum_score", "factor_sentiment_score", "multi_factor_composite",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -649,6 +680,30 @@ STRATEGY_ENVIRONMENT_NOTES = {
|
||||
"板块轮动初期、资金先于价格形成连续净流入的阶段",
|
||||
"资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势",
|
||||
),
|
||||
"景气-趋势-拥挤三维行业打分": (
|
||||
"行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市",
|
||||
"财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢",
|
||||
),
|
||||
"大小盘/成长价值风格切换(元策略)": (
|
||||
"大小盘或成长价值风格形成持续相对强弱的阶段",
|
||||
"风格快速往返切换时,近20日相对表现容易产生滞后信号",
|
||||
),
|
||||
"业绩超预期漂移(SUE/PEAD)": (
|
||||
"业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时",
|
||||
"预告与快报口径可能不同,公告后高开兑现会削弱漂移效应",
|
||||
),
|
||||
"多因子综合打分(IC动态加权)": (
|
||||
"因子表现具备一定延续性、市场并非由单一极端主题主导时",
|
||||
"近期有效因子可能快速失效,动态权重不能消除风格突变风险",
|
||||
),
|
||||
"热度突增潜伏(另类数据)": (
|
||||
"人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期",
|
||||
"榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高",
|
||||
),
|
||||
"机构榜溢价": (
|
||||
"机构专用席位在相对低位形成明确净买入、且成交承载正常时",
|
||||
"高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓",
|
||||
),
|
||||
}
|
||||
|
||||
for strategy in CURATED_STRATEGIES:
|
||||
@@ -679,6 +734,106 @@ def _quarter_periods(trade_date: str, count: int) -> list[str]:
|
||||
return sorted(periods)
|
||||
|
||||
|
||||
def _earnings_event_rows(
|
||||
forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], trade_date: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
forecast_map: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for row in forecasts:
|
||||
key = (str(row.get("ts_code") or ""), str(row.get("end_date") or ""))
|
||||
ann_date = str(row.get("ann_date") or "")
|
||||
if not all(key) or not ann_date or ann_date > trade_date:
|
||||
continue
|
||||
previous = forecast_map.get(key)
|
||||
if previous is None or ann_date > str(previous.get("ann_date") or ""):
|
||||
forecast_map[key] = row
|
||||
result = []
|
||||
for row in expresses:
|
||||
ts_code = str(row.get("ts_code") or "")
|
||||
end_date = str(row.get("end_date") or "")
|
||||
ann_date = str(row.get("ann_date") or "")
|
||||
forecast = forecast_map.get((ts_code, end_date))
|
||||
if not forecast or not ts_code or not end_date or not ann_date or ann_date > trade_date:
|
||||
continue
|
||||
lower = _optional_number(forecast.get("net_profit_min"))
|
||||
upper = _optional_number(forecast.get("net_profit_max"))
|
||||
forecast_profit = statistics.fmean(
|
||||
value for value in (lower, upper) if value is not None
|
||||
) if lower is not None or upper is not None else None
|
||||
actual_profit = _optional_number(row.get("n_income"))
|
||||
if forecast_profit in (None, 0) or actual_profit is None:
|
||||
continue
|
||||
# forecast is reported in ten-thousand yuan while express uses yuan.
|
||||
if abs(actual_profit) > max(abs(forecast_profit), 1) * 100:
|
||||
actual_profit /= 10000
|
||||
surprise_pct = (actual_profit / forecast_profit - 1) * 100
|
||||
result.append(
|
||||
{
|
||||
"end_date": end_date,
|
||||
"ann_date": ann_date,
|
||||
"ts_code": ts_code,
|
||||
"forecast_profit": forecast_profit,
|
||||
"actual_profit": actual_profit,
|
||||
"surprise_pct": surprise_pct,
|
||||
"revenue_yoy": _optional_number(row.get("yoy_sales")),
|
||||
"netprofit_yoy": _optional_number(row.get("yoy_net_profit")),
|
||||
"source": "forecast+express",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _popularity_factor_rows(
|
||||
trade_date: str,
|
||||
ths_rows: list[dict[str, Any]],
|
||||
dc_rows: list[dict[str, Any]],
|
||||
previous_ths: list[dict[str, Any]],
|
||||
previous_dc: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
def ranks(rows: list[dict[str, Any]], data_type: str) -> dict[str, int]:
|
||||
result = {}
|
||||
for row in rows:
|
||||
if data_type and str(row.get("data_type") or "") != data_type:
|
||||
continue
|
||||
ts_code = str(row.get("ts_code") or "")
|
||||
rank = int(_number(row.get("rank")))
|
||||
if ts_code and rank > 0:
|
||||
result[ts_code] = rank
|
||||
return result
|
||||
|
||||
ths = ranks(ths_rows, "热股")
|
||||
dc = ranks(dc_rows, "A股市场")
|
||||
previous_ths_map = ranks(previous_ths, "热股")
|
||||
previous_dc_map = ranks(previous_dc, "A股市场")
|
||||
result = []
|
||||
for ts_code in set(ths) | set(dc):
|
||||
ths_rank = ths.get(ts_code)
|
||||
dc_rank = dc.get(ts_code)
|
||||
current_best = min(value for value in (ths_rank, dc_rank) if value is not None)
|
||||
previous_candidates = [
|
||||
value for value in (previous_ths_map.get(ts_code), previous_dc_map.get(ts_code))
|
||||
if value is not None
|
||||
]
|
||||
previous_best = min(previous_candidates) if previous_candidates else None
|
||||
score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25
|
||||
result.append(
|
||||
{
|
||||
"trade_date": trade_date,
|
||||
"ts_code": ts_code,
|
||||
"ths_rank": ths_rank,
|
||||
"dc_rank": dc_rank,
|
||||
"combined_score": round(score, 2),
|
||||
"rank_change": (
|
||||
previous_best - current_best
|
||||
if previous_best is not None
|
||||
else min(30, max(0, 31 - current_best))
|
||||
if previous_ths_map or previous_dc_map else 0
|
||||
),
|
||||
"dual_source": bool(ths_rank and dc_rank),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class FactorDataService:
|
||||
def __init__(self, database: ReviewDatabase, client: TushareClient) -> None:
|
||||
self.database = database
|
||||
@@ -714,12 +869,15 @@ class FactorDataService:
|
||||
"cal_date,is_open",
|
||||
)
|
||||
last_open_by_year: dict[str, str] = {}
|
||||
last_open_by_month: dict[str, str] = {}
|
||||
for row in long_calendar:
|
||||
if row.get("is_open") == 1 and row.get("cal_date"):
|
||||
value = str(row["cal_date"])
|
||||
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
|
||||
last_open_by_month[value[:6]] = max(last_open_by_month.get(value[:6], ""), value)
|
||||
valuation_dates = set(dates[-min(80, len(dates)):])
|
||||
valuation_dates.update(last_open_by_year.values())
|
||||
valuation_dates.update(last_open_by_month.values())
|
||||
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
|
||||
indicator_dates_to_fetch = sorted(
|
||||
value for value in valuation_dates if value not in existing_indicators or value == trade_date
|
||||
@@ -814,6 +972,63 @@ class FactorDataService:
|
||||
notices.append(f"资金流接口不可用:{exc}")
|
||||
break
|
||||
|
||||
earnings_count = 0
|
||||
forecasts: list[dict[str, Any]] = []
|
||||
expresses: list[dict[str, Any]] = []
|
||||
for period in _quarter_periods(trade_date, 5):
|
||||
try:
|
||||
forecast_rows = self.client.query(
|
||||
"forecast_vip",
|
||||
{"period": period},
|
||||
"ts_code,ann_date,end_date,net_profit_min,net_profit_max,last_parent_net,p_change_min,p_change_max",
|
||||
)
|
||||
express_rows = self.client.query(
|
||||
"express_vip",
|
||||
{"period": period},
|
||||
"ts_code,ann_date,end_date,n_income,yoy_net_profit,yoy_sales",
|
||||
)
|
||||
except TushareError as exc:
|
||||
notices.append(f"业绩事件接口不可用:{exc}")
|
||||
break
|
||||
forecasts.extend(forecast_rows)
|
||||
expresses.extend(express_rows)
|
||||
if forecasts and expresses:
|
||||
earnings_count = self.database.upsert_earnings_events(
|
||||
_earnings_event_rows(forecasts, expresses, trade_date)
|
||||
)
|
||||
|
||||
popularity_count = 0
|
||||
previous_trade_date = dates[-2] if len(dates) >= 2 else ""
|
||||
try:
|
||||
ths_rows = self.client.query("ths_hot", {"trade_date": trade_date})
|
||||
dc_rows = self.client.query("dc_hot", {"trade_date": trade_date})
|
||||
previous_ths = (
|
||||
self.client.query("ths_hot", {"trade_date": previous_trade_date})
|
||||
if previous_trade_date else []
|
||||
)
|
||||
previous_dc = (
|
||||
self.client.query("dc_hot", {"trade_date": previous_trade_date})
|
||||
if previous_trade_date else []
|
||||
)
|
||||
popularity_count = self.database.upsert_popularity_factors(
|
||||
_popularity_factor_rows(
|
||||
trade_date, ths_rows, dc_rows, previous_ths, previous_dc
|
||||
)
|
||||
)
|
||||
except TushareError as exc:
|
||||
notices.append(f"人气榜因子不可用:{exc}")
|
||||
|
||||
institution_count = 0
|
||||
try:
|
||||
institution_rows = self.client.query(
|
||||
"top_inst",
|
||||
{"trade_date": trade_date},
|
||||
"trade_date,ts_code,exalter,buy,sell,net_buy,side,reason",
|
||||
)
|
||||
institution_count = self.database.upsert_lhb_institutions(institution_rows)
|
||||
except TushareError as exc:
|
||||
notices.append(f"机构席位明细不可用:{exc}")
|
||||
|
||||
return {
|
||||
"trade_date": trade_date,
|
||||
"calendar_dates": len(dates),
|
||||
@@ -828,6 +1043,9 @@ class FactorDataService:
|
||||
"moneyflow_dates": moneyflow_dates,
|
||||
"auction_rows": auction_count,
|
||||
"auction_dates": auction_dates,
|
||||
"earnings_events": earnings_count,
|
||||
"popularity_rows": popularity_count,
|
||||
"institution_rows": institution_count,
|
||||
"notice": ";".join(notices),
|
||||
}
|
||||
|
||||
@@ -1061,6 +1279,23 @@ class ScreenerEngine:
|
||||
for row in data.get("auction", [])
|
||||
if str(row.get("trade_date") or "") == actual_date
|
||||
}
|
||||
earnings_events: dict[str, dict[str, Any]] = {}
|
||||
for row in data.get("earnings_events", []):
|
||||
ts_code = str(row.get("ts_code") or "")
|
||||
ann_date = str(row.get("ann_date") or "")
|
||||
if ann_date <= actual_date and (
|
||||
ts_code not in earnings_events
|
||||
or ann_date > str(earnings_events[ts_code].get("ann_date") or "")
|
||||
):
|
||||
earnings_events[ts_code] = row
|
||||
popularity = {
|
||||
str(row.get("ts_code") or ""): row
|
||||
for row in data.get("popularity", [])
|
||||
}
|
||||
institutions = {
|
||||
str(row.get("ts_code") or ""): row
|
||||
for row in data.get("institutions", [])
|
||||
}
|
||||
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in data["bars"]:
|
||||
if row["trade_date"] <= history_date:
|
||||
@@ -1206,6 +1441,33 @@ class ScreenerEngine:
|
||||
vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0
|
||||
broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name)
|
||||
netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy"))
|
||||
earnings_event = earnings_events.get(ts_code, {})
|
||||
announcement_date = str(earnings_event.get("ann_date") or "")
|
||||
earnings_days = (
|
||||
sum(1 for value in dates if announcement_date < value <= actual_date)
|
||||
if announcement_date and announcement_date <= actual_date
|
||||
else None
|
||||
)
|
||||
announcement_bar = next(
|
||||
(item for item in shape_rows if str(item.get("trade_date") or "") == announcement_date),
|
||||
None,
|
||||
)
|
||||
announcement_bad = False
|
||||
if announcement_bar is not None:
|
||||
bar_index = shape_rows.index(announcement_bar)
|
||||
prior_volumes = [
|
||||
_number(item.get("vol")) for item in shape_rows[max(0, bar_index - 5):bar_index]
|
||||
if _number(item.get("vol")) > 0
|
||||
]
|
||||
volume_baseline = statistics.fmean(prior_volumes) if prior_volumes else 0
|
||||
announcement_bad = (
|
||||
_number(announcement_bar.get("close")) < _number(announcement_bar.get("open"))
|
||||
and _number(announcement_bar.get("pct_chg")) < 0
|
||||
and volume_baseline > 0
|
||||
and _number(announcement_bar.get("vol")) / volume_baseline >= 1.8
|
||||
)
|
||||
popularity_row = popularity.get(ts_code)
|
||||
institution_row = institutions.get(ts_code)
|
||||
factors.append(
|
||||
{
|
||||
"code": code,
|
||||
@@ -1263,6 +1525,26 @@ class ScreenerEngine:
|
||||
"netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2),
|
||||
"revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2),
|
||||
"ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
|
||||
"earnings_surprise_pct": _rounded_optional(earnings_event.get("surprise_pct"), 2),
|
||||
"earnings_days_since_announce": earnings_days,
|
||||
"earnings_event_quality": int(not announcement_bad) if earnings_days is not None else None,
|
||||
"popularity_score": _rounded_optional(
|
||||
popularity_row.get("combined_score") if popularity_row else None, 2
|
||||
),
|
||||
"popularity_rank_change": (
|
||||
int(popularity_row["rank_change"])
|
||||
if popularity_row and popularity_row.get("rank_change") is not None else None
|
||||
),
|
||||
"popularity_dual_source": (
|
||||
int(bool(popularity_row.get("dual_source"))) if popularity_row else None
|
||||
),
|
||||
"institution_net_buy_million": (
|
||||
round(_number(institution_row.get("net_buy_amount")) / 1_000_000, 2)
|
||||
if institution_row else None
|
||||
),
|
||||
"institution_seat_count": (
|
||||
int(institution_row.get("seat_count") or 0) if institution_row else None
|
||||
),
|
||||
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
|
||||
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
|
||||
"net_flow_5d_million": round(
|
||||
@@ -1322,6 +1604,7 @@ class ScreenerEngine:
|
||||
for row in factors:
|
||||
sectors[row["sector"]].append(row)
|
||||
sector_metrics = []
|
||||
market_amount = sum(max(0.0, row["amount_billion"]) for row in factors)
|
||||
for sector_name, sector_rows in sectors.items():
|
||||
average_return = statistics.fmean(row["return_5d"] for row in sector_rows)
|
||||
average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows)
|
||||
@@ -1329,12 +1612,31 @@ class ScreenerEngine:
|
||||
limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows)
|
||||
up_count = sum(row["pct_chg"] >= 5 for row in sector_rows)
|
||||
breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100
|
||||
sector_growth = [
|
||||
statistics.fmean(values)
|
||||
for row in sector_rows
|
||||
if (values := [
|
||||
value for value in (row.get("revenue_yoy"), row.get("netprofit_yoy"))
|
||||
if value is not None
|
||||
])
|
||||
]
|
||||
prosperity_raw = statistics.median(sector_growth) if sector_growth else -100.0
|
||||
average_turnover = statistics.fmean(row["turnover_rate"] for row in sector_rows)
|
||||
amount_share = (
|
||||
sum(max(0.0, row["amount_billion"]) for row in sector_rows) / market_amount * 100
|
||||
if market_amount else 0.0
|
||||
)
|
||||
crowding_raw = average_turnover + amount_share
|
||||
trend_raw = average_return_20d + breadth_ma20 / 10
|
||||
strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
|
||||
sector_metrics.append(
|
||||
{
|
||||
"ts_code": sector_name,
|
||||
"sector_return_20d": average_return_20d,
|
||||
"sector_net_flow_5d_million": sector_net_flow,
|
||||
"sector_prosperity_raw": prosperity_raw,
|
||||
"sector_trend_raw": trend_raw,
|
||||
"sector_crowding_raw": crowding_raw,
|
||||
}
|
||||
)
|
||||
stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc")
|
||||
@@ -1356,7 +1658,22 @@ class ScreenerEngine:
|
||||
sector_flow_ranks = _percentile_map(
|
||||
sector_metrics, "sector_net_flow_5d_million", "desc"
|
||||
)
|
||||
sector_prosperity_ranks = _percentile_map(
|
||||
sector_metrics, "sector_prosperity_raw", "desc"
|
||||
)
|
||||
sector_trend_ranks = _percentile_map(
|
||||
sector_metrics, "sector_trend_raw", "desc"
|
||||
)
|
||||
sector_crowding_ranks = _percentile_map(
|
||||
sector_metrics, "sector_crowding_raw", "desc"
|
||||
)
|
||||
for sector_name, sector_rows in sectors.items():
|
||||
prosperity_rank = sector_prosperity_ranks.get(sector_name, 0.0)
|
||||
trend_rank = sector_trend_ranks.get(sector_name, 0.0)
|
||||
crowding_rank = sector_crowding_ranks.get(sector_name, 0.0)
|
||||
composite_score = (
|
||||
prosperity_rank * 0.40 + trend_rank * 0.30 + (1 - crowding_rank) * 0.30
|
||||
)
|
||||
for row in sector_rows:
|
||||
row["sector_momentum_rank"] = round(
|
||||
sector_momentum_ranks.get(sector_name, 0.0), 4
|
||||
@@ -1364,6 +1681,75 @@ class ScreenerEngine:
|
||||
row["sector_flow_rank"] = round(
|
||||
sector_flow_ranks.get(sector_name, 0.0), 4
|
||||
)
|
||||
row["sector_prosperity_rank"] = round(prosperity_rank, 4)
|
||||
row["sector_trend_rank"] = round(trend_rank, 4)
|
||||
row["sector_crowding_rank"] = round(crowding_rank, 4)
|
||||
row["sector_composite_score"] = round(composite_score, 4)
|
||||
|
||||
factor_specs = {
|
||||
"factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")),
|
||||
"factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")),
|
||||
"factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")),
|
||||
"factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")),
|
||||
"factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")),
|
||||
}
|
||||
for output_field, specs in factor_specs.items():
|
||||
maps = [_available_percentile_map(factors, field, direction) for field, direction in specs]
|
||||
for row in factors:
|
||||
values = [mapping.get(row["ts_code"]) for mapping in maps]
|
||||
available = [value for value in values if value is not None]
|
||||
row[output_field] = round(statistics.fmean(available), 4) if available else None
|
||||
|
||||
return_rank_map = _available_percentile_map(factors, "return_20d", "desc")
|
||||
factor_weights = {}
|
||||
for output_field in factor_specs:
|
||||
pairs = [
|
||||
(row.get(output_field), return_rank_map.get(row["ts_code"]))
|
||||
for row in factors
|
||||
if row.get(output_field) is not None and return_rank_map.get(row["ts_code"]) is not None
|
||||
]
|
||||
correlation = _pearson([pair[0] for pair in pairs], [pair[1] for pair in pairs])
|
||||
factor_weights[output_field] = max(0.05, correlation)
|
||||
factor_weight_total = sum(factor_weights.values()) or 1
|
||||
for row in factors:
|
||||
weighted = [
|
||||
(row.get(field), weight)
|
||||
for field, weight in factor_weights.items()
|
||||
if row.get(field) is not None
|
||||
]
|
||||
row["multi_factor_composite"] = round(
|
||||
sum(value * weight for value, weight in weighted)
|
||||
/ (sum(weight for _, weight in weighted) or factor_weight_total),
|
||||
4,
|
||||
) if weighted else None
|
||||
|
||||
size_ranks = _available_percentile_map(factors, "total_mv_billion", "desc")
|
||||
large_rows = [row for row in factors if (size_ranks.get(row["ts_code"]) or 0) >= 0.70]
|
||||
small_rows = [
|
||||
row for row in factors
|
||||
if size_ranks.get(row["ts_code"]) is not None
|
||||
and size_ranks[row["ts_code"]] <= 0.30
|
||||
]
|
||||
large_return = statistics.fmean(row["return_20d"] for row in large_rows) if large_rows else 0
|
||||
small_return = statistics.fmean(row["return_20d"] for row in small_rows) if small_rows else 0
|
||||
prefer_large = large_return >= small_return
|
||||
growth_rows = [row for row in factors if (row.get("factor_growth_score") or 0) >= 0.70]
|
||||
value_rows = [row for row in factors if (row.get("factor_value_score") or 0) >= 0.70]
|
||||
growth_return = statistics.fmean(row["return_20d"] for row in growth_rows) if growth_rows else 0
|
||||
value_return = statistics.fmean(row["return_20d"] for row in value_rows) if value_rows else 0
|
||||
prefer_growth = growth_return >= value_return
|
||||
for row in factors:
|
||||
size_rank = size_ranks.get(row["ts_code"])
|
||||
row["style_size_fit"] = round(
|
||||
size_rank if prefer_large else 1 - size_rank, 4
|
||||
) if size_rank is not None else None
|
||||
style_factor = "factor_growth_score" if prefer_growth else "factor_value_score"
|
||||
row["style_growth_fit"] = row.get(style_factor)
|
||||
style_values = [
|
||||
value for value in (row.get("style_size_fit"), row.get("style_growth_fit"))
|
||||
if value is not None
|
||||
]
|
||||
row["style_fit_score"] = round(statistics.fmean(style_values), 4) if style_values else None
|
||||
momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc")
|
||||
return_ranks = _percentile_map(factors, "return_5d", "desc")
|
||||
market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0)
|
||||
@@ -1753,6 +2139,40 @@ def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> d
|
||||
return result
|
||||
|
||||
|
||||
def _available_percentile_map(
|
||||
rows: list[dict[str, Any]], field: str, direction: str,
|
||||
) -> dict[str, float | None]:
|
||||
available = [row for row in rows if row.get(field) is not None]
|
||||
result: dict[str, float | None] = {
|
||||
str(row.get("ts_code") or ""): None for row in rows
|
||||
}
|
||||
if not available:
|
||||
return result
|
||||
ordered = sorted(available, key=lambda item: _number(item.get(field)))
|
||||
denominator = max(1, len(ordered) - 1)
|
||||
for index, row in enumerate(ordered):
|
||||
percentile = 0.5 if len(ordered) == 1 else index / denominator
|
||||
result[str(row.get("ts_code") or "")] = (
|
||||
1 - percentile if direction == "asc" else percentile
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _pearson(first: list[float], second: list[float]) -> float:
|
||||
if len(first) != len(second) or len(first) < 20:
|
||||
return 0.0
|
||||
first_mean = statistics.fmean(first)
|
||||
second_mean = statistics.fmean(second)
|
||||
numerator = sum(
|
||||
(left - first_mean) * (right - second_mean)
|
||||
for left, right in zip(first, second)
|
||||
)
|
||||
left_sum = sum((value - first_mean) ** 2 for value in first)
|
||||
right_sum = sum((value - second_mean) ** 2 for value in second)
|
||||
denominator = math.sqrt(left_sum * right_sum)
|
||||
return numerator / denominator if denominator else 0.0
|
||||
|
||||
|
||||
def _risk_flags(
|
||||
row: dict[str, Any], regime: str, include_regime_risk: bool = True
|
||||
) -> list[str]:
|
||||
|
||||
@@ -76,7 +76,7 @@ from trade_journal import TradeJournalService
|
||||
from tushare_client import TushareClient, TushareError, _sector_coverage_issue
|
||||
|
||||
|
||||
SCREENER_LIBRARY_VERSION = 7
|
||||
SCREENER_LIBRARY_VERSION = 8
|
||||
|
||||
|
||||
def automatic_screener_jobs(
|
||||
@@ -1385,6 +1385,10 @@ class DashboardService:
|
||||
missing.append("估值数据")
|
||||
if used_fields & fundamental_fields and not factor_health["fundamental"]:
|
||||
missing.append("财务质量")
|
||||
if meta.get("requires_valuation") and not factor_health["valuation"]:
|
||||
missing.append("估值数据")
|
||||
if meta.get("requires_fundamental") and not factor_health["fundamental"]:
|
||||
missing.append("财务质量")
|
||||
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
|
||||
missing.append("历年分红")
|
||||
if used_fields & auction_fields and not factor_health["auction"]:
|
||||
@@ -1393,7 +1397,13 @@ class DashboardService:
|
||||
missing.append("沪深300基准")
|
||||
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
|
||||
missing.append("近5日资金流")
|
||||
return missing
|
||||
if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"):
|
||||
missing.append("业绩预告与快报")
|
||||
if meta.get("requires_popularity") and not factor_health.get("popularity"):
|
||||
missing.append("当日人气榜")
|
||||
if meta.get("requires_institutions") and not factor_health.get("institutions"):
|
||||
missing.append("龙虎榜机构席位")
|
||||
return list(dict.fromkeys(missing))
|
||||
|
||||
def screener_setup(self, trade_date: str) -> dict[str, Any]:
|
||||
normalized_date = normalize_date(trade_date)
|
||||
@@ -4272,27 +4282,29 @@ class DashboardService:
|
||||
self, payload: dict[str, Any], code: str, requested_date: str
|
||||
) -> dict[str, Any]:
|
||||
result = copy.deepcopy(payload)
|
||||
now = datetime.now().astimezone()
|
||||
try:
|
||||
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
|
||||
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
|
||||
except (AttributeError, ChartDataError):
|
||||
pass
|
||||
result = self._sanitize_stock_detail_prices(result, now)
|
||||
actual_date = self._stock_detail_bar_date(result)
|
||||
if actual_date:
|
||||
result["meta"] = {
|
||||
**(result.get("meta") or {}),
|
||||
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
|
||||
}
|
||||
now = datetime.now().astimezone()
|
||||
today = now.strftime("%Y%m%d")
|
||||
should_merge = (
|
||||
requested_date == today
|
||||
and actual_date <= today
|
||||
and now.time().replace(tzinfo=None) >= dt_time(9, 15)
|
||||
and now.weekday() < 5
|
||||
and now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
||||
)
|
||||
if should_merge:
|
||||
quote = self._ifind_realtime_stock_quote(code)
|
||||
if quote:
|
||||
if quote and self._valid_realtime_stock_quote(quote, today):
|
||||
self._merge_realtime_stock_detail(result, quote, requested_date)
|
||||
elif self.configured and actual_date < today:
|
||||
client = TushareClient(self.token)
|
||||
@@ -4300,11 +4312,87 @@ class DashboardService:
|
||||
resolved_date, _ = client.resolve_trade_context(requested_date)
|
||||
if resolved_date == today:
|
||||
quote = client.realtime_stock_quote(tushare_code(code), requested_date)
|
||||
self._merge_realtime_stock_detail(result, quote, requested_date)
|
||||
if self._valid_realtime_stock_quote(quote, today):
|
||||
self._merge_realtime_stock_detail(result, quote, requested_date)
|
||||
except TushareError:
|
||||
pass
|
||||
return self._enrich_stock_detail(result)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_stock_detail_prices(
|
||||
payload: dict[str, Any], market_now: datetime
|
||||
) -> dict[str, Any]:
|
||||
result = copy.deepcopy(payload)
|
||||
raw_prices = list(result.get("prices") or [])
|
||||
raw_latest_date = str(
|
||||
(raw_prices[-1] if raw_prices else {}).get("trade_date") or ""
|
||||
).replace("-", "")
|
||||
prices = []
|
||||
for bar in raw_prices:
|
||||
open_price = float(bar.get("open") or 0)
|
||||
high = float(bar.get("high") or 0)
|
||||
low = float(bar.get("low") or 0)
|
||||
close = float(bar.get("close") or 0)
|
||||
if (
|
||||
open_price > 0
|
||||
and high >= max(open_price, close)
|
||||
and 0 < low <= min(open_price, close)
|
||||
and close > 0
|
||||
):
|
||||
prices.append(bar)
|
||||
|
||||
today = market_now.strftime("%Y%m%d")
|
||||
market_open = (
|
||||
market_now.weekday() < 5
|
||||
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
||||
)
|
||||
if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == today:
|
||||
current = prices[-1]
|
||||
has_market_activity = (
|
||||
float(current.get("volume") or 0) > 0
|
||||
or float(current.get("amount_billion") or 0) > 0
|
||||
)
|
||||
if not market_open or not has_market_activity:
|
||||
prices.pop()
|
||||
|
||||
if raw_latest_date == today and (
|
||||
not prices
|
||||
or str(prices[-1].get("trade_date") or "").replace("-", "") != today
|
||||
):
|
||||
result["meta"] = {**(result.get("meta") or {}), "realtime": False}
|
||||
|
||||
result["prices"] = prices
|
||||
if prices:
|
||||
latest = prices[-1]
|
||||
stock = dict(result.get("stock") or {})
|
||||
stock.update(
|
||||
{
|
||||
"price": float(latest.get("close") or 0),
|
||||
"change": float(latest.get("change") or 0),
|
||||
"amount_billion": float(latest.get("amount_billion") or 0),
|
||||
}
|
||||
)
|
||||
result["stock"] = stock
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _valid_realtime_stock_quote(quote: dict[str, Any], trade_date: str) -> bool:
|
||||
price = float(quote.get("price") or 0)
|
||||
open_price = float(quote.get("open") or 0)
|
||||
high = float(quote.get("high") or 0)
|
||||
low = float(quote.get("low") or 0)
|
||||
volume = float(quote.get("volume") or 0)
|
||||
amount = float(quote.get("amount_billion") or 0)
|
||||
quote_date = str(quote.get("quote_time") or "")[:10].replace("-", "")
|
||||
return (
|
||||
price > 0
|
||||
and open_price > 0
|
||||
and high >= max(open_price, price)
|
||||
and 0 < low <= min(open_price, price)
|
||||
and (volume > 0 or amount > 0)
|
||||
and (not quote_date or quote_date == trade_date)
|
||||
)
|
||||
|
||||
def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None:
|
||||
ifind = getattr(self, "ifind", None)
|
||||
if not ifind or not ifind.configured:
|
||||
@@ -4339,6 +4427,7 @@ class DashboardService:
|
||||
"volume_unit": "lots",
|
||||
"amount_billion": float(row.get("amount") or 0) / 100_000_000,
|
||||
"turnover_rate": float(row.get("turnoverRatio") or 0),
|
||||
"quote_time": str(row.get("time") or ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
+185
-40
@@ -41,7 +41,7 @@ const state = {
|
||||
yesterdayQuery: "",
|
||||
yesterdaySortKey: "",
|
||||
yesterdaySortDirection: "desc",
|
||||
activeView: "limitPool",
|
||||
activeView: "sentimentCycleView",
|
||||
dragonTiger: null,
|
||||
dragonViewMode: "daily",
|
||||
dragonFilter: "all",
|
||||
@@ -89,6 +89,8 @@ const state = {
|
||||
entityDetailIntraday: null,
|
||||
entityDetailRequestSequence: 0,
|
||||
stockPreviewCode: "",
|
||||
stockPreviewType: "stock",
|
||||
stockPreviewItem: null,
|
||||
stockPreviewPayload: null,
|
||||
stockPreviewChart: "daily",
|
||||
stockPreviewFallback: null,
|
||||
@@ -195,7 +197,6 @@ const elements = {
|
||||
globalSearchResults: document.querySelector("#globalSearchResults"),
|
||||
entityDetailDialog: document.querySelector("#entityDetailDialog"),
|
||||
entityDetailChart: document.querySelector("#entityDetailChart"),
|
||||
themeDetailChart: document.querySelector("#themeDetailChart"),
|
||||
settingsDialog: document.querySelector("#settingsDialog"),
|
||||
adminDialog: document.querySelector("#adminDialog"),
|
||||
priceChart: document.querySelector("#priceChart"),
|
||||
@@ -366,9 +367,6 @@ function redrawThemeSensitiveVisuals() {
|
||||
if (state.activeView === "sentimentCycleView" && state.sentimentHistory) {
|
||||
drawSentimentTrendChart(state.sentimentHistory.rows || []);
|
||||
}
|
||||
if (state.activeView === "themeLibraryView" && state.themeDetail?.series) {
|
||||
drawEntityDetailChart(state.themeDetail.series, elements.themeDetailChart);
|
||||
}
|
||||
if (state.activeView === "heavenView") {
|
||||
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
|
||||
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
|
||||
@@ -430,11 +428,12 @@ async function initialize() {
|
||||
syncThemeControl();
|
||||
refreshIcons();
|
||||
initializeApplicationShell();
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const requestedDate = searchParams.get("date");
|
||||
elements.tradeDate.value = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate || "") && requestedDate <= todayString()
|
||||
? requestedDate
|
||||
: todayString();
|
||||
elements.tradeDate.value = todayString();
|
||||
const initialUrl = new URL(window.location.href);
|
||||
if (initialUrl.searchParams.has("date")) {
|
||||
initialUrl.searchParams.delete("date");
|
||||
history.replaceState(null, "", initialUrl);
|
||||
}
|
||||
elements.tradeDate.max = todayString();
|
||||
document.querySelector("#journalDate").value = elements.tradeDate.value;
|
||||
document.querySelector("#journalDate").max = todayString();
|
||||
@@ -581,7 +580,6 @@ function bindEvents() {
|
||||
state.heavenRequestSequence += 1;
|
||||
state.heavenManualData = null;
|
||||
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
|
||||
setDateInUrl(elements.tradeDate.value);
|
||||
loadDashboard();
|
||||
});
|
||||
document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1));
|
||||
@@ -673,6 +671,7 @@ function bindEvents() {
|
||||
if (window.innerWidth > 720) toggleMentorDirectory(false);
|
||||
if (!elements.stockPreview.hidden) closeStockPreview();
|
||||
updateSidebarControl();
|
||||
syncNavigationState(state.activeView);
|
||||
if (state.activeView === "dragonView") layoutDragonCards();
|
||||
});
|
||||
document.querySelectorAll("[data-open-view]").forEach((button) => {
|
||||
@@ -1273,14 +1272,6 @@ function renderSentimentHistory() {
|
||||
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`);
|
||||
const marker = document.querySelector("#sentimentCycleScoreMarker");
|
||||
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
|
||||
document.querySelectorAll("[data-sentiment-stage]").forEach((item) => {
|
||||
const current = item.dataset.sentimentStage === latest.phase;
|
||||
item.classList.toggle("current", current);
|
||||
item.hidden = !current;
|
||||
const label = item.querySelector("strong");
|
||||
if (label) label.textContent = `${item.dataset.sentimentStage}${current ? "(当前)" : ""}`;
|
||||
});
|
||||
|
||||
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
|
||||
<article class="sentiment-component-item">
|
||||
<div class="sentiment-component-main">
|
||||
@@ -2330,7 +2321,7 @@ function scheduleAuctionTransition(meta) {
|
||||
clearAuctionTimer();
|
||||
if (state.activeView !== "auctionView") return;
|
||||
let delay = 0;
|
||||
if (meta.phase === "selection" && !meta.available) {
|
||||
if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
|
||||
delay = 10_000;
|
||||
} else if (meta.next_transition_at) {
|
||||
const transitionAt = new Date(meta.next_transition_at).getTime();
|
||||
@@ -2518,7 +2509,7 @@ function renderThemeDirectory() {
|
||||
return `
|
||||
<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-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>
|
||||
</button>`;
|
||||
}).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">${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);
|
||||
requestAnimationFrame(() => drawEntityDetailChart(payload.series || [], elements.themeDetailChart));
|
||||
renderThemeDirectory();
|
||||
}
|
||||
|
||||
@@ -3748,15 +3738,24 @@ function curatedStrategySchool(strategy) {
|
||||
if (["行业轮动", "形态突破", "趋势追踪"].includes(category)) return "趋势";
|
||||
if (["短线竞价", "连板接力", "低吸反核"].includes(category)) return "短线";
|
||||
if (["动量反转"].includes(category)) return "动量";
|
||||
if (["元策略", "多因子"].includes(category)) return "量化";
|
||||
if (["业绩事件", "热度观察"].includes(category)) return "事件";
|
||||
if (["资金席位"].includes(category)) return "资金";
|
||||
if (/红利|价值|质量|成长|财务|现金流/.test(category)) return "基本面";
|
||||
if (/趋势|轮动|突破/.test(category)) return "趋势";
|
||||
if (/竞价|连板|龙头|反核|首阴|反包|打板/.test(category)) return "短线";
|
||||
if (/动量|反转/.test(category)) return "动量";
|
||||
if (/因子|量化|元策略/.test(category)) return "量化";
|
||||
if (/事件|热度|公告|业绩/.test(category)) return "事件";
|
||||
if (/席位|资金/.test(category)) return "资金";
|
||||
return "其他";
|
||||
}
|
||||
|
||||
function curatedSchoolIcon(school) {
|
||||
return { 基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap", 动量: "refresh-cw", 其他: "boxes" }[school] || "boxes";
|
||||
return {
|
||||
基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap",
|
||||
动量: "refresh-cw", 量化: "binary", 事件: "calendar-clock", 资金: "landmark", 其他: "boxes",
|
||||
}[school] || "boxes";
|
||||
}
|
||||
|
||||
function curatedStrategyRunState(strategy, result) {
|
||||
@@ -3774,7 +3773,7 @@ function renderCuratedStrategyLibrary() {
|
||||
if (!state.screenerSetup) return;
|
||||
const strategies = curatedStrategies();
|
||||
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
|
||||
const schools = ["全部", "基本面", "趋势", "短线", "动量"];
|
||||
const schools = ["全部", "基本面", "趋势", "短线", "动量", "量化", "事件", "资金"];
|
||||
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
|
||||
if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部";
|
||||
setText("curatedStrategyCount", `${strategies.length} 套`);
|
||||
@@ -7315,6 +7314,37 @@ function stockCodeFromTrigger(trigger) {
|
||||
return matched ? matched[1] : "";
|
||||
}
|
||||
|
||||
function marketPreviewTargetFromTrigger(trigger) {
|
||||
if (trigger?.classList?.contains("market-preview-trigger")) {
|
||||
const type = String(trigger.dataset.marketPreviewType || "").trim().toLowerCase();
|
||||
const id = String(trigger.dataset.marketPreviewId || "").trim().toUpperCase();
|
||||
if (type === "theme" && id) {
|
||||
const item = (state.themeLibrary?.items || []).find((row) => String(row.code) === id) || {};
|
||||
return {
|
||||
type,
|
||||
id,
|
||||
code: id,
|
||||
name: item.name || trigger.textContent?.trim() || "--",
|
||||
type_label: "题材",
|
||||
change: item.change,
|
||||
value: item.close,
|
||||
};
|
||||
}
|
||||
}
|
||||
const code = stockCodeFromTrigger(trigger);
|
||||
return code ? { type: "stock", id: code, code } : null;
|
||||
}
|
||||
|
||||
function previewTriggerFromEvent(event) {
|
||||
return event.target.closest?.(".stock-preview-trigger, .market-preview-trigger");
|
||||
}
|
||||
|
||||
function showMarketPreview(target, trigger) {
|
||||
if (!target) return;
|
||||
if (target.type === "stock") showStockPreview(target.id, trigger);
|
||||
else showEntityPreview(target, trigger);
|
||||
}
|
||||
|
||||
function findStockFallback(code) {
|
||||
const dashboardRows = [
|
||||
...(state.dashboard?.limits || []),
|
||||
@@ -7346,19 +7376,19 @@ function supportsStockPreviewHover() {
|
||||
|
||||
function handleStockPreviewPointerOver(event) {
|
||||
if (!supportsStockPreviewHover()) return;
|
||||
const trigger = event.target.closest?.(".stock-preview-trigger");
|
||||
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return;
|
||||
const code = stockCodeFromTrigger(trigger);
|
||||
if (!code) return;
|
||||
const trigger = previewTriggerFromEvent(event);
|
||||
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
|
||||
const target = marketPreviewTargetFromTrigger(trigger);
|
||||
if (!target) return;
|
||||
cancelStockPreviewClose();
|
||||
clearTimeout(stockPreviewOpenTimer);
|
||||
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), STOCK_PREVIEW_DELAY);
|
||||
stockPreviewOpenTimer = setTimeout(() => showMarketPreview(target, trigger), STOCK_PREVIEW_DELAY);
|
||||
}
|
||||
|
||||
function handleStockPreviewPointerOut(event) {
|
||||
if (!supportsStockPreviewHover()) return;
|
||||
const trigger = event.target.closest?.(".stock-preview-trigger");
|
||||
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return;
|
||||
const trigger = previewTriggerFromEvent(event);
|
||||
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
|
||||
clearTimeout(stockPreviewOpenTimer);
|
||||
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
|
||||
scheduleStockPreviewClose();
|
||||
@@ -7424,6 +7454,8 @@ async function showStockPreview(code, trigger) {
|
||||
if (!/^\d{6}$/.test(String(code))) return;
|
||||
stockPreviewAnchor = trigger;
|
||||
state.stockPreviewCode = String(code);
|
||||
state.stockPreviewType = "stock";
|
||||
state.stockPreviewItem = null;
|
||||
state.stockPreviewFallback = findStockFallback(code);
|
||||
state.stockPreviewPayload = null;
|
||||
state.stockPreviewChart = "daily";
|
||||
@@ -7461,8 +7493,80 @@ async function showStockPreview(code, trigger) {
|
||||
}
|
||||
}
|
||||
|
||||
async function showEntityPreview(item, trigger) {
|
||||
const type = String(item?.type || "").trim().toLowerCase();
|
||||
const id = String(item?.id || item?.code || "").trim().toUpperCase();
|
||||
if (type !== "theme" || !id) return;
|
||||
clearTimeout(stockPreviewOpenTimer);
|
||||
cancelStockPreviewClose();
|
||||
stockPreviewAnchor = trigger;
|
||||
state.stockPreviewCode = id;
|
||||
state.stockPreviewType = type;
|
||||
state.stockPreviewItem = { ...item, id, code: item.code || id, type, type_label: item.type_label || "题材" };
|
||||
state.stockPreviewFallback = {
|
||||
code: item.code || id,
|
||||
name: item.name || "--",
|
||||
sector: item.type_label || "题材",
|
||||
price: item.value,
|
||||
change: item.change,
|
||||
};
|
||||
state.stockPreviewPayload = null;
|
||||
state.stockPreviewChart = "daily";
|
||||
renderStockPreviewLoading();
|
||||
elements.stockPreview.hidden = false;
|
||||
const mobile = window.innerWidth <= 720;
|
||||
elements.stockPreviewBackdrop.hidden = !mobile;
|
||||
document.body.classList.toggle("stock-preview-open", mobile);
|
||||
requestAnimationFrame(repositionStockPreview);
|
||||
|
||||
const cacheKey = `${type}:${id}:latest`;
|
||||
const cached = stockPreviewCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
renderStockPreview(cached.payload);
|
||||
return;
|
||||
}
|
||||
if (cached) stockPreviewCache.delete(cacheKey);
|
||||
stockPreviewAbortController?.abort();
|
||||
stockPreviewAbortController = new AbortController();
|
||||
try {
|
||||
const params = new URLSearchParams({ type, id, trade_date: todayString() });
|
||||
const detail = await apiRequest(
|
||||
`/api/search/detail?${params}`,
|
||||
"GET",
|
||||
null,
|
||||
{ signal: stockPreviewAbortController.signal },
|
||||
);
|
||||
if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return;
|
||||
const entity = detail.entity || {};
|
||||
const payload = {
|
||||
stock: {
|
||||
code: entity.code || id,
|
||||
name: entity.name || item.name || "--",
|
||||
industry: entity.type_label || item.type_label || "题材",
|
||||
price: entity.value,
|
||||
change: entity.change,
|
||||
},
|
||||
prices: detail.series || [],
|
||||
intraday: [],
|
||||
meta: {
|
||||
trade_date: detail.meta?.trade_date || "",
|
||||
realtime: Boolean(detail.meta?.realtime),
|
||||
intraday_status: "idle",
|
||||
intraday_notice: "",
|
||||
},
|
||||
};
|
||||
stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + STOCK_PREVIEW_CACHE_MS });
|
||||
while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value);
|
||||
renderStockPreview(payload);
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return;
|
||||
renderStockPreviewError(error.message || "题材行情预览加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderStockPreviewLoading() {
|
||||
const fallback = state.stockPreviewFallback || {};
|
||||
selectStockPreviewChart("daily");
|
||||
setText("stockPreviewCode", state.stockPreviewCode || "--");
|
||||
setText("stockPreviewName", fallback.name || "正在加载");
|
||||
setText("stockPreviewSector", fallback.sector || "--");
|
||||
@@ -7510,6 +7614,16 @@ function selectStockPreviewChart(chart) {
|
||||
const payload = state.stockPreviewPayload;
|
||||
if (!payload) return;
|
||||
if (state.stockPreviewChart === "intraday") {
|
||||
if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "idle") {
|
||||
payload.meta.intraday_status = "loading";
|
||||
setText("stockPreviewDate", "正在加载分时");
|
||||
setText("stockPreviewSource", "正在读取最新分时");
|
||||
setText("stockPreviewSummary", "等待分时行情数据");
|
||||
clearStockPreviewChart("");
|
||||
loadEntityPreviewIntraday();
|
||||
return;
|
||||
}
|
||||
if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "loading") return;
|
||||
setText("stockPreviewDate", payload.meta?.intraday_trade_date || payload.meta?.trade_date || "最新行情");
|
||||
setText(
|
||||
"stockPreviewSource",
|
||||
@@ -7538,6 +7652,36 @@ function selectStockPreviewChart(chart) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntityPreviewIntraday() {
|
||||
const type = state.stockPreviewType;
|
||||
const id = state.stockPreviewCode;
|
||||
const payload = state.stockPreviewPayload;
|
||||
if (type === "stock" || !id || !payload) return;
|
||||
stockPreviewAbortController?.abort();
|
||||
stockPreviewAbortController = new AbortController();
|
||||
try {
|
||||
const params = new URLSearchParams({ type, id });
|
||||
const intraday = await apiRequest(
|
||||
`/api/chart/intraday?${params}`,
|
||||
"GET",
|
||||
null,
|
||||
{ signal: stockPreviewAbortController.signal },
|
||||
);
|
||||
if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return;
|
||||
payload.intraday = intraday.points || [];
|
||||
payload.meta.intraday_status = payload.intraday.length ? "available" : "empty";
|
||||
payload.meta.intraday_trade_date = intraday.meta?.trade_date || "";
|
||||
payload.meta.intraday_previous_close = intraday.meta?.previous_close || 0;
|
||||
payload.meta.intraday_notice = payload.intraday.length ? "" : "该题材暂无可用分时数据。";
|
||||
if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday");
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return;
|
||||
payload.meta.intraday_status = "unavailable";
|
||||
payload.meta.intraday_notice = error.message || "题材分时行情暂不可用。";
|
||||
if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday");
|
||||
}
|
||||
}
|
||||
|
||||
function closeStockPreview() {
|
||||
clearTimeout(stockPreviewOpenTimer);
|
||||
clearTimeout(stockPreviewCloseTimer);
|
||||
@@ -7548,14 +7692,19 @@ function closeStockPreview() {
|
||||
document.body.classList.remove("stock-preview-open");
|
||||
state.stockPreviewPayload = null;
|
||||
state.stockPreviewCode = "";
|
||||
state.stockPreviewType = "stock";
|
||||
state.stockPreviewItem = null;
|
||||
}
|
||||
|
||||
function openStockDetailFromPreview() {
|
||||
const code = state.stockPreviewCode;
|
||||
const fallback = state.stockPreviewFallback;
|
||||
const type = state.stockPreviewType;
|
||||
const item = state.stockPreviewItem;
|
||||
if (!code) return;
|
||||
closeStockPreview();
|
||||
openStock(code, fallback);
|
||||
if (type === "stock") openStock(code, fallback);
|
||||
else if (item) openEntityDetail(item);
|
||||
}
|
||||
|
||||
function repositionStockPreview() {
|
||||
@@ -8484,16 +8633,9 @@ function shiftDate(delta) {
|
||||
elements.tradeDate.value = next;
|
||||
state.heavenManualData = null;
|
||||
document.querySelector("#qiObservationDate").value = next;
|
||||
setDateInUrl(next);
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
function setDateInUrl(value) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("date", value);
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
function updateDateButtons() {
|
||||
document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString();
|
||||
}
|
||||
@@ -9198,7 +9340,10 @@ function syncNavigationState(viewId) {
|
||||
button.dataset.view === viewId
|
||||
|| (viewId === "screenerTrackingView" && button.dataset.view === "screenerView"),
|
||||
);
|
||||
button.classList.remove("mobile-active");
|
||||
button.classList.toggle(
|
||||
"mobile-active",
|
||||
window.innerWidth <= 720 && marketView && button.dataset.view === "limitPool" && viewId !== "limitPool",
|
||||
);
|
||||
});
|
||||
const selector = document.querySelector("#mobileMarketSelector");
|
||||
const select = document.querySelector("#mobileMarketViewSelect");
|
||||
|
||||
@@ -888,7 +888,7 @@ tbody tr.clickable{cursor:pointer}
|
||||
|
||||
#themeLibraryView.active-view{overflow:hidden}
|
||||
#themeLibraryView .theme-library-workspace-v2{height:100%;grid-template-rows:minmax(0,1fr);align-items:stretch;overflow:hidden}
|
||||
#themeLibraryView .theme-detail-stack-v2{grid-template-rows:minmax(0,1.35fr) minmax(0,.85fr)}
|
||||
#themeLibraryView .theme-detail-stack-v2{grid-template-rows:auto minmax(0,1fr)}
|
||||
#themeLibraryView .theme-directory-card-v2,
|
||||
#themeLibraryView .theme-detail-column-v2,
|
||||
#themeLibraryView .theme-detail-stack-v2{height:100%;min-height:0;overflow:hidden}
|
||||
@@ -926,7 +926,9 @@ tbody tr.clickable{cursor:pointer}
|
||||
:root body:is(
|
||||
[data-active-view="sentimentCycleView"],
|
||||
[data-active-view="rotationView"],
|
||||
[data-active-view="screenerView"]
|
||||
[data-active-view="screenerView"],
|
||||
[data-active-view="ladderView"],
|
||||
[data-active-view="reviewWorkspaceView"]
|
||||
) .app-main{
|
||||
height:var(--workspace-height);
|
||||
min-height:0;
|
||||
@@ -937,7 +939,9 @@ tbody tr.clickable{cursor:pointer}
|
||||
|
||||
:root #sentimentCycleView.active-view,
|
||||
:root #rotationView.active-view,
|
||||
:root #screenerView.active-view{
|
||||
:root #screenerView.active-view,
|
||||
:root #ladderView.active-view,
|
||||
:root #reviewWorkspaceView.active-view{
|
||||
height:auto;
|
||||
min-height:0;
|
||||
display:block;
|
||||
|
||||
+11
-28
@@ -96,8 +96,8 @@
|
||||
</div>
|
||||
<div class="nav-group market-nav-group">
|
||||
<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 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 market-sub-tab active" type="button" data-view="sentimentCycleView" title="情绪周期"><i data-lucide="activity"></i><span>情绪周期</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="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>
|
||||
@@ -182,7 +182,7 @@
|
||||
</div>
|
||||
</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-title-group">
|
||||
<h2>涨停池</h2>
|
||||
@@ -419,7 +419,7 @@
|
||||
</div>
|
||||
</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-title-group">
|
||||
<h2>情绪周期</h2>
|
||||
@@ -445,18 +445,6 @@
|
||||
<div id="sentimentChartTooltip" class="sentiment-chart-tooltip" hidden></div>
|
||||
</div>
|
||||
</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>
|
||||
<aside class="sentiment-analysis-rail">
|
||||
<section class="sentiment-cycle-summary card redesigned-card" aria-label="最新情绪状态">
|
||||
@@ -861,7 +849,7 @@
|
||||
<div class="curated-workspace">
|
||||
<aside class="curated-library-pane" aria-label="精选策略库">
|
||||
<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 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>
|
||||
@@ -1216,7 +1204,7 @@
|
||||
<section id="themeLibraryView" class="workspace-view page redesigned-theme-view">
|
||||
<header class="theme-page-head-v2 lad-head">
|
||||
<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>
|
||||
</div>
|
||||
<div class="theme-head-actions-v2">
|
||||
@@ -1242,7 +1230,7 @@
|
||||
</aside>
|
||||
|
||||
<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>
|
||||
<section class="theme-market-card-v2 card">
|
||||
<header class="theme-detail-heading-v2">
|
||||
@@ -1253,11 +1241,6 @@
|
||||
<div class="theme-change-v2"><span>当日涨幅</span><strong id="themeDetailChange">--</strong></div>
|
||||
</header>
|
||||
<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 class="theme-members-card-v2 card">
|
||||
@@ -1543,7 +1526,7 @@
|
||||
</dialog>
|
||||
|
||||
<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">
|
||||
<div class="stock-preview-identity">
|
||||
<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>
|
||||
</header>
|
||||
<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="daily">日K</button>
|
||||
<button class="stock-preview-tab" type="button" role="tab" aria-selected="false" data-preview-chart="intraday">分时</button>
|
||||
<button class="stock-preview-tab active" type="button" role="tab" aria-selected="true" data-preview-chart="daily">日K</button>
|
||||
<span id="stockPreviewDate">--</span>
|
||||
</div>
|
||||
<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">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>正在读取行情</span>
|
||||
|
||||
+5
-31
@@ -3501,8 +3501,6 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
||||
.theme-head-actions-v2,
|
||||
.theme-title-v2 > div,
|
||||
.theme-detail-name-line-v2,
|
||||
.theme-chart-heading-v2,
|
||||
.theme-chart-legend-v2,
|
||||
.theme-members-heading-v2,
|
||||
.theme-members-heading-v2 > div {
|
||||
display: flex;
|
||||
@@ -3627,8 +3625,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
||||
}
|
||||
|
||||
.theme-card-head-v2 h3,
|
||||
.theme-members-heading-v2 h3,
|
||||
.theme-chart-heading-v2 h4 { margin: 0; color: var(--r2-ink); }
|
||||
.theme-members-heading-v2 h3 { margin: 0; color: var(--r2-ink); }
|
||||
.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 > strong {
|
||||
@@ -3717,7 +3714,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
||||
.theme-detail-stack-v2:not([hidden]) {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(348px, 1.35fr) minmax(190px, .85fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
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.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 {
|
||||
min-height: 50px;
|
||||
justify-content: space-between;
|
||||
@@ -3858,7 +3836,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
||||
.theme-directory-card-v2 { max-height: 330px; }
|
||||
.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-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) {
|
||||
@@ -3869,11 +3847,9 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
||||
.theme-card-head-v2 { min-height: 50px; }
|
||||
.theme-directory-labels-v2 { min-height: 25px; }
|
||||
.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-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-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-directory-card-v2 { max-height: 360px; }
|
||||
.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-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 > 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(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-heading-v2 > div { align-items: flex-start; flex-direction: column; gap: 2px; }
|
||||
}
|
||||
|
||||
+6
-4
@@ -5688,7 +5688,8 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
||||
}
|
||||
|
||||
/* Phase 2 stock preview */
|
||||
.stock-preview-trigger {
|
||||
.stock-preview-trigger,
|
||||
.market-preview-trigger {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
@@ -5697,7 +5698,8 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
||||
}
|
||||
|
||||
.stock-preview-trigger:hover,
|
||||
.stock-preview-trigger:focus-visible {
|
||||
.stock-preview-trigger:focus-visible,
|
||||
.market-preview-trigger:hover {
|
||||
color: var(--action);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
@@ -5846,7 +5848,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
padding: 6px 10px 2px;
|
||||
background: var(--surface);
|
||||
background: var(--chart-background);
|
||||
}
|
||||
|
||||
.stock-preview-chart-shell canvas {
|
||||
@@ -5862,7 +5864,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
background: var(--chart-background);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
+18
-2
@@ -528,7 +528,7 @@
|
||||
}
|
||||
|
||||
/* 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);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
@@ -659,7 +659,7 @@
|
||||
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);
|
||||
background: var(--chart-background);
|
||||
}
|
||||
@@ -1236,6 +1236,22 @@
|
||||
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 {
|
||||
--review-blue: var(--action);
|
||||
--review-blue-dark: var(--action-hover);
|
||||
|
||||
+136
-21
@@ -135,12 +135,20 @@ async function mockApplication(page, authSession = session(), options = {}) {
|
||||
if (url.pathname === "/api/auth/me") payload = authSession;
|
||||
else if (url.pathname === "/api/dashboard") {
|
||||
options.dashboardRequests = (options.dashboardRequests || 0) + 1;
|
||||
options.dashboardTradeDates ||= [];
|
||||
options.dashboardTradeDates.push(url.searchParams.get("trade_date"));
|
||||
if (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") {
|
||||
if (options.previewDelay) {
|
||||
await new Promise((resolve) => setTimeout(resolve, options.previewDelay));
|
||||
}
|
||||
payload = {
|
||||
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 },
|
||||
@@ -168,7 +176,16 @@ async function mockApplication(page, authSession = session(), options = {}) {
|
||||
notes: [],
|
||||
};
|
||||
} 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 },
|
||||
entity: { id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数", value: 3800, change: 0.5 },
|
||||
series: [
|
||||
@@ -486,6 +503,27 @@ test("admin shell opens every primary workspace and global search", async ({ pag
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
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", "切换到夜间模式");
|
||||
});
|
||||
|
||||
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 mockApplication(page, session("user", true));
|
||||
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 expect(page.locator("#sentimentStageGuideTitle")).toHaveText("判定口径");
|
||||
await expect(page.locator('[data-sentiment-stage]:visible')).toHaveCount(1);
|
||||
await expect(page.locator('[data-sentiment-stage="退潮"]')).toBeVisible();
|
||||
await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
|
||||
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
|
||||
const alignment = await page.evaluate(() => {
|
||||
const guide = document.querySelector(".sentiment-stage-guide").getBoundingClientRect();
|
||||
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
|
||||
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
|
||||
const summary = document.querySelector(".sentiment-cycle-summary").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 label = document.querySelector(".sentiment-block .metric-label");
|
||||
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 statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
|
||||
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,
|
||||
detailVisible: detail.top < innerHeight,
|
||||
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
|
||||
chartHeight: chart.height,
|
||||
guideRowHeight: currentGuide.getBoundingClientRect().height,
|
||||
guideIsWhite: getComputedStyle(currentGuide).backgroundColor === "rgb(255, 255, 255)",
|
||||
sameType: labelStyle.fontSize === statusStyle.fontSize
|
||||
&& labelStyle.fontWeight === statusStyle.fontWeight
|
||||
&& labelStyle.lineHeight === statusStyle.lineHeight,
|
||||
sameBaseline: Math.abs(label.getBoundingClientRect().y - status.getBoundingClientRect().y) < 0.1,
|
||||
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.detailVisible).toBe(true);
|
||||
expect(alignment.detailAfterAnalysis).toBe(true);
|
||||
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
|
||||
expect(alignment.guideRowHeight).toBeLessThanOrEqual(52);
|
||||
expect(alignment.guideIsWhite).toBe(true);
|
||||
expect(alignment.sameType).toBe(true);
|
||||
expect(alignment.sameBaseline).toBe(true);
|
||||
expect(alignment.noStatusOffset).toBe(true);
|
||||
expect(alignment.rangeAligned).toBe(true);
|
||||
|
||||
const pageFrames = {};
|
||||
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 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 只");
|
||||
|
||||
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 }) => {
|
||||
@@ -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("#themeMemberCount")).toHaveText("有行情 1 / 1");
|
||||
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 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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await mockApplication(page, session("user", true));
|
||||
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);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator("#globalSearchButton")).toBeVisible();
|
||||
await expect(page.locator('[data-view="limitPool"]')).toHaveClass(/mobile-active/);
|
||||
const mobileShell = await page.evaluate(() => {
|
||||
const header = document.querySelector(".topbar").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 expect(page.locator("#reviewHistoryPanel")).toBeVisible();
|
||||
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);
|
||||
const tradeScroll = await page.evaluate(() => {
|
||||
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();
|
||||
const darkMessageStyle = await answer.evaluate((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 {
|
||||
background: style.backgroundColor,
|
||||
border: style.borderTopColor,
|
||||
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.border).not.toBe("rgb(255, 255, 255)");
|
||||
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 }) => {
|
||||
|
||||
@@ -12,18 +12,20 @@ from screener import (
|
||||
FACTOR_GROUPS,
|
||||
ScreenerEngine,
|
||||
_broken_reversal_metrics,
|
||||
_earnings_event_rows,
|
||||
_popularity_factor_rows,
|
||||
_risk_flags,
|
||||
_rsi,
|
||||
_quarter_periods,
|
||||
)
|
||||
from server import automatic_screener_jobs
|
||||
from server import DashboardService, automatic_screener_jobs
|
||||
|
||||
|
||||
class CuratedScreenerTests(unittest.TestCase):
|
||||
def test_curated_library_contains_original_and_advanced_strategies(self):
|
||||
self.assertEqual(13, len(ADVANCED_CURATED_STRATEGIES))
|
||||
self.assertEqual(23, len(CURATED_STRATEGIES))
|
||||
self.assertEqual(23, len({item["name"] for item in CURATED_STRATEGIES}))
|
||||
self.assertEqual(19, len(ADVANCED_CURATED_STRATEGIES))
|
||||
self.assertEqual(29, len(CURATED_STRATEGIES))
|
||||
self.assertEqual(29, len({item["name"] for item in CURATED_STRATEGIES}))
|
||||
self.assertTrue(
|
||||
{"行业动量轮动", "主力资金行业流入"}.issubset(
|
||||
{item["name"] for item in CURATED_STRATEGIES}
|
||||
@@ -32,6 +34,16 @@ class CuratedScreenerTests(unittest.TestCase):
|
||||
self.assertTrue(
|
||||
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):
|
||||
for strategy in CURATED_STRATEGIES:
|
||||
@@ -85,6 +97,51 @@ class CuratedScreenerTests(unittest.TestCase):
|
||||
}
|
||||
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):
|
||||
grouped = [field for fields in FACTOR_GROUPS.values() for field in fields]
|
||||
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.assertIn("fundamental_indicators", 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):
|
||||
for strategy in ADVANCED_CURATED_STRATEGIES:
|
||||
@@ -327,6 +387,129 @@ class CuratedScreenerTests(unittest.TestCase):
|
||||
self.assertLess(laggard["net_flow_5d_million"], 0)
|
||||
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):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
database = ReviewDatabase(Path(root) / "review.db")
|
||||
|
||||
@@ -194,7 +194,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
|
||||
def test_stock_hover_preview_always_uses_latest_market_context(self):
|
||||
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]
|
||||
self.assertIn('const cacheKey = `${code}:latest`;', preview_loader)
|
||||
self.assertIn('/preview`', preview_loader)
|
||||
|
||||
@@ -4,6 +4,7 @@ import tempfile
|
||||
import unittest
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from chart_data_provider import EastmoneyChartClient, MarketChartClient
|
||||
from database import ReviewDatabase
|
||||
@@ -42,6 +43,45 @@ class FakeIfind:
|
||||
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:
|
||||
configured = True
|
||||
|
||||
@@ -93,6 +133,14 @@ class IfindFeatureTests(unittest.TestCase):
|
||||
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
|
||||
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):
|
||||
dashboard = {"broken": [{"code": "000001", "reason": "原原因"}]}
|
||||
DashboardService._merge_ifind_event_enrichment(
|
||||
|
||||
@@ -76,6 +76,10 @@ class FakeMarketClient:
|
||||
return []
|
||||
|
||||
|
||||
class ConfiguredIfind:
|
||||
configured = True
|
||||
|
||||
|
||||
class MarketInsightsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
@@ -170,6 +174,44 @@ class MarketInsightsTests(unittest.TestCase):
|
||||
self.assertFalse(payload["meta"]["carried_forward"])
|
||||
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):
|
||||
library = self.service.theme_library("20260724")
|
||||
self.assertEqual(library["meta"]["trade_date"], "2026-07-23")
|
||||
|
||||
@@ -53,6 +53,14 @@ class FixedMarketDatetime(datetime):
|
||||
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):
|
||||
def setUp(self):
|
||||
self.service = DashboardService.__new__(DashboardService)
|
||||
@@ -110,7 +118,48 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
||||
result = self.service._prepare_stock_detail(payload, "002141", historical)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user