Trade journal
@qtrader/trade-journal
Keep a trading journal that actually teaches you something. Log each trade with its setup, thesis, emotions, plan adherence, and mistakes in a CSV, then get a weekly review that finds what is working and what is leaking money by setup, time of day, holding period, emotion, and plan adherence. Not financial advice.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Trade journal: append trades to a CSV and turn it into a weekly review. Node built-ins only.
- 3// Not financial advice.
- 4//
- 5// node scripts/journal.mjs add --file journal/trades.csv --symbol XYZ --side long --qty 100 --entry 50.2 [--exit 51.1]
- 6// --opened "2026-11-02 09:41" [--closed "2026-11-02 10:20"] [--stop 49.6] [--fees 1] --setup "opening range breakout"
- 7// [--thesis "..."] [--emotion-entry calm] [--emotion-exit relieved] [--followed-plan y] [--mistakes "moved stop;chased"] [--notes "..."]
- 8// node scripts/journal.mjs close --file journal/trades.csv --id 18 --exit 31.2 --closed "2026-11-03 10:05" [--emotion-exit calm] [--mistakes "..."] [--notes "..."]
- 9// node scripts/journal.mjs review --file journal/trades.csv [--from 2026-10-27] [--to 2026-11-02] [--min-n 3] [--out out/review]
- 10// writes <out>.md and <out>.json
- 11import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
- 12import { dirname } from "node:path";
- 13import { fileURLToPath } from "node:url";
- 15export const COLUMNS = ["id", "opened_at", "closed_at", "symbol", "side", "qty", "entry", "exit", "stop", "fees", "setup", "thesis", "emotion_entry", "emotion_exit", "followed_plan", "mistakes", "notes"];
- 17export class InputError extends Error {}
- 19export function parseCsv(text) {
- 20 const rows = [];
- 21 let row = [];
- 22 let field = "";
- 23 let quoted = false;
- 24 const pushRow = () => {
- 25 row.push(field);
- 26 field = "";
- 27 if (row.some((v) => v.trim() !== "")) rows.push(row);
- 28 row = [];
- 29 };
- 30 for (let i = 0; i < text.length; i++) {
- 31 const ch = text[i];
- 32 if (quoted) {
- 33 if (ch === '"' && text[i + 1] === '"') {
- 34 field += '"';
- 35 i++;
- 36 } else if (ch === '"') quoted = false;
- 37 else field += ch;
- 38 } else if (ch === '"') quoted = true;
- 39 else if (ch === ",") {
- 40 row.push(field);
- 41 field = "";
- 42 } else if (ch === "\n" || ch === "\r") {
- 43 if (ch === "\r" && text[i + 1] === "\n") i++;
- 44 pushRow();
- 45 } else field += ch;
- 46 }
- 47 pushRow();
- 48 const [header = [], ...body] = rows;
- 49 const keys = header.map((h) => h.trim().toLowerCase());
- 50 return body.map((r) => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? "").trim()])));
- 51}
- 53export function csvField(v) {
- 54 const s = v === undefined || v === null ? "" : String(v);
- 55 // Neutralize spreadsheet formulas in free text, then quote when needed.
- 56 const safe = /^[=+@]/.test(s) || (/^-/.test(s) && !/^-?\d/.test(s)) ? `'${s}` : s;
- 57 return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
- 58}
- 60const TS = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})$/;
- 62export function parseTs(s, label) {
- 63 const m = TS.exec(s ?? "");
- 64 if (!m) throw new InputError(`${label} must look like "2026-11-02 09:41" (exchange local time), got "${s ?? ""}"`);
- 65 const [, date, hh, mm] = m;
- 66 const minutes = Number(hh) * 60 + Number(mm);
- 67 if (Number(hh) > 23 || Number(mm) > 59) throw new InputError(`${label} has an invalid time`);
- 68 const day = Date.parse(`${date}T00:00:00Z`);
- 69 if (Number.isNaN(day)) throw new InputError(`${label} has an invalid date`);
- 70 return { date, minutes, abs: day / 60000 + minutes, weekday: new Date(day).getUTCDay() };
- 71}
- 73export function validateTrade(t, label = "trade") {
- 74 if (!t.symbol) throw new InputError(`${label}: symbol is required`);
- 75 if (t.side !== "long" && t.side !== "short") throw new InputError(`${label}: side must be long or short`);
- 76 for (const k of ["qty", "entry"]) {
- 77 const n = Number(t[k]);
- 78 if (!(n > 0)) throw new InputError(`${label}: ${k} must be a positive number`);
- 79 }
- 80 for (const k of ["exit", "stop", "fees"]) {
- 81 if (t[k] !== undefined && t[k] !== "" && !Number.isFinite(Number(t[k]))) throw new InputError(`${label}: ${k} must be a number`);
- 82 }
- 83 if (!t.setup) throw new InputError(`${label}: setup is required (name the pattern, for example "pullback to 20 EMA")`);
- 84 const o = parseTs(t.opened_at, `${label}: opened_at`);
- 85 if (t.closed_at) {
- 86 const c = parseTs(t.closed_at, `${label}: closed_at`);
- 87 if (c.abs < o.abs) throw new InputError(`${label}: closed_at is before opened_at`);
- 88 if (t.exit === undefined || t.exit === "") throw new InputError(`${label}: closed trades need an exit price`);
- 89 }
- 90 if (t.followed_plan && !["y", "n", "yes", "no"].includes(String(t.followed_plan).toLowerCase())) {
- 91 throw new InputError(`${label}: followed_plan must be y or n`);
- 92 }
- 93}
- 95export function addTrade(existingText, fields) {
- 96 const rows = existingText ? parseCsv(existingText) : [];
- 97 const nextId = rows.reduce((m, r) => Math.max(m, Number(r.id) || 0), 0) + 1;
- 98 const t = { ...fields, id: nextId, side: (fields.side ?? "").toLowerCase(), symbol: (fields.symbol ?? "").toUpperCase() };
- 99 validateTrade(t, "new trade");
- 100 const line = COLUMNS.map((c) => csvField(t[c])).join(",") + "\n";
- 101 return { id: nextId, line, header: existingText ? null : COLUMNS.join(",") + "\n" };
- 102}
- 104// Close an open trade in place: sets exit and closed_at (and optional fields), returns the new CSV text.
- 105export function closeTrade(text, id, fields) {
- 106 const rows = parseCsv(text);
- 107 const row = rows.find((r) => String(r.id) === String(id));
- 108 if (!row) throw new InputError(`no trade with id ${id}`);
- 109 if (row.closed_at) throw new InputError(`trade #${id} is already closed (${row.closed_at})`);
- 110 for (const [k, v] of Object.entries(fields)) if (v !== undefined) row[k] = v;
- 111 validateTrade(row, `trade #${id}`);
- 112 if (!row.closed_at) throw new InputError("--closed is required");
- 113 return COLUMNS.join(",") + "\n" + rows.map((r) => COLUMNS.map((c) => csvField(r[c])).join(",")).join("\n") + "\n";
- 114}
- 116// ---------- Review ----------
- 117export function timeOfDay(minutes) {
- 118 if (minutes < 570) return "1 pre-market (before 9:30)";
- 119 if (minutes < 630) return "2 open (9:30-10:30)";
- 120 if (minutes < 840) return "3 midday (10:30-14:00)";
- 121 if (minutes < 960) return "4 close (14:00-16:00)";
- 122 return "5 after-hours (16:00+)";
- 123}
- 125export function holdingBucket(mins, sameDay) {
- 126 if (mins < 30) return "1 scalp (<30m)";
- 127 if (sameDay) return "2 intraday";
- 128 if (mins <= 5 * 1440) return "3 swing (1-5 days)";
- 129 return "4 position (>5 days)";
- 130}
- 132const WEEKDAYS = ["7 Sun", "1 Mon", "2 Tue", "3 Wed", "4 Thu", "5 Fri", "6 Sat"];
- 134export function enrich(row, idx) {
- 135 const label = `line ${idx + 2}`;
- 136 validateTrade(row, label);
- 137 const qty = Number(row.qty);
- 138 const entry = Number(row.entry);
- 139 const o = parseTs(row.opened_at, label);
- 140 const t = {
- 141 id: row.id,
- 142 symbol: row.symbol,
- 143 side: row.side,
- 144 setup: row.setup.trim().toLowerCase(),
- 145 emotion_entry: (row.emotion_entry || "unrecorded").trim().toLowerCase(),
- 146 followed_plan: !row.followed_plan ? "unrecorded" : /^y/i.test(row.followed_plan) ? "yes" : "no",
- 147 mistakes: (row.mistakes || "")
- 148 .split(";")
- 149 .map((s) => s.trim().toLowerCase())
- 150 .filter(Boolean),
- 151 opened_date: o.date,
- 152 time_of_day: timeOfDay(o.minutes),
- 153 weekday: WEEKDAYS[o.weekday],
- 154 closed: !!row.closed_at,
- 155 };
- 156 if (!t.closed) return t;
- 157 const c = parseTs(row.closed_at, label);
- 158 const exit = Number(row.exit);
- 159 const fees = Number(row.fees || 0);
- 160 const dir = row.side === "long" ? 1 : -1;
- 161 t.pnl = (exit - entry) * qty * dir - fees;
- 162 t.return_pct = t.pnl / (entry * qty);
- 163 t.hold_minutes = c.abs - o.abs;
- 164 t.holding = holdingBucket(t.hold_minutes, c.date === o.date);
- 165 if (row.stop !== undefined && row.stop !== "") {
- 166 const risk = Math.abs(entry - Number(row.stop)) * qty;
- 167 t.r = risk > 0 ? t.pnl / risk : null;
- 168 } else t.r = null;
- 169 return t;
- 170}
- 172export function stats(trades) {
- 173 const n = trades.length;
- 174 const wins = trades.filter((t) => t.pnl > 0);
- 175 const losses = trades.filter((t) => t.pnl <= 0);
- 176 const sum = (a) => a.reduce((s, t) => s + t.pnl, 0);
- 177 const grossWin = sum(wins);
- 178 const grossLoss = -sum(losses);
- 179 const rs = trades.filter((t) => t.r !== null && t.r !== undefined);
- 180 return {
- 181 n,
- 182 wins: wins.length,
- 183 win_rate: n ? wins.length / n : null,
- 184 total_pnl: sum(trades),
- 185 expectancy: n ? sum(trades) / n : null,
- 186 avg_win: wins.length ? grossWin / wins.length : null,
- 187 avg_loss: losses.length ? -grossLoss / losses.length : null,
- 188 profit_factor: grossLoss > 0 ? grossWin / grossLoss : null,
- 189 avg_r: rs.length ? rs.reduce((s, t) => s + t.r, 0) / rs.length : null,
- 190 };
- 191}
- 193function groupBy(trades, key) {
- 194 const m = new Map();
- 195 for (const t of trades) {
- 196 const keys = Array.isArray(t[key]) ? (t[key].length ? t[key] : ["(none)"]) : [t[key]];
- 197 for (const k of keys) {
- 198 if (!m.has(k)) m.set(k, []);
- 199 m.get(k).push(t);
- 200 }
- 201 }
- 202 return [...m.entries()].map(([k, ts]) => ({ key: k, ...stats(ts) })).sort((a, b) => String(a.key).localeCompare(String(b.key)));
- 203}
- 205export const DIMENSIONS = [
- 206 ["setup", "Setup"],
- 207 ["time_of_day", "Time of day (entry)"],
- 208 ["holding", "Holding period"],
- 209 ["emotion_entry", "Emotion at entry"],
- 210 ["followed_plan", "Followed plan"],
- 211 ["weekday", "Weekday"],
- 212 ["side", "Side"],
- 213 ["mistakes", "Mistakes tagged"],
- 214];
- 216const money = (x) => (x === null ? "n/a" : `${x < 0 ? "-" : ""}$${Math.abs(x).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
- 217const pct = (x) => (x === null ? "n/a" : `${(x * 100).toFixed(0)}%`);
- 218const clean = (k) => String(k).replace(/^\d /, "");
- 220export function findPatterns(overall, groups, minN) {
- 221 const out = [];
- 222 for (const [dim, label] of DIMENSIONS) {
- 223 if (dim === "mistakes") continue;
- 224 const g = groups[dim].filter((x) => x.n >= minN && x.key !== "unrecorded");
- 225 if (g.length < 2) continue;
- 226 const best = [...g].sort((a, b) => b.expectancy - a.expectancy)[0];
- 227 const worst = [...g].sort((a, b) => a.expectancy - b.expectancy)[0];
- 228 if (best.expectancy > 0 && best.expectancy > overall.expectancy) {
- 229 out.push({ kind: "edge", dimension: label, key: clean(best.key), n: best.n, text: `${label}: "${clean(best.key)}" is your best (${best.n} trades, ${pct(best.win_rate)} win rate, ${money(best.expectancy)} per trade, ${money(best.total_pnl)} total).` });
- 230 }
- 231 if (worst.total_pnl < 0 && worst.key !== best.key) {
- 232 out.push({ kind: "leak", dimension: label, key: clean(worst.key), n: worst.n, text: `${label}: "${clean(worst.key)}" is costing you (${worst.n} trades, ${pct(worst.win_rate)} win rate, ${money(worst.expectancy)} per trade, ${money(worst.total_pnl)} total).` });
- 233 }
- 234 }
- 235 const mistakes = groups.mistakes.filter((x) => x.key !== "(none)").sort((a, b) => a.total_pnl - b.total_pnl);
- 236 for (const m of mistakes.slice(0, 3)) {
- 237 out.push({ kind: "mistake", dimension: "Mistakes tagged", key: m.key, n: m.n, text: `Mistake "${m.key}" appeared on ${m.n} trade${m.n === 1 ? "" : "s"} totaling ${money(m.total_pnl)}.` });
- 238 }
- 239 return out;
- 240}
- 242export function review(rows, { from = null, to = null, minN = 3 } = {}) {
- 243 const all = rows.map(enrich);
- 244 const inRange = all.filter((t) => (!from || t.opened_date >= from) && (!to || t.opened_date <= to));
- 245 const closed = inRange.filter((t) => t.closed);
- 246 const open = inRange.filter((t) => !t.closed);
- 247 const overall = stats(closed);
- 248 const groups = Object.fromEntries(DIMENSIONS.map(([k]) => [k, groupBy(closed, k)]));
- 249 const patterns = closed.length ? findPatterns(overall, groups, minN) : [];
- 250 const sorted = [...closed].sort((a, b) => b.pnl - a.pnl);
- 251 return {
- 252 range: { from: from ?? (inRange[0]?.opened_date ?? null), to: to ?? (inRange.at(-1)?.opened_date ?? null) },
- 253 min_n: minN,
- 254 overall,
- 255 open_trades: open.map((t) => ({ id: t.id, symbol: t.symbol, side: t.side, setup: t.setup })),
- 256 groups,
- 257 patterns,
- 258 best_trade: sorted[0] ?? null,
- 259 worst_trade: sorted.at(-1) ?? null,
- 260 };
- 261}
- 263export function toMarkdown(r) {
- 264 const L = [];
- 265 const o = r.overall;
- 266 L.push(`# Trading review: ${r.range.from ?? "?"} to ${r.range.to ?? "?"}`, "");
- 267 if (!o.n) {
- 268 L.push("No closed trades in this range.", "");
- 269 } else {
- 270 L.push(
- 271 `Closed trades ${o.n} | Win rate ${pct(o.win_rate)} | Net P&L ${money(o.total_pnl)} | Expectancy ${money(o.expectancy)} per trade | Profit factor ${o.profit_factor === null ? "n/a" : o.profit_factor.toFixed(2)} | Avg R ${o.avg_r === null ? "n/a" : o.avg_r.toFixed(2)}`,
- 272 "",
- 273 );
- 274 L.push("## Patterns", "");
- 275 if (r.patterns.length) for (const p of r.patterns) L.push(`- ${p.text}`);
- 276 else L.push(`- No group has at least ${r.min_n} trades yet. Keep logging.`);
- 277 L.push(`- Groups with fewer than ${r.min_n} trades are shown below but not called patterns.`, "");
- 278 for (const [dim, label] of DIMENSIONS) {
- 279 L.push(`## By ${label.toLowerCase()}`, "", "| Group | Trades | Win rate | Net P&L | Per trade | Avg R |", "| --- | ---: | ---: | ---: | ---: | ---: |");
- 280 for (const g of r.groups[dim]) {
- 281 L.push(`| ${clean(g.key)} | ${g.n} | ${pct(g.win_rate)} | ${money(g.total_pnl)} | ${money(g.expectancy)} | ${g.avg_r === null ? "n/a" : g.avg_r.toFixed(2)} |`);
- 282 }
- 283 L.push("");
- 284 }
- 285 L.push("## Best and worst", "");
- 286 L.push(`- Best: #${r.best_trade.id} ${r.best_trade.symbol} ${r.best_trade.setup}, ${money(r.best_trade.pnl)}`);
- 287 L.push(`- Worst: #${r.worst_trade.id} ${r.worst_trade.symbol} ${r.worst_trade.setup}, ${money(r.worst_trade.pnl)}`, "");
- 288 }
- 289 if (r.open_trades.length) L.push(`Open trades not counted: ${r.open_trades.map((t) => `#${t.id} ${t.symbol}`).join(", ")}`, "");
- 290 L.push("_Not financial advice. Small samples mislead: treat patterns as questions to test, not rules._", "");
- 291 return L.join("\n");
- 292}
- 294// ---------- CLI ----------
- 295function parseFlags(rest) {
- 296 const o = {};
- 297 for (let i = 0; i < rest.length; i++) {
- 298 const a = rest[i];
- 299 if (!a.startsWith("--")) throw new InputError(`unexpected argument "${a}"`);
- 300 if (rest[i + 1] === undefined) throw new InputError(`${a} needs a value`);
- 301 o[a.slice(2)] = rest[++i];
- 302 }
- 303 return o;
- 304}
- 306function main(argv) {
- 307 const [cmd, ...rest] = argv;
- 308 const o = parseFlags(rest);
- 309 const file = o.file ?? "journal/trades.csv";
- 310 if (cmd === "add") {
- 311 const fields = {
- 312 symbol: o.symbol,
- 313 side: o.side,
- 314 qty: o.qty,
- 315 entry: o.entry,
- 316 exit: o.exit,
- 317 stop: o.stop,
- 318 fees: o.fees,
- 319 opened_at: o.opened,
- 320 closed_at: o.closed,
- 321 setup: o.setup,
- 322 thesis: o.thesis,
- 323 emotion_entry: o["emotion-entry"],
- 324 emotion_exit: o["emotion-exit"],
- 325 followed_plan: o["followed-plan"],
- 326 mistakes: o.mistakes,
- 327 notes: o.notes,
- 328 };
- 329 const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
- 330 const { id, line, header } = addTrade(existing, fields);
- 331 mkdirSync(dirname(file) || ".", { recursive: true });
- 332 const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
- 333 appendFileSync(file, (header ?? "") + prefix + line);
- 334 console.log(`Logged trade #${id} to ${file}`);
- 335 return;
- 336 }
- 337 if (cmd === "close") {
- 338 if (!existsSync(file)) throw new InputError(`${file} not found`);
- 339 if (!o.id) throw new InputError("--id is required");
- 340 const updated = closeTrade(readFileSync(file, "utf8"), o.id, {
- 341 exit: o.exit,
- 342 closed_at: o.closed,
- 343 fees: o.fees,
- 344 emotion_exit: o["emotion-exit"],
- 345 followed_plan: o["followed-plan"],
- 346 mistakes: o.mistakes,
- 347 notes: o.notes,
- 348 });
- 349 writeFileSync(file, updated);
- 350 console.log(`Closed trade #${o.id} in ${file}`);
- 351 return;
- 352 }
- 353 if (cmd === "review") {
- 354 if (!existsSync(file)) throw new InputError(`${file} not found. Log a trade first with: node scripts/journal.mjs add ...`);
- 355 const minN = Number(o["min-n"] ?? 3);
- 356 for (const k of ["from", "to"]) if (o[k] && !/^\d{4}-\d{2}-\d{2}$/.test(o[k])) throw new InputError(`--${k} must be YYYY-MM-DD`);
- 357 const result = review(parseCsv(readFileSync(file, "utf8")), { from: o.from ?? null, to: o.to ?? null, minN });
- 358 const base = o.out ?? "out/review";
- 359 mkdirSync(dirname(base) || ".", { recursive: true });
- 360 const mdPath = base + ".md";
- 361 const jsonPath = base + ".json";
- 362 writeFileSync(mdPath, toMarkdown(result));
- 363 writeFileSync(jsonPath, JSON.stringify(result, null, 2) + "\n");
- 364 const md = toMarkdown(result).split("\n");
- 365 console.log([md[2], ...result.patterns.map((p) => `- ${p.text}`)].join("\n"));
- 366 console.log(`Wrote ${mdPath} and ${jsonPath}`);
- 367 return;
- 368 }
- 369 throw new InputError(`unknown command "${cmd ?? ""}". Use add, close, or review`);
- 370}
- 372function isMain() {
- 373 try {
- 374 return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
- 375 } catch {
- 376 return false;
- 377 }
- 378}
- 380if (isMain()) {
- 381 try {
- 382 main(process.argv.slice(2));
- 383 } catch (e) {
- 384 console.error(e instanceof InputError ? `Input problem: ${e.message}` : e.stack);
- 385 process.exit(e instanceof InputError ? 2 : 1);
- 386 }
- 387}
