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]) => `
${label}${value}
`).join(""); + document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0; + document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => ` + + ${displayCompactDate(item.trade_date)} + ${escapeHtml(item.name)}${escapeHtml(item.code)} + ${escapeHtml(item.action_label)} + ${formatNumber(item.price, 3)} + ${formatNumber(item.quantity, 0)} + ${formatNumber(item.position_pct, 1)}% + ${item.pnl_pct == null ? "--" : `${signed(item.pnl_pct)}%`}${item.pnl_amount == null ? "" : `${number(item.pnl_amount) > 0 ? "+" : ""}${formatNumber(item.pnl_amount, 2)}`} + ${escapeHtml(item.emotion_label)}
${(item.tags || []).map((tag) => `${escapeHtml(tag)}`).join("")}
+ ${escapeHtml(item.thesis || "--")} + ${escapeHtml(item.execution || "--")} +
+ + `).join(""); + bindStockRows(document.querySelector("#tradeLogTableBody")); +} + async function saveStockNote(event) { event.preventDefault(); if (!state.activeStock?.code) return; diff --git a/static/index.html b/static/index.html index 362f284..46329aa 100644 --- a/static/index.html +++ b/static/index.html @@ -926,6 +926,37 @@
+
+

交易日志

0 条
+
+
+
+ + + + + + + + + + + +
+
+ + +
+
+
+
+ + + +
日期股票动作价格数量仓位盈亏情绪 / 标签交易逻辑执行复核操作
+
暂无手工交易记录
+
+

最近记录

0 条
暂无复盘记录
diff --git a/static/styles.css b/static/styles.css index d2c5ff3..9ce3828 100644 --- a/static/styles.css +++ b/static/styles.css @@ -11472,3 +11472,61 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin .alert-item-icon { width: 32px; height: 32px; } .alert-item-actions { grid-column: 2; } } + +.trade-journal-section { grid-column: 1 / -1; padding: 0; } +.trade-journal-section > .workspace-heading { padding: 16px 16px 0; } +.trade-log-form { padding: 16px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--surface-muted); } +.trade-log-form-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; } +.trade-tags-field { grid-column: span 2; } +.trade-log-text-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; } +.trade-log-text-grid textarea { min-height: 82px; } + +.trade-log-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border-top: 1px solid var(--border); + background: var(--surface); +} + +.trade-log-summary > div { padding: 11px 16px; border-right: 1px solid var(--border); } +.trade-log-summary > div:last-child { border-right: 0; } +.trade-log-summary span { display: block; color: var(--text-secondary); font-size: 11px; } +.trade-log-summary strong { display: block; margin-top: 4px; font-size: 15px; font-variant-numeric: tabular-nums; } +.trade-log-table-frame { max-height: 520px; border: 0; border-radius: 0; } +.trade-log-table { min-width: 1380px; } +.trade-log-table td.number small { display: block; margin-top: 3px; font-size: 9px; } +.trade-copy { max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); } +.trade-row-actions { display: flex; gap: 4px; } + +.trade-action, +.trade-emotion { + display: inline-flex; + min-height: 22px; + align-items: center; + padding: 0 7px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface-muted); + font-size: 10px; + white-space: nowrap; +} + +.trade-action-buy, +.trade-action-add { border-color: #efc5c8; background: var(--market-up-soft); color: var(--market-up); } +.trade-action-sell, +.trade-action-trim { border-color: #b9dfcf; background: var(--market-down-soft); color: var(--market-down); } +.trade-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; } +.trade-tags em { padding: 2px 5px; border-radius: 3px; background: var(--action-soft); color: var(--action); font-size: 9px; font-style: normal; } + +@media (max-width: 900px) { + .trade-log-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .trade-tags-field { grid-column: auto; } +} + +@media (max-width: 720px) { + .trade-log-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .trade-log-summary > div { border-bottom: 1px solid var(--border); } + .trade-log-summary > div:nth-child(2n) { border-right: 0; } + .trade-log-form-grid, + .trade-log-text-grid { grid-template-columns: 1fr; } +} diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index dffcd52..015a298 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -55,6 +55,7 @@ async function mockApplication(page, authSession = session()) { else if (url.pathname === "/api/dashboard") payload = dashboard; else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] }; else if (url.pathname === "/api/alerts") payload = { items: [], unread_count: 0 }; + else if (url.pathname === "/api/trades") payload = { items: [], summary: {} }; else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } }; else if (url.pathname === "/api/dragon-tiger") { payload = { diff --git a/tests/test_trade_journal.py b/tests/test_trade_journal.py new file mode 100644 index 0000000..ecda1b5 --- /dev/null +++ b/tests/test_trade_journal.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from database import ReviewDatabase +from trade_journal import TradeJournalService + + +class TradeJournalTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.database = ReviewDatabase(Path(self.temp.name) / "review.db") + self.owner = self.database.create_user("trade_owner", "salt", "hash") + self.other = self.database.create_user("trade_other", "salt", "hash") + self.service = TradeJournalService(self.database) + + def tearDown(self) -> None: + self.temp.cleanup() + + @staticmethod + def payload(**overrides): + data = { + "trade_date": "2026-07-22", + "code": "002141", + "name": "贤丰控股", + "action": "buy", + "price": 10.25, + "quantity": 1000, + "position_pct": 20, + "pnl_amount": "", + "pnl_pct": "", + "thesis": "修复期低吸", + "execution": "按计划成交", + "emotion": "calm", + "tags": "计划内,低吸", + } + data.update(overrides) + return data + + def test_unrealized_entry_does_not_enter_win_rate(self): + self.service.save(self.owner["id"], self.payload()) + result = self.service.list_entries(self.owner["id"]) + + self.assertEqual(result["summary"]["total"], 1) + self.assertEqual(result["summary"]["realized"], 0) + self.assertIsNone(result["summary"]["win_rate"]) + self.assertEqual(result["items"][0]["tags"], ["计划内", "低吸"]) + + def test_realized_entries_build_summary(self): + self.service.save( + self.owner["id"], self.payload(action="sell", pnl_amount=500, pnl_pct=5) + ) + self.service.save( + self.owner["id"], + self.payload(code="600000", name="浦发银行", action="sell", pnl_amount=-200, pnl_pct=-2, position_pct=40), + ) + result = self.service.list_entries(self.owner["id"]) + + self.assertEqual(result["summary"]["realized"], 2) + self.assertEqual(result["summary"]["win_rate"], 50.0) + self.assertEqual(result["summary"]["pnl_amount"], 300.0) + self.assertEqual(result["summary"]["average_position"], 30.0) + + def test_update_and_delete_require_ownership(self): + trade_id = self.service.save(self.owner["id"], self.payload()) + with self.assertRaises(ValueError): + self.service.save( + self.other["id"], self.payload(id=trade_id, thesis="越权修改") + ) + self.assertFalse(self.database.delete_trade_entry(self.other["id"], trade_id)) + + self.service.save( + self.owner["id"], self.payload(id=trade_id, thesis="复盘后修订") + ) + self.assertEqual( + self.service.list_entries(self.owner["id"])["items"][0]["thesis"], + "复盘后修订", + ) + self.assertTrue(self.database.delete_trade_entry(self.owner["id"], trade_id)) + self.assertEqual(self.service.list_entries(self.owner["id"])["summary"]["total"], 0) + + def test_entries_are_isolated_between_accounts(self): + self.service.save(self.owner["id"], self.payload()) + self.assertEqual(self.service.list_entries(self.other["id"])["items"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/trade_journal.py b/trade_journal.py new file mode 100644 index 0000000..b5de4bf --- /dev/null +++ b/trade_journal.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +from datetime import date +from typing import Any + +from app_config import normalize_date, validate_stock_code, validate_text +from database import ReviewDatabase + + +TRADE_ACTIONS = {"buy": "买入", "sell": "卖出", "trim": "减仓", "add": "加仓", "watch": "观察"} +EMOTIONS = {"calm": "平静", "confident": "笃定", "hesitant": "犹豫", "anxious": "焦虑", "impulsive": "冲动"} + + +class TradeJournalService: + def __init__(self, database: ReviewDatabase) -> None: + self.database = database + + def save(self, user_id: int, payload: dict[str, Any]) -> int: + trade_id = int(payload.get("id") or 0) + trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) + code = validate_stock_code(str(payload.get("code") or "")) + name = validate_text(payload.get("name"), "股票名称", 40, required=True) + action = str(payload.get("action") or "") + if action not in TRADE_ACTIONS: + raise ValueError("交易动作不支持。") + emotion = str(payload.get("emotion") or "calm") + if emotion not in EMOTIONS: + raise ValueError("交易情绪不支持。") + price = self._number(payload.get("price"), "成交价格", 0, 1000000, required=True) + quantity = int(self._number(payload.get("quantity"), "成交数量", 0, 100000000)) + position_pct = self._number(payload.get("position_pct"), "仓位", 0, 100) + pnl_amount = self._optional_number(payload.get("pnl_amount"), "盈亏金额", -1e12, 1e12) + pnl_pct = self._optional_number(payload.get("pnl_pct"), "盈亏比例", -1000, 10000) + thesis = validate_text(payload.get("thesis"), "交易逻辑", 2000) + execution = validate_text(payload.get("execution"), "执行复核", 2000) + raw_tags = payload.get("tags") or [] + if isinstance(raw_tags, str): + raw_tags = [item.strip() for item in raw_tags.replace(",", ",").split(",")] + if not isinstance(raw_tags, list): + raise ValueError("交易标签格式不正确。") + tags = [validate_text(item, "交易标签", 20) for item in raw_tags if str(item).strip()][:8] + return self.database.save_trade_entry( + user_id, trade_date, code, name, action, price, quantity, position_pct, + pnl_amount, pnl_pct, thesis, execution, emotion, tags, trade_id or None, + ) + + def list_entries( + self, user_id: int, start_date: str = "", end_date: str = "", code: str = "" + ) -> dict[str, Any]: + start = normalize_date(start_date) if start_date else "" + end = normalize_date(end_date) if end_date else date.today().strftime("%Y%m%d") + if start and start > end: + raise ValueError("开始日期不能晚于结束日期。") + code = validate_stock_code(code) if code else "" + items = self.database.list_trade_entries(user_id, start, end, code) + for item in items: + item["tags"] = json.loads(item.get("tags") or "[]") + item["action_label"] = TRADE_ACTIONS.get(item["action"], item["action"]) + item["emotion_label"] = EMOTIONS.get(item["emotion"], item["emotion"]) + realized = [item for item in items if item.get("pnl_pct") is not None] + return {"items": items, "summary": self._summary(items, realized)} + + @staticmethod + def _summary(items: list[dict[str, Any]], realized: list[dict[str, Any]]) -> dict[str, Any]: + pnl_amounts = [float(item["pnl_amount"]) for item in realized if item.get("pnl_amount") is not None] + positions = [float(item["position_pct"]) for item in items if float(item.get("position_pct") or 0) > 0] + wins = sum(float(item.get("pnl_pct") or 0) > 0 for item in realized) + return { + "total": len(items), + "realized": len(realized), + "win_rate": round(wins / len(realized) * 100, 1) if realized else None, + "pnl_amount": round(sum(pnl_amounts), 2) if pnl_amounts else None, + "average_position": round(sum(positions) / len(positions), 1) if positions else None, + } + + @staticmethod + def _number(value: Any, label: str, minimum: float, maximum: float, required: bool = False) -> float: + if value in (None, ""): + if required: + raise ValueError(f"{label}不能为空。") + return 0.0 + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{label}格式不正确。") from exc + if parsed < minimum or parsed > maximum: + raise ValueError(f"{label}超出允许范围。") + return parsed + + @classmethod + def _optional_number( + cls, value: Any, label: str, minimum: float, maximum: float + ) -> float | None: + if value in (None, ""): + return None + return cls._number(value, label, minimum, maximum, required=True)