@riocrypto/common-server 1.0.2781 → 1.0.2783

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.
@@ -1,4 +1,4 @@
1
- import { Quote, Fiat, Crypto, BitsoBankAccount, Side, Country, Order, CryptoAddress, BankAccount, AuthRole, Auth, ImportOrderData, TreasuryProvider, FXProvider, EmarketsFXTrade, ExternalTradingAlgorithm, ExternalTrade, ExternalTradeType, ExternalTradingProvider, ExternalTradingAlgorithmType, STPMXNWithdrawal, AuthPermission, DeferredPaymentType, TwoWaySettlementType, OrderType, EmarketsSettlementType, EmarketsOrderType, BulkBankPayout, BulkCryptoPayout, BulkBankPayment, BulkCryptoPayment, OrderStatus, TWAPSession, TransnetworkFXTrade, TransnetworkSettlementType, TransnetworkOrderType, AuthMethod, StonexFXTrade } from "@riocrypto/common";
1
+ import { Quote, Fiat, Crypto, BitsoBankAccount, Side, Country, Order, CryptoAddress, BankAccount, AuthRole, Auth, ImportOrderData, TreasuryProvider, FXProvider, EmarketsFXTrade, ExternalTradingAlgorithm, ExternalTrade, ExternalTradeType, ExternalTradingProvider, ExternalTradingAlgorithmType, STPMXNWithdrawal, AuthPermission, DeferredPaymentType, TwoWaySettlementType, OrderType, EmarketsSettlementType, EmarketsOrderType, BulkBankPayout, BulkCryptoPayout, BulkBankPayment, BulkCryptoPayment, OrderStatus, TWAPSession, TransnetworkFXTrade, TransnetworkSettlementType, TransnetworkOrderType, AuthMethod, StonexFXTrade, CancelExternalTradeReason } from "@riocrypto/common";
2
2
  import { STPMXNWithdrawalDoc } from "../models/STP-mxn-withdrawal";
3
3
  declare class ClusterClient {
4
4
  private baseUrl;
@@ -125,26 +125,28 @@ declare class ClusterClient {
125
125
  receiverCLABE: string;
126
126
  amount: number;
127
127
  }): Promise<string>;
