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 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
+ });
@@ -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 };
@@ -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
+ }