diff --git a/database.py b/database.py index 6975547..9455bc3 100644 --- a/database.py +++ b/database.py @@ -297,6 +297,30 @@ class ReviewDatabase: CREATE INDEX IF NOT EXISTS idx_alerts_user_due ON alerts(user_id, available_date, is_read, id DESC); + + CREATE TABLE IF NOT EXISTS trade_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + trade_date TEXT NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + action TEXT NOT NULL, + price REAL NOT NULL, + quantity INTEGER NOT NULL DEFAULT 0, + position_pct REAL NOT NULL DEFAULT 0, + pnl_amount REAL, + pnl_pct REAL, + thesis TEXT NOT NULL DEFAULT '', + execution TEXT NOT NULL DEFAULT '', + emotion TEXT NOT NULL DEFAULT 'calm', + tags TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_trade_entries_user_date + ON trade_entries(user_id, trade_date DESC, id DESC); """ ) user_columns = { @@ -1550,6 +1574,95 @@ class ReviewDatabase: ) return cursor.rowcount > 0 + def save_trade_entry( + self, + user_id: int, + trade_date: str, + code: str, + name: str, + action: str, + price: float, + quantity: int, + position_pct: float, + pnl_amount: float | None, + pnl_pct: float | None, + thesis: str, + execution: str, + emotion: str, + tags: list[str], + trade_id: int | None = None, + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":")) + with self.connect() as connection: + if trade_id: + cursor = connection.execute( + """ + UPDATE trade_entries SET + trade_date=?, code=?, name=?, action=?, price=?, quantity=?, + position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?, + emotion=?, tags=?, updated_at=? + WHERE id=? AND user_id=? + """, + ( + trade_date, code, name, action, price, quantity, position_pct, + pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now, + int(trade_id), int(user_id), + ), + ) + if cursor.rowcount == 0: + raise ValueError("交易记录不存在或无权修改。") + return int(trade_id) + cursor = connection.execute( + """ + INSERT INTO trade_entries + (user_id, trade_date, code, name, action, price, quantity, + position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, + tags, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + int(user_id), trade_date, code, name, action, price, quantity, + position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, + tags_json, now, now, + ), + ) + return int(cursor.lastrowid) + + def list_trade_entries( + self, user_id: int, start_date: str = "", end_date: str = "", code: str = "", + limit: int = 300, + ) -> list[dict[str, Any]]: + clauses = ["user_id = ?"] + parameters: list[Any] = [int(user_id)] + if start_date: + clauses.append("trade_date >= ?") + parameters.append(start_date) + if end_date: + clauses.append("trade_date <= ?") + parameters.append(end_date) + if code: + clauses.append("code = ?") + parameters.append(code) + parameters.append(max(1, min(1000, int(limit)))) + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT * FROM trade_entries WHERE {' AND '.join(clauses)} + ORDER BY trade_date DESC, id DESC LIMIT ? + """, + parameters, + ).fetchall() + return [dict(row) for row in rows] + + def delete_trade_entry(self, user_id: int, trade_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM trade_entries WHERE id = ? AND user_id = ?", + (int(trade_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 50fd984..aa6bbc4 100644 --- a/server.py +++ b/server.py @@ -64,6 +64,7 @@ from sentiment_engine import ( latest_contiguous_history, ) from strategy_tracking import StrategyTrackingService +from trade_journal import TradeJournalService from tushare_client import TushareClient, TushareError @@ -131,6 +132,7 @@ class DashboardService: self.screener = ScreenerEngine(self.database) self.strategy_tracking = StrategyTrackingService(self.database) self.alert_service = AlertService(self.database) + self.trade_journal = TradeJournalService(self.database) self.mentor_skills = MentorSkillRegistry(MENTOR_SKILLS_DIR) self.realtime_aggregator = WebRealtimeAggregator() self.screener.ensure_builtin_strategies() @@ -1130,6 +1132,21 @@ class DashboardService: deleted = self.database.delete_alert(self.current_user_id, alert_id) return {"deleted": deleted, **self.alert_center()} + def trade_entries( + self, start_date: str = "", end_date: str = "", code: str = "" + ) -> dict[str, Any]: + return self.trade_journal.list_entries( + self.current_user_id, start_date, end_date, code + ) + + def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]: + trade_id = self.trade_journal.save(self.current_user_id, payload) + return {"id": trade_id, **self.trade_entries()} + + def delete_trade_entry(self, trade_id: int) -> dict[str, Any]: + deleted = self.database.delete_trade_entry(self.current_user_id, trade_id) + return {"deleted": deleted, **self.trade_entries()} + def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]: if not self.configured: raise ValueError("请先配置 Tushare Token。") @@ -3406,6 +3423,19 @@ class RequestHandler(BaseHTTPRequestHandler): except ValueError as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return + if parsed.path == "/api/trades": + query = parse_qs(parsed.query) + try: + self.send_json( + SERVICE.trade_entries( + query.get("start_date", [""])[0], + query.get("end_date", [""])[0], + query.get("code", [""])[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] @@ -3627,6 +3657,9 @@ class RequestHandler(BaseHTTPRequestHandler): if parsed.path == "/api/alerts": self.save_alert() return + if parsed.path == "/api/trades": + self.save_trade_entry() + return if parsed.path == "/api/admin/settings": self.save_system_settings() return @@ -3735,6 +3768,12 @@ class RequestHandler(BaseHTTPRequestHandler): {"ok": True, **SERVICE.delete_alert(int(alert_match.group(1)))} ) return + trade_match = re.fullmatch(r"/api/trades/(\d+)", parsed.path) + if trade_match: + self.send_json( + {"ok": True, **SERVICE.delete_trade_entry(int(trade_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() @@ -3843,6 +3882,13 @@ class RequestHandler(BaseHTTPRequestHandler): except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + def save_trade_entry(self) -> None: + try: + body = self.read_json_body() + self.send_json({"ok": True, **SERVICE.save_trade_entry(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 0153efa..d07596f 100644 --- a/static/app.js +++ b/static/app.js @@ -38,6 +38,9 @@ const state = { stockPreviewFallback: null, watchlist: [], notes: [], + tradeEntries: [], + tradeSummary: {}, + editingTradeId: 0, initialStockOpened: false, screenerSetup: null, selectedRegime: "", @@ -213,6 +216,8 @@ async function initialize() { elements.tradeDate.max = todayString(); document.querySelector("#journalDate").value = elements.tradeDate.value; document.querySelector("#journalDate").max = todayString(); + document.querySelector("#tradeLogDate").value = elements.tradeDate.value; + document.querySelector("#tradeLogDate").max = todayString(); document.querySelector("#backfillStart").value = todayString(); document.querySelector("#backfillEnd").value = todayString(); document.querySelector("#backfillStart").max = todayString(); @@ -493,6 +498,9 @@ function bindEvents() { }); }); document.querySelector("#journalForm").addEventListener("submit", saveJournal); + document.querySelector("#tradeLogForm").addEventListener("submit", saveTradeLog); + document.querySelector("#cancelTradeEdit").addEventListener("click", resetTradeLogForm); + document.querySelector("#tradeLogTableBody").addEventListener("click", handleTradeLogAction); document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote); document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist); document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven); @@ -1390,15 +1398,19 @@ async function saveSeatAlias(event) { async function loadReviewWorkspace() { try { - const [watchlistPayload, notesPayload] = await Promise.all([ + const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([ apiRequest("/api/watchlist"), apiRequest("/api/notes?scope=daily"), + apiRequest("/api/trades"), ]); state.watchlist = watchlistPayload.items || []; state.notes = notesPayload.items || []; + state.tradeEntries = tradesPayload.items || []; + state.tradeSummary = tradesPayload.summary || {}; renderWatchlist(); renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false); setText("notesCount", `${state.notes.length} 条`); + renderTradeLog(); } catch (error) { showToast(error.message || "我的复盘加载失败"); } @@ -1488,6 +1500,121 @@ async function saveJournal(event) { } } +async function saveTradeLog(event) { + event.preventDefault(); + const button = document.querySelector("#saveTradeLog"); + button.disabled = true; + try { + const payload = await apiRequest("/api/trades", "POST", { + id: state.editingTradeId || undefined, + trade_date: document.querySelector("#tradeLogDate").value, + code: document.querySelector("#tradeLogCode").value.trim(), + name: document.querySelector("#tradeLogName").value.trim(), + action: document.querySelector("#tradeLogAction").value, + price: document.querySelector("#tradeLogPrice").value, + quantity: document.querySelector("#tradeLogQuantity").value, + position_pct: document.querySelector("#tradeLogPosition").value, + pnl_amount: document.querySelector("#tradeLogPnlAmount").value, + pnl_pct: document.querySelector("#tradeLogPnlPct").value, + emotion: document.querySelector("#tradeLogEmotion").value, + tags: document.querySelector("#tradeLogTags").value, + thesis: document.querySelector("#tradeLogThesis").value, + execution: document.querySelector("#tradeLogExecution").value, + }); + state.tradeEntries = payload.items || []; + state.tradeSummary = payload.summary || {}; + resetTradeLogForm(); + renderTradeLog(); + showToast("交易记录已保存"); + } catch (error) { + showToast(error.message || "交易记录保存失败"); + } finally { + button.disabled = false; + } +} + +function resetTradeLogForm() { + state.editingTradeId = 0; + document.querySelector("#tradeLogForm").reset(); + document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString(); + document.querySelector("#tradeLogQuantity").value = "0"; + document.querySelector("#tradeLogPosition").value = "0"; + document.querySelector("#cancelTradeEdit").hidden = true; + setText("saveTradeLog", "保存交易"); +} + +function editTradeLog(id) { + const item = state.tradeEntries.find((entry) => number(entry.id) === id); + if (!item) return; + state.editingTradeId = id; + document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date); + document.querySelector("#tradeLogCode").value = item.code; + document.querySelector("#tradeLogName").value = item.name; + document.querySelector("#tradeLogAction").value = item.action; + document.querySelector("#tradeLogPrice").value = item.price; + document.querySelector("#tradeLogQuantity").value = item.quantity; + document.querySelector("#tradeLogPosition").value = item.position_pct; + document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? ""; + document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? ""; + document.querySelector("#tradeLogEmotion").value = item.emotion; + document.querySelector("#tradeLogTags").value = (item.tags || []).join(", "); + document.querySelector("#tradeLogThesis").value = item.thesis || ""; + document.querySelector("#tradeLogExecution").value = item.execution || ""; + document.querySelector("#cancelTradeEdit").hidden = false; + setText("saveTradeLog", "保存修改"); + document.querySelector("#tradeLogForm").scrollIntoView({ behavior: motionEnabled() ? "smooth" : "auto", block: "start" }); +} + +async function handleTradeLogAction(event) { + const button = event.target.closest("[data-trade-action]"); + if (!button) return; + const id = number(button.dataset.tradeId); + if (button.dataset.tradeAction === "edit") { + editTradeLog(id); + return; + } + if (!window.confirm("确定删除这条交易记录吗?")) return; + try { + const payload = await apiRequest(`/api/trades/${id}`, "DELETE"); + state.tradeEntries = payload.items || []; + state.tradeSummary = payload.summary || {}; + if (state.editingTradeId === id) resetTradeLogForm(); + renderTradeLog(); + showToast("交易记录已删除"); + } catch (error) { + showToast(error.message || "交易记录删除失败"); + } +} + +function renderTradeLog() { + const summary = state.tradeSummary || {}; + setText("tradeLogCount", `${state.tradeEntries.length} 条`); + document.querySelector("#tradeLogSummary").innerHTML = [ + ["记录", `${number(summary.total)} 条`], + ["已实现", `${number(summary.realized)} 条`], + ["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`], + ["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`], + ["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`], + ].map(([label, value]) => `
| 日期 | 股票 | 动作 | 价格 | 数量 | 仓位 | 盈亏 | 情绪 / 标签 | 交易逻辑 | 执行复核 | 操作 |
|---|