128
- placeEmarketsTrade({ amountToTrade, limitPrice, settlementType, orderType, side, isExternalTrade, }: {
128
+ placeEmarketsTrade({ externalTradeId, amountToTrade, limitPrice, settlementType, orderType, side, }: {
129
+ externalTradeId: string;
129
130
  amountToTrade: number;
130
131
  limitPrice: number;
131
132
  settlementType: EmarketsSettlementType;
132
133
  orderType: EmarketsOrderType;
133
134
  side: Side;
134
- isExternalTrade: boolean;
135
135
  }): Promise<EmarketsFXTrade>;
136
- placeTransnetworkTrade({ amountToTrade, limitPrice, settlementType, orderType, side, isExternalTrade, }: {
136
+ placeTransnetworkTrade({ externalTradeId, amountToTrade, limitPrice, settlementType, orderType, side, }: {
137
+ externalTradeId: string;
137
138
  amountToTrade: number;
138
139
  limitPrice: number;
139
140
  settlementType: TransnetworkSettlementType;
140
141
  orderType: TransnetworkOrderType;
141
142
  side: Side;
142
- isExternalTrade: boolean;
143
143
  }): Promise<TransnetworkFXTrade>;
144
144
  retryEmarketsTrade(id: string): Promise<void>;
145
145
  cancelEmarketsTrade(id: string): Promise<void>;
146
+ cancelTransnetworkTrade(id: string): Promise<void>;
146
147
  retryExternalTrade(id: string): Promise<void>;
147
- cancelExternalTrade(id: string): Promise<void>;
148
+ takeExternalTradeQuote(id: string, side: Side): Promise<void>;
149
+ cancelExternalTrade(id: string, reason?: CancelExternalTradeReason): Promise<void>;
148
150
  importExternalTrade(data: {
149
151
  provider: ExternalTradingProvider;
150
152
  originCurrency: Crypto | Fiat;
@@ -176,16 +178,19 @@ declare class ClusterClient {
176
178
  stopExternalTradingAlgorithm(id: string): Promise<ExternalTradingAlgorithm>;
177
179
  getExternalTradingAlgorithm(id: string): Promise<ExternalTradingAlgorithm>;
178
180
  getExternalTrade(id: string): Promise<ExternalTrade>;
179
- placeExternalTrade({ provider, type, originCurrency, destinationCurrency, requestedOriginAmount, requestedDestinationAmount, limitPrice, settlementType, arbitrageSessionId, }: {
181
+ placeExternalTrade({ provider, type, originCurrency, destinationCurrency, requestedOriginAmount, requestedDestinationAmount, limitPrice, twoWaySettlementDateOffset, arbitrageSessionId, fxTradeId, timeInForce, valueDate, }: {
180
182
  provider: ExternalTradingProvider;
181
183
  type: ExternalTradeType;
182
184
  originCurrency: Crypto | Fiat;
183
185
  destinationCurrency: Crypto | Fiat;
184
186
  requestedOriginAmount?: number;
185
187
  requestedDestinationAmount?: number;
186
- limitPrice: number;
187
- settlementType?: EmarketsSettlementType | TransnetworkSettlementType;
188
+ limitPrice?: number;
189
+ twoWaySettlementDateOffset?: number;
188
190
  arbitrageSessionId?: string;
191
+ fxTradeId?: string;
192
+ timeInForce?: "day" | "gtc" | "ioc" | "fok";
193
+ valueDate?: string;
189
194
  }): Promise<ExternalTrade>;
190
195
  generateSTPDepositCLABE(userId: string): Promise<{
191
196
  CLABE: string;
@@ -482,14 +482,14 @@ class ClusterClient {
482
482
  return (_a = response.data) === null || _a === void 0 ? void 0 : _a.url;
483
483
  });
484
484
  }
485
- placeEmarketsTrade({ amountToTrade, limitPrice, settlementType, orderType, side, isExternalTrade, }) {
485
+ placeEmarketsTrade({ externalTradeId, amountToTrade, limitPrice, settlementType, orderType, side, }) {
486
486
  return __awaiter(this, void 0, void 0, function* () {
487
487
  const response = yield this.axios.post(`${this.baseUrl}/api/emarkets/bot/trades`, {
488
+ externalTradeId,
488
489
  amountToTrade,
489
490
  limitPrice,
490
491
  orderType,
491
492
  side,
492
- isExternalTrade,
493
493
  settlementType,
494
494
  }, {
495
495
  headers: {
@@ -499,14 +499,14 @@ class ClusterClient {
499
499
  return response.data;
500
500
  });
501
501
  }
502
- placeTransnetworkTrade({ amountToTrade, limitPrice, settlementType, orderType, side, isExternalTrade, }) {
502
+ placeTransnetworkTrade({ externalTradeId, amountToTrade, limitPrice, settlementType, orderType, side, }) {
503
503
  return __awaiter(this, void 0, void 0, function* () {
504
504
  const response = yield this.axios.post(`${this.baseUrl}/api/transnetwork/bot/trades`, {
505
+ externalTradeId,
505
506
  amountToTrade,
506
507
  limitPrice,
507
508
  orderType,
508
509
  side,
509
- isExternalTrade,
510
510
  settlementType,
511
511
  }, {
512
512
  headers: {
@@ -534,14 +534,28 @@ class ClusterClient {
534
534
  });
535
535
  });
536
536
  }
537
+ cancelTransnetworkTrade(id) {
538
+ return __awaiter(this, void 0, void 0, function* () {
539
+ yield this.axios.post(`${this.baseUrl}/api/transnetwork/bot/trades/${id}/cancel`, {}, {
540
+ headers: {
541
+ "x-cluster-api-key": this.clusterApiKey,
542
+ },
543
+ });
544
+ });
545
+ }
537
546
  retryExternalTrade(id) {
538
547
  return __awaiter(this, void 0, void 0, function* () {
539
548
  yield this.axios.post(`${this.baseUrl}/api/trading/external/trades/${id}/retry`, {}, { headers: { "x-cluster-api-key": this.clusterApiKey } });
540
549
  });
541
550
  }
542
- cancelExternalTrade(id) {
551
+ takeExternalTradeQuote(id, side) {
552
+ return __awaiter(this, void 0, void 0, function* () {
553
+ yield this.axios.post(`${this.baseUrl}/api/trading/external/trades/${id}/take-quote`, { side }, { headers: { "x-cluster-api-key": this.clusterApiKey } });
554
+ });
555
+ }
556
+ cancelExternalTrade(id, reason) {
543
557
  return __awaiter(this, void 0, void 0, function* () {
544
- yield this.axios.post(`${this.baseUrl}/api/trading/external/trades/${id}/cancel`, {}, { headers: { "x-cluster-api-key": this.clusterApiKey } });
558
+ yield this.axios.post(`${this.baseUrl}/api/trading/external/trades/${id}/cancel`, reason ? { reason } : {}, { headers: { "x-cluster-api-key": this.clusterApiKey } });
545
559
  });
546
560
  }
547
561
  importExternalTrade(data) {
@@ -640,7 +654,7 @@ class ClusterClient {
640
654
  return response.data;
641
655
  });
642
656
  }
643
- placeExternalTrade({ provider, type, originCurrency, destinationCurrency, requestedOriginAmount, requestedDestinationAmount, limitPrice, settlementType, arbitrageSessionId, }) {
657
+ placeExternalTrade({ provider, type, originCurrency, destinationCurrency, requestedOriginAmount, requestedDestinationAmount, limitPrice, twoWaySettlementDateOffset, arbitrageSessionId, fxTradeId, timeInForce, valueDate, }) {
644
658
  return __awaiter(this, void 0, void 0, function* () {
645
659
  const response = yield this.axios.post(`${this.baseUrl}/api/trading/external/trades`, {
646
660
  provider,
@@ -650,8 +664,11 @@ class ClusterClient {
650
664
  requestedOriginAmount,
651
665
  requestedDestinationAmount,
652
666
  limitPrice,
653
- settlementType,
667
+ twoWaySettlementDateOffset,
654
668
  arbitrageSessionId,
669
+ fxTradeId,
670
+ timeInForce,
671
+ valueDate,
655
672
  }, {
656
673
  headers: {
657
674
  "x-cluster-api-key": this.clusterApiKey,
@@ -0,0 +1,61 @@
1
+ import { Fiat, FXTradingSidePolicy, RioSettings, ResolvedFXPlacement, Side } from "@riocrypto/common";
2
+ /**
3
+ * Minimum trade size (in MXN) below which the Transnetwork bot will reject
4
+ * an order. Kept here because it's a hard operational constraint of the
5
+ * provider, not a per-policy admin setting.
6
+ */
7
+ export declare const TRANSNETWORK_MIN_MXN_AMOUNT = 1000000;
8
+ type RioSettingsForPolicy = Pick<RioSettings, "fxTradingPolicies">;
9
+ /**
10
+ * Resolve the active FX trading side-policy for a given (fiat, side, offset).
11
+ *
12
+ * Resolution order:
13
+ * 1. `fxTradingPolicies[fiat].byOffset[offset][side]` if defined
14
+ * 2. `fxTradingPolicies[fiat].default[side]` if defined
15
+ * 3. An empty policy that routes every trade to manual (Other) with no
16
+ * execution threshold. This is the safe default when no policy is
17
+ * configured for a (fiat, side) - the operator must explicitly opt in
18
+ * to any automated path.
19
+ *
20
+ * Per-side overrides are all-or-nothing: a configured override fully
21
+ * replaces the default; no field-level merging happens. Offsets without
22
+ * an explicit override always fall back to the default for that
23
+ * (fiat, side), regardless of how distant they are from any configured
24
+ * override.
25
+ *
26
+ * A caller that passes `undefined` (e.g. an order whose request body
27
+ * omitted twoWaySettlementType, which means "default / spot") is
28
+ * treated as T+0 so the same byOffset[0] override and provider
29
+ * eligibility apply as an explicit T+0 trade.
30
+ */
31
+ export declare const getFXTradingPolicy: (rioSettings: RioSettingsForPolicy, fiat: Fiat, side: Side, twoWaySettlementDateOffset: number | undefined) => FXTradingSidePolicy;
32
+ /**
33
+ * Resolve the runtime placement for an FX trade given a side-policy and
34
+ * the trade's amount. Returns `{ provider, automatic, executionThresholdMs }`:
35
+ * - First the time range covering "now" in the currency's country-local
36
+ * timezone is selected. If no range covers the current minute,
37
+ * the trade falls back to manual (Other/automatic, threshold=0).
38
+ * - The matched time window's `executionThresholdMs` is always
39
+ * surfaced even when the inner amount-range lookup falls through to
40
+ * Other - aggregation behavior is a property of the time window, not
41
+ * of the band the trade landed in.
42
+ * - Within that time range, the first amount range matching the trade
43
+ * amount wins.
44
+ * - If the matched amount range's provider fails operational eligibility
45
+ * (e.g. Transnetwork below its 1M minimum, StoneX at T+0), falls back
46
+ * to manual (Other/automatic) while preserving the window's threshold.
47
+ * - If no amount range matches, same fallback (preserves threshold).
48
+ * - For providers that don't support cosigner approval (Matching, Other)
49
+ * the `automatic` flag is forced to true regardless of what is stored
50
+ * on the range - the dashboard renders the checkbox forced-on for
51
+ * these providers, but defense-in-depth on the runtime is cheap.
52
+ *
53
+ * `now` defaults to `new Date()` and is exposed so the cron sweep can
54
+ * pin a single timestamp across the whole batch.
55
+ *
56
+ * `Other` returning `automatic: true` is intentional: there is no
57
+ * cosigner gate for manual trades, so they immediately settle without
58
+ * waiting for approval.
59
+ */
60
+ export declare const resolveFXProviderForAmount: (policy: FXTradingSidePolicy, fiat: Fiat, amountFiat: number, offset: number | undefined, now?: Date) => ResolvedFXPlacement;
61
+ export {};
@@ -0,0 +1,287 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveFXProviderForAmount = exports.getFXTradingPolicy = exports.TRANSNETWORK_MIN_MXN_AMOUNT = void 0;
4
+ const common_1 = require("@riocrypto/common");
5
+ /**
6
+ * Minimum trade size (in MXN) below which the Transnetwork bot will reject
7
+ * an order. Kept here because it's a hard operational constraint of the
8
+ * provider, not a per-policy admin setting.
9
+ */
10
+ exports.TRANSNETWORK_MIN_MXN_AMOUNT = 1000000;
11
+ /**
12
+ * Settlement-offset constraints baked into provider eligibility. The
13
+ * Emarkets and Transnetwork bots only quote spot / very-short-dated MXN
14
+ * (T+0..T+2), and StoneX is RFQ/RFS for forward MXN (T+1+) and refuses
15
+ * same-day (T+0). Keep these in sync with
16
+ * `isProviderSelectableForFiatAndOffset` in the dashboard's
17
+ * fx-trading-policy-card so saved policies match what the runtime will
18
+ * actually pick.
19
+ */
20
+ const SHORT_DATED_BOT_MAX_OFFSET = 2;
21
+ const STONEX_MIN_OFFSET = 1;
22
+ /**
23
+ * Providers that do not support a cosigner approval workflow. For these
24
+ * providers the `automatic` flag is meaningless and the runtime always
25
+ * treats the band as automatic, regardless of what is persisted on the
26
+ * policy. The dashboard mirrors this by rendering the checkbox forced-on
27
+ * and disabled for these providers.
28
+ *
29
+ * StoneX is intentionally NOT in this set: even though placement is RFQ
30
+ * (not fill-or-kill), internal-fx supports gating a StoneX placement
31
+ * behind the same cosigner webhook the bots use, so operators can
32
+ * require approval before an RFQ stream is opened.
33
+ */
34
+ const NON_COSIGNER_PROVIDERS = new Set([
35
+ common_1.FXProvider.Matching,
36
+ common_1.FXProvider.Other,
37
+ ]);
38
+ const FALLBACK_RESOLUTION = {
39
+ provider: common_1.FXProvider.Other,
40
+ automatic: true,
41
+ // 0ms threshold = place immediately. The Other path completes
42
+ // manually with no aggregation; surfacing a longer threshold here
43
+ // would just pin manual trades in PendingExecutionThreshold for no
44
+ // reason.
45
+ executionThresholdMs: 0,
46
+ };
47
+ /**
48
+ * Defensive shape-check. Legacy persisted policies use a different shape
49
+ * (`providers[]` / `automaticTradeLimit`, or the pre-per-window-threshold
50
+ * shape that put `executionThresholdMs` on the side policy) - left in
51
+ * place by an out-of-date deploy, by an admin who hasn't saved the new
52
+ * editor yet, or by a typo'd manual DB edit. The resolver MUST NOT
53
+ * crash on that data; instead it treats those slots as "not configured"
54
+ * so the safe Other/automatic fallback applies and trades route to
55
+ * manual until the admin saves the new policy shape.
56
+ */
57
+ const isWellFormedSidePolicy = (policy) => {
58
+ if (!policy || typeof policy !== "object")
59
+ return false;
60
+ const candidate = policy;
61
+ return Array.isArray(candidate.byTimeRange);
62
+ };
63
+ /**
64
+ * Defensive shape-check for a single time range. The resolver tolerates
65
+ * a malformed entry by skipping it (so a malformed window doesn't take
66
+ * down the whole side). Note: we do NOT require contiguous coverage at
67
+ * the resolver layer - the rio-settings save route enforces that. Stale
68
+ * data with a gap still routes to manual via fallback.
69
+ *
70
+ * `executionThresholdMs` may be absent on legacy rows that were saved
71
+ * before the threshold moved per-window; we treat that as "place
72
+ * immediately" rather than rejecting the whole window, which matches
73
+ * the safer default and keeps existing routing rules intact for
74
+ * operators who haven't re-saved yet.
75
+ */
76
+ const isWellFormedTimeRange = (range) => {
77
+ if (!range || typeof range !== "object")
78
+ return false;
79
+ const candidate = range;
80
+ return (typeof candidate.startMinute === "number" &&
81
+ typeof candidate.endMinute === "number" &&
82
+ Array.isArray(candidate.byAmountRange));
83
+ };
84
+ const safeExecutionThresholdMs = (range) => {
85
+ return typeof range.executionThresholdMs === "number" &&
86
+ Number.isFinite(range.executionThresholdMs) &&
87
+ range.executionThresholdMs >= 0
88
+ ? range.executionThresholdMs
89
+ : 0;
90
+ };
91
+ const pickPolicyBySide = (policyBySide, side) => {
92
+ if (!policyBySide) {
93
+ return undefined;
94
+ }
95
+ const candidate = policyBySide[side];
96
+ if (!isWellFormedSidePolicy(candidate)) {
97
+ return undefined;
98
+ }
99
+ return candidate;
100
+ };
101
+ /**
102
+ * Resolve the active FX trading side-policy for a given (fiat, side, offset).
103
+ *
104
+ * Resolution order:
105
+ * 1. `fxTradingPolicies[fiat].byOffset[offset][side]` if defined
106
+ * 2. `fxTradingPolicies[fiat].default[side]` if defined
107
+ * 3. An empty policy that routes every trade to manual (Other) with no
108
+ * execution threshold. This is the safe default when no policy is
109
+ * configured for a (fiat, side) - the operator must explicitly opt in
110
+ * to any automated path.
111
+ *
112
+ * Per-side overrides are all-or-nothing: a configured override fully
113
+ * replaces the default; no field-level merging happens. Offsets without
114
+ * an explicit override always fall back to the default for that
115
+ * (fiat, side), regardless of how distant they are from any configured
116
+ * override.
117
+ *
118
+ * A caller that passes `undefined` (e.g. an order whose request body
119
+ * omitted twoWaySettlementType, which means "default / spot") is
120
+ * treated as T+0 so the same byOffset[0] override and provider
121
+ * eligibility apply as an explicit T+0 trade.
122
+ */
123
+ const getFXTradingPolicy = (rioSettings, fiat, side, twoWaySettlementDateOffset) => {
124
+ var _a;
125
+ const effectiveOffset = twoWaySettlementDateOffset !== null && twoWaySettlementDateOffset !== void 0 ? twoWaySettlementDateOffset : 0;
126
+ const fiatPolicies = (_a = rioSettings.fxTradingPolicies) === null || _a === void 0 ? void 0 : _a[fiat];
127
+ if (fiatPolicies) {
128
+ if (fiatPolicies.byOffset) {
129
+ const offsetPolicy = pickPolicyBySide(fiatPolicies.byOffset[effectiveOffset], side);
130
+ if (offsetPolicy) {
131
+ return offsetPolicy;
132
+ }
133
+ }
134
+ const defaultPolicy = pickPolicyBySide(fiatPolicies.default, side);
135
+ if (defaultPolicy) {
136
+ return defaultPolicy;
137
+ }
138
+ }
139
+ return { byTimeRange: [] };
140
+ };
141
+ exports.getFXTradingPolicy = getFXTradingPolicy;
142
+ /**
143
+ * Returns true when the given provider can operationally handle the trade.
144
+ * These are hard provider-side constraints, not admin-configurable.
145
+ *
146
+ * `offset === undefined` is interpreted as T+0 (the system convention:
147
+ * "no settlement type specified" == "default" == spot), matching
148
+ * `getFXTradingPolicy`. As a result Emarkets / Transnetwork stay eligible
149
+ * for undefined-offset callers but StoneX (which refuses T+0) does not.
150
+ */
151
+ const isProviderEligible = (provider, fiat, amountFiat, offset) => {
152
+ if (provider === common_1.FXProvider.StoneX ||
153
+ provider === common_1.FXProvider.Emarkets ||
154
+ provider === common_1.FXProvider.Transnetwork) {
155
+ if (fiat !== common_1.Fiat.MXN)
156
+ return false;
157
+ }
158
+ if (provider === common_1.FXProvider.Transnetwork) {
159
+ if (amountFiat === undefined || amountFiat < exports.TRANSNETWORK_MIN_MXN_AMOUNT) {
160
+ return false;
161
+ }
162
+ }
163
+ const effectiveOffset = offset !== null && offset !== void 0 ? offset : 0;
164
+ if ((provider === common_1.FXProvider.Emarkets ||
165
+ provider === common_1.FXProvider.Transnetwork) &&
166
+ effectiveOffset > SHORT_DATED_BOT_MAX_OFFSET) {
167
+ return false;
168
+ }
169
+ if (provider === common_1.FXProvider.StoneX && effectiveOffset < STONEX_MIN_OFFSET) {
170
+ return false;
171
+ }
172
+ return true;
173
+ };
174
+ /**
175
+ * Returns true if `amount` falls inside `[range.minAmount, range.maxAmount)`.
176
+ * Both bounds are required by the type, but the resolver defensively
177
+ * treats a missing/non-finite `maxAmount` (legacy data, manual db edit)
178
+ * as a non-match so the trade falls through to the safe Other/manual
179
+ * fallback rather than silently inheriting a previous "open-ended"
180
+ * interpretation.
181
+ */
182
+ const rangeMatchesAmount = (range, amount) => {
183
+ const min = Math.max(range.minAmount, 0);
184
+ if (amount < min)
185
+ return false;
186
+ if (typeof range.maxAmount !== "number" || !Number.isFinite(range.maxAmount)) {
187
+ return false;
188
+ }
189
+ return amount < range.maxAmount;
190
+ };
191
+ /**
192
+ * Returns true if `minuteOfDay` (0..1439, in the time range's reference
193
+ * timezone) falls inside `[range.startMinute, range.endMinute)`.
194
+ */
195
+ const rangeMatchesMinute = (range, minuteOfDay) => {
196
+ if (minuteOfDay < range.startMinute)
197
+ return false;
198
+ return minuteOfDay < range.endMinute;
199
+ };
200
+ /**
201
+ * Returns the current minute-of-day in the given IANA timezone, in the
202
+ * range [0, 1440). Uses `Intl.DateTimeFormat` so DST transitions are
203
+ * applied automatically by the platform - no manual offset bookkeeping.
204
+ *
205
+ * `now` is injectable for testability and so a single cron tick can
206
+ * snapshot the time once and reuse it across all FX trades being swept.
207
+ */
208
+ const getMinuteOfDayInTimezone = (now, timezone) => {
209
+ const parts = new Intl.DateTimeFormat("en-US", {
210
+ timeZone: timezone,
211
+ hour12: false,
212
+ hour: "2-digit",
213
+ minute: "2-digit",
214
+ }).formatToParts(now);
215
+ let hour = 0;
216
+ let minute = 0;
217
+ for (const part of parts) {
218
+ if (part.type === "hour")
219
+ hour = parseInt(part.value, 10);
220
+ if (part.type === "minute")
221
+ minute = parseInt(part.value, 10);
222
+ }
223
+ // Intl formats midnight as "24" in some locales/Node versions; clamp
224
+ // defensively so we never return 1440 (which would miss the [0, 1440)
225
+ // last range and fall through to manual).
226
+ if (hour === 24)
227
+ hour = 0;
228
+ return hour * 60 + minute;
229
+ };
230
+ /**
231
+ * Resolve the runtime placement for an FX trade given a side-policy and
232
+ * the trade's amount. Returns `{ provider, automatic, executionThresholdMs }`:
233
+ * - First the time range covering "now" in the currency's country-local
234
+ * timezone is selected. If no range covers the current minute,
235
+ * the trade falls back to manual (Other/automatic, threshold=0).
236
+ * - The matched time window's `executionThresholdMs` is always
237
+ * surfaced even when the inner amount-range lookup falls through to
238
+ * Other - aggregation behavior is a property of the time window, not
239
+ * of the band the trade landed in.
240
+ * - Within that time range, the first amount range matching the trade
241
+ * amount wins.
242
+ * - If the matched amount range's provider fails operational eligibility
243
+ * (e.g. Transnetwork below its 1M minimum, StoneX at T+0), falls back
244
+ * to manual (Other/automatic) while preserving the window's threshold.
245
+ * - If no amount range matches, same fallback (preserves threshold).
246
+ * - For providers that don't support cosigner approval (Matching, Other)
247
+ * the `automatic` flag is forced to true regardless of what is stored
248
+ * on the range - the dashboard renders the checkbox forced-on for
249
+ * these providers, but defense-in-depth on the runtime is cheap.
250
+ *
251
+ * `now` defaults to `new Date()` and is exposed so the cron sweep can
252
+ * pin a single timestamp across the whole batch.
253
+ *
254
+ * `Other` returning `automatic: true` is intentional: there is no
255
+ * cosigner gate for manual trades, so they immediately settle without
256
+ * waiting for approval.
257
+ */
258
+ const resolveFXProviderForAmount = (policy, fiat, amountFiat, offset, now = new Date()) => {
259
+ const country = (0, common_1.getCountryForFiat)(fiat);
260
+ const timezone = (0, common_1.getCountryTimezone)(country);
261
+ const minuteOfDay = getMinuteOfDayInTimezone(now, timezone);
262
+ const matchedTimeRange = policy.byTimeRange.find((range) => isWellFormedTimeRange(range) && rangeMatchesMinute(range, minuteOfDay));
263
+ if (!matchedTimeRange) {
264
+ return FALLBACK_RESOLUTION;
265
+ }
266
+ // Threshold for the active time window. Always returned (even when
267
+ // we fall through to Other below) so callers can decide aggregation
268
+ // based on the window the trade is currently in, not the window when
269
+ // it was created.
270
+ const executionThresholdMs = safeExecutionThresholdMs(matchedTimeRange);
271
+ const matchedAmountRange = matchedTimeRange.byAmountRange.find((range) => rangeMatchesAmount(range, amountFiat));
272
+ if (!matchedAmountRange) {
273
+ return Object.assign(Object.assign({}, FALLBACK_RESOLUTION), { executionThresholdMs });
274
+ }
275
+ if (!isProviderEligible(matchedAmountRange.provider, fiat, amountFiat, offset)) {
276
+ return Object.assign(Object.assign({}, FALLBACK_RESOLUTION), { executionThresholdMs });
277
+ }
278
+ const automatic = NON_COSIGNER_PROVIDERS.has(matchedAmountRange.provider)
279
+ ? true
280
+ : matchedAmountRange.automatic;
281
+ return {
282
+ provider: matchedAmountRange.provider,
283
+ automatic,
284
+ executionThresholdMs,
285
+ };
286
+ };
287
+ exports.resolveFXProviderForAmount = resolveFXProviderForAmount;
package/build/index.d.ts CHANGED
@@ -171,3 +171,4 @@ export * from "./helpers/interpolate-forward-curve-rate";
171
171
  export * from "./helpers/find-user-tier-forward-curve-rate";
172
172
  export * from "./helpers/resolve-forward-curve-rate";
173
173
  export * from "./helpers/get-resolved-forward-curve-tiers";
174
+ export * from "./helpers/get-fx-trading-policy";
package/build/index.js CHANGED
@@ -187,3 +187,4 @@ __exportStar(require("./helpers/interpolate-forward-curve-rate"), exports);
187
187
  __exportStar(require("./helpers/find-user-tier-forward-curve-rate"), exports);
188
188
  __exportStar(require("./helpers/resolve-forward-curve-rate"), exports);
189
189
  __exportStar(require("./helpers/get-resolved-forward-curve-tiers"), exports);
190
+ __exportStar(require("./helpers/get-fx-trading-policy"), exports);
@@ -3,8 +3,7 @@ import { Mongoose, Model, Document, HydratedDocument } from "mongoose";
3
3
  interface EmarketsFXTradeAttrs {
4
4
  createdAt: Date;
5
5
  status: EmarketsFXTradeStatus;
6
- FXTradeId?: string;
7
- isExternalTrade: boolean;
6
+ externalTradeId: string;
8
7
  side: Side;
9
8
  fiat: Fiat;
10
9
  amountToTrade: number;
@@ -20,8 +19,7 @@ interface EmarketsFXTradeDoc extends Document {
20
19
  createdAt: Date;
21
20
  side: Side;
22
21
  status: EmarketsFXTradeStatus;
23
- FXTradeId?: string;
24
- isExternalTrade: boolean;
22
+ externalTradeId: string;
25
23
  fiat: Fiat;
26
24
  amountToTrade: number;
27
25
  amountReceived?: number;
@@ -19,12 +19,9 @@ const buildEmarketsFXTrade = (mongoose) => {
19
19
  type: String,
20
20
  required: true,
21
21
  },
22
- FXTradeId: {
22
+ externalTradeId: {
23
23
  type: String,
24
24
  },
25
- isExternalTrade: {
26
- type: Boolean,
27
- },
28
25
  fiat: {
29
26
  type: String,
30
27
  required: true,
@@ -18,6 +18,7 @@ interface ExternalTradeAttrs {
18
18
  actualPrice?: number;
19
19
  associatedMidmarketPrice?: number;
20
20
  status: ExternalTradeStatus;
21
+ fxTradeId?: string;
21
22
  providerOptions?: {
22
23
  emarkets?: {
23
24
  settlementType?: EmarketsSettlementType;
@@ -58,6 +59,7 @@ interface ExternalTradeDoc extends Document {
58
59
  actualPrice?: number;
59
60
  associatedMidmarketPrice?: number;
60
61
  status: ExternalTradeStatus;
62
+ fxTradeId?: string;
61
63
  providerOptions?: {
62
64
  emarkets?: {
63
65
  settlementType?: EmarketsSettlementType;
@@ -29,6 +29,9 @@ const buildExternalTrade = (mongoose) => {
29
29
  type: String,
30
30
  required: true,
31
31
  },
32
+ fxTradeId: {
33
+ type: String,
34
+ },
32
35
  provider: {
33
36
  type: String,
34
37
  required: true,
@@ -1,4 +1,4 @@
1
- import { Fiat, FXProvider, FXTradeStatus, Side, Crypto, EmarketsSettlementType, TransnetworkSettlementType } from "@riocrypto/common";
1
+ import { Fiat, FXProvider, FXTradeStatus, Side, Crypto, ExternalTradeStatus } from "@riocrypto/common";
2
2
  import { Mongoose, Model, Document, HydratedDocument } from "mongoose";
3
3
  interface FXTradeAttrs {
4
4
  createdAt: Date;
@@ -6,17 +6,19 @@ interface FXTradeAttrs {
6
6
  orderIds: string[];
7
7
  amount: number;
8
8
  fiat: Fiat;
9
- settlementType?: EmarketsSettlementType | TransnetworkSettlementType;
10
9
  averageOfferedPrice: number;
11
10
  amountTraded: number;
12
11
  price?: number;
13
12
  provider: FXProvider;
13
+ twoWaySettlementDate: Date;
14
+ twoWaySettlementDateOffset: number;
14
15
  executedTrades: {
15
16
  amount: number;
16
17
  provider: FXProvider;
17
- price: number;
18
+ price?: number;
19
+ status: ExternalTradeStatus;
18
20
  matchedTradeId?: string;
19
- providerOrderId?: string;
21
+ externalTradeId?: string;
20
22
  }[];
21
23
  isRunning?: boolean;
22
24
  status: FXTradeStatus;
@@ -29,16 +31,18 @@ interface FXTradeDoc extends Document {
29
31
  amount: number;
30
32
  fiat: Fiat;
31
33
  amountTraded: number;
32
- settlementType?: EmarketsSettlementType | TransnetworkSettlementType;
33
34
  averageOfferedPrice: number;
34
35
  price?: number;
35
36
  provider: FXProvider;
37
+ twoWaySettlementDate: Date;
38
+ twoWaySettlementDateOffset: number;
36
39
  executedTrades: {
37
40
  amount: number;
38
41
  provider: FXProvider;
39
- price: number;
42
+ price?: number;
43
+ status: ExternalTradeStatus;
40
44
  matchedTradeId?: string;
41
- providerOrderId?: string;
45
+ externalTradeId?: string;
42
46
  }[];
43
47
  status: FXTradeStatus;
44
48
  isRunning?: boolean;
@@ -27,8 +27,11 @@ const buildFXTrade = (mongoose) => {
27
27
  type: String,
28
28
  required: true,
29
29
  },
30
- settlementType: {
31
- type: String,
30
+ twoWaySettlementDate: {
31
+ type: mongoose.Schema.Types.Date,
32
+ },
33
+ twoWaySettlementDateOffset: {
34
+ type: Number,
32
35
  },
33
36
  amountTraded: {
34
37
  type: Number,
@@ -1,4 +1,4 @@
1
- import { Country, DeferredPaymentType, EmarketsSettlementType, Fiat, FXProvider, Processor, Side, TransnetworkSettlementType } from "@riocrypto/common";
1
+ import { Country, DeferredPaymentType, EmarketsSettlementType, Fiat, FXProvider, FXTradingPolicies, Processor, Side, TransnetworkSettlementType } from "@riocrypto/common";
2
2
  import { TVFXDataProvider } from "@riocrypto/common";
3
3
  import mongoose, { HydratedDocument } from "mongoose";
4
4
  interface RioSettingsAttrs {
@@ -28,12 +28,6 @@ interface RioSettingsAttrs {
28
28
  sellOrderGeneralExchangeRateMarkup: {
29
29
  [key in Fiat]: number;
30
30
  };
31
- buyOrderAutomaticFXTradeLimit: {
32
- [key in Fiat]: number;
33
- };
34
- sellOrderAutomaticFXTradeLimit: {
35
- [key in Fiat]: number;
36
- };
37
31
  defaultServiceFee: {
38
32
  [key in Country]?: {
39
33
  [key in Side]?: number;
@@ -51,7 +45,7 @@ interface RioSettingsAttrs {
51
45
  };
52
46
  };
53
47
  };
54
- fxExecutionThreshold: number;
48
+ fxTradingPolicies?: FXTradingPolicies;
55
49
  activeFiatPaymentProcessors: {
56
50
  [Side.Buy]: Processor[];
57
51
  [Side.Sell]: Processor[];
@@ -117,12 +111,6 @@ interface RioSettingsDoc extends mongoose.Document {
117
111
  sellOrderAfterHoursExchangeRateMarkup: {
118
112
  [key in Fiat]: number;
119
113
  };
120
- buyOrderAutomaticFXTradeLimit: {
121
- [key in Fiat]: number;
122
- };
123
- sellOrderAutomaticFXTradeLimit: {
124
- [key in Fiat]: number;
125
- };
126
114
  additionalBuyOrderLiquidityUSD?: number;
127
115
  additionalSellOrderLiquidityUSD?: number;
128
116
  previousDayUSDMXNConversionRate?: number;
@@ -140,7 +128,7 @@ interface RioSettingsDoc extends mongoose.Document {
140
128
  };
141
129
  };
142
130
  };
143
- fxExecutionThreshold: number;
131
+ fxTradingPolicies?: FXTradingPolicies;
144
132
  activeFiatPaymentProcessors: {
145
133
  [Side.Buy]: Processor[];
146
134
  [Side.Sell]: Processor[];
@@ -15,12 +15,6 @@ const buildRioSettings = (mongoose) => {
15
15
  defaultDeferredPaymentFee: {
16
16
  type: Object,
17
17
  },
18
- buyOrderAutomaticFXTradeLimit: {
19
- type: Object,
20
- },
21
- sellOrderAutomaticFXTradeLimit: {
22
- type: Object,
23
- },
24
18
  isAfterHours: {
25
19
  type: Object,
26
20
  },
@@ -48,8 +42,8 @@ const buildRioSettings = (mongoose) => {
48
42
  fxProviders: {
49
43
  type: Object,
50
44
  },
51
- fxExecutionThreshold: {
52
- type: Number,
45
+ fxTradingPolicies: {
46
+ type: Object,
53
47
  },
54
48
  defaultServiceFee: {
55
49
  type: Object,
@@ -3,8 +3,7 @@ import { Mongoose, Model, Document, HydratedDocument } from "mongoose";
3
3
  interface TransnetworkFXTradeAttrs {
4
4
  createdAt: Date;
5
5
  status: TransnetworkFXTradeStatus;
6
- FXTradeId?: string;
7
- isExternalTrade: boolean;
6
+ externalTradeId?: string;
8
7
  side: Side;
9
8
  fiat: Fiat;
10
9
  amountToTrade: number;
@@ -21,8 +20,7 @@ interface TransnetworkFXTradeDoc extends Document {
21
20
  createdAt: Date;
22
21
  side: Side;
23
22
  status: TransnetworkFXTradeStatus;
24
- FXTradeId?: string;
25
- isExternalTrade: boolean;
23
+ externalTradeId?: string;
26
24
  fiat: Fiat;
27
25
  amountToTrade: number;
28
26
  amountReceived?: number;
@@ -19,12 +19,9 @@ const buildTransnetworkFXTrade = (mongoose) => {
19
19
  type: String,
20
20
  required: true,
21
21
  },
22
- FXTradeId: {
22
+ externalTradeId: {
23
23
  type: String,
24
24
  },
25
- isExternalTrade: {
26
- type: Boolean,
27
- },
28
25
  fiat: {
29
26
  type: String,
30
27
  required: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riocrypto/common-server",
3
- "version": "1.0.2781",
3
+ "version": "1.0.2783",
4
4
  "description": "",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -24,7 +24,7 @@
24
24
  "@google-cloud/secret-manager": "^5.6.0",
25
25
  "@google-cloud/storage": "^7.19.0",
26
26
  "@hyperdx/node-opentelemetry": "^0.10.3",
27
- "@riocrypto/common": "1.0.2583",
27
+ "@riocrypto/common": "1.0.2587",
28
28
  "@slack/web-api": "^7.15.0",
29
29
  "@types/express": "^4.17.25",
30
30
  "axios": "1.13.6",