fx-forward-math 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/index.cjs +147 -0
- package/dist/index.d.cts +88 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +112 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moshe Malka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# fx-forward-math
|
|
2
|
+
|
|
3
|
+
FX **trading conventions** math — forward points ↔ outright forwards, cross-rate derivation, triangular-arbitrage checks, pip / big-figure handling, and pip value. Zero dependencies.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install fx-forward-math
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
`currency.js` and `dinero.js` handle *consumer* money — formatting and allocation. Neither knows what a **pip** is, how to turn **forward points** into an outright, or how to derive a **cross rate**. That's the arithmetic an FX desk actually uses, and npm had no library for it.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { outrightForward, crossRate, triangularArbitrage } from "fx-forward-math";
|
|
15
|
+
|
|
16
|
+
// 25 forward points on EUR/USD (pip = 0.0001):
|
|
17
|
+
outrightForward("EUR/USD", 1.0850, 25); // 1.0875
|
|
18
|
+
// −40 points on USD/JPY (pip = 0.01):
|
|
19
|
+
outrightForward("USD/JPY", 156.20, -40); // 155.80
|
|
20
|
+
|
|
21
|
+
// EUR/JPY implied from the two USD legs:
|
|
22
|
+
crossRate("EUR/USD", 1.0850, "USD/JPY", 156.20); // 169.477
|
|
23
|
+
|
|
24
|
+
triangularArbitrage("EUR/USD", 1.0850, "USD/JPY", 156.20, "EUR/JPY", 169.60);
|
|
25
|
+
// { impliedCross: 169.477, differencePips: 12.3, arbitrageFree: false }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
Pairs are `"BASE/QUOTE"`; the rate is how many QUOTE units one BASE unit buys. Pip size is inferred from the quote currency (JPY-quoted pairs → 0.01, most others → 0.0001) and overridable everywhere.
|
|
31
|
+
|
|
32
|
+
- **`outrightForward(pair, spot, forwardPoints)`** / **`forwardPoints(pair, spot, outright)`** — convert both ways.
|
|
33
|
+
- **`crossRate(pairA, rateA, pairB, rateB)`** — derive the cross from two legs sharing a currency; returns the rate of *A's non-shared currency / B's non-shared currency* (any leg orientation).
|
|
34
|
+
- **`triangularArbitrage(legA, rA, legB, rB, crossPair, crossQuote, tolerancePips?)`** — implied vs quoted cross and whether they agree.
|
|
35
|
+
- **`pipValueQuote(pair, notional)`** / **`pipValueBase(pair, notional, rate)`** — value of one pip.
|
|
36
|
+
- **`splitRate(pair, rate)`** → `{ bigFigure, pips }`; **`roundToPip(pair, rate)`**; **`pipSize`**, **`pipDecimals`**, **`parsePair`**.
|
|
37
|
+
|
|
38
|
+
All rates must be positive and finite, else `RangeError`.
|
|
39
|
+
|
|
40
|
+
## Related
|
|
41
|
+
|
|
42
|
+
Pairs with [`fx-value-date`](https://github.com/moshejs/fx-value-date) (settlement dates). Part of a fixed-income & markets toolkit: [`32nds`](https://github.com/moshejs/32nds) · [`day-count`](https://github.com/moshejs/day-count) · [`newyorkfed`](https://github.com/moshejs/newyorkfed) · [`instrument-identifiers`](https://github.com/moshejs/instrument-identifiers).
|
|
43
|
+
|
|
44
|
+
## Author
|
|
45
|
+
|
|
46
|
+
Built by **[Moshe Malka](https://moshemalka.com)** — engineering leader in New York City. Studio work at [Quentin.Code](https://www.quentin.software/).
|
|
47
|
+
|
|
48
|
+
MIT © Moshe Malka
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
crossRate: () => crossRate,
|
|
24
|
+
forwardPoints: () => forwardPoints,
|
|
25
|
+
outrightForward: () => outrightForward,
|
|
26
|
+
parsePair: () => parsePair,
|
|
27
|
+
pipDecimals: () => pipDecimals,
|
|
28
|
+
pipSize: () => pipSize,
|
|
29
|
+
pipValueBase: () => pipValueBase,
|
|
30
|
+
pipValueQuote: () => pipValueQuote,
|
|
31
|
+
roundToPip: () => roundToPip,
|
|
32
|
+
splitRate: () => splitRate,
|
|
33
|
+
triangularArbitrage: () => triangularArbitrage
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(index_exports);
|
|
36
|
+
var QUOTE_PIP_DECIMALS = {
|
|
37
|
+
JPY: 2,
|
|
38
|
+
KRW: 2,
|
|
39
|
+
CLP: 2,
|
|
40
|
+
HUF: 2,
|
|
41
|
+
IDR: 2,
|
|
42
|
+
TWD: 2
|
|
43
|
+
};
|
|
44
|
+
var DEFAULT_PIP_DECIMALS = 4;
|
|
45
|
+
var PAIR_RE = /^([A-Z]{3})\/([A-Z]{3})$/;
|
|
46
|
+
function parsePair(pair) {
|
|
47
|
+
const m = PAIR_RE.exec(pair.trim().toUpperCase());
|
|
48
|
+
if (!m) throw new RangeError(`Invalid currency pair: "${pair}" (expected "BASE/QUOTE")`);
|
|
49
|
+
if (m[1] === m[2]) throw new RangeError(`Base and quote currency are identical in "${pair}"`);
|
|
50
|
+
return { base: m[1], quote: m[2] };
|
|
51
|
+
}
|
|
52
|
+
function pipDecimals(pair, override) {
|
|
53
|
+
if (override !== void 0) {
|
|
54
|
+
if (!Number.isInteger(override) || override < 0 || override > 10) {
|
|
55
|
+
throw new RangeError(`pipDecimals override must be an integer 0\u201310, got ${override}`);
|
|
56
|
+
}
|
|
57
|
+
return override;
|
|
58
|
+
}
|
|
59
|
+
const { quote } = parsePair(pair);
|
|
60
|
+
return QUOTE_PIP_DECIMALS[quote] ?? DEFAULT_PIP_DECIMALS;
|
|
61
|
+
}
|
|
62
|
+
function pipSize(pair, override) {
|
|
63
|
+
return 1 / 10 ** pipDecimals(pair, override);
|
|
64
|
+
}
|
|
65
|
+
function checkFinite(x, label) {
|
|
66
|
+
if (!Number.isFinite(x)) throw new RangeError(`${label} must be a finite number, got ${x}`);
|
|
67
|
+
}
|
|
68
|
+
function checkRate(rate, label) {
|
|
69
|
+
checkFinite(rate, label);
|
|
70
|
+
if (rate <= 0) throw new RangeError(`${label} must be positive, got ${rate}`);
|
|
71
|
+
}
|
|
72
|
+
function outrightForward(pair, spot, forwardPoints2, pipDecimalsOverride) {
|
|
73
|
+
checkRate(spot, "spot");
|
|
74
|
+
checkFinite(forwardPoints2, "forwardPoints");
|
|
75
|
+
return spot + forwardPoints2 * pipSize(pair, pipDecimalsOverride);
|
|
76
|
+
}
|
|
77
|
+
function forwardPoints(pair, spot, outright, pipDecimalsOverride) {
|
|
78
|
+
checkRate(spot, "spot");
|
|
79
|
+
checkRate(outright, "outright");
|
|
80
|
+
return (outright - spot) / pipSize(pair, pipDecimalsOverride);
|
|
81
|
+
}
|
|
82
|
+
function crossRate(pairA, rateA, pairB, rateB) {
|
|
83
|
+
const a = parsePair(pairA);
|
|
84
|
+
const b = parsePair(pairB);
|
|
85
|
+
checkRate(rateA, "rateA");
|
|
86
|
+
checkRate(rateB, "rateB");
|
|
87
|
+
const shared = [a.base, a.quote].find((c) => c === b.base || c === b.quote);
|
|
88
|
+
if (!shared) {
|
|
89
|
+
throw new RangeError(`No common currency between ${pairA} and ${pairB}`);
|
|
90
|
+
}
|
|
91
|
+
const aOther = a.base === shared ? a.quote : a.base;
|
|
92
|
+
const bOther = b.base === shared ? b.quote : b.base;
|
|
93
|
+
if (aOther === bOther) throw new RangeError(`${pairA} and ${pairB} describe the same pair`);
|
|
94
|
+
const aInShared = a.quote === shared ? rateA : 1 / rateA;
|
|
95
|
+
const bInShared = b.quote === shared ? rateB : 1 / rateB;
|
|
96
|
+
return aInShared / bInShared;
|
|
97
|
+
}
|
|
98
|
+
function triangularArbitrage(legAPair, legARate, legBPair, legBRate, crossPair, crossQuote, tolerancePips = 1) {
|
|
99
|
+
const impliedCross = crossRate(legAPair, legARate, legBPair, legBRate);
|
|
100
|
+
checkRate(crossQuote, "crossQuote");
|
|
101
|
+
const size = pipSize(crossPair);
|
|
102
|
+
const differencePips = (crossQuote - impliedCross) / size;
|
|
103
|
+
return {
|
|
104
|
+
impliedCross,
|
|
105
|
+
quotedCross: crossQuote,
|
|
106
|
+
differencePips,
|
|
107
|
+
arbitrageFree: Math.abs(differencePips) <= tolerancePips
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function pipValueQuote(pair, notionalBase, pipDecimalsOverride) {
|
|
111
|
+
checkFinite(notionalBase, "notionalBase");
|
|
112
|
+
return notionalBase * pipSize(pair, pipDecimalsOverride);
|
|
113
|
+
}
|
|
114
|
+
function pipValueBase(pair, notionalBase, rate, pipDecimalsOverride) {
|
|
115
|
+
checkRate(rate, "rate");
|
|
116
|
+
return pipValueQuote(pair, notionalBase, pipDecimalsOverride) / rate;
|
|
117
|
+
}
|
|
118
|
+
function splitRate(pair, rate, pipDecimalsOverride) {
|
|
119
|
+
checkRate(rate, "rate");
|
|
120
|
+
const dp = pipDecimals(pair, pipDecimalsOverride);
|
|
121
|
+
const bigFigureUnit = 1 / 10 ** (dp - 2);
|
|
122
|
+
const bigFigure = Math.floor(rate / bigFigureUnit) * bigFigureUnit;
|
|
123
|
+
const pips = (rate - bigFigure) / pipSize(pair, pipDecimalsOverride);
|
|
124
|
+
return { bigFigure: round(bigFigure, dp), pips: round(pips, 4) };
|
|
125
|
+
}
|
|
126
|
+
function roundToPip(pair, rate, pipDecimalsOverride) {
|
|
127
|
+
const dp = pipDecimals(pair, pipDecimalsOverride);
|
|
128
|
+
return round(rate, dp);
|
|
129
|
+
}
|
|
130
|
+
function round(x, dp) {
|
|
131
|
+
const f = 10 ** dp;
|
|
132
|
+
return Math.round(x * f) / f;
|
|
133
|
+
}
|
|
134
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
135
|
+
0 && (module.exports = {
|
|
136
|
+
crossRate,
|
|
137
|
+
forwardPoints,
|
|
138
|
+
outrightForward,
|
|
139
|
+
parsePair,
|
|
140
|
+
pipDecimals,
|
|
141
|
+
pipSize,
|
|
142
|
+
pipValueBase,
|
|
143
|
+
pipValueQuote,
|
|
144
|
+
roundToPip,
|
|
145
|
+
splitRate,
|
|
146
|
+
triangularArbitrage
|
|
147
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fx-forward-math — FX quote and forward conventions math.
|
|
3
|
+
*
|
|
4
|
+
* The trading-desk arithmetic that consumer money libraries ignore: forward
|
|
5
|
+
* points ↔ outright forwards with correct per-pair pip scaling, cross-rate
|
|
6
|
+
* derivation, triangular-arbitrage checks, pip / big-figure handling, and pip
|
|
7
|
+
* value. Pure functions, zero dependencies.
|
|
8
|
+
*
|
|
9
|
+
* A currency pair is written "BASE/QUOTE" (e.g. "EUR/USD"): the rate is how
|
|
10
|
+
* many QUOTE units one BASE unit buys.
|
|
11
|
+
*/
|
|
12
|
+
interface PairConvention {
|
|
13
|
+
/** Decimal places a spot rate is quoted to (EUR/USD → 4, USD/JPY → 2). */
|
|
14
|
+
pipDecimals: number;
|
|
15
|
+
}
|
|
16
|
+
interface Pair {
|
|
17
|
+
base: string;
|
|
18
|
+
quote: string;
|
|
19
|
+
}
|
|
20
|
+
/** Parse "EUR/USD" into `{ base, quote }`. Throws on malformed input. */
|
|
21
|
+
declare function parsePair(pair: string): Pair;
|
|
22
|
+
/**
|
|
23
|
+
* Decimal places a pair's spot rate is quoted to. Determined by the quote
|
|
24
|
+
* currency (JPY pairs → 2, most others → 4). Pass `override` to force it.
|
|
25
|
+
*/
|
|
26
|
+
declare function pipDecimals(pair: string, override?: number): number;
|
|
27
|
+
/** The size of one pip for a pair, e.g. EUR/USD → 0.0001, USD/JPY → 0.01. */
|
|
28
|
+
declare function pipSize(pair: string, override?: number): number;
|
|
29
|
+
/**
|
|
30
|
+
* Outright forward rate from spot and forward points.
|
|
31
|
+
* `forward = spot + points × pipSize`.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* outrightForward("EUR/USD", 1.0850, 25); // 1.0875 (25 points = 0.0025)
|
|
35
|
+
* outrightForward("USD/JPY", 156.20, -40); // 155.80 (JPY pip = 0.01)
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
declare function outrightForward(pair: string, spot: number, forwardPoints: number, pipDecimalsOverride?: number): number;
|
|
39
|
+
/**
|
|
40
|
+
* Forward points from spot and an outright forward:
|
|
41
|
+
* `(forward − spot) / pipSize`.
|
|
42
|
+
*/
|
|
43
|
+
declare function forwardPoints(pair: string, spot: number, outright: number, pipDecimalsOverride?: number): number;
|
|
44
|
+
/**
|
|
45
|
+
* Derive a cross rate BASE/QUOTE from two rates that share a common currency
|
|
46
|
+
* (typically USD). Handles all four leg orientations automatically.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* // EUR/JPY from EUR/USD and USD/JPY:
|
|
50
|
+
* crossRate("EUR/USD", 1.0850, "USD/JPY", 156.20); // 169.477
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
declare function crossRate(pairA: string, rateA: number, pairB: string, rateB: number): number;
|
|
54
|
+
/**
|
|
55
|
+
* Triangular-arbitrage consistency check for three rates forming a loop
|
|
56
|
+
* (e.g. EUR/USD, USD/JPY, EUR/JPY). Returns the implied cross, the quoted
|
|
57
|
+
* cross, and whether they agree within `tolerancePips`.
|
|
58
|
+
*/
|
|
59
|
+
interface TriangularCheck {
|
|
60
|
+
impliedCross: number;
|
|
61
|
+
quotedCross: number;
|
|
62
|
+
differencePips: number;
|
|
63
|
+
arbitrageFree: boolean;
|
|
64
|
+
}
|
|
65
|
+
declare function triangularArbitrage(legAPair: string, legARate: number, legBPair: string, legBRate: number, crossPair: string, crossQuote: number, tolerancePips?: number): TriangularCheck;
|
|
66
|
+
/**
|
|
67
|
+
* Value of one pip on a notional, expressed in the QUOTE currency.
|
|
68
|
+
* `pipValueQuote("EUR/USD", 1_000_000)` → 100 (USD per pip).
|
|
69
|
+
*/
|
|
70
|
+
declare function pipValueQuote(pair: string, notionalBase: number, pipDecimalsOverride?: number): number;
|
|
71
|
+
/**
|
|
72
|
+
* Value of one pip on a notional, expressed in the BASE currency, given the
|
|
73
|
+
* current rate. `pipValueBase("EUR/USD", 1_000_000, 1.085)` ≈ 92.17.
|
|
74
|
+
*/
|
|
75
|
+
declare function pipValueBase(pair: string, notionalBase: number, rate: number, pipDecimalsOverride?: number): number;
|
|
76
|
+
/**
|
|
77
|
+
* Split a rate into its big figure (the "handle") and pips. For EUR/USD
|
|
78
|
+
* 1.08525 → `{ bigFigure: 1.08, pips: 52.5 }`.
|
|
79
|
+
*/
|
|
80
|
+
interface RateParts {
|
|
81
|
+
bigFigure: number;
|
|
82
|
+
pips: number;
|
|
83
|
+
}
|
|
84
|
+
declare function splitRate(pair: string, rate: number, pipDecimalsOverride?: number): RateParts;
|
|
85
|
+
/** Round a rate to the pair's quoting precision. */
|
|
86
|
+
declare function roundToPip(pair: string, rate: number, pipDecimalsOverride?: number): number;
|
|
87
|
+
|
|
88
|
+
export { type Pair, type PairConvention, type RateParts, type TriangularCheck, crossRate, forwardPoints, outrightForward, parsePair, pipDecimals, pipSize, pipValueBase, pipValueQuote, roundToPip, splitRate, triangularArbitrage };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fx-forward-math — FX quote and forward conventions math.
|
|
3
|
+
*
|
|
4
|
+
* The trading-desk arithmetic that consumer money libraries ignore: forward
|
|
5
|
+
* points ↔ outright forwards with correct per-pair pip scaling, cross-rate
|
|
6
|
+
* derivation, triangular-arbitrage checks, pip / big-figure handling, and pip
|
|
7
|
+
* value. Pure functions, zero dependencies.
|
|
8
|
+
*
|
|
9
|
+
* A currency pair is written "BASE/QUOTE" (e.g. "EUR/USD"): the rate is how
|
|
10
|
+
* many QUOTE units one BASE unit buys.
|
|
11
|
+
*/
|
|
12
|
+
interface PairConvention {
|
|
13
|
+
/** Decimal places a spot rate is quoted to (EUR/USD → 4, USD/JPY → 2). */
|
|
14
|
+
pipDecimals: number;
|
|
15
|
+
}
|
|
16
|
+
interface Pair {
|
|
17
|
+
base: string;
|
|
18
|
+
quote: string;
|
|
19
|
+
}
|
|
20
|
+
/** Parse "EUR/USD" into `{ base, quote }`. Throws on malformed input. */
|
|
21
|
+
declare function parsePair(pair: string): Pair;
|
|
22
|
+
/**
|
|
23
|
+
* Decimal places a pair's spot rate is quoted to. Determined by the quote
|
|
24
|
+
* currency (JPY pairs → 2, most others → 4). Pass `override` to force it.
|
|
25
|
+
*/
|
|
26
|
+
declare function pipDecimals(pair: string, override?: number): number;
|
|
27
|
+
/** The size of one pip for a pair, e.g. EUR/USD → 0.0001, USD/JPY → 0.01. */
|
|
28
|
+
declare function pipSize(pair: string, override?: number): number;
|
|
29
|
+
/**
|
|
30
|
+
* Outright forward rate from spot and forward points.
|
|
31
|
+
* `forward = spot + points × pipSize`.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* outrightForward("EUR/USD", 1.0850, 25); // 1.0875 (25 points = 0.0025)
|
|
35
|
+
* outrightForward("USD/JPY", 156.20, -40); // 155.80 (JPY pip = 0.01)
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
declare function outrightForward(pair: string, spot: number, forwardPoints: number, pipDecimalsOverride?: number): number;
|
|
39
|
+
/**
|
|
40
|
+
* Forward points from spot and an outright forward:
|
|
41
|
+
* `(forward − spot) / pipSize`.
|
|
42
|
+
*/
|
|
43
|
+
declare function forwardPoints(pair: string, spot: number, outright: number, pipDecimalsOverride?: number): number;
|
|
44
|
+
/**
|
|
45
|
+
* Derive a cross rate BASE/QUOTE from two rates that share a common currency
|
|
46
|
+
* (typically USD). Handles all four leg orientations automatically.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* // EUR/JPY from EUR/USD and USD/JPY:
|
|
50
|
+
* crossRate("EUR/USD", 1.0850, "USD/JPY", 156.20); // 169.477
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
declare function crossRate(pairA: string, rateA: number, pairB: string, rateB: number): number;
|
|
54
|
+
/**
|
|
55
|
+
* Triangular-arbitrage consistency check for three rates forming a loop
|
|
56
|
+
* (e.g. EUR/USD, USD/JPY, EUR/JPY). Returns the implied cross, the quoted
|
|
57
|
+
* cross, and whether they agree within `tolerancePips`.
|
|
58
|
+
*/
|
|
59
|
+
interface TriangularCheck {
|
|
60
|
+
impliedCross: number;
|
|
61
|
+
quotedCross: number;
|
|
62
|
+
differencePips: number;
|
|
63
|
+
arbitrageFree: boolean;
|
|
64
|
+
}
|
|
65
|
+
declare function triangularArbitrage(legAPair: string, legARate: number, legBPair: string, legBRate: number, crossPair: string, crossQuote: number, tolerancePips?: number): TriangularCheck;
|
|
66
|
+
/**
|
|
67
|
+
* Value of one pip on a notional, expressed in the QUOTE currency.
|
|
68
|
+
* `pipValueQuote("EUR/USD", 1_000_000)` → 100 (USD per pip).
|
|
69
|
+
*/
|
|
70
|
+
declare function pipValueQuote(pair: string, notionalBase: number, pipDecimalsOverride?: number): number;
|
|
71
|
+
/**
|
|
72
|
+
* Value of one pip on a notional, expressed in the BASE currency, given the
|
|
73
|
+
* current rate. `pipValueBase("EUR/USD", 1_000_000, 1.085)` ≈ 92.17.
|
|
74
|
+
*/
|
|
75
|
+
declare function pipValueBase(pair: string, notionalBase: number, rate: number, pipDecimalsOverride?: number): number;
|
|
76
|
+
/**
|
|
77
|
+
* Split a rate into its big figure (the "handle") and pips. For EUR/USD
|
|
78
|
+
* 1.08525 → `{ bigFigure: 1.08, pips: 52.5 }`.
|
|
79
|
+
*/
|
|
80
|
+
interface RateParts {
|
|
81
|
+
bigFigure: number;
|
|
82
|
+
pips: number;
|
|
83
|
+
}
|
|
84
|
+
declare function splitRate(pair: string, rate: number, pipDecimalsOverride?: number): RateParts;
|
|
85
|
+
/** Round a rate to the pair's quoting precision. */
|
|
86
|
+
declare function roundToPip(pair: string, rate: number, pipDecimalsOverride?: number): number;
|
|
87
|
+
|
|
88
|
+
export { type Pair, type PairConvention, type RateParts, type TriangularCheck, crossRate, forwardPoints, outrightForward, parsePair, pipDecimals, pipSize, pipValueBase, pipValueQuote, roundToPip, splitRate, triangularArbitrage };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var QUOTE_PIP_DECIMALS = {
|
|
3
|
+
JPY: 2,
|
|
4
|
+
KRW: 2,
|
|
5
|
+
CLP: 2,
|
|
6
|
+
HUF: 2,
|
|
7
|
+
IDR: 2,
|
|
8
|
+
TWD: 2
|
|
9
|
+
};
|
|
10
|
+
var DEFAULT_PIP_DECIMALS = 4;
|
|
11
|
+
var PAIR_RE = /^([A-Z]{3})\/([A-Z]{3})$/;
|
|
12
|
+
function parsePair(pair) {
|
|
13
|
+
const m = PAIR_RE.exec(pair.trim().toUpperCase());
|
|
14
|
+
if (!m) throw new RangeError(`Invalid currency pair: "${pair}" (expected "BASE/QUOTE")`);
|
|
15
|
+
if (m[1] === m[2]) throw new RangeError(`Base and quote currency are identical in "${pair}"`);
|
|
16
|
+
return { base: m[1], quote: m[2] };
|
|
17
|
+
}
|
|
18
|
+
function pipDecimals(pair, override) {
|
|
19
|
+
if (override !== void 0) {
|
|
20
|
+
if (!Number.isInteger(override) || override < 0 || override > 10) {
|
|
21
|
+
throw new RangeError(`pipDecimals override must be an integer 0\u201310, got ${override}`);
|
|
22
|
+
}
|
|
23
|
+
return override;
|
|
24
|
+
}
|
|
25
|
+
const { quote } = parsePair(pair);
|
|
26
|
+
return QUOTE_PIP_DECIMALS[quote] ?? DEFAULT_PIP_DECIMALS;
|
|
27
|
+
}
|
|
28
|
+
function pipSize(pair, override) {
|
|
29
|
+
return 1 / 10 ** pipDecimals(pair, override);
|
|
30
|
+
}
|
|
31
|
+
function checkFinite(x, label) {
|
|
32
|
+
if (!Number.isFinite(x)) throw new RangeError(`${label} must be a finite number, got ${x}`);
|
|
33
|
+
}
|
|
34
|
+
function checkRate(rate, label) {
|
|
35
|
+
checkFinite(rate, label);
|
|
36
|
+
if (rate <= 0) throw new RangeError(`${label} must be positive, got ${rate}`);
|
|
37
|
+
}
|
|
38
|
+
function outrightForward(pair, spot, forwardPoints2, pipDecimalsOverride) {
|
|
39
|
+
checkRate(spot, "spot");
|
|
40
|
+
checkFinite(forwardPoints2, "forwardPoints");
|
|
41
|
+
return spot + forwardPoints2 * pipSize(pair, pipDecimalsOverride);
|
|
42
|
+
}
|
|
43
|
+
function forwardPoints(pair, spot, outright, pipDecimalsOverride) {
|
|
44
|
+
checkRate(spot, "spot");
|
|
45
|
+
checkRate(outright, "outright");
|
|
46
|
+
return (outright - spot) / pipSize(pair, pipDecimalsOverride);
|
|
47
|
+
}
|
|
48
|
+
function crossRate(pairA, rateA, pairB, rateB) {
|
|
49
|
+
const a = parsePair(pairA);
|
|
50
|
+
const b = parsePair(pairB);
|
|
51
|
+
checkRate(rateA, "rateA");
|
|
52
|
+
checkRate(rateB, "rateB");
|
|
53
|
+
const shared = [a.base, a.quote].find((c) => c === b.base || c === b.quote);
|
|
54
|
+
if (!shared) {
|
|
55
|
+
throw new RangeError(`No common currency between ${pairA} and ${pairB}`);
|
|
56
|
+
}
|
|
57
|
+
const aOther = a.base === shared ? a.quote : a.base;
|
|
58
|
+
const bOther = b.base === shared ? b.quote : b.base;
|
|
59
|
+
if (aOther === bOther) throw new RangeError(`${pairA} and ${pairB} describe the same pair`);
|
|
60
|
+
const aInShared = a.quote === shared ? rateA : 1 / rateA;
|
|
61
|
+
const bInShared = b.quote === shared ? rateB : 1 / rateB;
|
|
62
|
+
return aInShared / bInShared;
|
|
63
|
+
}
|
|
64
|
+
function triangularArbitrage(legAPair, legARate, legBPair, legBRate, crossPair, crossQuote, tolerancePips = 1) {
|
|
65
|
+
const impliedCross = crossRate(legAPair, legARate, legBPair, legBRate);
|
|
66
|
+
checkRate(crossQuote, "crossQuote");
|
|
67
|
+
const size = pipSize(crossPair);
|
|
68
|
+
const differencePips = (crossQuote - impliedCross) / size;
|
|
69
|
+
return {
|
|
70
|
+
impliedCross,
|
|
71
|
+
quotedCross: crossQuote,
|
|
72
|
+
differencePips,
|
|
73
|
+
arbitrageFree: Math.abs(differencePips) <= tolerancePips
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function pipValueQuote(pair, notionalBase, pipDecimalsOverride) {
|
|
77
|
+
checkFinite(notionalBase, "notionalBase");
|
|
78
|
+
return notionalBase * pipSize(pair, pipDecimalsOverride);
|
|
79
|
+
}
|
|
80
|
+
function pipValueBase(pair, notionalBase, rate, pipDecimalsOverride) {
|
|
81
|
+
checkRate(rate, "rate");
|
|
82
|
+
return pipValueQuote(pair, notionalBase, pipDecimalsOverride) / rate;
|
|
83
|
+
}
|
|
84
|
+
function splitRate(pair, rate, pipDecimalsOverride) {
|
|
85
|
+
checkRate(rate, "rate");
|
|
86
|
+
const dp = pipDecimals(pair, pipDecimalsOverride);
|
|
87
|
+
const bigFigureUnit = 1 / 10 ** (dp - 2);
|
|
88
|
+
const bigFigure = Math.floor(rate / bigFigureUnit) * bigFigureUnit;
|
|
89
|
+
const pips = (rate - bigFigure) / pipSize(pair, pipDecimalsOverride);
|
|
90
|
+
return { bigFigure: round(bigFigure, dp), pips: round(pips, 4) };
|
|
91
|
+
}
|
|
92
|
+
function roundToPip(pair, rate, pipDecimalsOverride) {
|
|
93
|
+
const dp = pipDecimals(pair, pipDecimalsOverride);
|
|
94
|
+
return round(rate, dp);
|
|
95
|
+
}
|
|
96
|
+
function round(x, dp) {
|
|
97
|
+
const f = 10 ** dp;
|
|
98
|
+
return Math.round(x * f) / f;
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
crossRate,
|
|
102
|
+
forwardPoints,
|
|
103
|
+
outrightForward,
|
|
104
|
+
parsePair,
|
|
105
|
+
pipDecimals,
|
|
106
|
+
pipSize,
|
|
107
|
+
pipValueBase,
|
|
108
|
+
pipValueQuote,
|
|
109
|
+
roundToPip,
|
|
110
|
+
splitRate,
|
|
111
|
+
triangularArbitrage
|
|
112
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fx-forward-math",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "FX trading conventions math — forward points ↔ outright forwards, cross-rate derivation, triangular-arbitrage checks, pip / big-figure handling, and pip value. Zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"fx",
|
|
7
|
+
"forex",
|
|
8
|
+
"forward-points",
|
|
9
|
+
"cross-rate",
|
|
10
|
+
"triangular-arbitrage",
|
|
11
|
+
"pip",
|
|
12
|
+
"big-figure",
|
|
13
|
+
"currency",
|
|
14
|
+
"trading",
|
|
15
|
+
"finance",
|
|
16
|
+
"foreign-exchange"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://moshemalka.com",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/moshejs/fx-forward-math.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": "https://github.com/moshejs/fx-forward-math/issues",
|
|
24
|
+
"author": "Moshe Malka <hello@moshemalka.com> (https://moshemalka.com)",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.cjs",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js",
|
|
34
|
+
"require": "./dist/index.cjs"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist"
|
|
39
|
+
],
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"tsup": "^8.5.0",
|
|
49
|
+
"typescript": "^5.8.0",
|
|
50
|
+
"vitest": "^3.2.0"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=18"
|
|
54
|
+
}
|
|
55
|
+
}
|