Compare commits

..
Author SHA1 Message Date
总管andmultica-agent 89b8d33de7 fix(HEL-164): 排除构建中的数据库备份
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 11:32:21 +00:00
总管andmultica-agent d9ee725744 fix(HEL-164): 拒绝缓存不完整涨停快照
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 09:44:51 +00:00
总管andmultica-agent 1cb2745867 feat(HEL-164): 显示管理员刷新实际结果
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 09:29:06 +00:00
总管andmultica-agent f27471238a fix(HEL-164): 撤回盘后刷新改动并恢复原逻辑
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 09:05:42 +00:00
总工andmultica-agent 6d7a839202 chore(HEL-208): 刷新 architecture-inventory 以对齐 HEL-207 行情改动
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 08:09:49 +00:00
总工 fc1e5b89e4 merge(HEL-208): 集成收盘行情修复 cf206c7 到 .11 正式线(基于 09a935a) 2026-08-28 08:09:28 +00:00
cf206c7de9 fix(HEL-207): 收盘后改走日线,禁止误调 rt_k,回退旧快照记失败
将实时窗口与调度窗口统一到 15:05;盘后优先日线并补关闭盘后同步;沿用旧快照时 sync/job 记 failed。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 08:00:08 +00:00
11 changed files with 169 additions and 23 deletions
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
*.log
runtime/
data/cache/
data/backups/
data/private-mentor-skills/
data/*.db
data/*.db-shm
@@ -41,13 +41,16 @@ class DashboardMixin:
raise TushareError(f"No daily data returned for {trade_date}")
notices: list[str] = []
limit_data_source = "official"
try:
limit_rows = self._load_limit_lists(trade_date)
previous_limit_rows = self._load_limit_type(previous_trade_date, "U")
if not limit_rows:
limit_data_source = "derived"
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
limit_rows = self._derive_limits(trade_date, daily)
except TushareError as exc:
limit_data_source = "derived"
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
limit_rows = self._derive_limits(trade_date, daily)
previous_daily = self._load_daily(previous_trade_date)
@@ -79,6 +82,7 @@ class DashboardMixin:
"trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_trade_date),
"source": "tushare",
"limit_data_source": limit_data_source,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "".join(notices),
},
+5 -1
View File
@@ -198,6 +198,11 @@ class MarketServiceMixin:
raise TushareError("公共行情尚未配置")
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"]["requested_date"] = self._display_compact_date(normalized_date)
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
@@ -1163,4 +1168,3 @@ class MarketServiceMixin:
len(dashboard.get(key) or [])
for key in ("limits", "broken", "down_limits", "yesterday_limits")
)
+3 -1
View File
@@ -26,13 +26,15 @@ class SystemHttpMixin:
def start_background_refresh(self) -> None:
try:
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())
)
started = bool(refresh.get("started"))
self.send_json(
{
"ok": True,
"started": started,
"job_key": str(refresh.get("job_key") or ""),
"message": "后台刷新已开始" if started else "已有后台刷新任务正在运行",
},
HTTPStatus.ACCEPTED,
+14 -3
View File
@@ -7,6 +7,16 @@ from datetime import 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:
def start_background_jobs(self) -> threading.Thread:
return self.jobs.start_scheduler(
@@ -20,15 +30,16 @@ class JobServiceMixin:
workers_stopped = self.jobs.wait_for_idle(timeout_seconds)
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)
key = f"manual:{normalized}:{time.time_ns()}"
return self.jobs.submit(
started = self.jobs.submit(
"market.refresh",
key,
lambda: self.sync_dashboard(normalized),
lambda: _verified_dashboard_result(self.sync_dashboard(normalized)),
{"trade_date": normalized, "trigger": "administrator"},
)
return {"started": started, "job_key": key if started else ""}
def _background_refresh_tick(self) -> None:
if not (
+14 -14
View File
@@ -461,8 +461,8 @@
},
{
"path": "frontend/index.html",
"bytes": 47891,
"lines": 661
"bytes": 48077,
"lines": 662
},
{
"path": "backend/features/screener/catalog.py",
@@ -486,8 +486,8 @@
},
{
"path": "backend/data/providers/tushare_dashboard.py",
"bytes": 28051,
"lines": 644
"bytes": 28234,
"lines": 648
},
{
"path": "backend/data/providers/tushare_industries.py",
@@ -554,6 +554,11 @@
"bytes": 13176,
"lines": 293
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 12894,
"lines": 274
},
{
"path": "backend/features/market/insights_auction_data.py",
"bytes": 12829,
@@ -574,11 +579,6 @@
"bytes": 10539,
"lines": 244
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 9993,
"lines": 220
},
{
"path": "backend/data/providers/tushare_sectors.py",
"bytes": 9876,
@@ -769,6 +769,11 @@
"bytes": 2299,
"lines": 57
},
{
"path": "backend/jobs/service.py",
"bytes": 2219,
"lines": 60
},
{
"path": "backend/features/screener/regime.py",
"bytes": 2202,
@@ -804,11 +809,6 @@
"bytes": 1791,
"lines": 46
},
{
"path": "backend/jobs/service.py",
"bytes": 1746,
"lines": 49
},
{
"path": "backend/features/alerts/routes.py",
"bytes": 1687,
+1
View File
@@ -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="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
<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>
</form>
<section class="settings-section">
+26
View File
@@ -471,6 +471,32 @@
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 {
flex: 0 0 auto;
+58 -4
View File
@@ -40,17 +40,71 @@ async function loadDashboard(force = false, background = false, showOverlay = tr
async function startAdminRefresh() {
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
buttons.forEach((button) => { button.disabled = true; });
const requestedDate = elements.tradeDate.value;
setAdminRefreshStatus("running", `正在刷新 ${requestedDate} 的行情,请稍候…`, "loader-circle");
try {
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value });
showToast(payload.message || "后台刷新已提交");
setStatus("后台刷新运行中,当前页面保持不变");
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate });
if (!payload.started || !payload.job_key) {
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) {
showToast(error.message || "后台刷新启动失败");
const message = error.message || "后台刷新失败";
setAdminRefreshStatus("failure", `刷新失败:${message}`, "circle-x");
setStatus("后台刷新失败");
showToast(message);
} finally {
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) {
state.dashboard = payload;
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
+24
View File
@@ -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()
+19
View File
@@ -111,6 +111,25 @@ class RealtimeDashboardTests(unittest.TestCase):
self.assertEqual(quote["amount_billion"], 3.0)
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__":
unittest.main()