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// Self-test against the hand calculations in README.md. Usage: node scripts/selftest.mjs
- 3import { readFileSync } from "node:fs";
- 4import { dirname, join } from "node:path";
- 5import { fileURLToPath } from "node:url";
- 6import { atr, fixedFractional, heat, kelly, parseCsv, run, statsFromTrades } from "./size.mjs";
- 8const here = dirname(fileURLToPath(import.meta.url));
- 9const ex = (f) => join(here, "../examples", f);
- 10let passed = 0;
- 11let failed = 0;
- 12function close(a, e, label, tol = 1e-9) {
- 13 if (typeof a === "number" && Math.abs(a - e) <= tol) passed++;
- 14 else {
- 15 failed++;
- 16 console.error(`FAIL ${label}: expected ${e}, got ${a}`);
- 17 }
- 18}
- 19function eq(a, e, label) {
- 20 if (JSON.stringify(a) === JSON.stringify(e)) passed++;
- 21 else {
- 22 failed++;
- 23 console.error(`FAIL ${label}: expected ${JSON.stringify(e)}, got ${JSON.stringify(a)}`);
- 24 }
- 25}
- 26function throws(fn, text, label) {
- 27 try {
- 28 fn();
- 29 failed++;
- 30 console.error(`FAIL ${label}: no error`);
- 31 } catch (e) {
- 32 if (e.message.includes(text)) passed++;
- 33 else {
- 34 failed++;
- 35 console.error(`FAIL ${label}: ${e.message}`);
- 36 }
- 37 }
- 38}
- 40// Fixed fractional: 50,000 x 1% = 500 at risk; 500 / (100 - 95) = 100 shares
- 41{
- 42 const r = fixedFractional({ equity: 50000, riskPct: 1, entry: 100, stop: 95 });
- 43 eq([r.side, r.shares], ["long", 100], "fixed shares");
- 44 close(r.dollar_risk, 500, "fixed dollar risk");
- 45 close(r.position_pct_of_equity, 20, "fixed position %");
- 46 const capped = fixedFractional({ equity: 50000, riskPct: 1, entry: 100, stop: 95, maxPositionPct: 15 });
- 47 eq(capped.shares, 75, "capped at 15% of equity = 7,500 / 100");
- 48 const short = fixedFractional({ equity: 50000, riskPct: 1, entry: 100, stop: 104 });
- 49 eq([short.side, short.shares], ["short", 125], "short: 500 / 4");
- 50 eq(fixedFractional({ equity: 1000, riskPct: 1, entry: 100, stop: 70 }).shares, 0, "too-wide stop rounds to 0");
- 51 close(fixedFractional({ equity: 1000, riskPct: 1, entry: 100, stop: 70, fractional: true }).shares, 0.3333, "fractional floors to 4 places");
- 52 throws(() => fixedFractional({ equity: 1000, riskPct: 1, entry: 100, stop: 100 }), "differ", "stop equals entry");
- 53}
- 55// ATR(3) by hand on examples/prices.csv: TR = 1, .8, 1.1, .8, 1.7
- 56{
- 57 const bars = parseCsv(readFileSync(ex("prices.csv"), "utf8")).map((r) => ({ high: +r.high, low: +r.low, close: +r.close }));
- 58 const seed = (1 + 0.8 + 1.1) / 3;
- 59 const s3 = (seed * 2 + 0.8) / 3;
- 60 const s4 = (s3 * 2 + 1.7) / 3;
- 61 close(atr(bars, 3), s4, "Wilder ATR(3)");
- 62 close(s4, 1.1740740740740741, "hand value", 1e-12);
- 63 const r = run(["atr", "--equity", "10000", "--risk-pct", "1", "--prices", ex("prices.csv"), "--atr-period", "3"]);
- 64 close(r.stop, 11.8 - 2 * s4, "ATR stop = last close - 2 ATR");
- 65 eq(r.shares, 42, "floor(100 / 2.348148) = 42");
- 66 const sh = run(["atr", "--equity", "10000", "--risk-pct", "1", "--atr", "2", "--entry", "50", "--side", "short", "--atr-mult", "1.5"]);
- 67 eq([sh.side, sh.stop, sh.shares], ["short", 53, 33], "short ATR: stop 53, floor(100 / 3) = 33");
- 68 throws(() => atr(bars, 14), "at least 14", "not enough bars");
- 69}
- 71// Kelly: f* = p - (1 - p) / b
- 72{
- 73 const k = kelly({ winRate: 0.55, payoff: 1.5, fraction: 0.5, cap: 1 });
- 74 close(k.full_kelly, 0.25, "full Kelly .55 - .45 / 1.5");
- 75 close(k.risk_fraction, 0.125, "half Kelly");
- 76 const capped = kelly({ winRate: 0.55, payoff: 1.5 });
- 77 close(capped.risk_fraction, 0.05, "default cap 5%");
- 78 eq(capped.notes[0].startsWith("capped at 5%"), true, "cap note");
- 79 const none = kelly({ winRate: 0.4, payoff: 1 });
- 80 eq([none.risk_fraction, none.notes[0].startsWith("no edge")], [0, true], "no edge means 0");
- 81 const st = statsFromTrades(parseCsv(readFileSync(ex("trades.csv"), "utf8")));
- 82 close(st.win_rate, 0.5, "sample win rate 3 of 6");
- 83 close(st.payoff, (650 / 3) / (250 / 3), "payoff 216.67 / 83.33 = 2.6");
- 84 const fromFile = run(["kelly", "--trades", ex("trades.csv"), "--cap", "1"]);
- 85 close(fromFile.full_kelly, 0.5 - 0.5 / 2.6, "Kelly from trades");
- 86 const sized = run(["kelly", "--win-rate", "0.55", "--payoff", "1.5", "--equity", "50000", "--entry", "100", "--stop", "95"]);
- 87 eq(sized.sizing.shares, 500, "5% of 50,000 = 2,500 at risk / 5 = 500 shares");
- 88 throws(() => kelly({ winRate: 55, payoff: 1.5 }), "between 0 and 1", "win rate as percent is rejected");
- 89}
- 91// Heat on examples/positions.csv
- 92{
- 93 const h = heat({ equity: 100000, positions: parseCsv(readFileSync(ex("positions.csv"), "utf8")), add: "FFF,long,120,25,23" });
- 94 eq(
- 95 h.positions.map((p) => p.risk),
- 96 [600, 800, 200, 3000, 1200],
- 97 "risk per position: from current price to stop; no stop = full value",
- 98 );
- 99 close(h.heat_pct, 5.8, "total heat");
- 100 eq(h.status, "OK", "under 6%");
- 101 eq(h.positions[3].flags, ["NO STOP: full value at risk", "over 2% single-trade limit"], "flags on DDD");
- 102 eq(h.positions[1].flags, ["stop locks in profit vs entry"], "flag on BBB");
- 103 eq([h.proposed.fits, h.proposed.max_qty_that_fits], [false, 100], "only 0.2% room = 200 / 2 = 100 shares");
- 104 close(h.proposed.heat_after_pct, 6.04, "heat after");
- 105 const over = heat({ equity: 50000, positions: parseCsv(readFileSync(ex("positions.csv"), "utf8")) });
- 106 eq(over.status, "OVER", "same book on half the equity is over");
- 107}
- 108throws(() => run(["size"]), "unknown command", "unknown command");
- 110console.log(`${passed} passed, ${failed} failed`);
- 111process.exit(failed ? 1 : 0);
