${escapeHtml(item.content)}
` : ""} + ${item.code ? `` : ""} +diff --git a/alert_service.py b/alert_service.py
new file mode 100644
index 0000000..fc36b47
--- /dev/null
+++ b/alert_service.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import secrets
+from datetime import date, datetime
+from typing import Any
+
+from app_config import validate_text
+from database import ReviewDatabase
+
+
+class AlertService:
+ def __init__(self, database: ReviewDatabase) -> None:
+ self.database = database
+
+ def create_manual(self, user_id: int, payload: dict[str, Any]) -> int:
+ title = validate_text(payload.get("title"), "提醒标题", 80, required=True)
+ content = validate_text(payload.get("content"), "提醒内容", 500)
+ code = validate_text(payload.get("code"), "股票代码", 12)
+ available_date = self.calendar_date(
+ str(payload.get("remind_date") or date.today().isoformat())
+ )
+ return self.database.save_alert(
+ user_id=user_id,
+ kind="manual",
+ title=title,
+ content=content,
+ available_date=available_date,
+ code=code,
+ dedupe_key=f"manual:{secrets.token_hex(12)}",
+ )
+
+ def sync_strategy_tracking(self, user_id: int, tracking: dict[str, Any]) -> int:
+ synced = 0
+ today = date.today().strftime("%Y%m%d")
+ for batch in tracking.get("batches") or []:
+ items = batch.get("items") or []
+ summary = batch.get("summary") or {}
+ if not items:
+ continue
+ run_id = int(batch.get("run_id") or 0)
+ strategy_name = str(batch.get("strategy_name") or "选股策略")
+ observed = int(summary.get("observed") or 0)
+ completed = int(summary.get("completed") or 0)
+ if observed:
+ win_rate = summary.get("t1_win_rate")
+ suffix = f",当前红盘率 {win_rate:.1f}%" if win_rate is not None else ""
+ self.database.save_alert(
+ user_id, "strategy_t1", f"{strategy_name} 已有 T+1 反馈",
+ f"{observed}/{len(items)} 只标的已有首日表现{suffix}。",
+ today, "", f"strategy:{run_id}:t1",
+ )
+ synced += 1
+ if completed == len(items):
+ average = summary.get("average_t5")
+ suffix = f",平均收益 {average:+.2f}%" if average is not None else ""
+ self.database.save_alert(
+ user_id, "strategy_t5", f"{strategy_name} 五日跟踪完成",
+ f"本批 {len(items)} 只标的已完成 T+5 跟踪{suffix}。",
+ today, "", f"strategy:{run_id}:t5",
+ )
+ synced += 1
+ return synced
+
+ def list_alerts(
+ self, user_id: int, status: str = "all", as_of: str = ""
+ ) -> dict[str, Any]:
+ if status not in {"all", "unread"}:
+ raise ValueError("提醒筛选不支持。")
+ compact_date = self.calendar_date(as_of or date.today().isoformat())
+ items = self.database.list_alerts(user_id, compact_date, status == "unread")
+ for item in items:
+ item["due"] = str(item.get("available_date") or "") <= compact_date
+ return {
+ "items": items,
+ "unread_count": self.database.count_unread_alerts(user_id, compact_date),
+ "as_of": compact_date,
+ }
+
+ @staticmethod
+ def calendar_date(value: str) -> str:
+ compact = value.replace("-", "").strip()
+ try:
+ parsed = datetime.strptime(compact, "%Y%m%d")
+ except ValueError as exc:
+ raise ValueError("提醒日期格式应为 YYYY-MM-DD。") from exc
+ return parsed.strftime("%Y%m%d")
diff --git a/database.py b/database.py
index e84a2fa..6975547 100644
--- a/database.py
+++ b/database.py
@@ -277,6 +277,26 @@ class ReviewDatabase:
CREATE INDEX IF NOT EXISTS idx_strategy_tracks_user_run
ON strategy_tracks(user_id, run_id DESC, id);
+
+ CREATE TABLE IF NOT EXISTS alerts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL DEFAULT '',
+ available_date TEXT NOT NULL,
+ code TEXT NOT NULL DEFAULT '',
+ dedupe_key TEXT NOT NULL,
+ is_read INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ read_at TEXT,
+ UNIQUE(user_id, dedupe_key),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_alerts_user_due
+ ON alerts(user_id, available_date, is_read, id DESC);
"""
)
user_columns = {
@@ -1426,6 +1446,110 @@ class ReviewDatabase:
for ts_code, selection_date in unique_targets
}
+ def save_alert(
+ self,
+ user_id: int,
+ kind: str,
+ title: str,
+ content: str,
+ available_date: str,
+ code: str,
+ dedupe_key: str,
+ ) -> int:
+ now = datetime.now().astimezone().isoformat(timespec="seconds")
+ with self.connect() as connection:
+ connection.execute(
+ """
+ INSERT INTO alerts
+ (user_id, kind, title, content, available_date, code, dedupe_key,
+ is_read, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
+ ON CONFLICT(user_id, dedupe_key) DO UPDATE SET
+ title=excluded.title, content=excluded.content,
+ available_date=excluded.available_date, updated_at=excluded.updated_at
+ """,
+ (
+ int(user_id), kind, title, content, available_date, code,
+ dedupe_key, now, now,
+ ),
+ )
+ row = connection.execute(
+ "SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
+ (int(user_id), dedupe_key),
+ ).fetchone()
+ return int(row["id"])
+
+ def list_alerts(
+ self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100
+ ) -> list[dict[str, Any]]:
+ with self.connect() as connection:
+ if unread_only:
+ rows = connection.execute(
+ """
+ SELECT id, kind, title, content, available_date, code, is_read,
+ created_at, updated_at, read_at
+ FROM alerts
+ WHERE user_id = ? AND available_date <= ? AND is_read = 0
+ ORDER BY available_date DESC, id DESC LIMIT ?
+ """,
+ (int(user_id), as_of, max(1, min(300, int(limit)))),
+ ).fetchall()
+ else:
+ rows = connection.execute(
+ """
+ SELECT id, kind, title, content, available_date, code, is_read,
+ created_at, updated_at, read_at
+ FROM alerts WHERE user_id = ?
+ ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END,
+ is_read, available_date, id DESC LIMIT ?
+ """,
+ (int(user_id), as_of, max(1, min(300, int(limit)))),
+ ).fetchall()
+ return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows]
+
+ def count_unread_alerts(self, user_id: int, as_of: str) -> int:
+ with self.connect() as connection:
+ row = connection.execute(
+ """
+ SELECT COUNT(*) AS total FROM alerts
+ WHERE user_id = ? AND available_date <= ? AND is_read = 0
+ """,
+ (int(user_id), as_of),
+ ).fetchone()
+ return int(row["total"] if row else 0)
+
+ def mark_alert_read(self, user_id: int, alert_id: int) -> bool:
+ now = datetime.now().astimezone().isoformat(timespec="seconds")
+ with self.connect() as connection:
+ cursor = connection.execute(
+ """
+ UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
+ WHERE id = ? AND user_id = ?
+ """,
+ (now, now, int(alert_id), int(user_id)),
+ )
+ return cursor.rowcount > 0
+
+ def mark_all_alerts_read(self, user_id: int, as_of: str) -> int:
+ now = datetime.now().astimezone().isoformat(timespec="seconds")
+ with self.connect() as connection:
+ cursor = connection.execute(
+ """
+ UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
+ WHERE user_id = ? AND available_date <= ? AND is_read = 0
+ """,
+ (now, now, int(user_id), as_of),
+ )
+ return int(cursor.rowcount)
+
+ def delete_alert(self, user_id: int, alert_id: int) -> bool:
+ with self.connect() as connection:
+ cursor = connection.execute(
+ "DELETE FROM alerts WHERE id = ? AND user_id = ?",
+ (int(alert_id), int(user_id)),
+ )
+ return cursor.rowcount > 0
+
def start_sync(self, trade_date: str, source: str) -> int:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
diff --git a/server.py b/server.py
index a2359e2..50fd984 100644
--- a/server.py
+++ b/server.py
@@ -15,6 +15,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
+from alert_service import AlertService
from api_access import required_role
from app_config import (
DATA_DIR,
@@ -129,6 +130,7 @@ class DashboardService:
self._system_credentials = self._load_system_credentials(environment_credentials)
self.screener = ScreenerEngine(self.database)
self.strategy_tracking = StrategyTrackingService(self.database)
+ self.alert_service = AlertService(self.database)
self.mentor_skills = MentorSkillRegistry(MENTOR_SKILLS_DIR)
self.realtime_aggregator = WebRealtimeAggregator()
self.screener.ensure_builtin_strategies()
@@ -1104,6 +1106,30 @@ class DashboardService:
"notice": notice,
}
+ def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
+ tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
+ self.alert_service.sync_strategy_tracking(self.current_user_id, tracking)
+ return self.alert_service.list_alerts(
+ self.current_user_id, status, as_of
+ )
+
+ def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
+ alert_id = self.alert_service.create_manual(self.current_user_id, payload)
+ return {"id": alert_id, **self.alert_center()}
+
+ def mark_alert_read(self, alert_id: int) -> dict[str, Any]:
+ self.database.mark_alert_read(self.current_user_id, alert_id)
+ return self.alert_center()
+
+ def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]:
+ compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat())
+ self.database.mark_all_alerts_read(self.current_user_id, compact_date)
+ return self.alert_center(as_of=compact_date)
+
+ def delete_alert(self, alert_id: int) -> dict[str, Any]:
+ deleted = self.database.delete_alert(self.current_user_id, alert_id)
+ return {"deleted": deleted, **self.alert_center()}
+
def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
if not self.configured:
raise ValueError("请先配置 Tushare Token。")
@@ -3368,6 +3394,18 @@ class RequestHandler(BaseHTTPRequestHandler):
if parsed.path == "/api/account/status":
self.send_json({"ok": True, **SERVICE.status()})
return
+ if parsed.path == "/api/alerts":
+ query = parse_qs(parsed.query)
+ try:
+ self.send_json(
+ SERVICE.alert_center(
+ query.get("status", ["all"])[0],
+ query.get("as_of", [date.today().isoformat()])[0],
+ )
+ )
+ except ValueError as exc:
+ self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+ return
if parsed.path == "/api/dashboard":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
@@ -3574,6 +3612,21 @@ class RequestHandler(BaseHTTPRequestHandler):
if parsed.path == "/api/account/password":
self.change_password()
return
+ alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path)
+ if alert_read_match:
+ self.send_json(
+ {"ok": True, **SERVICE.mark_alert_read(int(alert_read_match.group(1)))}
+ )
+ return
+ if parsed.path == "/api/alerts/read-all":
+ body = self.read_json_body(True)
+ self.send_json(
+ {"ok": True, **SERVICE.mark_all_alerts_read(str(body.get("as_of") or ""))}
+ )
+ return
+ if parsed.path == "/api/alerts":
+ self.save_alert()
+ return
if parsed.path == "/api/admin/settings":
self.save_system_settings()
return
@@ -3676,6 +3729,12 @@ class RequestHandler(BaseHTTPRequestHandler):
)
self.send_json({"ok": True, "deleted": deleted})
return
+ alert_match = re.fullmatch(r"/api/alerts/(\d+)", parsed.path)
+ if alert_match:
+ self.send_json(
+ {"ok": True, **SERVICE.delete_alert(int(alert_match.group(1)))}
+ )
+ return
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
if sector_phase_match:
name = unquote(sector_phase_match.group(1)).strip()
@@ -3777,6 +3836,13 @@ class RequestHandler(BaseHTTPRequestHandler):
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+ def save_alert(self) -> None:
+ try:
+ body = self.read_json_body()
+ self.send_json({"ok": True, **SERVICE.create_alert(body)}, HTTPStatus.CREATED)
+ except (ValueError, json.JSONDecodeError) as exc:
+ self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+
def session_token(self) -> str:
cookie = SimpleCookie()
try:
diff --git a/static/app.js b/static/app.js
index 8af24a2..0153efa 100644
--- a/static/app.js
+++ b/static/app.js
@@ -44,6 +44,9 @@ const state = {
selectedStrategy: null,
screenerResult: null,
screenerTracking: null,
+ alerts: [],
+ alertFilter: "all",
+ alertUnreadCount: 0,
screenerMobileView: "strategy",
sentimentHistory: null,
sentimentRange: 20,
@@ -86,6 +89,7 @@ const elements = {
loading: document.querySelector("#loadingOverlay"),
toast: document.querySelector("#toast"),
stockDialog: document.querySelector("#stockDialog"),
+ alertsDialog: document.querySelector("#alertsDialog"),
globalSearchDialog: document.querySelector("#globalSearchDialog"),
globalSearchInput: document.querySelector("#globalSearchInput"),
globalSearchResults: document.querySelector("#globalSearchResults"),
@@ -216,6 +220,7 @@ async function initialize() {
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
document.querySelector("#qiObservationDate").max = todayString();
document.querySelector("#accountBirthDate").max = todayString();
+ document.querySelector("#alertDate").value = todayString();
bindEvents();
try {
const session = await apiRequest("/api/auth/me");
@@ -249,6 +254,7 @@ async function startAuthenticatedApp() {
}
}
loadDashboard();
+ loadAlerts();
if (new URLSearchParams(window.location.search).get("settings") === "1") {
setTimeout(openSettings, 0);
}
@@ -373,6 +379,14 @@ function bindEvents() {
toggleHeaderCommandMenu();
});
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
+ document.querySelector("#alertButton").addEventListener("click", openAlerts);
+ document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close());
+ document.querySelector("#alertForm").addEventListener("submit", saveAlert);
+ document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead);
+ document.querySelector("#alertList").addEventListener("click", handleAlertAction);
+ document.querySelectorAll("[data-alert-filter]").forEach((button) => {
+ button.addEventListener("click", () => selectAlertFilter(button.dataset.alertFilter));
+ });
document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch);
document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close());
elements.globalSearchDialog.addEventListener("click", (event) => {
@@ -482,6 +496,7 @@ function bindEvents() {
document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote);
document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist);
document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven);
+ document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
@@ -4234,6 +4249,128 @@ function repositionStockPreview() {
elements.stockPreview.style.top = `${Math.round(top)}px`;
}
+async function loadAlerts(openDialog = false) {
+ try {
+ const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() });
+ const payload = await apiRequest(`/api/alerts?${query}`);
+ state.alerts = payload.items || [];
+ state.alertUnreadCount = number(payload.unread_count);
+ renderAlerts();
+ if (openDialog && !elements.alertsDialog.open) elements.alertsDialog.showModal();
+ } catch (error) {
+ if (openDialog) showToast(error.message || "提醒加载失败");
+ }
+}
+
+function openAlerts() {
+ toggleHeaderCommandMenu(false);
+ toggleAccountDropdown(false);
+ document.querySelector("#alertDate").value ||= todayString();
+ elements.alertsDialog.showModal();
+ loadAlerts();
+}
+
+function openStockReminder() {
+ const stock = state.activeStock || {};
+ document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`;
+ document.querySelector("#alertCode").value = stock.code || "";
+ document.querySelector("#alertDate").value = todayString();
+ if (elements.stockDialog.open) elements.stockDialog.close();
+ openAlerts();
+ document.querySelector("#alertContent").focus();
+}
+
+function selectAlertFilter(filter) {
+ state.alertFilter = filter === "unread" ? "unread" : "all";
+ document.querySelectorAll("[data-alert-filter]").forEach((button) => {
+ button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
+ });
+ loadAlerts();
+}
+
+async function saveAlert(event) {
+ event.preventDefault();
+ const button = event.currentTarget.querySelector("button[type='submit']");
+ button.disabled = true;
+ try {
+ const payload = await apiRequest("/api/alerts", "POST", {
+ title: document.querySelector("#alertTitle").value.trim(),
+ remind_date: document.querySelector("#alertDate").value,
+ code: document.querySelector("#alertCode").value.trim(),
+ content: document.querySelector("#alertContent").value.trim(),
+ });
+ event.currentTarget.reset();
+ document.querySelector("#alertDate").value = todayString();
+ state.alertFilter = "all";
+ state.alerts = payload.items || [];
+ state.alertUnreadCount = number(payload.unread_count);
+ renderAlerts();
+ showToast("提醒已保存");
+ } catch (error) {
+ showToast(error.message || "提醒保存失败");
+ } finally {
+ button.disabled = false;
+ }
+}
+
+async function markAllAlertsRead() {
+ try {
+ await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() });
+ await loadAlerts();
+ } catch (error) {
+ showToast(error.message || "提醒状态更新失败");
+ }
+}
+
+async function handleAlertAction(event) {
+ const button = event.target.closest("[data-alert-action]");
+ if (!button) return;
+ const id = number(button.dataset.alertId);
+ if (!id) return;
+ try {
+ if (button.dataset.alertAction === "delete") {
+ await apiRequest(`/api/alerts/${id}`, "DELETE");
+ } else {
+ await apiRequest(`/api/alerts/${id}/read`, "POST", {});
+ }
+ await loadAlerts();
+ } catch (error) {
+ showToast(error.message || "提醒操作失败");
+ }
+}
+
+function renderAlerts() {
+ const badge = document.querySelector("#alertBadge");
+ badge.hidden = state.alertUnreadCount <= 0;
+ badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount);
+ document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0);
+ setText("alertListCount", `${state.alerts.length} 条`);
+ document.querySelectorAll("[data-alert-filter]").forEach((button) => {
+ button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
+ });
+ document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0;
+ const container = document.querySelector("#alertList");
+ container.innerHTML = state.alerts.map((item) => {
+ const upcoming = !item.due;
+ const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈";
+ return ` ${escapeHtml(item.content)}