npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@himalaya-quant/position-manager

v0.0.7

Published

A typescript library that's responsible for managing backtesting simulated positions

Readme

HimalayaQuant Position Manager

Stateful position lifecycle manager for backtesting engines. Handles open, monitor, partial close, and close operations for a single trading position, with built-in P&L accounting, position sizing, and SL/TP evaluation.

Part of the HimalayaQuant backtest engine, this library has no runtime dependencies


Contents


Overview

The PositionManager sits between the strategy layer and raw market data. The strategy decides when and where to trade — the manager handles everything else.

Strategy Layer          PositionManager            Market Data
──────────────    ──────────────────────────    ──────────────
registerSignal ──▶  pending signal queue
                    ↓ (next candle open)
                    open()  ◀────────────────── OHLC.open
                    evaluateCandle() ◀────────── OHLC candle
                      ├─ update trailing stop
                      ├─ check SL hit
                      └─ check TP hit
partialClose() ──▶  reduce size, update capital
close()        ──▶  aggregate P&L, emit ClosedPosition
getStats()     ──▶  BacktestStats

What it does not do: it has no knowledge of signal logic, indicators, or any analytical tool. It receives instructions and executes them.


Installation

npm i @himalaya-quant/position-manager

Quick Start

complete runnable example is in example.ts

import { PositionManager } from '@himalaya-quant/position-manager';

const pm = new PositionManager({
    initialCapital: 10_000,
    riskPerTrade: 0.02, // 2% of capital at risk per trade
    fallbackAllocation: 0.1, // 10% allocated when no SL is provided
    spread: 0.0002, // 2 pip spread on EUR/USD
    trailingStop: 0.005, // optional: 50 pip trailing stop
});

// ── Backtest loop ──────────────────────────────────────────────────────────

for (const candle of candles) {
    // 1. Your strategy decides when to act
    if (shouldEnter(candle) && !pm.hasOpenPosition) {
        pm.registerSignal({
            direction: 'long',
            stopLoss: candle.close - 0.005,
            takeProfit: candle.close + 0.015,
            createdAtTimestamp: candle.timestamp,
        });
    }

    // 2. Manager handles everything else
    const closed = pm.evaluateCandle(candle);
    if (closed) {
        console.log(
            `Trade closed: ${closed.exitReason}, P&L: ${closed.pnlAbsolute.toFixed(2)}€`,
        );
    }

    // 3. Optional: manual SL/TP adjustments mid-trade
    if (pm.hasOpenPosition && shouldMoveSL(candle)) {
        pm.updateStopLoss(newSLPrice);
    }
}

// ── End of backtest ────────────────────────────────────────────────────────

pm.forceCloseAtEnd(candles.at(-1)!);
console.log(pm.getStats());

Configuration

PositionManagerConfig is passed to the constructor and is immutable for the lifetime of the backtest.

| Property | Type | Description | | -------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | initialCapital | number | Starting capital in currency (EUR, USD, etc..) | | riskPerTrade | number | Fraction of capital at risk per trade, e.g. 0.02 = 2%. Used only when an SL is present. | | fallbackAllocation | number | Fraction of capital allocated when no SL is provided, e.g. 0.10 = 10%. See Position Sizing. | | spread | number | Fixed spread in price units. e.g. 0.0002 = 2 pip on EUR/USD. Applied asymmetrically on entry. | | trailingStop | number? | Distance of the trailing stop from the most favourable price, in price units (same unit as entryPrice). 0.0050 = 50 pip on EUR/USD, 50.0 = 50 points on S&P 500. Omit to disable. |


API

registerSignal

pm.registerSignal(signal: PendingSignal): void

Queues a signal to be materialised at the next evaluateCandle call, using that candle's open price as entry. This is the mechanism that prevents lookahead bias — see Design Decisions.

pm.registerSignal({
    direction: 'long',
    stopLoss: 1.195, // optional
    takeProfit: 1.22, // optional
    createdAtTimestamp: candle.timestamp,
});

If registerSignal is called a second time before the signal is consumed, the new signal silently overwrites the previous one. If your strategy requires different behaviour, check pm.hasPendingSignal before calling.


evaluateCandle

pm.evaluateCandle(candle: OHLC): ClosedPosition | null

