From cfd4bd00a5be3506367b4171f47cae15bf1329a4 Mon Sep 17 00:00:00 2001 From: OpenJules Agent Date: Sat, 20 Jun 2026 22:57:46 +0700 Subject: [PATCH] Add HOSE lot-aware position sizing helper --- src/risk/positionSizing.ts | 56 ++++++++++++++ tests/positionSizing.test.ts | 137 +++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/risk/positionSizing.ts create mode 100644 tests/positionSizing.test.ts diff --git a/src/risk/positionSizing.ts b/src/risk/positionSizing.ts new file mode 100644 index 0000000..8499cfa --- /dev/null +++ b/src/risk/positionSizing.ts @@ -0,0 +1,56 @@ +export interface PositionSizeInput { + accountEquity: number; + riskFraction: number; + entryPrice: number; + stopPrice: number; + lotSize?: number; +} + +export interface PositionSizeResult { + shares: number; + riskAmount: number; + riskPerShare: number; +} + +function validateInput(input: PositionSizeInput): Required> { + if (input.accountEquity <= 0) { + throw new Error("accountEquity must be greater than 0"); + } + + if (input.riskFraction <= 0 || input.riskFraction > 1) { + throw new Error("riskFraction must be greater than 0 and less than or equal to 1"); + } + + if (input.entryPrice <= 0) { + throw new Error("entryPrice must be greater than 0"); + } + + if (input.stopPrice < 0) { + throw new Error("stopPrice must be greater than or equal to 0"); + } + + if (input.stopPrice >= input.entryPrice) { + throw new Error("stopPrice must be less than entryPrice"); + } + + const lotSize = input.lotSize ?? 100; + if (!Number.isInteger(lotSize) || lotSize < 1) { + throw new Error("lotSize must be an integer greater than or equal to 1"); + } + + return { lotSize }; +} + +export function calculatePositionSize(input: PositionSizeInput): PositionSizeResult { + const { lotSize } = validateInput(input); + const riskAmount = input.accountEquity * input.riskFraction; + const riskPerShare = input.entryPrice - input.stopPrice; + const rawShares = riskAmount / riskPerShare; + const shares = Math.floor(rawShares / lotSize) * lotSize; + + return { + shares, + riskAmount, + riskPerShare, + }; +} diff --git a/tests/positionSizing.test.ts b/tests/positionSizing.test.ts new file mode 100644 index 0000000..1e2e6bc --- /dev/null +++ b/tests/positionSizing.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { calculatePositionSize } from '../src/risk/positionSizing.js'; + +describe('calculatePositionSize', () => { + it('computes the worked example and rounds to the HOSE lot size', () => { + const result = calculatePositionSize({ + accountEquity: 100_000_000, + riskFraction: 0.01, + entryPrice: 30_000, + stopPrice: 28_000, + }); + + expect(result).toEqual({ + shares: 500, + riskAmount: 1_000_000, + riskPerShare: 2_000, + }); + }); + + it('floors down to the nearest 100-share lot when raw shares are not a multiple of 100', () => { + const result = calculatePositionSize({ + accountEquity: 10_000_000, + riskFraction: 0.0599, + entryPrice: 10_000, + stopPrice: 9_000, + }); + + expect(result).toEqual({ + shares: 500, + riskAmount: 599_000, + riskPerShare: 1_000, + }); + }); + + it('rounds down to 0 shares when the risk budget does not cover one lot', () => { + const result = calculatePositionSize({ + accountEquity: 100_000, + riskFraction: 0.25, + entryPrice: 10_000, + stopPrice: 9_700, + }); + + expect(result).toEqual({ + shares: 0, + riskAmount: 25_000, + riskPerShare: 300, + }); + }); + + it('rejects non-positive account equity', () => { + expect(() => + calculatePositionSize({ + accountEquity: 0, + riskFraction: 0.01, + entryPrice: 30_000, + stopPrice: 28_000, + }), + ).toThrow('accountEquity must be greater than 0'); + }); + + it('rejects riskFraction values at or below 0', () => { + expect(() => + calculatePositionSize({ + accountEquity: 100_000_000, + riskFraction: 0, + entryPrice: 30_000, + stopPrice: 28_000, + }), + ).toThrow('riskFraction must be greater than 0 and less than or equal to 1'); + }); + + it('rejects riskFraction values greater than 1', () => { + expect(() => + calculatePositionSize({ + accountEquity: 100_000_000, + riskFraction: 1.1, + entryPrice: 30_000, + stopPrice: 28_000, + }), + ).toThrow('riskFraction must be greater than 0 and less than or equal to 1'); + }); + + it('rejects non-positive entry prices', () => { + expect(() => + calculatePositionSize({ + accountEquity: 100_000_000, + riskFraction: 0.01, + entryPrice: 0, + stopPrice: 28_000, + }), + ).toThrow('entryPrice must be greater than 0'); + }); + + it('rejects negative stop prices', () => { + expect(() => + calculatePositionSize({ + accountEquity: 100_000_000, + riskFraction: 0.01, + entryPrice: 30_000, + stopPrice: -1, + }), + ).toThrow('stopPrice must be greater than or equal to 0'); + }); + + it('rejects stop prices that are at or above the entry price', () => { + expect(() => + calculatePositionSize({ + accountEquity: 100_000_000, + riskFraction: 0.01, + entryPrice: 30_000, + stopPrice: 30_000, + }), + ).toThrow('stopPrice must be less than entryPrice'); + }); + + it('rejects invalid lot sizes', () => { + expect(() => + calculatePositionSize({ + accountEquity: 100_000_000, + riskFraction: 0.01, + entryPrice: 30_000, + stopPrice: 28_000, + lotSize: 0, + }), + ).toThrow('lotSize must be an integer greater than or equal to 1'); + + expect(() => + calculatePositionSize({ + accountEquity: 100_000_000, + riskFraction: 0.01, + entryPrice: 30_000, + stopPrice: 28_000, + lotSize: 1.5, + }), + ).toThrow('lotSize must be an integer greater than or equal to 1'); + }); +});