Skip to content
SlashClone
Made by@qtrader

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.

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