Skip to content
SlashClone
Made by@qtrader

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.

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