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// Self-test for the trade journal. Usage: node scripts/selftest.mjs (exits 1 on any failure)
- 3import { readFileSync } from "node:fs";
- 4import { dirname, join } from "node:path";
- 5import { fileURLToPath } from "node:url";
- 6import { addTrade, closeTrade, csvField, enrich, holdingBucket, parseCsv, review, timeOfDay } from "./journal.mjs";
- 8const here = dirname(fileURLToPath(import.meta.url));
- 9let passed = 0;
- 10let failed = 0;
- 11function close(a, e, label, tol = 1e-9) {
- 12 if (typeof a === "number" && Math.abs(a - e) <= tol) passed++;
- 13 else {
- 14 failed++;
- 15 console.error(`FAIL ${label}: expected ${e}, got ${a}`);
- 16 }
- 17}
- 18function eq(a, e, label) {
- 19 if (JSON.stringify(a) === JSON.stringify(e)) passed++;
- 20 else {
- 21 failed++;
- 22 console.error(`FAIL ${label}: expected ${JSON.stringify(e)}, got ${JSON.stringify(a)}`);
- 23 }
- 24}
- 25function throws(fn, text, label) {
- 26 try {
- 27 fn();
- 28 failed++;
- 29 console.error(`FAIL ${label}: no error`);
- 30 } catch (e) {
- 31 if (e.message.includes(text)) passed++;
- 32 else {
- 33 failed++;
- 34 console.error(`FAIL ${label}: ${e.message}`);
- 35 }
- 36 }
- 37}
- 39const rows = parseCsv(readFileSync(join(here, "../examples/trades.csv"), "utf8"));
- 41// P&L and R by hand
- 42{
- 43 const t1 = enrich(rows[0], 0); // long 100 @ 50.00 -> 51.20, stop 49.40, fees 1
- 44 close(t1.pnl, 119, "long pnl = 1.20 x 100 - 1");
- 45 close(t1.r, 119 / 60, "R = 119 / (0.60 x 100)");
- 46 eq([t1.time_of_day, t1.holding, t1.weekday], ["2 open (9:30-10:30)", "1 scalp (<30m)", "1 Mon"], "buckets for trade 1");
- 47 const t2 = enrich(rows[1], 1); // short 200 @ 30.00 -> 30.35, stop 30.50
- 48 close(t2.pnl, -71, "short pnl = -0.35 x 200 - 1");
- 49 close(t2.r, -0.71, "short R = -71 / 100");
- 50 const t5 = enrich(rows[4], 4); // held 2 days
- 51 eq(t5.holding, "3 swing (1-5 days)", "multi-day hold");
- 52 eq(enrich(rows[17], 17).closed, false, "blank closed_at is an open trade");
- 53}
- 55// Buckets
- 56eq(timeOfDay(9 * 60 + 29), "1 pre-market (before 9:30)", "9:29 is pre-market");
- 57eq(timeOfDay(10 * 60 + 30), "3 midday (10:30-14:00)", "10:30 is midday");
- 58eq(timeOfDay(16 * 60), "5 after-hours (16:00+)", "16:00 is after-hours");
- 59eq(holdingBucket(29, true), "1 scalp (<30m)", "29 minutes is a scalp");
- 60eq(holdingBucket(6 * 1440, false), "4 position (>5 days)", "6 days is a position");
- 62// Review totals and patterns on the example journal
- 63{
- 64 const r = review(rows, { minN: 3 });
- 65 eq(r.overall.n, 17, "17 closed trades");
- 66 close(r.overall.total_pnl, 763.4, "net P&L", 1e-6);
- 67 eq(r.open_trades.map((t) => t.id), ["18"], "trade 18 is open");
- 68 const setup = Object.fromEntries(r.groups.setup.map((g) => [g.key, g]));
- 69 eq([setup["opening range breakout"].n, setup["opening range breakout"].wins], [8, 6], "ORB 6 of 8");
- 70 close(setup["vwap fade"].total_pnl, -355, "vwap fade total", 1e-6);
- 71 const kinds = r.patterns.map((p) => `${p.kind}:${p.dimension}:${p.key}`);
- 72 eq(kinds.includes("edge:Setup:opening range breakout"), true, "finds the ORB edge");
- 73 eq(kinds.includes("leak:Setup:vwap fade"), true, "finds the vwap fade leak");
- 74 eq(kinds.includes("leak:Followed plan:no"), true, "finds the off-plan leak");
- 75 eq(kinds.includes("mistake:Mistakes tagged:chased"), true, "lists the chasing mistake");
- 76 eq(r.patterns.some((p) => p.key === "power hour trend"), false, "groups under min-n are not patterns");
- 77 const week = review(rows, { from: "2026-10-26", to: "2026-10-30" });
- 78 eq(week.overall.n, 8, "date filter keeps the last week's 8 closed trades");
- 79}
- 81// Adding trades
- 82{
- 83 const first = addTrade("", { symbol: "xyz", side: "Long", qty: "10", entry: "5", opened_at: "2026-11-02 09:31", setup: "gap fill", thesis: 'Said "fill", then, filled' });
- 84 eq(first.id, 1, "first id is 1");
- 85 eq(first.header.startsWith("id,opened_at,closed_at,symbol"), true, "header on a new file");
- 86 eq(first.line.includes('"Said ""fill"", then, filled"'), true, "quotes commas and quotes");
- 87 const next = addTrade(readFileSync(join(here, "../examples/trades.csv"), "utf8"), { symbol: "XYZ", side: "short", qty: "1", entry: "5", exit: "4", opened_at: "2026-11-02 09:31", closed_at: "2026-11-02 10:00", setup: "x" });
- 88 eq([next.id, next.header], [19, null], "next id after 18, no header");
- 89 throws(() => addTrade("", { symbol: "X", side: "long", qty: "1", entry: "5", opened_at: "2026-11-02 9:31", setup: "x" }), "must look like", "time format");
- 90 throws(() => addTrade("", { symbol: "X", side: "long", qty: "1", entry: "5", opened_at: "2026-11-02 09:31", closed_at: "2026-11-02 09:00", exit: "5", setup: "x" }), "before opened_at", "close before open");
- 91 throws(() => addTrade("", { symbol: "X", side: "long", qty: "1", entry: "5", opened_at: "2026-11-02 09:31" }), "setup is required", "setup required");
- 92 throws(() => addTrade("", { symbol: "X", side: "long", qty: "1", entry: "5", opened_at: "2026-11-02 09:31", closed_at: "2026-11-02 10:00", setup: "x" }), "exit price", "closed needs exit");
- 93 eq(csvField("=HYPERLINK(1)"), "'=HYPERLINK(1)", "neutralizes spreadsheet formulas");
- 94 eq(csvField("-0.5"), "-0.5", "keeps negative numbers");
- 95}
- 97// Closing an open trade rewrites only that row
- 98{
- 99 const text = readFileSync(join(here, "../examples/trades.csv"), "utf8");
- 100 const updated = closeTrade(text, "18", { exit: "30.90", closed_at: "2026-11-02 10:05", emotion_exit: "relieved" });
- 101 const t18 = enrich(parseCsv(updated).find((r) => r.id === "18"), 17);
- 102 close(t18.pnl, 60, "short 100 from 31.50 to 30.90 = +60");
- 103 eq(t18.holding, "3 swing (1-5 days)", "Friday to Monday is a swing");
- 104 eq(parseCsv(updated).length, 18, "row count unchanged");
- 105 throws(() => closeTrade(updated, "18", { exit: "1", closed_at: "2026-11-02 11:00" }), "already closed", "cannot close twice");
- 106 throws(() => closeTrade(text, "99", {}), "no trade with id 99", "unknown id");
- 107}
- 109console.log(`${passed} passed, ${failed} failed`);
- 110process.exit(failed ? 1 : 0);
