@metamask-previews/perps-controller 17.3.0-preview-d3f9964be → 17.4.0-preview-aecbce4ff
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/CHANGELOG.md +72 -1
- package/dist/PerpsController-method-action-types.d.ts +10 -3
- package/dist/PerpsController-method-action-types.d.ts.map +1 -1
- package/dist/PerpsController-method-action-types.js.map +1 -1
- package/dist/PerpsController.d.ts +10 -3
- package/dist/PerpsController.d.ts.map +1 -1
- package/dist/PerpsController.js +62 -8
- package/dist/PerpsController.js.map +1 -1
- package/dist/constants/perpsConfig.d.ts +55 -0
- package/dist/constants/perpsConfig.d.ts.map +1 -1
- package/dist/constants/perpsConfig.js +55 -0
- package/dist/constants/perpsConfig.js.map +1 -1
- package/dist/providers/AggregatedPerpsProvider.d.ts +19 -0
- package/dist/providers/AggregatedPerpsProvider.d.ts.map +1 -1
- package/dist/providers/AggregatedPerpsProvider.js +21 -0
- package/dist/providers/AggregatedPerpsProvider.js.map +1 -1
- package/dist/providers/HyperLiquidProvider.d.ts +7 -0
- package/dist/providers/HyperLiquidProvider.d.ts.map +1 -1
- package/dist/providers/HyperLiquidProvider.js +133 -34
- package/dist/providers/HyperLiquidProvider.js.map +1 -1
- package/dist/providers/LighterProvider.d.ts.map +1 -1
- package/dist/providers/LighterProvider.js +2 -0
- package/dist/providers/LighterProvider.js.map +1 -1
- package/dist/services/MarketDataService.d.ts.map +1 -1
- package/dist/services/MarketDataService.js +16 -4
- package/dist/services/MarketDataService.js.map +1 -1
- package/dist/services/RewardsIntegrationService.d.ts +47 -2
- package/dist/services/RewardsIntegrationService.d.ts.map +1 -1
- package/dist/services/RewardsIntegrationService.js +400 -19
- package/dist/services/RewardsIntegrationService.js.map +1 -1
- package/dist/services/ServiceContext.d.ts +9 -1
- package/dist/services/ServiceContext.d.ts.map +1 -1
- package/dist/services/ServiceContext.js.map +1 -1
- package/dist/services/TerminalMarketService.d.ts +1 -1
- package/dist/services/TerminalMarketService.d.ts.map +1 -1
- package/dist/services/TerminalMarketService.js +11 -2
- package/dist/services/TerminalMarketService.js.map +1 -1
- package/dist/services/TradingService.d.ts.map +1 -1
- package/dist/services/TradingService.js +291 -18
- package/dist/services/TradingService.js.map +1 -1
- package/dist/types/index.d.ts +65 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/messenger.d.ts +17 -1
- package/dist/types/messenger.d.ts.map +1 -1
- package/dist/types/messenger.js.map +1 -1
- package/dist/utils/hyperLiquidAdapter.d.ts +7 -0
- package/dist/utils/hyperLiquidAdapter.d.ts.map +1 -1
- package/dist/utils/hyperLiquidAdapter.js +1 -1
- package/dist/utils/hyperLiquidAdapter.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/subscriptionFeeWaiver.d.ts +201 -0
- package/dist/utils/subscriptionFeeWaiver.d.ts.map +1 -0
- package/dist/utils/subscriptionFeeWaiver.js +334 -0
- package/dist/utils/subscriptionFeeWaiver.js.map +1 -0
- package/package.json +11 -6
|
@@ -6,7 +6,7 @@ import { PerpsMeasurementName } from '../constants/performanceMetrics.js';
|
|
|
6
6
|
import { PERPS_CONSTANTS } from '../constants/perpsConfig.js';
|
|
7
7
|
import { PerpsAnalyticsEvent, PerpsTraceNames, PerpsTraceOperations, } from '../types/index.js';
|
|
8
8
|
import { ensureError } from '../utils/errorUtils.js';
|
|
9
|
-
import { isLimitExecutionOrderType } from '../utils/orderTypes.js';
|
|
9
|
+
import { isLimitExecutionOrderType, SCALE_ORDER_COUNT, } from '../utils/orderTypes.js';
|
|
10
10
|
/**
|
|
11
11
|
* TradingService
|
|
12
12
|
*
|
|
@@ -440,7 +440,7 @@ export class TradingService {
|
|
|
440
440
|
},
|
|
441
441
|
});
|
|
442
442
|
// Calculate fee discount at execution time (fresh, secure)
|
|
443
|
-
const feeResolution = await this.#calculateFeeDiscountWithMeasurement();
|
|
443
|
+
const feeResolution = await this.#calculateFeeDiscountWithMeasurement(this.#resolveOrderNotionalUsd(params));
|
|
444
444
|
this.#deps.debugLogger.log('TradingService: Fee resolution calculated', {
|
|
445
445
|
feeDiscountBips: feeResolution?.discountBips,
|
|
446
446
|
feeSource: feeResolution?.source,
|
|
@@ -594,16 +594,25 @@ export class TradingService {
|
|
|
594
594
|
* @param options - The configuration options.
|
|
595
595
|
* @param options.symbol - The trading pair symbol.
|
|
596
596
|
* @param options.context - The service context for dependencies.
|
|
597
|
+
* @param options.provider - The provider the write will be submitted through,
|
|
598
|
+
* used to narrow the read to positions that route can reach. Omit for a
|
|
599
|
+
* read-only lookup that does not precede a write.
|
|
600
|
+
* @param options.providerId - Explicit route, when the caller supplied one.
|
|
597
601
|
* @returns The result of the operation.
|
|
598
602
|
*/
|
|
599
603
|
async #loadPositionData(options) {
|
|
600
|
-
const { symbol, context } = options;
|
|
604
|
+
const { symbol, context, provider, providerId } = options;
|
|
601
605
|
const positionLoadStart = this.#deps.performance.now();
|
|
602
606
|
try {
|
|
603
607
|
const positions = context.getPositions
|
|
604
608
|
? await context.getPositions()
|
|
605
609
|
: [];
|
|
606
|
-
|
|
610
|
+
// Matching on symbol alone can pick another provider's position when two
|
|
611
|
+
// providers list the same market, so a routed write narrows first.
|
|
612
|
+
const candidates = provider
|
|
613
|
+
? this.#positionsForWriteRoute({ positions, provider, providerId })
|
|
614
|
+
: positions;
|
|
615
|
+
const position = candidates.find((pos) => pos.symbol === symbol);
|
|
607
616
|
this.#deps.tracer.setMeasurement(PerpsMeasurementName.PerpsGetPositionsOperation, this.#deps.performance.now() - positionLoadStart, 'millisecond');
|
|
608
617
|
return position;
|
|
609
618
|
}
|
|
@@ -869,13 +878,219 @@ export class TradingService {
|
|
|
869
878
|
});
|
|
870
879
|
}
|
|
871
880
|
/**
|
|
872
|
-
*
|
|
873
|
-
* Uses controller dependencies injected via setControllerDependencies()
|
|
874
|
-
* Helper method for placeOrder orchestration
|
|
881
|
+
* Resolve the total USD notional a batch close will submit.
|
|
875
882
|
*
|
|
876
|
-
*
|
|
883
|
+
* HyperLiquid takes one builder context for the whole batch, so the fee is
|
|
884
|
+
* resolved once against everything it closes rather than per position.
|
|
885
|
+
* Positions are read through the service context; when that read is
|
|
886
|
+
* unavailable or empty the notional is undefined, which the resolver reads as
|
|
887
|
+
* "no notional to blend against".
|
|
888
|
+
*
|
|
889
|
+
* @param options - The configuration options.
|
|
890
|
+
* @param options.params - Which positions the batch will close.
|
|
891
|
+
* @param options.provider - The provider that will submit the batch, and so
|
|
892
|
+
* the only one whose positions it can close.
|
|
893
|
+
* @returns The summed notional in USD, or undefined when it cannot be read.
|
|
894
|
+
*/
|
|
895
|
+
async #resolveBatchCloseNotionalUsd(options) {
|
|
896
|
+
const { params, provider } = options;
|
|
897
|
+
try {
|
|
898
|
+
// Read through the provider that will actually submit the batch, then
|
|
899
|
+
// keep only what that route can close: an aggregating provider's
|
|
900
|
+
// `getPositions` still spans every active provider, so summing the rest
|
|
901
|
+
// would inflate the notional and shrink the waiver for positions this
|
|
902
|
+
// call never touches.
|
|
903
|
+
const positions = this.#positionsForWriteRoute({
|
|
904
|
+
positions: await provider.getPositions(),
|
|
905
|
+
provider,
|
|
906
|
+
});
|
|
907
|
+
// `closeAll`, or an omitted/empty symbol list, means every position.
|
|
908
|
+
// `closeAll` is checked first because the provider gives it precedence
|
|
909
|
+
// over a symbol list: pricing a filtered subset while the batch closes
|
|
910
|
+
// everything would resolve the fee against too small a notional and
|
|
911
|
+
// over-grant the waiver.
|
|
912
|
+
const selected = params.closeAll !== true && params.symbols && params.symbols.length > 0
|
|
913
|
+
? positions.filter((position) => params.symbols?.includes(position.symbol))
|
|
914
|
+
: positions;
|
|
915
|
+
const total = selected.reduce((sum, position) => {
|
|
916
|
+
const value = Math.abs(Number.parseFloat(position.positionValue));
|
|
917
|
+
return Number.isFinite(value) ? sum + value : sum;
|
|
918
|
+
}, 0);
|
|
919
|
+
return total > 0 ? total : undefined;
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
// Pricing the batch must never fail the close. Without a notional the
|
|
923
|
+
// resolver quotes the full waiver, which is what it did before.
|
|
924
|
+
this.#deps.debugLogger.log('TradingService: Could not price batch close for the fee resolver', {
|
|
925
|
+
error: ensureError(error, 'TradingService.resolveBatchCloseNotionalUsd').message,
|
|
926
|
+
});
|
|
927
|
+
return undefined;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Keep only the positions a write through this route can actually touch.
|
|
932
|
+
*
|
|
933
|
+
* An aggregating provider reads positions from every active provider while a
|
|
934
|
+
* write goes to one, so pricing a close against the unfiltered list can bill
|
|
935
|
+
* against positions the call cannot close — and, when two providers list the
|
|
936
|
+
* same symbol, can price one provider's position for a write submitted to
|
|
937
|
+
* another. A provider that does not aggregate reports no write route and its
|
|
938
|
+
* positions are all its own.
|
|
939
|
+
*
|
|
940
|
+
* @param options - The configuration options.
|
|
941
|
+
* @param options.positions - Positions as read, possibly across providers.
|
|
942
|
+
* @param options.provider - The provider the write will be submitted through.
|
|
943
|
+
* @param options.providerId - Explicit route, when the caller supplied one.
|
|
944
|
+
* @returns The positions that route can reach.
|
|
945
|
+
*/
|
|
946
|
+
#positionsForWriteRoute(options) {
|
|
947
|
+
const { positions, provider, providerId } = options;
|
|
948
|
+
const route = provider.getWriteProviderId?.(providerId);
|
|
949
|
+
if (route === undefined) {
|
|
950
|
+
return positions;
|
|
951
|
+
}
|
|
952
|
+
// A position carries its provider only when an aggregator injected one;
|
|
953
|
+
// without that attribution there is nothing to filter on.
|
|
954
|
+
return positions.filter((position) => position.providerId === undefined || position.providerId === route);
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* The position's USD value per unit of size.
|
|
958
|
+
*
|
|
959
|
+
* Used to price a partial close, which names a size but usually no price.
|
|
960
|
+
*
|
|
961
|
+
* @param position - The loaded position, when one was found.
|
|
962
|
+
* @returns The per-unit price, or undefined when it cannot be derived.
|
|
963
|
+
*/
|
|
964
|
+
#resolvePositionUnitPrice(position) {
|
|
965
|
+
if (!position) {
|
|
966
|
+
return undefined;
|
|
967
|
+
}
|
|
968
|
+
const value = Math.abs(Number.parseFloat(position.positionValue));
|
|
969
|
+
const size = Math.abs(Number.parseFloat(position.size));
|
|
970
|
+
if (!Number.isFinite(value) || !Number.isFinite(size) || size <= 0) {
|
|
971
|
+
return undefined;
|
|
972
|
+
}
|
|
973
|
+
return value / size;
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Resolve an order's USD notional for the fee resolver.
|
|
977
|
+
*
|
|
978
|
+
* `usdAmount` is the hybrid model's source of truth and is preferred whenever
|
|
979
|
+
* the caller supplied it. Otherwise the notional is `size × price`, taking the
|
|
980
|
+
* best price available: an explicit limit price, then the caller's price
|
|
981
|
+
* snapshot, then the live market price it was quoted against.
|
|
982
|
+
*
|
|
983
|
+
* Returns undefined when no price is available rather than guessing. The
|
|
984
|
+
* resolver reads that as "no notional to blend against" and quotes the full
|
|
985
|
+
* waiver rate — the same answer it gave before the notional was threaded
|
|
986
|
+
* through, so an unpriceable order is no worse off than it was.
|
|
987
|
+
*
|
|
988
|
+
* @param params - The order-shaped parameters in scope at the call site.
|
|
989
|
+
* @param params.size - Order size in base units, when known.
|
|
990
|
+
* @param params.usdAmount - Order notional in USD, when the caller supplied it.
|
|
991
|
+
* @param params.price - Limit price, when the placement carries one.
|
|
992
|
+
* @param params.triggerPrice - Trigger level, for a placement that has no
|
|
993
|
+
* limit price of its own.
|
|
994
|
+
* @param params.currentPrice - Live market price the order was quoted against.
|
|
995
|
+
* @param params.priceAtCalculation - Price snapshot taken when size was derived.
|
|
996
|
+
* @param params.scaleMinPrice - Lowest rung of a Scale ladder, when the
|
|
997
|
+
* placement is one.
|
|
998
|
+
* @param params.scaleMaxPrice - Highest rung of a Scale ladder, when the
|
|
999
|
+
* placement is one.
|
|
1000
|
+
* @param params.scaleNumOrders - Number of rungs in a Scale ladder, when
|
|
1001
|
+
* the placement supplies it.
|
|
1002
|
+
* @param params.scaleSkew - Optional linear size weighting across a Scale
|
|
1003
|
+
* ladder. A value above 1 puts more size on the highest-price rungs.
|
|
1004
|
+
* @returns The order notional in USD, or undefined when it cannot be priced.
|
|
877
1005
|
*/
|
|
878
|
-
|
|
1006
|
+
#resolveOrderNotionalUsd(params) {
|
|
1007
|
+
const usdAmount = params.usdAmount === undefined
|
|
1008
|
+
? undefined
|
|
1009
|
+
: Number.parseFloat(params.usdAmount);
|
|
1010
|
+
if (usdAmount !== undefined &&
|
|
1011
|
+
Number.isFinite(usdAmount) &&
|
|
1012
|
+
usdAmount > 0) {
|
|
1013
|
+
return usdAmount;
|
|
1014
|
+
}
|
|
1015
|
+
const size = params.size === undefined ? undefined : Number.parseFloat(params.size);
|
|
1016
|
+
if (size === undefined || !Number.isFinite(size) || size <= 0) {
|
|
1017
|
+
return undefined;
|
|
1018
|
+
}
|
|
1019
|
+
const limitPrice = params.price === undefined ? undefined : Number.parseFloat(params.price);
|
|
1020
|
+
// A trigger placement carries no limit price; the level it activates at is
|
|
1021
|
+
// the only price it states, so it prices the order.
|
|
1022
|
+
const triggerPrice = params.triggerPrice === undefined
|
|
1023
|
+
? undefined
|
|
1024
|
+
: Number.parseFloat(params.triggerPrice);
|
|
1025
|
+
// A Scale ladder states no single price. For an even ladder the midpoint
|
|
1026
|
+
// is its average price, but a skew weights the rung sizes, so the midpoint
|
|
1027
|
+
// is no longer the ladder's notional price. Use the same linear weights as
|
|
1028
|
+
// `splitScaleSizes` when the caller supplies the rung count. The provider
|
|
1029
|
+
// rounds each rung onto its venue size grid later; this is the equivalent
|
|
1030
|
+
// weighted calculation before that bounded rounding, without making the
|
|
1031
|
+
// service depend on provider-specific market metadata.
|
|
1032
|
+
const scaleMinPrice = params.scaleMinPrice === undefined
|
|
1033
|
+
? undefined
|
|
1034
|
+
: Number.parseFloat(params.scaleMinPrice);
|
|
1035
|
+
const scaleMaxPrice = params.scaleMaxPrice === undefined
|
|
1036
|
+
? undefined
|
|
1037
|
+
: Number.parseFloat(params.scaleMaxPrice);
|
|
1038
|
+
const scaleWeightedPrice = scaleMinPrice !== undefined &&
|
|
1039
|
+
scaleMaxPrice !== undefined &&
|
|
1040
|
+
Number.isFinite(scaleMinPrice) &&
|
|
1041
|
+
Number.isFinite(scaleMaxPrice) &&
|
|
1042
|
+
scaleMinPrice > 0 &&
|
|
1043
|
+
scaleMaxPrice > 0
|
|
1044
|
+
? (() => {
|
|
1045
|
+
const count = params.scaleNumOrders;
|
|
1046
|
+
const skew = params.scaleSkew;
|
|
1047
|
+
if (count === undefined ||
|
|
1048
|
+
!Number.isInteger(count) ||
|
|
1049
|
+
count < SCALE_ORDER_COUNT.min ||
|
|
1050
|
+
count > SCALE_ORDER_COUNT.max ||
|
|
1051
|
+
(skew !== undefined && (!Number.isFinite(skew) || skew <= 0))) {
|
|
1052
|
+
return (scaleMinPrice + scaleMaxPrice) / 2;
|
|
1053
|
+
}
|
|
1054
|
+
let weightedPrice = 0;
|
|
1055
|
+
let weightSum = 0;
|
|
1056
|
+
for (let index = 0; index < count; index++) {
|
|
1057
|
+
const weight = 1 + (((skew ?? 1) - 1) * index) / (count - 1);
|
|
1058
|
+
const price = scaleMinPrice +
|
|
1059
|
+
((scaleMaxPrice - scaleMinPrice) * index) / (count - 1);
|
|
1060
|
+
weightedPrice += price * weight;
|
|
1061
|
+
weightSum += weight;
|
|
1062
|
+
}
|
|
1063
|
+
return weightedPrice / weightSum;
|
|
1064
|
+
})()
|
|
1065
|
+
: undefined;
|
|
1066
|
+
const price = [
|
|
1067
|
+
limitPrice,
|
|
1068
|
+
triggerPrice,
|
|
1069
|
+
scaleWeightedPrice,
|
|
1070
|
+
params.priceAtCalculation,
|
|
1071
|
+
params.currentPrice,
|
|
1072
|
+
]
|
|
1073
|
+
.filter((candidate) => candidate !== undefined &&
|
|
1074
|
+
Number.isFinite(candidate) &&
|
|
1075
|
+
candidate > 0)
|
|
1076
|
+
.at(0);
|
|
1077
|
+
return price === undefined ? undefined : size * price;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Resolve the fee discount for a submission, measuring the call.
|
|
1081
|
+
*
|
|
1082
|
+
* Uses controller dependencies injected via `setControllerDependencies()`;
|
|
1083
|
+
* without them there is no resolver to ask and the caller pays the
|
|
1084
|
+
* undiscounted fee. Helper method for the placement orchestration paths.
|
|
1085
|
+
*
|
|
1086
|
+
* @param orderNotionalUsd - The order's notional in USD, when it can be
|
|
1087
|
+
* priced. This is what lets the subscription source resolve to a blended
|
|
1088
|
+
* rate: omitting it resolves every bounded allowance as a full waiver,
|
|
1089
|
+
* charging 0 bips on an order the preview quoted a blend for.
|
|
1090
|
+
* @returns The resolved fee, or undefined when controller dependencies are
|
|
1091
|
+
* unavailable.
|
|
1092
|
+
*/
|
|
1093
|
+
async #calculateFeeDiscountWithMeasurement(orderNotionalUsd) {
|
|
879
1094
|
// Check if controller dependencies are available
|
|
880
1095
|
if (!this.#controllerDeps) {
|
|
881
1096
|
this.#deps.debugLogger.log('TradingService: Controller dependencies not set, skipping fee discount');
|
|
@@ -883,14 +1098,18 @@ export class TradingService {
|
|
|
883
1098
|
}
|
|
884
1099
|
const { rewardsIntegrationService } = this.#controllerDeps;
|
|
885
1100
|
const orderExecutionFeeDiscountStartTime = this.#deps.performance.now();
|
|
886
|
-
//
|
|
887
|
-
|
|
1101
|
+
// The notional is what lets the subscription source resolve to a blended
|
|
1102
|
+
// rate. Submitting without it would resolve every bounded allowance as a
|
|
1103
|
+
// full waiver, charging 0 bips on an order the preview quoted a blend for.
|
|
1104
|
+
const resolution = await rewardsIntegrationService.resolveFee(orderNotionalUsd);
|
|
888
1105
|
const orderExecutionFeeDiscountDuration = this.#deps.performance.now() - orderExecutionFeeDiscountStartTime;
|
|
889
1106
|
// Record measurement
|
|
890
1107
|
this.#deps.tracer.setMeasurement(PerpsMeasurementName.PerpsRewardsOrderExecutionFeeDiscountApiCall, orderExecutionFeeDiscountDuration, 'millisecond');
|
|
891
1108
|
this.#deps.debugLogger.log('TradingService: Fee discount API call completed', {
|
|
892
1109
|
discountBips: resolution.discountBips,
|
|
893
1110
|
source: resolution.source,
|
|
1111
|
+
orderNotionalUsd,
|
|
1112
|
+
subscriptionWaiverKind: resolution.subscriptionWaiverKind,
|
|
894
1113
|
duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`,
|
|
895
1114
|
});
|
|
896
1115
|
return resolution;
|
|
@@ -928,7 +1147,7 @@ export class TradingService {
|
|
|
928
1147
|
},
|
|
929
1148
|
});
|
|
930
1149
|
// Calculate fee discount only if required dependencies are available
|
|
931
|
-
const feeResolution = await this.#calculateFeeDiscountWithMeasurement();
|
|
1150
|
+
const feeResolution = await this.#calculateFeeDiscountWithMeasurement(this.#resolveOrderNotionalUsd(params.newOrder));
|
|
932
1151
|
// Execute order edit with fee discount management
|
|
933
1152
|
const result = await this.#withFeeDiscount({
|
|
934
1153
|
provider,
|
|
@@ -1311,6 +1530,8 @@ export class TradingService {
|
|
|
1311
1530
|
position = await this.#loadPositionData({
|
|
1312
1531
|
symbol: params.symbol,
|
|
1313
1532
|
context,
|
|
1533
|
+
provider,
|
|
1534
|
+
providerId: params.providerId,
|
|
1314
1535
|
});
|
|
1315
1536
|
// Emit submitted event before the provider round-trip
|
|
1316
1537
|
this.#trackSubmitted(PerpsAnalyticsEvent.PositionCloseTransaction, {
|
|
@@ -1321,8 +1542,20 @@ export class TradingService {
|
|
|
1321
1542
|
[PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId,
|
|
1322
1543
|
}),
|
|
1323
1544
|
});
|
|
1324
|
-
// Calculate fee discount with measurement
|
|
1325
|
-
|
|
1545
|
+
// Calculate fee discount with measurement. A full close commonly carries
|
|
1546
|
+
// only a symbol, so `params` alone prices it as undefined and the resolver
|
|
1547
|
+
// would quote a full waiver on an order the preview blended. The position
|
|
1548
|
+
// loaded above is the authoritative notional for exactly that case.
|
|
1549
|
+
const feeResolution = await this.#calculateFeeDiscountWithMeasurement(this.#resolveOrderNotionalUsd({
|
|
1550
|
+
...params,
|
|
1551
|
+
// A partial close names a size but often no price; the position's own
|
|
1552
|
+
// value per unit prices it. A full close names neither, and falls back
|
|
1553
|
+
// to the whole position value below.
|
|
1554
|
+
currentPrice: params.currentPrice ?? this.#resolvePositionUnitPrice(position),
|
|
1555
|
+
}) ??
|
|
1556
|
+
this.#resolveOrderNotionalUsd({
|
|
1557
|
+
usdAmount: position?.positionValue,
|
|
1558
|
+
}));
|
|
1326
1559
|
// Execute position close with fee discount management
|
|
1327
1560
|
result = await this.#withFeeDiscount({
|
|
1328
1561
|
provider,
|
|
@@ -1443,7 +1676,9 @@ export class TradingService {
|
|
|
1443
1676
|
});
|
|
1444
1677
|
// Use batch close if provider supports it (provider handles filtering)
|
|
1445
1678
|
if (provider.closePositions) {
|
|
1446
|
-
|
|
1679
|
+
// The batch submits under one builder context, so its notional is the
|
|
1680
|
+
// sum of the positions it will close, not any single one of them.
|
|
1681
|
+
const feeResolution = await this.#calculateFeeDiscountWithMeasurement(await this.#resolveBatchCloseNotionalUsd({ params, provider }));
|
|
1447
1682
|
operationResult = await this.#withFeeDiscount({
|
|
1448
1683
|
provider,
|
|
1449
1684
|
feeResolution,
|
|
@@ -1605,8 +1840,40 @@ export class TradingService {
|
|
|
1605
1840
|
[PERPS_EVENT_PROPERTY.SOURCE]: source,
|
|
1606
1841
|
...this.#buildAttributionProperties(params.trackingData),
|
|
1607
1842
|
});
|
|
1608
|
-
//
|
|
1609
|
-
|
|
1843
|
+
// The batch shares one builder fee. Price each included trigger at its
|
|
1844
|
+
// own level and size, then use the largest notional. An omitted size
|
|
1845
|
+
// covers the absolute position size, including for short positions.
|
|
1846
|
+
let positionPromise;
|
|
1847
|
+
const getPosition = () => (positionPromise ??= params.position
|
|
1848
|
+
? Promise.resolve(params.position)
|
|
1849
|
+
: this.#loadPositionData({
|
|
1850
|
+
symbol: params.symbol,
|
|
1851
|
+
context,
|
|
1852
|
+
provider,
|
|
1853
|
+
providerId: params.providerId,
|
|
1854
|
+
}));
|
|
1855
|
+
const triggerNotionalsUsd = await Promise.all([
|
|
1856
|
+
{ price: params.takeProfitPrice, size: params.takeProfitSize },
|
|
1857
|
+
{ price: params.stopLossPrice, size: params.stopLossSize },
|
|
1858
|
+
]
|
|
1859
|
+
.filter((trigger) => trigger.price !== undefined)
|
|
1860
|
+
.map(async ({ price, size }) => {
|
|
1861
|
+
const triggerSize = size ?? (await getPosition())?.size;
|
|
1862
|
+
return this.#resolveOrderNotionalUsd({
|
|
1863
|
+
size: triggerSize === undefined
|
|
1864
|
+
? undefined
|
|
1865
|
+
: Math.abs(Number.parseFloat(triggerSize)).toString(),
|
|
1866
|
+
price,
|
|
1867
|
+
});
|
|
1868
|
+
}));
|
|
1869
|
+
// A known sibling cannot stand in for a trigger whose size is unknown.
|
|
1870
|
+
// Passing undefined withholds a bounded waiver for the entire batch.
|
|
1871
|
+
const pricedTriggerNotionalsUsd = triggerNotionalsUsd.filter((notional) => notional !== undefined && Number.isFinite(notional) && notional > 0);
|
|
1872
|
+
const tpslNotionalUsd = pricedTriggerNotionalsUsd.length > 0 &&
|
|
1873
|
+
pricedTriggerNotionalsUsd.length === triggerNotionalsUsd.length
|
|
1874
|
+
? Math.max(...pricedTriggerNotionalsUsd)
|
|
1875
|
+
: undefined;
|
|
1876
|
+
const feeResolution = await this.#calculateFeeDiscountWithMeasurement(tpslNotionalUsd);
|
|
1610
1877
|
// Execute with fee discount management
|
|
1611
1878
|
result = await this.#withFeeDiscount({
|
|
1612
1879
|
provider,
|
|
@@ -1860,7 +2127,13 @@ export class TradingService {
|
|
|
1860
2127
|
[PERPS_EVENT_PROPERTY.ACTION]: flipAction,
|
|
1861
2128
|
...this.#buildAttributionProperties(trackingData),
|
|
1862
2129
|
});
|
|
1863
|
-
|
|
2130
|
+
// The flip order is 2x the position: one leg closes it, one opens the
|
|
2131
|
+
// opposite. `orderParams` deliberately carries no price, so the notional
|
|
2132
|
+
// comes from the position's own reported USD value.
|
|
2133
|
+
const flipNotionalUsd = this.#resolveOrderNotionalUsd({
|
|
2134
|
+
usdAmount: position.positionValue,
|
|
2135
|
+
});
|
|
2136
|
+
const feeResolution = await this.#calculateFeeDiscountWithMeasurement(flipNotionalUsd === undefined ? undefined : flipNotionalUsd * 2);
|
|
1864
2137
|
// Place flip order (HyperLiquid handles margin transfer automatically)
|
|
1865
2138
|
const result = await this.#withFeeDiscount({
|
|
1866
2139
|
provider,
|