feat: add structured manual trading journal
This commit is contained in:
+128
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user