The main loop step. Call once per candle, in chronological order. Internally executes the following sequence — order is critical:

  1. Materialise pending signal — opens a position at candle.open if a signal was registered on the previous candle.
  2. Update trailing stop — advances the trailing SL to the new extreme (if configured).
  3. Check SL — uses candle.low for longs, candle.high for shorts.
  4. Check TP — uses candle.high for longs, candle.low for shorts.
  5. ReturnClosedPosition if a SL/TP was hit this candle, null otherwise.

open

pm.open(direction, entryPrice, timestamp, sl?, tp?): void

Opens a position directly, without a pending signal. Useful when the strategy wants immediate entry rather than T+1.

  • Spread is applied to entryPrice before storing (see Spread Application).
  • Size is calculated automatically from the config (see Position Sizing).
  • SL and TP, if provided, are validated against the adjusted entry price before the position opens.

Throws if a position is already open.


partialClose

pm.partialClose(exitPrice, timestamp, sizeToClose): PartialExit

Closes a portion of the open position. Capital is updated immediately. The partial exit is recorded and will be included in the final ClosedPosition when the trade is fully closed.

sizeToClose must be strictly less than the current remaining size. To close the entire position, use close().

Throws if no position is open, or if sizeToClose >= currentSize.


close

pm.close(exitPrice, timestamp, reason: ExitReason): ClosedPosition

Closes the entire remaining position. Aggregates P&L from all prior partial exits plus the final lot. Capital is updated and the position is cleared.

ExitReason values: 'SL_HIT' | 'TP_HIT' | 'SIGNAL' | 'FORCE_CLOSE'

Throws if no position is open.


updateStopLoss / updateTakeProfit

pm.updateStopLoss(newSL: number | undefined): void
pm.updateTakeProfit(newTP: number | undefined): void

Modifies the SL or TP of the active position. Every change is appended to slHistory with old and new values.

Pass undefined to remove the level entirely.

Validation rules:

| | Long | Short | | --- | ----------------------- | ----------------------- | | SL | must be <= entryPrice | must be >= entryPrice | | TP | must be > entryPrice | must be < entryPrice |

Breakeven stop: placing SL exactly at entryPrice is valid. The check uses > (strict) for long and < (strict) for short, so SL === entryPrice is intentionally allowed.

Both methods throw if no position is open.


forceCloseAtEnd

pm.forceCloseAtEnd(lastCandle: OHLC): ClosedPosition | null

Closes any open position at lastCandle.close with reason FORCE_CLOSE. Call this after the loop to clean up positions left open at the end of the backtest period. Returns null if no position is open.


getStats

pm.getStats(): BacktestStats

Computes and returns aggregate metrics over all closed trades. Recalculated on every call — not cached.

| Property | Description | | ---------------- | --------------------------------------------------------- | | totalTrades | Total number of closed trades | | winningTrades | Trades with pnlAbsolute > 0 | | losingTrades | Trades with pnlAbsolute < 0 | | winRate | winningTrades / totalTrades (0–1) | | profitFactor | Sum of gains / sum of losses. > 1 = profitable strategy | | avgWin | Average P&L of winning trades in € | | avgLoss | Average P&L of losing trades in € (absolute value) | | riskReward | avgWin / avgLoss | | maxDrawdown | Maximum peak-to-valley loss on the equity curve, in € | | maxDrawdownPct | Maximum peak-to-valley loss as % of the peak capital | | finalCapital | Capital after all closed trades | | totalReturn | (finalCapital - initialCapital) / initialCapital | | equityCurve | Capital after each trade, starting with initialCapital |


Getters

| Property | Type | Description | | ------------------ | ----------------------------------------- | ------------------------------------------------------- | | hasOpenPosition | boolean | true if a position is currently active | | hasPendingSignal | boolean | true if a signal is queued for the next candle | | capital | number | Current capital, updated after every (partial) close | | activePosition | Readonly<OpenPosition> \| null | Frozen snapshot of the active position. See note below. | | trades | ReadonlyArray<Readonly<ClosedPosition>> | Frozen copy of all closed trades. |

activePosition and trades return immutable snapshots. Mutating the returned objects will either throw (strict mode) or have no effect on the manager's internal state. Never rely on the reference persisting across calls — always read from the getter.


