Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89b8d33de7 | ||
|
|
d9ee725744 | ||
|
|
1cb2745867 | ||
|
|
f27471238a |
@@ -9,6 +9,7 @@ __pycache__/
|
|||||||
*.log
|
*.log
|
||||||
runtime/
|
runtime/
|
||||||
data/cache/
|
data/cache/
|
||||||
|
data/backups/
|
||||||
data/private-mentor-skills/
|
data/private-mentor-skills/
|
||||||
data/*.db
|
data/*.db
|
||||||
data/*.db-shm
|
data/*.db-shm
|
||||||
|
|||||||
@@ -26,18 +26,31 @@ class DashboardMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
daily = self._load_daily(trade_date)
|
daily = self._load_daily(trade_date)
|
||||||
|
if (
|
||||||
|
not daily
|
||||||
|
and requested_date == datetime.now().astimezone().strftime("%Y%m%d")
|
||||||
|
and trade_date == requested_date
|
||||||
|
and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15)
|
||||||
|
):
|
||||||
|
return self._realtime_dashboard(
|
||||||
|
requested_date,
|
||||||
|
trade_date,
|
||||||
|
previous_trade_date,
|
||||||
|
)
|
||||||
if not daily:
|
if not daily:
|
||||||
# 15:05 后只走日线;日线未就绪时不得回退调用无权限的 rt_k。
|
|
||||||
raise TushareError(f"No daily data returned for {trade_date}")
|
raise TushareError(f"No daily data returned for {trade_date}")
|
||||||
|
|
||||||
notices: list[str] = []
|
notices: list[str] = []
|
||||||
|
limit_data_source = "official"
|
||||||
try:
|
try:
|
||||||
limit_rows = self._load_limit_lists(trade_date)
|
limit_rows = self._load_limit_lists(trade_date)
|
||||||
previous_limit_rows = self._load_limit_type(previous_trade_date, "U")
|
previous_limit_rows = self._load_limit_type(previous_trade_date, "U")
|
||||||
if not limit_rows:
|
if not limit_rows:
|
||||||
|
limit_data_source = "derived"
|
||||||
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
|
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
|
||||||
limit_rows = self._derive_limits(trade_date, daily)
|
limit_rows = self._derive_limits(trade_date, daily)
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
|
limit_data_source = "derived"
|
||||||
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
|
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
|
||||||
limit_rows = self._derive_limits(trade_date, daily)
|
limit_rows = self._derive_limits(trade_date, daily)
|
||||||
previous_daily = self._load_daily(previous_trade_date)
|
previous_daily = self._load_daily(previous_trade_date)
|
||||||
@@ -69,6 +82,7 @@ class DashboardMixin:
|
|||||||
"trade_date": _display_date(trade_date),
|
"trade_date": _display_date(trade_date),
|
||||||
"previous_trade_date": _display_date(previous_trade_date),
|
"previous_trade_date": _display_date(previous_trade_date),
|
||||||
"source": "tushare",
|
"source": "tushare",
|
||||||
|
"limit_data_source": limit_data_source,
|
||||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
"notice": ";".join(notices),
|
"notice": ";".join(notices),
|
||||||
},
|
},
|
||||||
@@ -86,13 +100,13 @@ class DashboardMixin:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def should_use_realtime(requested_date: str, trade_date: str) -> bool:
|
def should_use_realtime(requested_date: str, trade_date: str) -> bool:
|
||||||
"""Use rt_k only inside the intraday window; 15:05+ must use daily bars."""
|
"""Use rt_k for today's open market until end-of-day datasets settle."""
|
||||||
now = datetime.now().astimezone()
|
now = datetime.now().astimezone()
|
||||||
today = now.strftime("%Y%m%d")
|
today = now.strftime("%Y%m%d")
|
||||||
return (
|
return (
|
||||||
requested_date == today
|
requested_date == today
|
||||||
and trade_date == today
|
and trade_date == today
|
||||||
and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(15, 5)
|
and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _realtime_dashboard(
|
def _realtime_dashboard(
|
||||||
|
|||||||
@@ -188,30 +188,6 @@ class MarketServiceMixin:
|
|||||||
age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
|
age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
|
||||||
return age_seconds >= 8
|
return age_seconds >= 8
|
||||||
|
|
||||||
def _closing_snapshot_due(
|
|
||||||
self,
|
|
||||||
normalized_date: str,
|
|
||||||
snapshot: dict[str, Any],
|
|
||||||
) -> bool:
|
|
||||||
"""After 15:05, keep requesting daily bars until today's EOD snapshot exists."""
|
|
||||||
if not self.configured or normalized_date != date.today().strftime("%Y%m%d"):
|
|
||||||
return False
|
|
||||||
now = datetime.now().astimezone()
|
|
||||||
if now.weekday() >= 5:
|
|
||||||
return False
|
|
||||||
local_time = now.time().replace(tzinfo=None)
|
|
||||||
if local_time < datetime.strptime("15:05", "%H:%M").time():
|
|
||||||
return False
|
|
||||||
meta = snapshot.get("meta") or {}
|
|
||||||
snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "")
|
|
||||||
if (
|
|
||||||
snapshot_trade_date == normalized_date
|
|
||||||
and not meta.get("realtime")
|
|
||||||
and not meta.get("carried_forward")
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sync_dashboard(self, trade_date: str) -> dict[str, Any]:
|
def sync_dashboard(self, trade_date: str) -> dict[str, Any]:
|
||||||
normalized_date = normalize_date(trade_date)
|
normalized_date = normalize_date(trade_date)
|
||||||
source = "tushare"
|
source = "tushare"
|
||||||
@@ -222,6 +198,11 @@ class MarketServiceMixin:
|
|||||||
raise TushareError("公共行情尚未配置")
|
raise TushareError("公共行情尚未配置")
|
||||||
dashboard = self._tushare_client().dashboard(normalized_date)
|
dashboard = self._tushare_client().dashboard(normalized_date)
|
||||||
|
|
||||||
|
if (dashboard.get("meta") or {}).get("limit_data_source") == "derived":
|
||||||
|
raise TushareError(
|
||||||
|
str((dashboard.get("meta") or {}).get("notice") or "官方涨跌停数据尚未返回")
|
||||||
|
)
|
||||||
|
|
||||||
dashboard["meta"]["source"] = source
|
dashboard["meta"]["source"] = source
|
||||||
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
|
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
|
||||||
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
|
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
|
||||||
@@ -256,15 +237,9 @@ class MarketServiceMixin:
|
|||||||
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}"
|
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}"
|
||||||
)
|
)
|
||||||
self.database.finish_sync(
|
self.database.finish_sync(
|
||||||
sync_id, "failed", self._record_count(carried), str(exc), "tushare"
|
sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
|
||||||
)
|
)
|
||||||
result = self._apply_reason_overrides(
|
return self._apply_reason_overrides(self._with_storage(carried, cached=True))
|
||||||
self._with_storage(carried, cached=True)
|
|
||||||
)
|
|
||||||
# 页面仍可读到沿用快照;后台任务通过顶层 status=failed 记失败。
|
|
||||||
result["status"] = "failed"
|
|
||||||
result["error"] = str(exc)
|
|
||||||
return result
|
|
||||||
self.database.finish_sync(sync_id, "failed", message=str(exc))
|
self.database.finish_sync(sync_id, "failed", message=str(exc))
|
||||||
raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc
|
raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1193,4 +1168,3 @@ class MarketServiceMixin:
|
|||||||
len(dashboard.get(key) or [])
|
len(dashboard.get(key) or [])
|
||||||
for key in ("limits", "broken", "down_limits", "yesterday_limits")
|
for key in ("limits", "broken", "down_limits", "yesterday_limits")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,15 @@ class SystemHttpMixin:
|
|||||||
def start_background_refresh(self) -> None:
|
def start_background_refresh(self) -> None:
|
||||||
try:
|
try:
|
||||||
body = self.read_json_body(allow_empty=True)
|
body = self.read_json_body(allow_empty=True)
|
||||||
started = self.application_service.request_background_sync(
|
refresh = self.application_service.request_background_sync(
|
||||||
str(body.get("trade_date") or date.today().isoformat())
|
str(body.get("trade_date") or date.today().isoformat())
|
||||||
)
|
)
|
||||||
|
started = bool(refresh.get("started"))
|
||||||
self.send_json(
|
self.send_json(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"started": started,
|
"started": started,
|
||||||
|
"job_key": str(refresh.get("job_key") or ""),
|
||||||
"message": "后台刷新已开始" if started else "已有后台刷新任务正在运行",
|
"message": "后台刷新已开始" if started else "已有后台刷新任务正在运行",
|
||||||
},
|
},
|
||||||
HTTPStatus.ACCEPTED,
|
HTTPStatus.ACCEPTED,
|
||||||
|
|||||||
+14
-12
@@ -7,6 +7,16 @@ from datetime import date
|
|||||||
from backend.bootstrap.config import normalize_date
|
from backend.bootstrap.config import normalize_date
|
||||||
|
|
||||||
|
|
||||||
|
def _verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]:
|
||||||
|
meta = dashboard.get("meta") or {}
|
||||||
|
if isinstance(meta, dict) and meta.get("carried_forward"):
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": str(meta.get("notice") or "未获取到所选日期的最新行情"),
|
||||||
|
}
|
||||||
|
return dashboard
|
||||||
|
|
||||||
|
|
||||||
class JobServiceMixin:
|
class JobServiceMixin:
|
||||||
def start_background_jobs(self) -> threading.Thread:
|
def start_background_jobs(self) -> threading.Thread:
|
||||||
return self.jobs.start_scheduler(
|
return self.jobs.start_scheduler(
|
||||||
@@ -20,15 +30,16 @@ class JobServiceMixin:
|
|||||||
workers_stopped = self.jobs.wait_for_idle(timeout_seconds)
|
workers_stopped = self.jobs.wait_for_idle(timeout_seconds)
|
||||||
return scheduler_stopped and workers_stopped
|
return scheduler_stopped and workers_stopped
|
||||||
|
|
||||||
def request_background_sync(self, trade_date: str) -> bool:
|
def request_background_sync(self, trade_date: str) -> dict[str, object]:
|
||||||
normalized = normalize_date(trade_date)
|
normalized = normalize_date(trade_date)
|
||||||
key = f"manual:{normalized}:{time.time_ns()}"
|
key = f"manual:{normalized}:{time.time_ns()}"
|
||||||
return self.jobs.submit(
|
started = self.jobs.submit(
|
||||||
"market.refresh",
|
"market.refresh",
|
||||||
key,
|
key,
|
||||||
lambda: self.sync_dashboard(normalized),
|
lambda: _verified_dashboard_result(self.sync_dashboard(normalized)),
|
||||||
{"trade_date": normalized, "trigger": "administrator"},
|
{"trade_date": normalized, "trigger": "administrator"},
|
||||||
)
|
)
|
||||||
|
return {"started": started, "job_key": key if started else ""}
|
||||||
|
|
||||||
def _background_refresh_tick(self) -> None:
|
def _background_refresh_tick(self) -> None:
|
||||||
if not (
|
if not (
|
||||||
@@ -46,13 +57,4 @@ class JobServiceMixin:
|
|||||||
lambda: self.sync_dashboard(today),
|
lambda: self.sync_dashboard(today),
|
||||||
{"trade_date": today, "trigger": "realtime-poll"},
|
{"trade_date": today, "trigger": "realtime-poll"},
|
||||||
)
|
)
|
||||||
elif self._closing_snapshot_due(today, snapshot):
|
|
||||||
# 15:05 后改走日线生成当日快照;按分钟去重,避免日线未就绪时刷爆任务。
|
|
||||||
bucket = int(time.time() // 60)
|
|
||||||
self.jobs.submit(
|
|
||||||
"market.refresh",
|
|
||||||
f"closing:{today}:{bucket}",
|
|
||||||
lambda: self.sync_dashboard(today),
|
|
||||||
{"trade_date": today, "trigger": "post-close"},
|
|
||||||
)
|
|
||||||
self._schedule_automatic_screeners(today, snapshot)
|
self._schedule_automatic_screeners(today, snapshot)
|
||||||
|
|||||||
@@ -461,8 +461,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/index.html",
|
"path": "frontend/index.html",
|
||||||
"bytes": 47891,
|
"bytes": 48077,
|
||||||
"lines": 661
|
"lines": 662
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/screener/catalog.py",
|
"path": "backend/features/screener/catalog.py",
|
||||||
@@ -486,8 +486,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_dashboard.py",
|
"path": "backend/data/providers/tushare_dashboard.py",
|
||||||
"bytes": 27730,
|
"bytes": 28234,
|
||||||
"lines": 634
|
"lines": 648
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_industries.py",
|
"path": "backend/data/providers/tushare_industries.py",
|
||||||
@@ -554,6 +554,11 @@
|
|||||||
"bytes": 13176,
|
"bytes": 13176,
|
||||||
"lines": 293
|
"lines": 293
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/shared/dashboard.js",
|
||||||
|
"bytes": 12894,
|
||||||
|
"lines": 274
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_auction_data.py",
|
"path": "backend/features/market/insights_auction_data.py",
|
||||||
"bytes": 12829,
|
"bytes": 12829,
|
||||||
@@ -574,11 +579,6 @@
|
|||||||
"bytes": 10539,
|
"bytes": 10539,
|
||||||
"lines": 244
|
"lines": 244
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/shared/dashboard.js",
|
|
||||||
"bytes": 9993,
|
|
||||||
"lines": 220
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_sectors.py",
|
"path": "backend/data/providers/tushare_sectors.py",
|
||||||
"bytes": 9876,
|
"bytes": 9876,
|
||||||
@@ -769,16 +769,16 @@
|
|||||||
"bytes": 2299,
|
"bytes": 2299,
|
||||||
"lines": 57
|
"lines": 57
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/jobs/service.py",
|
||||||
|
"bytes": 2219,
|
||||||
|
"lines": 60
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/screener/regime.py",
|
"path": "backend/features/screener/regime.py",
|
||||||
"bytes": 2202,
|
"bytes": 2202,
|
||||||
"lines": 53
|
"lines": 53
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/jobs/service.py",
|
|
||||||
"bytes": 2201,
|
|
||||||
"lines": 58
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_client.py",
|
"path": "backend/data/providers/tushare_client.py",
|
||||||
"bytes": 2166,
|
"bytes": 2166,
|
||||||
|
|||||||
@@ -609,6 +609,7 @@
|
|||||||
<label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label>
|
<label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label>
|
||||||
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
|
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
|
||||||
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
|
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
|
||||||
|
<div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div>
|
||||||
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配置</button></div>
|
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配置</button></div>
|
||||||
</form>
|
</form>
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
|
|||||||
@@ -471,6 +471,32 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-refresh-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-refresh-status svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-refresh-status[data-tone="running"] { color: var(--primary); }
|
||||||
|
.admin-refresh-status[data-tone="success"] { color: var(--down); }
|
||||||
|
.admin-refresh-status[data-tone="warning"] { color: var(--warning); }
|
||||||
|
.admin-refresh-status[data-tone="failure"] { color: var(--up); }
|
||||||
|
|
||||||
.account-button > span {
|
.account-button > span {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
|||||||
@@ -40,17 +40,71 @@ async function loadDashboard(force = false, background = false, showOverlay = tr
|
|||||||
async function startAdminRefresh() {
|
async function startAdminRefresh() {
|
||||||
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
|
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
|
||||||
buttons.forEach((button) => { button.disabled = true; });
|
buttons.forEach((button) => { button.disabled = true; });
|
||||||
|
const requestedDate = elements.tradeDate.value;
|
||||||
|
setAdminRefreshStatus("running", `正在刷新 ${requestedDate} 的行情,请稍候…`, "loader-circle");
|
||||||
try {
|
try {
|
||||||
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value });
|
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate });
|
||||||
showToast(payload.message || "后台刷新已提交");
|
if (!payload.started || !payload.job_key) {
|
||||||
setStatus("后台刷新运行中,当前页面保持不变");
|
setAdminRefreshStatus("warning", "已有刷新任务正在运行,请稍后再试。", "clock-3");
|
||||||
|
showToast(payload.message || "已有后台刷新任务正在运行");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(`正在刷新 ${requestedDate} 的行情`);
|
||||||
|
const job = await waitForAdminRefresh(payload.job_key);
|
||||||
|
if (job.status === "failed") {
|
||||||
|
const reason = job.message || job.error_code || "数据源未返回结果";
|
||||||
|
setAdminRefreshStatus("failure", `刷新失败:${reason}`, "circle-x");
|
||||||
|
setStatus("后台刷新失败");
|
||||||
|
showToast("后台刷新失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams({ trade_date: requestedDate });
|
||||||
|
const dashboard = await apiRequest(`/api/dashboard?${query}`);
|
||||||
|
applyDashboard(dashboard);
|
||||||
|
const meta = dashboard.meta || {};
|
||||||
|
const actualDate = String(meta.trade_date || "").slice(0, 10);
|
||||||
|
const requestedCompact = requestedDate.replaceAll("-", "");
|
||||||
|
const actualCompact = actualDate.replaceAll("-", "");
|
||||||
|
const updated = formatTimestamp(meta.updated_at);
|
||||||
|
if (actualCompact !== requestedCompact || meta.carried_forward) {
|
||||||
|
const reason = meta.notice ? `;${meta.notice}` : "";
|
||||||
|
setAdminRefreshStatus("warning", `刷新已完成,但没有获取到 ${requestedDate} 的最新行情;当前仍是 ${actualDate || "未知日期"}${reason}`, "triangle-alert");
|
||||||
|
showToast("刷新完成,但未获取到所选日期的最新行情");
|
||||||
|
} else if (meta.notice) {
|
||||||
|
setAdminRefreshStatus("warning", `已刷新到 ${actualDate}(${updated}),但数据源提示:${meta.notice}`, "triangle-alert");
|
||||||
|
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
|
||||||
|
} else {
|
||||||
|
setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的最新行情,更新时间 ${updated}`, "circle-check");
|
||||||
|
showToast(`刷新成功:已获取 ${actualDate} 的最新行情`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message || "后台刷新启动失败");
|
const message = error.message || "后台刷新失败";
|
||||||
|
setAdminRefreshStatus("failure", `刷新失败:${message}`, "circle-x");
|
||||||
|
setStatus("后台刷新失败");
|
||||||
|
showToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
buttons.forEach((button) => { button.disabled = false; });
|
buttons.forEach((button) => { button.disabled = false; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAdminRefreshStatus(tone, message, icon = "circle-dot") {
|
||||||
|
const status = document.querySelector("#adminRefreshStatus");
|
||||||
|
if (!status) return;
|
||||||
|
status.dataset.tone = tone;
|
||||||
|
status.innerHTML = `<i data-lucide="${icon}"></i><span>${escapeHtml(message)}</span>`;
|
||||||
|
refreshIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAdminRefresh(jobKey) {
|
||||||
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||||
|
const payload = await apiRequest("/api/admin/settings");
|
||||||
|
const job = (payload.data?.jobs || []).find((item) => item.idempotency_key === jobKey);
|
||||||
|
if (job && ["success", "failed"].includes(job.status)) return job;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
throw new Error("刷新等待超时,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
function applyDashboard(payload, background = false) {
|
function applyDashboard(payload, background = false) {
|
||||||
state.dashboard = payload;
|
state.dashboard = payload;
|
||||||
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
|
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from backend.jobs.service import _verified_dashboard_result
|
||||||
|
|
||||||
|
|
||||||
|
class AdminRefreshStatusTests(unittest.TestCase):
|
||||||
|
def test_carried_snapshot_is_reported_as_failed_job(self):
|
||||||
|
result = _verified_dashboard_result(
|
||||||
|
{"meta": {"carried_forward": True, "notice": "官方涨跌停数据尚未返回"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "failed")
|
||||||
|
self.assertEqual(result["error"], "官方涨跌停数据尚未返回")
|
||||||
|
|
||||||
|
def test_current_snapshot_is_reported_as_successful_job(self):
|
||||||
|
dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}}
|
||||||
|
|
||||||
|
self.assertIs(_verified_dashboard_result(dashboard), dashboard)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from backend.data.providers.tushare_client import TushareClient
|
|
||||||
from backend.data.providers.tushare_transport import TushareError
|
|
||||||
from server import DashboardService
|
|
||||||
|
|
||||||
|
|
||||||
class FixedDatetime(datetime):
|
|
||||||
fixed_now = datetime(2026, 8, 28, 15, 4).astimezone()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def now(cls, tz=None):
|
|
||||||
return cls.fixed_now
|
|
||||||
|
|
||||||
|
|
||||||
class WindowClient(TushareClient):
|
|
||||||
def __init__(self, token: str = "test-token"):
|
|
||||||
super().__init__(token)
|
|
||||||
self.calls: list[str] = []
|
|
||||||
self.daily_rows: list[dict] = []
|
|
||||||
self.rt_k_error: Exception | None = None
|
|
||||||
|
|
||||||
def query(self, api_name, params=None, fields=""):
|
|
||||||
self.calls.append(api_name)
|
|
||||||
params = params or {}
|
|
||||||
if api_name == "trade_cal":
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"cal_date": "20260828",
|
|
||||||
"is_open": 1,
|
|
||||||
"pretrade_date": "20260827",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if api_name == "daily":
|
|
||||||
return list(self.daily_rows)
|
|
||||||
if api_name == "rt_k":
|
|
||||||
if self.rt_k_error is not None:
|
|
||||||
raise self.rt_k_error
|
|
||||||
raise AssertionError("rt_k should not be called in this scenario")
|
|
||||||
if api_name in {"limit_list_d", "stock_basic", "stk_limit", "daily_basic"}:
|
|
||||||
return []
|
|
||||||
raise AssertionError(f"Unexpected API call: {api_name} {params}")
|
|
||||||
|
|
||||||
def resolve_trade_context(self, requested_date: str):
|
|
||||||
return requested_date, "20260827"
|
|
||||||
|
|
||||||
|
|
||||||
class SyncDatabaseStub:
|
|
||||||
def __init__(self, latest=None):
|
|
||||||
self.latest = latest
|
|
||||||
self.snapshots: dict[str, dict] = {}
|
|
||||||
self.sync_runs: list[dict] = []
|
|
||||||
self._sync_id = 0
|
|
||||||
|
|
||||||
def start_sync(self, trade_date: str, source: str) -> int:
|
|
||||||
self._sync_id += 1
|
|
||||||
self.sync_runs.append(
|
|
||||||
{
|
|
||||||
"id": self._sync_id,
|
|
||||||
"trade_date": trade_date,
|
|
||||||
"source": source,
|
|
||||||
"status": "running",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return self._sync_id
|
|
||||||
|
|
||||||
def finish_sync(
|
|
||||||
self,
|
|
||||||
sync_id: int,
|
|
||||||
status: str,
|
|
||||||
record_count: int = 0,
|
|
||||||
message: str = "",
|
|
||||||
source: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
for row in self.sync_runs:
|
|
||||||
if row["id"] == sync_id:
|
|
||||||
row.update(
|
|
||||||
{
|
|
||||||
"status": status,
|
|
||||||
"record_count": record_count,
|
|
||||||
"message": message,
|
|
||||||
"source": source or row["source"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
raise AssertionError(f"unknown sync_id {sync_id}")
|
|
||||||
|
|
||||||
def save_snapshot(self, trade_date: str, source: str, payload: dict) -> None:
|
|
||||||
self.snapshots[trade_date] = {"source": source, "payload": payload}
|
|
||||||
|
|
||||||
def save_data_snapshot(self, kind: str, cache_key: str, source: str, payload: dict) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_latest_real_snapshot(self, _trade_date: str, strictly_before: bool = False):
|
|
||||||
return self.latest
|
|
||||||
|
|
||||||
def reason_overrides(self, _trade_date: str):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardRefreshWindowTests(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
TushareClient._realtime_reference_cache.clear()
|
|
||||||
TushareClient._capital_cache.clear()
|
|
||||||
TushareClient._latest_realtime_market.clear()
|
|
||||||
TushareClient._stock_activity_cache.clear()
|
|
||||||
|
|
||||||
def _service(self, client: WindowClient, latest=None) -> DashboardService:
|
|
||||||
service = object.__new__(DashboardService)
|
|
||||||
service._system_credentials = {"tushare_token": "test-token"}
|
|
||||||
service.sync_lock = threading.Lock()
|
|
||||||
service.database = SyncDatabaseStub(latest=latest)
|
|
||||||
service.data_gateway = None
|
|
||||||
service._tushare_client = lambda: client
|
|
||||||
service._enrich_dashboard_sentiment = lambda dashboard, _date: dashboard
|
|
||||||
service._apply_reason_overrides = lambda dashboard: dashboard
|
|
||||||
return service
|
|
||||||
|
|
||||||
def test_should_use_realtime_at_1504(self) -> None:
|
|
||||||
FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 4).astimezone()
|
|
||||||
with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime):
|
|
||||||
self.assertTrue(TushareClient.should_use_realtime("20260828", "20260828"))
|
|
||||||
|
|
||||||
def test_should_use_daily_at_1505(self) -> None:
|
|
||||||
FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 5).astimezone()
|
|
||||||
with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime):
|
|
||||||
self.assertFalse(TushareClient.should_use_realtime("20260828", "20260828"))
|
|
||||||
|
|
||||||
def test_after_close_empty_daily_does_not_call_rt_k(self) -> None:
|
|
||||||
FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone()
|
|
||||||
client = WindowClient()
|
|
||||||
client.daily_rows = []
|
|
||||||
with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime):
|
|
||||||
with self.assertRaises(TushareError):
|
|
||||||
client.dashboard("20260828")
|
|
||||||
self.assertIn("daily", client.calls)
|
|
||||||
self.assertNotIn("rt_k", client.calls)
|
|
||||||
|
|
||||||
def test_after_close_uses_daily_when_ready(self) -> None:
|
|
||||||
FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone()
|
|
||||||
client = WindowClient()
|
|
||||||
client.daily_rows = [
|
|
||||||
{
|
|
||||||
"ts_code": "000001.SZ",
|
|
||||||
"trade_date": "20260828",
|
|
||||||
"open": 10,
|
|
||||||
"high": 11,
|
|
||||||
"low": 9.5,
|
|
||||||
"close": 10.5,
|
|
||||||
"pre_close": 10,
|
|
||||||
"pct_chg": 5,
|
|
||||||
"vol": 1000,
|
|
||||||
"amount": 1_000_000,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def load_limit_lists(_trade_date):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def load_limit_type(_trade_date, _limit_type):
|
|
||||||
return []
|
|
||||||
|
|
||||||
client._load_limit_lists = load_limit_lists # type: ignore[method-assign]
|
|
||||||
client._load_limit_type = load_limit_type # type: ignore[method-assign]
|
|
||||||
client._derive_limits = lambda *args, **kwargs: [] # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime):
|
|
||||||
dashboard = client.dashboard("20260828")
|
|
||||||
|
|
||||||
self.assertIn("daily", client.calls)
|
|
||||||
self.assertNotIn("rt_k", client.calls)
|
|
||||||
self.assertFalse(dashboard["meta"].get("realtime"))
|
|
||||||
self.assertEqual(dashboard["meta"]["trade_date"], "2026-08-28")
|
|
||||||
|
|
||||||
def test_fallback_old_snapshot_marks_sync_and_job_status_failed(self) -> None:
|
|
||||||
FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone()
|
|
||||||
client = WindowClient()
|
|
||||||
client.daily_rows = []
|
|
||||||
latest = {
|
|
||||||
"meta": {"source": "tushare", "trade_date": "2026-08-27"},
|
|
||||||
"overview": {},
|
|
||||||
"limits": [],
|
|
||||||
"broken": [],
|
|
||||||
"down_limits": [],
|
|
||||||
"yesterday_limits": [],
|
|
||||||
}
|
|
||||||
service = self._service(client, latest=latest)
|
|
||||||
|
|
||||||
with patch("backend.data.providers.tushare_dashboard.datetime", FixedDatetime):
|
|
||||||
result = service.sync_dashboard("20260828")
|
|
||||||
|
|
||||||
self.assertEqual(result["status"], "failed")
|
|
||||||
self.assertTrue(result["meta"]["carried_forward"])
|
|
||||||
self.assertEqual(result["meta"]["trade_date"], "2026-08-27")
|
|
||||||
self.assertEqual(service.database.sync_runs[-1]["status"], "failed")
|
|
||||||
self.assertNotIn("rt_k", client.calls)
|
|
||||||
|
|
||||||
def test_closing_snapshot_due_after_1505_when_today_missing(self) -> None:
|
|
||||||
FixedDatetime.fixed_now = datetime(2026, 8, 28, 15, 49).astimezone()
|
|
||||||
service = object.__new__(DashboardService)
|
|
||||||
service._system_credentials = {"tushare_token": "test-token"}
|
|
||||||
with patch("backend.features.market.service.datetime", FixedDatetime), patch(
|
|
||||||
"backend.features.market.service.date"
|
|
||||||
) as fake_date:
|
|
||||||
fake_date.today.return_value = FixedDatetime.fixed_now.date()
|
|
||||||
self.assertTrue(service._closing_snapshot_due("20260828", {}))
|
|
||||||
self.assertFalse(
|
|
||||||
service._closing_snapshot_due(
|
|
||||||
"20260828",
|
|
||||||
{
|
|
||||||
"meta": {
|
|
||||||
"trade_date": "2026-08-28",
|
|
||||||
"realtime": False,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -111,6 +111,25 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
self.assertEqual(quote["amount_billion"], 3.0)
|
self.assertEqual(quote["amount_billion"], 3.0)
|
||||||
self.assertAlmostEqual(quote["turnover_rate"], 0.01)
|
self.assertAlmostEqual(quote["turnover_rate"], 0.01)
|
||||||
|
|
||||||
|
def test_close_dashboard_marks_official_limit_data(self):
|
||||||
|
dashboard = self.client.dashboard("20260720")
|
||||||
|
|
||||||
|
self.assertEqual(dashboard["meta"]["limit_data_source"], "official")
|
||||||
|
|
||||||
|
def test_close_dashboard_marks_derived_limit_data_as_incomplete(self):
|
||||||
|
original_query = self.client.query
|
||||||
|
|
||||||
|
def query(api_name, params=None, fields=""):
|
||||||
|
if api_name == "limit_list_d":
|
||||||
|
return []
|
||||||
|
return original_query(api_name, params, fields)
|
||||||
|
|
||||||
|
self.client.query = query
|
||||||
|
dashboard = self.client.dashboard("20260720")
|
||||||
|
|
||||||
|
self.assertEqual(dashboard["meta"]["limit_data_source"], "derived")
|
||||||
|
self.assertIn("日线数据推算", dashboard["meta"]["notice"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user