Position sizer
@qtrader/position-sizer
Work out how many shares to buy before you click. Fixed-fractional, ATR (volatility) based, and capped fractional-Kelly sizing, plus a portfolio heat check that totals the risk across open positions and tells you if a new trade fits. 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// Position sizing and portfolio heat. Node built-ins only. Not financial advice.
- 3//
- 4// node scripts/size.mjs fixed --equity 50000 --risk-pct 1 --entry 100 --stop 95 [--max-position-pct 25] [--fractional]
- 5// node scripts/size.mjs atr --equity 50000 --risk-pct 1 (--atr 2.4 --entry 100 | --prices data/X.csv) [--atr-period 14] [--atr-mult 2] [--side long|short]
- 6// node scripts/size.mjs kelly (--win-rate 0.55 --payoff 1.5 | --trades data/trades.csv) [--fraction 0.5] [--cap 0.05] [--equity 50000 --entry 100 --stop 95]
- 7// node scripts/size.mjs heat --equity 50000 --positions data/positions.csv [--max-heat-pct 6] [--max-risk-pct 2] [--add SYM,long,qty,entry,stop]
- 8// Add --json to print JSON, or --out out/size.json to save it.
- 9import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
- 10import { dirname } from "node:path";
- 11import { fileURLToPath } from "node:url";
- 13export class InputError extends Error {}
- 15export function parseCsv(text) {
- 16 const rows = [];
- 17 let row = [];
- 18 let field = "";
- 19 let quoted = false;
- 20 const pushRow = () => {
- 21 row.push(field);
- 22 field = "";
- 23 if (row.some((v) => v.trim() !== "")) rows.push(row);
- 24 row = [];
- 25 };
- 26 for (let i = 0; i < text.length; i++) {
- 27 const ch = text[i];
- 28 if (quoted) {
- 29 if (ch === '"' && text[i + 1] === '"') {
- 30 field += '"';
- 31 i++;
- 32 } else if (ch === '"') quoted = false;
- 33 else field += ch;
- 34 } else if (ch === '"') quoted = true;
- 35 else if (ch === ",") {
- 36 row.push(field);
- 37 field = "";
- 38 } else if (ch === "\n" || ch === "\r") {
- 39 if (ch === "\r" && text[i + 1] === "\n") i++;
- 40 pushRow();
- 41 } else field += ch;
- 42 }
- 43 pushRow();
- 44 const [header = [], ...body] = rows;
- 45 const keys = header.map((h) => h.trim().toLowerCase());
- 46 return body.map((r) => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? "").trim()])));
- 47}
- 49function need(o, k) {
- 50 if (o[k] === undefined || o[k] === "") throw new InputError(`--${k} is required`);
- 51 const n = Number(o[k]);
- 52 if (!Number.isFinite(n)) throw new InputError(`--${k} must be a number, got "${o[k]}"`);
- 53 return n;
- 54}
- 55function opt(o, k, d) {
- 56 return o[k] === undefined ? d : need(o, k);
- 57}
- 58const roundShares = (x, fractional) => (fractional ? Math.floor(x * 10000) / 10000 : Math.floor(x + 1e-9));
- 60// Core: shares so that (entry - stop) x shares = equity x risk%, then capped by position size and cash.
- 61export function fixedFractional({ equity, riskPct, entry, stop, maxPositionPct = 100, fractional = false }) {
- 62 if (equity <= 0) throw new InputError("equity must be positive");
- 63 if (riskPct <= 0 || riskPct > 100) throw new InputError("risk-pct must be between 0 and 100");
- 64 if (entry <= 0 || stop <= 0) throw new InputError("entry and stop must be positive prices");
- 65 if (entry === stop) throw new InputError("stop must differ from entry");
- 66 const side = stop < entry ? "long" : "short";
- 67 const perShareRisk = Math.abs(entry - stop);
- 68 const riskBudget = (equity * riskPct) / 100;
- 69 const byRisk = roundShares(riskBudget / perShareRisk, fractional);
- 70 const byCap = roundShares((equity * maxPositionPct) / 100 / entry, fractional);
- 71 const shares = Math.min(byRisk, byCap);
- 72 const notes = [];
- 73 if (byCap < byRisk) notes.push(`capped by max position ${maxPositionPct}% of equity (risk alone allowed ${byRisk})`);
- 74 if (shares === 0) notes.push("size rounds to 0 shares: the stop is too wide for this risk budget, or use --fractional");
- 75 return {
- 76 method: "fixed-fractional",
- 77 side,
- 78 shares,
- 79 entry,
- 80 stop,
- 81 per_share_risk: perShareRisk,
- 82 risk_budget: riskBudget,
- 83 dollar_risk: shares * perShareRisk,
- 84 risk_pct_of_equity: (shares * perShareRisk * 100) / equity,
- 85 position_value: shares * entry,
- 86 position_pct_of_equity: (shares * entry * 100) / equity,
- 87 notes,
- 88 };
- 89}
- 91// Wilder ATR. TR[0] = high - low; TR[k] = max(h - l, |h - prevClose|, |l - prevClose|).
- 92// Seed = mean of TR[0..n-1]; then ATR = (prev x (n - 1) + TR) / n.
- 93export function atr(bars, n = 14) {
- 94 if (bars.length < n) throw new InputError(`need at least ${n} bars for ATR(${n}), got ${bars.length}`);
- 95 const tr = bars.map((b, k) => (k === 0 ? b.high - b.low : Math.max(b.high - b.low, Math.abs(b.high - bars[k - 1].close), Math.abs(b.low - bars[k - 1].close))));
- 96 let value = tr.slice(0, n).reduce((a, b) => a + b, 0) / n;
- 97 for (let k = n; k < tr.length; k++) value = (value * (n - 1) + tr[k]) / n;
- 98 return value;
- 99}
- 101export function atrSizing({ equity, riskPct, entry, atrValue, mult = 2, side = "long", maxPositionPct = 100, fractional = false }) {
- 102 if (atrValue <= 0) throw new InputError("ATR must be positive");
- 103 const dist = atrValue * mult;
- 104 const stop = side === "short" ? entry + dist : entry - dist;
- 105 if (stop <= 0) throw new InputError(`stop ${stop.toFixed(2)} is at or below 0; lower --atr-mult`);
- 106 return { ...fixedFractional({ equity, riskPct, entry, stop, maxPositionPct, fractional }), method: "atr", atr: atrValue, atr_mult: mult };
- 107}
- 109// Kelly for a bet that wins `payoff` x the amount risked with probability p, else loses the amount risked:
- 110// f* = p - (1 - p) / payoff. We size with fraction x f*, capped. f is the share of equity put AT RISK (to the stop).
- 111export function kelly({ winRate, payoff, fraction = 0.5, cap = 0.05 }) {
- 112 if (!(winRate > 0 && winRate < 1)) throw new InputError("win-rate must be between 0 and 1 (for example 0.55)");
- 113 if (!(payoff > 0)) throw new InputError("payoff (average win / average loss) must be positive");
- 114 if (!(fraction > 0 && fraction <= 1)) throw new InputError("fraction must be in (0, 1]");
- 115 if (!(cap > 0 && cap <= 1)) throw new InputError("cap must be in (0, 1]");
- 116 const full = winRate - (1 - winRate) / payoff;
- 117 const notes = [];
- 118 let applied = Math.max(0, full) * fraction;
- 119 if (full <= 0) notes.push("no edge: full Kelly is 0 or negative, so the size is 0. Do not take this trade on these stats.");
- 120 if (applied > cap) {
- 121 notes.push(`capped at ${cap * 100}% (${fraction} x Kelly was ${(applied * 100).toFixed(2)}%)`);
- 122 applied = cap;
- 123 }
- 124 return { method: "kelly", win_rate: winRate, payoff, full_kelly: full, fraction, cap, risk_fraction: applied, notes };
- 125}
- 127export function statsFromTrades(rows) {
- 128 const key = ["r_multiple", "r", "pnl", "profit", "net_pnl"].find((k) => rows.length && k in rows[0]);
- 129 if (!key) throw new InputError("trades file needs a pnl (or r_multiple) column");
- 130 const vals = rows.map((r) => Number(r[key])).filter((x) => Number.isFinite(x));
- 131 const wins = vals.filter((x) => x > 0);
- 132 const losses = vals.filter((x) => x < 0);
- 133 if (!wins.length || !losses.length) throw new InputError("need at least one win and one loss to estimate payoff");
- 134 const avgWin = wins.reduce((a, b) => a + b, 0) / wins.length;
- 135 const avgLoss = -losses.reduce((a, b) => a + b, 0) / losses.length;
- 136 return { n: vals.length, column: key, win_rate: wins.length / vals.length, payoff: avgWin / avgLoss, avg_win: avgWin, avg_loss: avgLoss };
- 137}
- 139// Open risk per position: what you would give back if every stop hit right now, measured from the
- 140// reference price (current price if given, else entry) to the stop. No stop = whole position at risk.
- 141export function heat({ equity, positions, maxHeatPct = 6, maxRiskPct = 2, add = null }) {
- 142 if (equity <= 0) throw new InputError("equity must be positive");
- 143 const rows = positions.map((p, idx) => {
- 144 const qty = Number(p.qty);
- 145 const entry = Number(p.entry);
- 146 const side = (p.side || "long").toLowerCase();
- 147 if (!p.symbol) throw new InputError(`positions line ${idx + 2}: symbol is blank`);
- 148 if (!Number.isFinite(qty) || qty <= 0) throw new InputError(`positions line ${idx + 2}: qty must be a positive number`);
- 149 if (!Number.isFinite(entry) || entry <= 0) throw new InputError(`positions line ${idx + 2}: entry must be a positive price`);
- 150 if (side !== "long" && side !== "short") throw new InputError(`positions line ${idx + 2}: side must be long or short`);
- 151 const ref = p.price !== undefined && p.price !== "" ? Number(p.price) : entry;
- 152 const hasStop = p.stop !== undefined && p.stop !== "";
- 153 const stop = hasStop ? Number(p.stop) : null;
- 154 const perShare = !hasStop ? ref : side === "long" ? Math.max(0, ref - stop) : Math.max(0, stop - ref);
- 155 const risk = qty * perShare;
- 156 const flags = [];
- 157 if (!hasStop) flags.push("NO STOP: full value at risk");
- 158 if (hasStop && (side === "long" ? stop >= entry : stop <= entry)) flags.push("stop locks in profit vs entry");
- 159 if ((risk * 100) / equity > maxRiskPct) flags.push(`over ${maxRiskPct}% single-trade limit`);
- 160 return { symbol: p.symbol, side, qty, entry, price: ref, stop, risk, risk_pct: (risk * 100) / equity, flags };
- 161 });
- 162 const total = rows.reduce((a, r) => a + r.risk, 0);
- 163 const totalPct = (total * 100) / equity;
- 164 const out = {
- 165 method: "heat",
- 166 equity,
- 167 max_heat_pct: maxHeatPct,
- 168 max_risk_pct: maxRiskPct,
- 169 positions: rows,
- 170 total_risk: total,
- 171 heat_pct: totalPct,
- 172 status: totalPct > maxHeatPct + 1e-12 ? "OVER" : "OK",
- 173 room_pct: Math.max(0, maxHeatPct - totalPct),
- 174 };
- 175 if (add) {
- 176 const [symbol, side, qtyS, entryS, stopS] = add.split(",").map((s) => s.trim());
- 177 const entry = Number(entryS);
- 178 const stop = Number(stopS);
- 179 const qty = Number(qtyS);
- 180 if (!symbol || !Number.isFinite(entry) || !Number.isFinite(stop) || !Number.isFinite(qty) || entry === stop) {
- 181 throw new InputError("--add must look like SYMBOL,long,qty,entry,stop with numbers, and stop different from entry");
- 182 }
- 183 const perShare = Math.abs(entry - stop);
- 184 const risk = qty * perShare;
- 185 const roomDollars = (out.room_pct * equity) / 100;
- 186 const singleDollars = (maxRiskPct * equity) / 100;
- 187 const maxQty = Math.floor(Math.min(roomDollars, singleDollars) / perShare + 1e-9);
- 188 out.proposed = {
- 189 symbol,
- 190 side: (side || "long").toLowerCase(),
- 191 qty,
- 192 risk,
- 193 risk_pct: (risk * 100) / equity,
- 194 heat_after_pct: ((total + risk) * 100) / equity,
- 195 fits: qty <= maxQty,
- 196 max_qty_that_fits: maxQty,
- 197 };
- 198 }
- 199 return out;
- 200}
- 202const money = (x) => x.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
- 203const p2 = (x) => `${x.toFixed(2)}%`;
- 205export function toText(r) {
- 206 const L = [];
- 207 if (r.method === "heat") {
- 208 L.push(`Portfolio heat: ${p2(r.heat_pct)} of equity at risk ($${money(r.total_risk)}) | limit ${p2(r.max_heat_pct)} | ${r.status}`);
- 209 L.push("Symbol Side Qty Ref Stop Risk $ Risk % Flags");
- 210 for (const p of r.positions) {
- 211 L.push(
- 212 `${p.symbol.padEnd(7)} ${p.side.padEnd(6)} ${String(p.qty).padEnd(8)} ${String(p.price).padEnd(8)} ${String(p.stop ?? "-").padEnd(8)} ${money(p.risk).padStart(11)} ${p2(p.risk_pct).padStart(7)} ${p.flags.join("; ")}`,
- 213 );
- 214 }
- 215 if (r.proposed) {
- 216 const q = r.proposed;
- 217 L.push(`Proposed ${q.symbol} x${q.qty}: risk $${money(q.risk)} (${p2(q.risk_pct)}), heat after ${p2(q.heat_after_pct)}. ${q.fits ? "Fits." : `Does not fit. Max that fits: ${q.max_qty_that_fits} shares.`}`);
- 218 }
- 219 } else if (r.method === "kelly") {
- 220 L.push(`Kelly: win rate ${p2(r.win_rate * 100)}, payoff ${r.payoff.toFixed(2)} -> full Kelly ${p2(r.full_kelly * 100)}, using ${r.fraction} x Kelly capped at ${p2(r.cap * 100)} = risk ${p2(r.risk_fraction * 100)} of equity per trade`);
- 221 if (r.sizing) L.push(`That is $${money(r.sizing.risk_budget)} at risk: ${r.sizing.shares} shares (${r.sizing.side}) at ${r.sizing.entry} with stop ${r.sizing.stop}`);
- 222 if (r.sample) L.push(`From ${r.sample.n} trades in column "${r.sample.column}".${r.sample.n < 30 ? " Fewer than 30 trades: these stats are noisy, so treat this as an upper bound." : ""}`);
- 223 } else {
- 224 L.push(`${r.method}: ${r.shares} shares ${r.side} at ${r.entry}, stop ${Number(r.stop.toFixed(4))}${r.atr !== undefined ? ` (${r.atr_mult} x ATR ${Number(r.atr.toFixed(4))})` : ""}`);
- 225 L.push(`Risk $${money(r.dollar_risk)} (${p2(r.risk_pct_of_equity)} of equity, budget $${money(r.risk_budget)}) | Position $${money(r.position_value)} (${p2(r.position_pct_of_equity)} of equity)`);
- 226 }
- 227 for (const n of r.notes ?? []) L.push(`Note: ${n}`);
- 228 L.push("Not financial advice.");
- 229 return L.join("\n") + "\n";
- 230}
- 232export function run(argv, readFile = (p) => readFileSync(p, "utf8")) {
- 233 const [cmd, ...rest] = argv;
- 234 const o = {};
- 235 for (let i = 0; i < rest.length; i++) {
- 236 const a = rest[i];
- 237 if (!a.startsWith("--")) throw new InputError(`unexpected argument "${a}"`);
- 238 const k = a.slice(2);
- 239 if (k === "json" || k === "fractional") o[k] = true;
- 240 else {
- 241 if (rest[i + 1] === undefined) throw new InputError(`--${k} needs a value`);
- 242 o[k] = rest[++i];
- 243 }
- 244 }
- 245 const fractional = !!o.fractional;
- 246 const maxPositionPct = opt(o, "max-position-pct", 100);
- 247 if (cmd === "fixed") {
- 248 return fixedFractional({ equity: need(o, "equity"), riskPct: need(o, "risk-pct"), entry: need(o, "entry"), stop: need(o, "stop"), maxPositionPct, fractional });
- 249 }
- 250 if (cmd === "atr") {
- 251 const n = opt(o, "atr-period", 14);
- 252 let atrValue;
- 253 let entry;
- 254 if (o.prices) {
- 255 const bars = parseCsv(readFile(o.prices)).map((r) => ({ high: +r.high, low: +r.low, close: +r.close }));
- 256 if (bars.some((b) => ![b.high, b.low, b.close].every(Number.isFinite))) throw new InputError(`${o.prices} needs numeric high, low, close columns`);
- 257 atrValue = atr(bars, n);
- 258 entry = opt(o, "entry", bars[bars.length - 1].close);
- 259 } else {
- 260 atrValue = need(o, "atr");
- 261 entry = need(o, "entry");
- 262 }
- 263 const side = o.side ?? "long";
- 264 if (side !== "long" && side !== "short") throw new InputError("--side must be long or short");
- 265 return atrSizing({ equity: need(o, "equity"), riskPct: need(o, "risk-pct"), entry, atrValue, mult: opt(o, "atr-mult", 2), side, maxPositionPct, fractional });
- 266 }
- 267 if (cmd === "kelly") {
- 268 let sample = null;
- 269 let winRate;
- 270 let payoff;
- 271 if (o.trades) {
- 272 sample = statsFromTrades(parseCsv(readFile(o.trades)));
- 273 winRate = sample.win_rate;
- 274 payoff = sample.payoff;
- 275 } else {
- 276 winRate = need(o, "win-rate");
- 277 payoff = need(o, "payoff");
- 278 }
- 279 const k = kelly({ winRate, payoff, fraction: opt(o, "fraction", 0.5), cap: opt(o, "cap", 0.05) });
- 280 if (sample) k.sample = sample;
- 281 if (o.equity !== undefined && o.entry !== undefined && o.stop !== undefined && k.risk_fraction > 0) {
- 282 k.sizing = fixedFractional({ equity: need(o, "equity"), riskPct: k.risk_fraction * 100, entry: need(o, "entry"), stop: need(o, "stop"), maxPositionPct, fractional });
- 283 k.notes.push(...k.sizing.notes);
- 284 }
- 285 return k;
- 286 }
- 287 if (cmd === "heat") {
- 288 if (!o.positions) throw new InputError("--positions is required (CSV: symbol,side,qty,entry,stop[,price])");
- 289 return heat({ equity: need(o, "equity"), positions: parseCsv(readFile(o.positions)), maxHeatPct: opt(o, "max-heat-pct", 6), maxRiskPct: opt(o, "max-risk-pct", 2), add: o.add ?? null });
- 290 }
- 291 throw new InputError(`unknown command "${cmd ?? ""}". Use fixed, atr, kelly, or heat`);
- 292}
- 294function isMain() {
- 295 try {
- 296 return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
- 297 } catch {
- 298 return false;
- 299 }
- 300}
- 302if (isMain()) {
- 303 const argv = process.argv.slice(2);
- 304 try {
- 305 const outIdx = argv.indexOf("--out");
- 306 const outPath = outIdx === -1 ? null : argv[outIdx + 1];
- 307 if (outIdx !== -1) argv.splice(outIdx, 2);
- 308 const result = run(argv);
- 309 if (outPath) {
- 310 mkdirSync(dirname(outPath) || ".", { recursive: true });
- 311 writeFileSync(outPath, JSON.stringify(result, null, 2) + "\n");
- 312 }
- 313 process.stdout.write(argv.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : toText(result));
- 314 } catch (e) {
- 315 console.error(e instanceof InputError ? `Input problem: ${e.message}` : e.stack);
- 316 process.exit(e instanceof InputError ? 2 : 1);
- 317 }
- 318}