Position Sizing

Size is calculated automatically on every open(). It is never a free parameter.

With SL — Risk-Based Sizing

riskInEuro = currentCapital × riskPerTrade
slDistance = |entryPrice − stopLoss|
size       = riskInEuro / slDistance

The size is chosen so that a full SL hit loses exactly riskPerTrade × currentCapital. Risk stays constant as a fraction of capital regardless of how capital evolves over the backtest.

Example: 10,000€ capital, 2% risk, entry 1.2002, SL 1.1950 → distance 0.0052 → size = 200 / 0.0052 = 38,461 units.

Without SL — Fallback Allocation

allocatedCapital = currentCapital × fallbackAllocation
size             = allocatedCapital / entryPrice

Without an SL, there is no price level at which the manager exits automatically. The worst-case loss equals the full allocated amount — fallbackAllocation × currentCapital — if the price reaches zero. This is not comparable to the controlled risk of the SL-based approach. Prefer risk-based sizing whenever possible.

Spread Application

Spread is applied to the entry price before sizing and before SL/TP validation:

| Direction | Adjusted Entry | | --------- | ----------------------------------------------- | | Long | entryPrice + spread (buyer pays the ask) | | Short | entryPrice − spread (seller receives the bid) |

Exit prices are not adjusted for spread, as it is implicitly embedded in the OHLC mid prices.


Design Decisions

This section documents behaviours that are intentional by design. Please read before opening an issue.


No entry at signal candle (T → T+1)

registerSignal() never opens a position immediately. The signal is queued and materialised at the open price of the next candle.

Why: a signal generated at candle T uses data up to and including T. The close price of T was not available when the signal was being evaluated — it emerged as a result of the move that triggered the signal. Entering at close[T] introduces lookahead bias and produces unrealistically positive backtest results.

The open[T+1] is the first price realistically accessible after the signal is confirmed.


One position at a time

Attempting to open a second position while one is already active throws an error.

Why: multiple concurrent positions multiply complexity non-linearly — aggregated exposure, per-position capital allocation, interaction between SL/TP levels. This constraint keeps equity curves clean, capital accounting unambiguous, and results interpretable. Multiple positions may be added as a future extension but are out of scope for this component.


SL takes priority over TP on the same candle

When a single candle's range covers both the SL and the TP level (e.g. low <= SL and high >= TP on a long), the position is closed at the SL with reason SL_HIT.

Why: we cannot know the intracandle order of events from OHLC data alone. Assuming the best case (TP hit first) would overstate performance. Assuming the worst case (SL hit first) is the conservative and reproducible choice.


partialClose requires strictly less than full size

partialClose(exitPrice, timestamp, sizeToClose) throws if sizeToClose >= currentSize.

Why: closing exactly 100% of the remaining size is semantically a full close, not a partial one. Allowing it would leave a position open with size = 0 — a state that is neither flat nor active, which causes undefined behaviour in P&L calculations and makes hasOpenPosition misleading. Use close() to shut the position entirely.


Breakeven stop is valid

updateStopLoss(entryPrice) is allowed. The SL validation uses strict inequality (> for long, < for short), so placing the stop exactly at entry is intentional and will not throw.


Trailing stop logs to slHistory

Every automatic trailing stop update is recorded in slHistory with the same structure as a manual SL change. This is by design — the audit trail does not distinguish between manual and automatic updates. Filter by timestamp range or cross-reference with candle data if you need to separate the two.


Spread is only applied on entry

The exit price is not adjusted for spread. OHLC mid prices implicitly include half the spread on each side; applying it again on exit would double-count the cost. The entry adjustment (paying ask on long, receiving bid on short) captures the full round-trip cost at open.


getStats() is computed on demand

Stats are recalculated from scratch on every getStats() call. There is no incremental state. This trades a small amount of CPU for guaranteed correctness — stale or partially-updated stat objects are not possible.


Pending signal is overwritten silently

Calling registerSignal() twice before evaluateCandle() replaces the first signal with the second, without error. The manager has no opinion on whether this is correct — that decision belongs to the strategy. Check pm.hasPendingSignal before registering if your strategy requires a different policy.