openbroker 1.9.6 → 1.10.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.
@@ -19,7 +19,7 @@ import type {
19
19
  OutcomeQuestion,
20
20
  } from './types.js';
21
21
  import { loadConfig, isMainnet } from './config.js';
22
- import { roundPrice, roundSize } from './utils.js';
22
+ import { MIN_ORDER_NOTIONAL_USD, roundPrice, roundSize } from './utils.js';
23
23
 
24
24
  export interface RealtimeBookSnapshot {
25
25
  coin: string;
@@ -2596,6 +2596,8 @@ export class HyperliquidClient {
2596
2596
  * @param limitPrice - Limit price for the order (use triggerPrice for market-like execution)
2597
2597
  * @param tpsl - 'tp' for take profit, 'sl' for stop loss
2598
2598
  * @param reduceOnly - Whether order is reduce-only (should be true for TP/SL)
2599
+ * @param isMarket - Execute as a market trigger on fire; limitPrice then only
2600
+ * caps the fill (slippage band) instead of resting on the book
2599
2601
  */
2600
2602
  async triggerOrder(
2601
2603
  coin: string,
@@ -2605,7 +2607,8 @@ export class HyperliquidClient {
2605
2607
  limitPrice: number,
2606
2608
  tpsl: 'tp' | 'sl',
2607
2609
  reduceOnly: boolean = true,
2608
- leverage?: number
2610
+ leverage?: number,
2611
+ isMarket: boolean = false
2609
2612
  ): Promise<OrderResponse> {
2610
2613
  await this.requireTrading();
2611
2614
  await this.getMetaAndAssetCtxs();
@@ -2622,9 +2625,8 @@ export class HyperliquidClient {
2622
2625
  const assetIndex = this.getAssetIndex(coin);
2623
2626
  const szDecimals = this.getSzDecimals(coin);
2624
2627
 
2625
- // For trigger orders, we use the trigger order type
2626
- // isMarket: false means it becomes a limit order at limitPrice when triggered
2627
- // For stop loss, we typically want some slippage protection
2628
+ // isMarket: true fires as a market order capped by limitPrice (slippage band);
2629
+ // isMarket: false rests as a limit order at limitPrice once triggered.
2628
2630
  const orderWire = {
2629
2631
  a: assetIndex,
2630
2632
  b: isBuy,
@@ -2634,7 +2636,7 @@ export class HyperliquidClient {
2634
2636
  t: {
2635
2637
  trigger: {
2636
2638
  triggerPx: roundPrice(triggerPrice, szDecimals),
2637
- isMarket: false,
2639
+ isMarket,
2638
2640
  tpsl,
2639
2641
  },
2640
2642
  },
@@ -2671,23 +2673,30 @@ export class HyperliquidClient {
2671
2673
  }
2672
2674
 
2673
2675
  /**
2674
- * Place a stop loss order
2676
+ * Place a stop loss order.
2677
+ *
2678
+ * Executes as a market trigger by default: in a gap move that jumps past the
2679
+ * trigger, a stop-limit's band can be skipped entirely and the position sits
2680
+ * unprotected — the exact scenario an SL exists for. The limit price still
2681
+ * caps the fill at `slippageBps` past the trigger. Pass `isMarket: false`
2682
+ * for the stop-limit variant (rests at the band price once triggered).
2675
2683
  */
2676
2684
  async stopLoss(
2677
2685
  coin: string,
2678
2686
  isBuy: boolean,
2679
2687
  size: number,
2680
2688
  triggerPrice: number,
2681
- slippageBps: number = 100 // 1% slippage for SL execution
2689
+ slippageBps: number = 100, // 1% slippage cap for SL execution
2690
+ isMarket: boolean = true
2682
2691
  ): Promise<OrderResponse> {
2683
- // For stop loss, limit price should be worse than trigger to ensure fill
2692
+ // Limit price sits worse than trigger to ensure fill
2684
2693
  // Buy SL: limit above trigger, Sell SL: limit below trigger
2685
2694
  const slippageMult = slippageBps / 10000;
2686
2695
  const limitPrice = isBuy
2687
2696
  ? triggerPrice * (1 + slippageMult)
2688
2697
  : triggerPrice * (1 - slippageMult);
2689
2698
 
2690
- return this.triggerOrder(coin, isBuy, size, triggerPrice, limitPrice, 'sl', true);
2699
+ return this.triggerOrder(coin, isBuy, size, triggerPrice, limitPrice, 'sl', true, undefined, isMarket);
2691
2700
  }
2692
2701
 
2693
2702
  /**
@@ -2704,19 +2713,42 @@ export class HyperliquidClient {
2704
2713
  }
2705
2714
 
2706
2715
  /**
2707
- * Place a paired TP/SL trigger set using Hyperliquid's normalTpsl grouping.
2708
- * The two orders are submitted together so the venue treats them as a linked
2709
- * bracket pair instead of two unrelated reduce-only triggers.
2716
+ * Place TP and/or SL triggers for an open position in one batch.
2717
+ *
2718
+ * Defaults to Hyperliquid's `positionTpsl` grouping: the triggers are tied
2719
+ * to the open position (cancelled when it closes, OCO between themselves)
2720
+ * — the same mechanism the Hyperliquid frontend uses for position TP/SL.
2721
+ * `normalTpsl` is available for a standalone OCO pair not bound to the
2722
+ * position. SL executes as a market trigger by default with the limit price
2723
+ * capping the fill `slSlippageBps` past the trigger (see `stopLoss`).
2724
+ *
2725
+ * `isBuy` is the EXIT side (opposite of the position direction). Statuses
2726
+ * in the response align with the orders sent: TP first (when present),
2727
+ * then SL.
2710
2728
  */
2711
- async tpslPair(
2729
+ async tpslOrders(
2712
2730
  coin: string,
2713
2731
  isBuy: boolean,
2714
2732
  size: number,
2715
- takeProfitPrice: number,
2716
- stopLossPrice: number,
2717
- stopLossSlippageBps: number = 100,
2718
- leverage?: number
2733
+ opts: {
2734
+ takeProfitPrice?: number;
2735
+ stopLossPrice?: number;
2736
+ stopLossSlippageBps?: number;
2737
+ /** SL fires as a market trigger (default true); false = stop-limit. */
2738
+ stopLossIsMarket?: boolean;
2739
+ grouping?: 'positionTpsl' | 'normalTpsl';
2740
+ leverage?: number;
2741
+ } = {}
2719
2742
  ): Promise<OrderResponse> {
2743
+ const { takeProfitPrice, stopLossPrice, leverage } = opts;
2744
+ const stopLossSlippageBps = opts.stopLossSlippageBps ?? 100;
2745
+ const stopLossIsMarket = opts.stopLossIsMarket ?? true;
2746
+ const grouping = opts.grouping ?? 'positionTpsl';
2747
+
2748
+ if (takeProfitPrice === undefined && stopLossPrice === undefined) {
2749
+ throw new Error('tpslOrders requires takeProfitPrice, stopLossPrice, or both');
2750
+ }
2751
+
2720
2752
  await this.requireTrading();
2721
2753
  await this.getMetaAndAssetCtxs();
2722
2754
 
@@ -2726,18 +2758,22 @@ export class HyperliquidClient {
2726
2758
  }
2727
2759
 
2728
2760
  const slippageMult = stopLossSlippageBps / 10000;
2729
- const stopLossLimitPrice = isBuy
2730
- ? stopLossPrice * (1 + slippageMult)
2731
- : stopLossPrice * (1 - slippageMult);
2761
+ const stopLossLimitPrice = stopLossPrice === undefined
2762
+ ? undefined
2763
+ : isBuy
2764
+ ? stopLossPrice * (1 + slippageMult)
2765
+ : stopLossPrice * (1 - slippageMult);
2732
2766
 
2733
- await this.ensureHip3Ready(coin, size * Math.max(takeProfitPrice, stopLossLimitPrice), leverage);
2767
+ const worstPrice = Math.max(takeProfitPrice ?? 0, stopLossLimitPrice ?? 0);
2768
+ await this.ensureHip3Ready(coin, size * worstPrice, leverage);
2734
2769
 
2735
2770
  const assetIndex = this.getAssetIndex(coin);
2736
2771
  const szDecimals = this.getSzDecimals(coin);
2737
2772
  const roundedSize = roundSize(size, szDecimals);
2738
2773
 
2739
- const orderWires = [
2740
- {
2774
+ const orderWires = [];
2775
+ if (takeProfitPrice !== undefined) {
2776
+ orderWires.push({
2741
2777
  a: assetIndex,
2742
2778
  b: isBuy,
2743
2779
  p: roundPrice(takeProfitPrice, szDecimals),
@@ -2750,22 +2786,182 @@ export class HyperliquidClient {
2750
2786
  tpsl: 'tp' as const,
2751
2787
  },
2752
2788
  },
2753
- },
2754
- {
2789
+ });
2790
+ }
2791
+ if (stopLossPrice !== undefined) {
2792
+ orderWires.push({
2755
2793
  a: assetIndex,
2756
2794
  b: isBuy,
2757
- p: roundPrice(stopLossLimitPrice, szDecimals),
2795
+ p: roundPrice(stopLossLimitPrice!, szDecimals),
2758
2796
  s: roundedSize,
2759
2797
  r: true,
2760
2798
  t: {
2761
2799
  trigger: {
2762
2800
  triggerPx: roundPrice(stopLossPrice, szDecimals),
2763
- isMarket: false,
2801
+ isMarket: stopLossIsMarket,
2764
2802
  tpsl: 'sl' as const,
2765
2803
  },
2766
2804
  },
2805
+ });
2806
+ }
2807
+
2808
+ const orderRequest: {
2809
+ orders: typeof orderWires;
2810
+ grouping: 'positionTpsl' | 'normalTpsl';
2811
+ builder?: BuilderInfo;
2812
+ } = {
2813
+ orders: orderWires,
2814
+ grouping,
2815
+ };
2816
+
2817
+ if (!this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
2818
+ orderRequest.builder = this.builderInfo;
2819
+ this.log('Including builder fee:', this.builderInfo);
2820
+ }
2821
+
2822
+ try {
2823
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
2824
+ this.log('TP/SL orders response:', JSON.stringify(response, null, 2));
2825
+ return response as unknown as OrderResponse;
2826
+ } catch (error) {
2827
+ this.log('TP/SL orders error:', error);
2828
+ return {
2829
+ status: 'err',
2830
+ response: error instanceof Error ? error.message : String(error),
2831
+ };
2832
+ }
2833
+ }
2834
+
2835
+ /**
2836
+ * Place a paired TP/SL trigger set for an open position.
2837
+ *
2838
+ * Back-compat wrapper over `tpslOrders`. Since v1.10.0 this uses the
2839
+ * `positionTpsl` grouping (triggers track the open position and OCO-cancel)
2840
+ * instead of a standalone `normalTpsl` pair, and the SL fires as a market
2841
+ * trigger capped by the slippage band instead of a stop-limit.
2842
+ */
2843
+ async tpslPair(
2844
+ coin: string,
2845
+ isBuy: boolean,
2846
+ size: number,
2847
+ takeProfitPrice: number,
2848
+ stopLossPrice: number,
2849
+ stopLossSlippageBps: number = 100,
2850
+ leverage?: number
2851
+ ): Promise<OrderResponse> {
2852
+ return this.tpslOrders(coin, isBuy, size, {
2853
+ takeProfitPrice,
2854
+ stopLossPrice,
2855
+ stopLossSlippageBps,
2856
+ leverage,
2857
+ });
2858
+ }
2859
+
2860
+ /**
2861
+ * Atomic bracket: a limit entry with TP/SL children in one `normalTpsl`
2862
+ * batch — exactly how the Hyperliquid frontend does order-attached TP/SL.
2863
+ * The children arm only when the entry fills and are sized to it; their
2864
+ * statuses come back as the plain strings "waitingForFill" /
2865
+ * "waitingForTrigger" (parse with `parseOrderStatus`).
2866
+ *
2867
+ * `isBuy` is the ENTRY side; exits are placed on the opposite side,
2868
+ * reduce-only. Statuses align with [entry, tp?, sl?].
2869
+ */
2870
+ async bracketOrder(
2871
+ coin: string,
2872
+ isBuy: boolean,
2873
+ size: number,
2874
+ entryPrice: number,
2875
+ opts: {
2876
+ entryTif?: 'Gtc' | 'Alo';
2877
+ takeProfitPrice?: number;
2878
+ stopLossPrice?: number;
2879
+ stopLossSlippageBps?: number;
2880
+ /** SL fires as a market trigger (default true); false = stop-limit. */
2881
+ stopLossIsMarket?: boolean;
2882
+ leverage?: number;
2883
+ } = {}
2884
+ ): Promise<OrderResponse> {
2885
+ const { takeProfitPrice, stopLossPrice, leverage } = opts;
2886
+ const entryTif = opts.entryTif ?? 'Gtc';
2887
+ const stopLossSlippageBps = opts.stopLossSlippageBps ?? 100;
2888
+ const stopLossIsMarket = opts.stopLossIsMarket ?? true;
2889
+
2890
+ if (takeProfitPrice === undefined && stopLossPrice === undefined) {
2891
+ throw new Error('bracketOrder requires takeProfitPrice, stopLossPrice, or both');
2892
+ }
2893
+
2894
+ await this.requireTrading();
2895
+ await this.getMetaAndAssetCtxs();
2896
+
2897
+ if (leverage && !this.isHip3(coin)) {
2898
+ this.log(`Setting leverage for ${coin} to ${leverage}x cross`);
2899
+ await this.updateLeverage(coin, leverage, true);
2900
+ }
2901
+
2902
+ await this.ensureHip3Ready(coin, size * entryPrice, leverage);
2903
+
2904
+ const assetIndex = this.getAssetIndex(coin);
2905
+ const szDecimals = this.getSzDecimals(coin);
2906
+ const roundedSize = roundSize(size, szDecimals);
2907
+ const exitBuy = !isBuy;
2908
+
2909
+ const slippageMult = stopLossSlippageBps / 10000;
2910
+ const stopLossLimitPrice = stopLossPrice === undefined
2911
+ ? undefined
2912
+ : exitBuy
2913
+ ? stopLossPrice * (1 + slippageMult)
2914
+ : stopLossPrice * (1 - slippageMult);
2915
+
2916
+ const orderWires: Array<{
2917
+ a: number;
2918
+ b: boolean;
2919
+ p: string;
2920
+ s: string;
2921
+ r: boolean;
2922
+ t: { limit: { tif: 'Gtc' | 'Alo' } } | { trigger: { triggerPx: string; isMarket: boolean; tpsl: 'tp' | 'sl' } };
2923
+ }> = [
2924
+ {
2925
+ a: assetIndex,
2926
+ b: isBuy,
2927
+ p: roundPrice(entryPrice, szDecimals),
2928
+ s: roundedSize,
2929
+ r: false,
2930
+ t: { limit: { tif: entryTif } },
2767
2931
  },
2768
2932
  ];
2933
+ if (takeProfitPrice !== undefined) {
2934
+ orderWires.push({
2935
+ a: assetIndex,
2936
+ b: exitBuy,
2937
+ p: roundPrice(takeProfitPrice, szDecimals),
2938
+ s: roundedSize,
2939
+ r: true,
2940
+ t: {
2941
+ trigger: {
2942
+ triggerPx: roundPrice(takeProfitPrice, szDecimals),
2943
+ isMarket: false,
2944
+ tpsl: 'tp',
2945
+ },
2946
+ },
2947
+ });
2948
+ }
2949
+ if (stopLossPrice !== undefined) {
2950
+ orderWires.push({
2951
+ a: assetIndex,
2952
+ b: exitBuy,
2953
+ p: roundPrice(stopLossLimitPrice!, szDecimals),
2954
+ s: roundedSize,
2955
+ r: true,
2956
+ t: {
2957
+ trigger: {
2958
+ triggerPx: roundPrice(stopLossPrice, szDecimals),
2959
+ isMarket: stopLossIsMarket,
2960
+ tpsl: 'sl',
2961
+ },
2962
+ },
2963
+ });
2964
+ }
2769
2965
 
2770
2966
  const orderRequest: {
2771
2967
  orders: typeof orderWires;
@@ -2783,10 +2979,10 @@ export class HyperliquidClient {
2783
2979
 
2784
2980
  try {
2785
2981
  const response = await this.exchange.order(orderRequest, this.vaultParam);
2786
- this.log('TP/SL pair response:', JSON.stringify(response, null, 2));
2982
+ this.log('Bracket order response:', JSON.stringify(response, null, 2));
2787
2983
  return response as unknown as OrderResponse;
2788
2984
  } catch (error) {
2789
- this.log('TP/SL pair error:', error);
2985
+ this.log('Bracket order error:', error);
2790
2986
  return {
2791
2987
  status: 'err',
2792
2988
  response: error instanceof Error ? error.message : String(error),
@@ -3247,6 +3443,29 @@ export class HyperliquidClient {
3247
3443
  ) {
3248
3444
  await this.getMetaAndAssetCtxs();
3249
3445
 
3446
+ if (!Number.isFinite(durationMinutes) || durationMinutes < 5 || durationMinutes > 1440) {
3447
+ throw new Error('TWAP duration must be between 5 and 1440 minutes');
3448
+ }
3449
+
3450
+ // Native TWAP fires a sub-order every 30s; each must clear the exchange
3451
+ // minimum notional or the venue silently skips slices.
3452
+ if (!reduceOnly) {
3453
+ try {
3454
+ const mid = parseFloat((await this.getAllMids())[coin]);
3455
+ if (Number.isFinite(mid) && mid > 0) {
3456
+ const perSlice = (size * mid) / Math.max(1, Math.round(durationMinutes) * 2);
3457
+ if (perSlice < MIN_ORDER_NOTIONAL_USD) {
3458
+ throw new Error(
3459
+ `TWAP slices of ~$${perSlice.toFixed(2)} fall below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum — shorten the duration or increase the size`
3460
+ );
3461
+ }
3462
+ }
3463
+ } catch (error) {
3464
+ if (error instanceof Error && error.message.includes('exchange minimum')) throw error;
3465
+ this.log('TWAP min-notional pre-check skipped (no mid available):', error);
3466
+ }
3467
+ }
3468
+
3250
3469
  if (leverage) {
3251
3470
  await this.updateLeverage(coin, leverage);
3252
3471
  }
@@ -138,6 +138,43 @@ export function sleep(ms: number): Promise<void> {
138
138
  return new Promise(resolve => setTimeout(resolve, ms));
139
139
  }
140
140
 
141
+ /** Hyperliquid rejects orders below this notional (waived for reduce-only). */
142
+ export const MIN_ORDER_NOTIONAL_USD = 10;
143
+
144
+ export type ParsedOrderStatus =
145
+ | { kind: 'resting'; oid: number }
146
+ | { kind: 'filled'; totalSz: number; avgPx: number; oid: number }
147
+ /** normalTpsl children behind an unfilled parent — success states, not errors. */
148
+ | { kind: 'waiting'; state: 'waitingForFill' | 'waitingForTrigger' }
149
+ | { kind: 'error'; error: string }
150
+ | { kind: 'unknown'; raw: unknown };
151
+
152
+ /**
153
+ * Parse a single entry of an order response's `statuses` array. Handles the
154
+ * plain-string success states ("waitingForFill" / "waitingForTrigger") that
155
+ * Hyperliquid returns for normalTpsl children armed behind an unfilled parent,
156
+ * alongside the usual resting/filled/error objects.
157
+ */
158
+ export function parseOrderStatus(status: unknown): ParsedOrderStatus {
159
+ if (status === 'waitingForFill' || status === 'waitingForTrigger') {
160
+ return { kind: 'waiting', state: status };
161
+ }
162
+ if (status && typeof status === 'object') {
163
+ const data = status as Record<string, unknown>;
164
+ if (data.resting) {
165
+ return { kind: 'resting', oid: (data.resting as { oid: number }).oid };
166
+ }
167
+ if (data.filled) {
168
+ const f = data.filled as { totalSz: string; avgPx: string; oid: number };
169
+ return { kind: 'filled', totalSz: parseFloat(f.totalSz), avgPx: parseFloat(f.avgPx), oid: f.oid };
170
+ }
171
+ if (data.error) {
172
+ return { kind: 'error', error: String(data.error) };
173
+ }
174
+ }
175
+ return { kind: 'unknown', raw: status };
176
+ }
177
+
141
178
  /**
142
179
  * Generate a random client order ID
143
180
  */
package/scripts/lib.ts CHANGED
@@ -47,7 +47,10 @@ export {
47
47
  generateCloid,
48
48
  orderToWire,
49
49
  checkBuilderFeeApproval,
50
+ MIN_ORDER_NOTIONAL_USD,
51
+ parseOrderStatus,
50
52
  } from './core/utils.js';
53
+ export type { ParsedOrderStatus } from './core/utils.js';
51
54
 
52
55
  export type * from './core/types.js';
53
56