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