feat: add structured manual trading journal

This commit is contained in:
leefer
2026-07-23 00:12:28 +08:00
parent a2a5db8f6a
commit 7d8d270e98
8 changed files with 564 additions and 1 deletions
+113
View File
@@ -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:
+46
View File
@@ -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:
+128 -1
View File
@@ -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]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0;
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
<tr data-code="${escapeHtml(item.code)}">
<td>${displayCompactDate(item.trade_date)}</td>
<td><span class="stock-cell"><strong>${escapeHtml(item.name)}</strong><small class="stock-code">${escapeHtml(item.code)}</small></span></td>
<td><span class="trade-action trade-action-${escapeHtml(item.action)}">${escapeHtml(item.action_label)}</span></td>
<td class="number">${formatNumber(item.price, 3)}</td>
<td class="number">${formatNumber(item.quantity, 0)}</td>
<td class="number">${formatNumber(item.position_pct, 1)}%</td>
<td class="number ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "--" : `${signed(item.pnl_pct)}%`}${item.pnl_amount == null ? "" : `<small>${number(item.pnl_amount) > 0 ? "+" : ""}${formatNumber(item.pnl_amount, 2)}</small>`}</td>
<td><span class="trade-emotion">${escapeHtml(item.emotion_label)}</span><div class="trade-tags">${(item.tags || []).map((tag) => `<em>${escapeHtml(tag)}</em>`).join("")}</div></td>
<td class="trade-copy" title="${escapeHtml(item.thesis || "")}">${escapeHtml(item.thesis || "--")}</td>
<td class="trade-copy" title="${escapeHtml(item.execution || "")}">${escapeHtml(item.execution || "--")}</td>
<td><div class="trade-row-actions"><button class="table-action" type="button" data-trade-action="edit" data-trade-id="${number(item.id)}">编辑</button><button class="table-action down" type="button" data-trade-action="delete" data-trade-id="${number(item.id)}"></button></div></td>
</tr>
`).join("");
bindStockRows(document.querySelector("#tradeLogTableBody"));
}
async function saveStockNote(event) {
event.preventDefault();
if (!state.activeStock?.code) return;
+31
View File
@@ -926,6 +926,37 @@
<div class="dialog-actions"><button class="button primary" type="submit">保存复盘</button></div>
</form>
</section>
<section class="workspace-section trade-journal-section">
<div class="workspace-heading"><h3>交易日志</h3><span id="tradeLogCount">0 条</span></div>
<div id="tradeLogSummary" class="trade-log-summary"></div>
<form id="tradeLogForm" class="trade-log-form">
<div class="trade-log-form-grid">
<label class="form-field"><span>交易日</span><input id="tradeLogDate" type="date" required></label>
<label class="form-field"><span>股票代码</span><input id="tradeLogCode" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required placeholder="002141"></label>
<label class="form-field"><span>股票名称</span><input id="tradeLogName" maxlength="40" required placeholder="贤丰控股"></label>
<label class="form-field"><span>动作</span><select id="tradeLogAction"><option value="buy">买入</option><option value="sell">卖出</option><option value="add">加仓</option><option value="trim">减仓</option><option value="watch">观察</option></select></label>
<label class="form-field"><span>成交价格</span><input id="tradeLogPrice" type="number" min="0.001" step="0.001" required></label>
<label class="form-field"><span>数量</span><input id="tradeLogQuantity" type="number" min="0" step="100" value="0"></label>
<label class="form-field"><span>仓位 %</span><input id="tradeLogPosition" type="number" min="0" max="100" step="0.1" value="0"></label>
<label class="form-field"><span>盈亏金额(可选)</span><input id="tradeLogPnlAmount" type="number" step="0.01"></label>
<label class="form-field"><span>盈亏 %(可选)</span><input id="tradeLogPnlPct" type="number" step="0.01"></label>
<label class="form-field"><span>当时情绪</span><select id="tradeLogEmotion"><option value="calm">平静</option><option value="confident">笃定</option><option value="hesitant">犹豫</option><option value="anxious">焦虑</option><option value="impulsive">冲动</option></select></label>
<label class="form-field trade-tags-field"><span>标签</span><input id="tradeLogTags" maxlength="160" placeholder="计划内, 追高, 止损"></label>
</div>
<div class="trade-log-text-grid">
<label class="form-field"><span>交易逻辑</span><textarea id="tradeLogThesis" maxlength="2000" placeholder="为什么在这里采取动作"></textarea></label>
<label class="form-field"><span>执行复核</span><textarea id="tradeLogExecution" maxlength="2000" placeholder="是否按计划执行,偏差在哪里"></textarea></label>
</div>
<div class="dialog-actions"><button id="cancelTradeEdit" class="button" type="button" hidden>取消编辑</button><button id="saveTradeLog" class="button primary" type="submit">保存交易</button></div>
</form>
<div class="table-frame trade-log-table-frame">
<table class="data-table trade-log-table">
<thead><tr><th>日期</th><th>股票</th><th>动作</th><th class="number">价格</th><th class="number">数量</th><th class="number">仓位</th><th class="number">盈亏</th><th>情绪 / 标签</th><th>交易逻辑</th><th>执行复核</th><th>操作</th></tr></thead>
<tbody id="tradeLogTableBody"></tbody>
</table>
<div id="tradeLogEmpty" class="empty-state">暂无手工交易记录</div>
</div>
</section>
<section class="workspace-section notes-history-section">
<div class="workspace-heading"><h3>最近记录</h3><span id="notesCount">0 条</span></div>
<div id="notesHistory" class="notes-history"><div class="empty-state">暂无复盘记录</div></div>
+58
View File
@@ -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; }
}
+1
View File
@@ -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 = {
+90
View File
@@ -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()
+97
View File
@@ -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)