perp-cli 0.15.0 → 0.16.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/README.ja.md +243 -0
- package/README.ko.md +243 -0
- package/README.md +129 -535
- package/README.zh.md +243 -0
- package/dist/api/public/lighter.d.ts +5 -0
- package/dist/api/public/lighter.js +5 -0
- package/dist/api/public/pacifica.js +2 -2
- package/dist/arb/sizing.js +4 -2
- package/dist/arb/state.d.ts +6 -0
- package/dist/balance-by-address.d.ts +39 -0
- package/dist/balance-by-address.js +44 -0
- package/dist/bot/quote-rules.js +1 -1
- package/dist/bot/strategies/funding-arb-strategy.js +10 -3
- package/dist/bot/strategies/funding-arb-utils.d.ts +14 -2
- package/dist/bot/strategies/funding-arb-utils.js +48 -15
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +1 -1
- package/dist/bot/strategies/spot-perp-arb-strategy.js +4 -2
- package/dist/bot/strategies/spot-perp-executor.js +2 -2
- package/dist/cli-program.js +1 -1
- package/dist/commands/account.js +168 -24
- package/dist/commands/arb/index.js +19 -77
- package/dist/commands/arb-auto.d.ts +24 -0
- package/dist/commands/arb-auto.js +155 -81
- package/dist/commands/arb.d.ts +8 -0
- package/dist/commands/arb.js +49 -23
- package/dist/commands/backtest.js +23 -21
- package/dist/commands/market.d.ts +48 -0
- package/dist/commands/market.js +241 -78
- package/dist/commands/options.d.ts +5 -0
- package/dist/commands/options.js +20 -3
- package/dist/commands/pacifica-earn.js +8 -21
- package/dist/commands/portfolio.d.ts +16 -0
- package/dist/commands/portfolio.js +51 -8
- package/dist/commands/trade.js +4 -4
- package/dist/cross-chain-margin.d.ts +3 -1
- package/dist/cross-chain-margin.js +4 -2
- package/dist/dashboard/server.js +2 -2
- package/dist/dashboard/ui.js +9 -6
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +10 -0
- package/dist/exchanges/address.d.ts +8 -0
- package/dist/exchanges/address.js +47 -0
- package/dist/exchanges/arcus/adapter.d.ts +10 -6
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.js +3 -2
- package/dist/exchanges/aster.d.ts +27 -7
- package/dist/exchanges/aster.js +124 -42
- package/dist/exchanges/canonical-symbols.d.ts +7 -0
- package/dist/exchanges/canonical-symbols.js +20 -0
- package/dist/exchanges/capabilities.d.ts +2 -0
- package/dist/exchanges/capabilities.js +2 -1
- package/dist/exchanges/derive/options.d.ts +5 -0
- package/dist/exchanges/derive/options.js +20 -1
- package/dist/exchanges/extended/adapter.d.ts +16 -2
- package/dist/exchanges/extended/adapter.js +96 -34
- package/dist/exchanges/extended/public.d.ts +3 -0
- package/dist/exchanges/extended/public.js +9 -2
- package/dist/exchanges/grvt/adapter.d.ts +16 -2
- package/dist/exchanges/grvt/adapter.js +98 -21
- package/dist/exchanges/hyperliquid-outcome.d.ts +2 -0
- package/dist/exchanges/hyperliquid-outcome.js +29 -1
- package/dist/exchanges/hyperliquid-spot.d.ts +8 -0
- package/dist/exchanges/hyperliquid-spot.js +20 -12
- package/dist/exchanges/hyperliquid.d.ts +180 -13
- package/dist/exchanges/hyperliquid.js +487 -185
- package/dist/exchanges/interface.d.ts +64 -8
- package/dist/exchanges/interface.js +31 -0
- package/dist/exchanges/lighter-public.d.ts +66 -11
- package/dist/exchanges/lighter-public.js +249 -81
- package/dist/exchanges/lighter-spot.d.ts +7 -1
- package/dist/exchanges/lighter-spot.js +99 -90
- package/dist/exchanges/lighter.d.ts +70 -11
- package/dist/exchanges/lighter.js +285 -92
- package/dist/exchanges/ondo/adapter.d.ts +34 -8
- package/dist/exchanges/ondo/adapter.js +73 -16
- package/dist/exchanges/ondo/public.d.ts +27 -2
- package/dist/exchanges/ondo/public.js +63 -31
- package/dist/exchanges/outcome-interface.d.ts +4 -0
- package/dist/exchanges/pacifica-spot.d.ts +6 -0
- package/dist/exchanges/pacifica-spot.js +23 -15
- package/dist/exchanges/pacifica-vaults.d.ts +9 -0
- package/dist/exchanges/pacifica-vaults.js +14 -1
- package/dist/exchanges/pacifica.d.ts +77 -6
- package/dist/exchanges/pacifica.js +321 -130
- package/dist/exchanges/public-balances.d.ts +25 -0
- package/dist/exchanges/public-balances.js +158 -0
- package/dist/exchanges/public-positions.d.ts +43 -0
- package/dist/exchanges/public-positions.js +154 -0
- package/dist/exchanges/qfex/adapter.d.ts +52 -9
- package/dist/exchanges/qfex/adapter.js +74 -9
- package/dist/exchanges/qfex/public.d.ts +62 -9
- package/dist/exchanges/qfex/public.js +128 -22
- package/dist/exchanges/qfex/ws-transport.d.ts +1 -1
- package/dist/exchanges/risex/adapter.d.ts +19 -7
- package/dist/exchanges/risex/adapter.js +58 -12
- package/dist/exchanges/spot-factory.js +1 -1
- package/dist/exchanges/txflow/adapter.d.ts +14 -7
- package/dist/exchanges/txflow/adapter.js +51 -19
- package/dist/exchanges/txflow/client.js +3 -1
- package/dist/exchanges/txflow/public.d.ts +12 -1
- package/dist/exchanges/txflow/public.js +82 -11
- package/dist/exchanges/unknown-symbol.d.ts +6 -1
- package/dist/exchanges/unknown-symbol.js +7 -2
- package/dist/exchanges/variational/adapter.d.ts +2 -6
- package/dist/exchanges/venue-types.d.ts +46 -3
- package/dist/exchanges/venue-types.js +80 -9
- package/dist/exchanges/venue.d.ts +99 -3
- package/dist/exchanges/venue.js +72 -0
- package/dist/exchanges/venues/hyperliquid.js +9 -0
- package/dist/exchanges/venues/lighter-rh.js +13 -3
- package/dist/exchanges/venues/lighter.js +13 -3
- package/dist/exchanges/venues/pacifica.js +9 -0
- package/dist/exchanges/venues/risex.js +15 -0
- package/dist/funding/legacy-public.d.ts +5 -3
- package/dist/funding/legacy-public.js +33 -37
- package/dist/funding/rates.d.ts +54 -5
- package/dist/funding/rates.js +49 -13
- package/dist/liquidity.d.ts +3 -1
- package/dist/liquidity.js +8 -4
- package/dist/mcp-pacifica-earn.js +11 -5
- package/dist/mcp-server.js +104 -15
- package/dist/pacifica/client.d.ts +2 -1
- package/dist/pacifica/client.js +11 -0
- package/dist/positions-by-address.d.ts +34 -0
- package/dist/positions-by-address.js +46 -0
- package/dist/strategies/funding-arb.js +35 -62
- package/dist/utils.d.ts +11 -1
- package/dist/venue-health.js +1 -1
- package/dist/ws/arcus-market.d.ts +9 -0
- package/dist/ws/arcus-market.js +9 -0
- package/dist/ws/aster-market.d.ts +33 -0
- package/dist/ws/aster-market.js +30 -0
- package/dist/ws/index.d.ts +30 -9
- package/dist/ws/index.js +180 -15
- package/dist/ws/lighter-market.d.ts +14 -0
- package/dist/ws/lighter-market.js +13 -0
- package/dist/ws/node.d.ts +30 -9
- package/dist/ws/node.js +204 -16
- package/dist/ws/unclassified.d.ts +16 -0
- package/dist/ws/unclassified.js +43 -0
- package/package.json +15 -2
- package/skills/perp-cli/SKILL.md +97 -33
- package/skills/perp-cli/package.json +15 -2
- package/skills/perp-cli/references/agent-operations.md +15 -12
- package/skills/perp-cli/references/commands.md +67 -12
- package/skills/perp-cli/references/strategies.md +38 -3
- package/skills/perp-cli/references/ws-library.md +55 -24
- package/skills/perp-cli/scripts/arb-monitor.sh +13 -10
- package/skills/perp-cli/scripts/funding-analysis.sh +57 -29
- package/skills/perp-cli/scripts/preflight.sh +27 -19
- package/skills/perp-cli/scripts/spot-perp-scan.sh +58 -38
- package/skills/perp-cli/scripts/validate-arb.sh +56 -39
- package/dist/cli-spec.d.ts +0 -46
- package/dist/cli-spec.js +0 -81
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { assertCandleInterval } from "./interface.js";
|
|
2
|
+
import { isLighterMarketLive } from "../ws/lighter-market.js";
|
|
1
3
|
import { alignProtectionPrice, floorSize, formatComputedPrice, mulDecimal, slippagePrice, toUnitsExact, unitsToString } from "./precision.js";
|
|
2
4
|
import { unsupported } from "./trading-v2.js";
|
|
3
5
|
import { LocalEvmSigner } from "../signer/index.js";
|
|
4
6
|
import { isExpired } from "../agent-wallet/expiry.js";
|
|
5
|
-
import { PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure, unknownActionRemediation, parsePositiveDecimal, DEFINITE_ORDER_FAILURE_CODES } from "../errors.js";
|
|
7
|
+
import { PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure, unknownActionRemediation, parsePositiveDecimal, DEFINITE_ORDER_FAILURE_CODES, noAccountKeyError } from "../errors.js";
|
|
6
8
|
import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
|
|
7
9
|
import { lighterFamilyNetworks, LIGHTER_RH_RATE_LIMIT, LIGHTER_RH_RESERVED_KEY_INDEXES, LIGHTER_GTT_EXPIRY_MS, LIGHTER_CLIENT_ORDER_INDEX_MAX, LIGHTER_REFERENCE_PRICE_SOURCE, } from "./venues/lighter-family.js";
|
|
8
10
|
import { acquireRate, rateLimitedError } from "./rate-limiter.js";
|
|
9
|
-
import { lighterWafChallenge } from "./lighter-public.js";
|
|
11
|
+
import { fetchLighterFundingStats, lighterFundingStatsWarning, LIGHTER_BOOK_ORDERS_LIMIT, lighterBook, lighterWafChallenge } from "./lighter-public.js";
|
|
10
12
|
import { fetchAndCache, invalidateCache, setCached, withCache, TTL_ACCOUNT } from "../cache.js";
|
|
11
13
|
import { LighterWasmSigner } from "./lighter-signer.js";
|
|
12
14
|
/** Lighter trigger orderType enum (matches WASM signer). */
|
|
@@ -35,6 +37,29 @@ export function classifyTriggerOrderType(side, triggerPrice, markPrice, isMarket
|
|
|
35
37
|
}
|
|
36
38
|
/** Lighter order time-in-force enum (lighter-go / SDK `TimeInForce`): 0 IOC, 1 GTT, 2 post-only. */
|
|
37
39
|
export const LIGHTER_TIF = { IOC: 0, GTT: 1, POST_ONLY: 2 };
|
|
40
|
+
/**
|
|
41
|
+
* One GET /api/v1/fundings row → a funding history row (fraction per 1 h, signed).
|
|
42
|
+
* `rate` is percent per hour, unsigned; `direction` "long" = longs pay (positive), "short" = shorts pay.
|
|
43
|
+
*/
|
|
44
|
+
export function lighterFundingRow(venue, f) {
|
|
45
|
+
const x = f;
|
|
46
|
+
if (typeof x.timestamp !== "number" || !Number.isFinite(x.timestamp) || x.timestamp <= 0) {
|
|
47
|
+
throw new PerpError("EXCHANGE_ERROR", `${venue} /fundings: row without a numeric timestamp: ${JSON.stringify(f).slice(0, 160)}`, { exchange: venue });
|
|
48
|
+
}
|
|
49
|
+
if (typeof x.rate !== "string" || !/^\d+(\.\d+)?$/.test(x.rate)) {
|
|
50
|
+
throw new PerpError("EXCHANGE_ERROR", `${venue} /fundings: row without an unsigned decimal rate: ${JSON.stringify(f).slice(0, 160)}`, { exchange: venue });
|
|
51
|
+
}
|
|
52
|
+
if (x.direction !== "long" && x.direction !== "short") {
|
|
53
|
+
throw new PerpError("EXCHANGE_ERROR", `${venue} /fundings: unknown direction ${JSON.stringify(x.direction)}`, { exchange: venue });
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
time: x.timestamp * 1000,
|
|
57
|
+
rate: mulDecimal(x.rate, x.direction === "long" ? "0.01" : "-0.01"),
|
|
58
|
+
price: null,
|
|
59
|
+
intervalHours: 1,
|
|
60
|
+
rateKind: "lastSettled",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
38
63
|
/** Decimal step for `decimals` digits ("1" for 0, "0.01" for 2). */
|
|
39
64
|
function lighterStep(decimals) {
|
|
40
65
|
return decimals <= 0 ? "1" : `0.${"0".repeat(decimals - 1)}1`;
|
|
@@ -369,6 +394,65 @@ export function lighterRowCreatedMs(row) {
|
|
|
369
394
|
* leverage = floor(10000 / bps), which returns the integer leverage that was set. margin_mode:
|
|
370
395
|
* 0 cross, 1 isolated (the values updateLeverage signs). Anything else throws — never a guess.
|
|
371
396
|
*/
|
|
397
|
+
/**
|
|
398
|
+
* Balance of one Lighter-family account object (GET /account) — the one implementation behind
|
|
399
|
+
* LighterAdapter.getBalance() and the keyless `account balance --address`. Equity includes the
|
|
400
|
+
* spot collateral asset (USDC on Lighter, USDG on Lighter RH; separate from perp collateral).
|
|
401
|
+
*/
|
|
402
|
+
export function lighterBalanceOf(acct, venue, collateralSymbol) {
|
|
403
|
+
const totalAsset = parseFiniteVenueNumber(acct.total_asset_value, "total_asset_value", "lighter");
|
|
404
|
+
const available = parseFiniteVenueNumber(acct.available_balance, "available_balance", "lighter");
|
|
405
|
+
const collateral = parseFiniteVenueNumber(acct.collateral, "collateral", "lighter");
|
|
406
|
+
const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + parseFiniteVenueNumber(p.unrealized_pnl, "position.unrealized_pnl", "lighter"), 0) ?? 0;
|
|
407
|
+
// Include spot collateral balance (USDC on Lighter, USDG on Lighter RH; separate from perp collateral)
|
|
408
|
+
// An account without that asset holds none of it (0); an unreadable assets list / balance throws.
|
|
409
|
+
let spotUsdc = 0;
|
|
410
|
+
if (!Array.isArray(acct.assets)) {
|
|
411
|
+
throw new PerpError("EXCHANGE_ERROR", `${venue} account has no assets list — spot ${collateralSymbol} balance unknown`, { exchange: venue });
|
|
412
|
+
}
|
|
413
|
+
const usdcAsset = acct.assets.find(a => a?.symbol === collateralSymbol);
|
|
414
|
+
if (usdcAsset !== undefined)
|
|
415
|
+
spotUsdc = parseFiniteVenueNumber(usdcAsset.balance, `assets.${collateralSymbol}.balance`, "lighter");
|
|
416
|
+
return {
|
|
417
|
+
equity: String(totalAsset + spotUsdc),
|
|
418
|
+
available: String(available),
|
|
419
|
+
marginUsed: String(Math.max(0, collateral - available)),
|
|
420
|
+
unrealizedPnl: String(unrealizedPnl),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Open positions of one Lighter account (`GET /account` `accounts[].positions[]`): the venue lists
|
|
425
|
+
* every market the account ever traded or configured, so rows with `position` 0 are dropped. The
|
|
426
|
+
* ONE mapper of the keyed `getPositions` and the public-address read; `priceDecimalsOf` formats
|
|
427
|
+
* the computed mark (position_value / size) to the market's price decimals when known.
|
|
428
|
+
*/
|
|
429
|
+
export function lighterPositionRows(positions, venue, priceDecimalsOf) {
|
|
430
|
+
return (positions ?? [])
|
|
431
|
+
.filter((p) => parseFiniteVenueNumber(p.position, "position", "lighter") !== 0)
|
|
432
|
+
.map((p) => {
|
|
433
|
+
const posSize = parseFiniteVenueNumber(p.position, "position", "lighter");
|
|
434
|
+
const positionValue = parseFiniteVenueNumber(p.position_value, "position_value", "lighter");
|
|
435
|
+
return {
|
|
436
|
+
symbol: String(p.symbol || `Market-${p.market_id}`),
|
|
437
|
+
side: (Number(p.sign) > 0 ? "long" : "short"),
|
|
438
|
+
size: String(Math.abs(posSize)),
|
|
439
|
+
entryPrice: String(p.avg_entry_price || "0"),
|
|
440
|
+
markPrice: (() => {
|
|
441
|
+
if (posSize === 0)
|
|
442
|
+
return "0";
|
|
443
|
+
const rawMark = positionValue / Math.abs(posSize);
|
|
444
|
+
const priceDec = priceDecimalsOf(String(p.symbol || ""));
|
|
445
|
+
return priceDec !== undefined ? rawMark.toFixed(priceDec) : formatComputedPrice(rawMark);
|
|
446
|
+
})(),
|
|
447
|
+
// The venue reports "0" when the position has no liquidation price (e.g. cross margin
|
|
448
|
+
// well covered): that is "none", not a price.
|
|
449
|
+
liquidationPrice: Number(p.liquidation_price) > 0 ? String(p.liquidation_price) : null,
|
|
450
|
+
unrealizedPnl: String(p.unrealized_pnl || "0"),
|
|
451
|
+
// The leverage CONFIGURED for the market (what `trade leverage` set) — not notional / equity.
|
|
452
|
+
...lighterPositionSettings(p, venue),
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
}
|
|
372
456
|
export function lighterPositionSettings(p, venue) {
|
|
373
457
|
const pct = typeof p.initial_margin_fraction === "string" && /^\d+(\.\d+)?$/.test(p.initial_margin_fraction) ? Number(p.initial_margin_fraction) : NaN;
|
|
374
458
|
const bps = Math.round(pct * 100);
|
|
@@ -409,6 +493,17 @@ export function lighterSelectAccount(accounts, accountIndex, venue) {
|
|
|
409
493
|
* window is unknown (SYMBOL_NOT_FOUND) until the entry expires.
|
|
410
494
|
*/
|
|
411
495
|
export const LIGHTER_MARKET_LIST_TTL_MS = 5 * 60_000;
|
|
496
|
+
/** GET /api/v1/candles resolutions, live-verified 2026-10-19 on lighter + lighter-rh mainnet and lighter testnet (3m / 2h / 6h / 8h / 1w / 1M / 2m: code 20001 "invalid param"). */
|
|
497
|
+
export const LIGHTER_CANDLE_INTERVALS = {
|
|
498
|
+
"1m": 60_000, "5m": 300_000, "15m": 900_000, "30m": 1_800_000,
|
|
499
|
+
"1h": 3_600_000, "4h": 14_400_000, "12h": 43_200_000, "1d": 86_400_000,
|
|
500
|
+
};
|
|
501
|
+
/** "Returns at most 500 candles per call" (apidocs.lighter.xyz/reference/candles). */
|
|
502
|
+
export const LIGHTER_CANDLES_PER_CALL = 500;
|
|
503
|
+
/** Max `limit` of GET /api/v1/recentTrades. */
|
|
504
|
+
export const LIGHTER_RECENT_TRADES_MAX = 100;
|
|
505
|
+
/** Requests one `market kline` may send (the venue allows 60 / minute on standard accounts). */
|
|
506
|
+
export const LIGHTER_KLINE_MAX_PAGES = 10;
|
|
412
507
|
/** A candle volume field: a finite number / numeric string → its string form; absent or non-numeric → null (never "" / "undefined"). */
|
|
413
508
|
function candleNumber(v) {
|
|
414
509
|
if (typeof v === "number" && Number.isFinite(v))
|
|
@@ -442,6 +537,8 @@ export class LighterAdapter {
|
|
|
442
537
|
_address;
|
|
443
538
|
_marketMap = new Map(); // symbol → marketIndex
|
|
444
539
|
_marketDecimals = new Map(); // symbol → decimals
|
|
540
|
+
/** symbol → live (isLighterMarketLive). Inactive markets stay in _marketMap for reads and closes. */
|
|
541
|
+
_marketLive = new Map();
|
|
445
542
|
_evmKey;
|
|
446
543
|
_apiKey;
|
|
447
544
|
_accountIndexInit;
|
|
@@ -842,7 +939,8 @@ export class LighterAdapter {
|
|
|
842
939
|
* the 60 / minute budget.
|
|
843
940
|
*/
|
|
844
941
|
async _refreshMarketMap() {
|
|
845
|
-
|
|
942
|
+
// v2: rows carry `live` (entries cached before it lack the field).
|
|
943
|
+
const perps = await withCache(`mkt:${this.venueId}:${this.networkName}:orderBooks:v2`, LIGHTER_MARKET_LIST_TTL_MS, async () => {
|
|
846
944
|
const res = await this.restGet("/orderBooks", {});
|
|
847
945
|
if (!Array.isArray(res.order_books) || res.order_books.length === 0) {
|
|
848
946
|
throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBooks returned no order_books list — the market list is unknown`, {
|
|
@@ -859,15 +957,29 @@ export class LighterAdapter {
|
|
|
859
957
|
if (typeof d.symbol !== "string" || !Number.isInteger(d.market_id) || !Number.isInteger(d.supported_size_decimals) || !Number.isInteger(d.supported_price_decimals)) {
|
|
860
958
|
throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBooks: perp market row without symbol / market_id / decimals: ${JSON.stringify(d).slice(0, 160)}`, { exchange: this.name });
|
|
861
959
|
}
|
|
862
|
-
rows.push({ symbol: d.symbol.toUpperCase(), marketId: d.market_id, size: d.supported_size_decimals, price: d.supported_price_decimals });
|
|
960
|
+
rows.push({ symbol: d.symbol.toUpperCase(), marketId: d.market_id, size: d.supported_size_decimals, price: d.supported_price_decimals, live: isLighterMarketLive(d) });
|
|
863
961
|
}
|
|
864
962
|
return rows;
|
|
865
963
|
});
|
|
866
964
|
for (const m of perps) {
|
|
867
965
|
this._marketMap.set(m.symbol, m.marketId);
|
|
868
966
|
this._marketDecimals.set(m.symbol, { size: m.size, price: m.price });
|
|
967
|
+
(this._marketLive ??= new Map()).set(m.symbol, m.live);
|
|
869
968
|
}
|
|
870
969
|
}
|
|
970
|
+
/**
|
|
971
|
+
* An order that can open or add exposure (not reduce-only) on a market that is not live
|
|
972
|
+
* (`status` inactive / `is_frozen`) is refused before signing. Reduce-only orders and closes are
|
|
973
|
+
* still sent, so a position on a market that went inactive can be closed.
|
|
974
|
+
*/
|
|
975
|
+
assertOpenable(symbol, reduceOnly) {
|
|
976
|
+
if (reduceOnly || this._marketLive?.get(symbol.toUpperCase()) !== false)
|
|
977
|
+
return;
|
|
978
|
+
throw new PerpError("ORDER_REJECTED", `${this.name} market ${symbol.toUpperCase()} is inactive (status not "active" or frozen) — only reduce-only orders and closes are sent; nothing was signed`, {
|
|
979
|
+
exchange: this.name, marketInactive: true,
|
|
980
|
+
remediation: `Close or reduce with --reduce-only; list live markets: perp -e ${this.name} market list`,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
871
983
|
getMarketIndex(symbol) {
|
|
872
984
|
const idx = this._marketMap.get(symbol.toUpperCase());
|
|
873
985
|
if (idx === undefined)
|
|
@@ -1204,28 +1316,44 @@ export class LighterAdapter {
|
|
|
1204
1316
|
}
|
|
1205
1317
|
return { baseAmount, priceTicks };
|
|
1206
1318
|
}
|
|
1207
|
-
|
|
1319
|
+
/** Settled / predicted hourly funding per market_id (WS market_stats snapshot, shared with the public directory). */
|
|
1320
|
+
fundingStats() {
|
|
1321
|
+
return fetchLighterFundingStats(this.venueId, this.networkName);
|
|
1322
|
+
}
|
|
1323
|
+
/** Why the last listing's fundingRate is null (the settled-rate snapshot failed), else null. */
|
|
1324
|
+
fundingWarning() {
|
|
1325
|
+
return lighterFundingStatsWarning(this.venueId, this.networkName);
|
|
1326
|
+
}
|
|
1327
|
+
async getMarkets(opts) {
|
|
1208
1328
|
if (this._marketsCache && Date.now() - this._marketsCacheTime < LighterAdapter.CACHE_TTL)
|
|
1209
1329
|
return this._marketsCache;
|
|
1330
|
+
const withFunding = opts?.funding !== false;
|
|
1210
1331
|
const markets = [];
|
|
1211
1332
|
// Ensure _marketDecimals is populated (has supported_size_decimals per symbol)
|
|
1212
1333
|
await this.ensureMarketMap();
|
|
1213
1334
|
// Fetch order book details and funding rates in parallel
|
|
1214
1335
|
const [obRes, fundingRates] = await Promise.all([
|
|
1215
1336
|
this.restGet("/orderBookDetails", {}),
|
|
1216
|
-
|
|
1337
|
+
// Settled + predicted hourly rates (WS market_stats snapshot — the public directory and
|
|
1338
|
+
// perp-ws read the same source). Unreadable → fundingRate null (unknown), reason printed.
|
|
1339
|
+
!withFunding ? Promise.resolve(new Map()) : this.fundingStats().catch((e) => {
|
|
1340
|
+
console.error(`[${this.name}] settled funding rate unavailable (fundingRate null): ${e instanceof Error ? e.message : String(e)}`);
|
|
1341
|
+
return new Map();
|
|
1342
|
+
}),
|
|
1217
1343
|
]);
|
|
1218
1344
|
// An absent list is a failed fetch, never "no markets" (SSOT Rule #2).
|
|
1219
1345
|
if (!Array.isArray(obRes.order_book_details)) {
|
|
1220
1346
|
throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBookDetails: missing order_book_details array`, { exchange: this.name });
|
|
1221
1347
|
}
|
|
1222
1348
|
const isPriceStr = (v) => typeof v === "string" && v.length > 0 && Number(v) > 0;
|
|
1223
|
-
|
|
1349
|
+
// Live perps only (status "active", not frozen): the one rule shared with the WS directory and
|
|
1350
|
+
// lighter-public.ts. Inactive markets (26 on mainnet, 2026-10-19) were listed as live.
|
|
1351
|
+
const perps = obRes.order_book_details.filter(d => d.market_type === "perp" && isLighterMarketLive(d));
|
|
1224
1352
|
const unpriced = [];
|
|
1225
1353
|
for (const d of perps) {
|
|
1226
1354
|
const imf = d.min_initial_margin_fraction ?? d.default_initial_margin_fraction ?? 500;
|
|
1227
1355
|
const maxLev = imf > 0 ? Math.floor(10000 / imf) : 50;
|
|
1228
|
-
const fr = fundingRates.get(d.
|
|
1356
|
+
const fr = fundingRates.get(d.market_id);
|
|
1229
1357
|
const dec = this._marketDecimals.get(d.symbol.toUpperCase());
|
|
1230
1358
|
// markPrice = the venue's MARK price: orderBookDetails `mark_price` (the source the public
|
|
1231
1359
|
// data path lighter-public.ts uses; /funding-rates carries no mark price). last_trade_price
|
|
@@ -1238,13 +1366,20 @@ export class LighterAdapter {
|
|
|
1238
1366
|
symbol: d.symbol,
|
|
1239
1367
|
markPrice: d.mark_price,
|
|
1240
1368
|
indexPrice: isPriceStr(d.index_price) ? d.index_price : null,
|
|
1369
|
+
// Last settled hourly rate (market_stats funding_rate, = the newest /fundings print, = the
|
|
1370
|
+
// perp-ws `rate`), a fraction per 1 h; the running hour's predicted rate is additive.
|
|
1241
1371
|
fundingRate: fr?.rate ?? null,
|
|
1372
|
+
fundingRateKind: fr?.rate != null ? "lastSettled" : null,
|
|
1373
|
+
predictedFundingRate: fr?.predictedRate ?? null,
|
|
1242
1374
|
volume24h: d.daily_quote_token_volume != null ? String(d.daily_quote_token_volume) : null,
|
|
1375
|
+
// BASE units, one side (WS market_stats open_interest is the same quantity in USD).
|
|
1243
1376
|
openInterest: d.open_interest != null ? String(d.open_interest) : null,
|
|
1244
1377
|
maxLeverage: maxLev,
|
|
1245
1378
|
sizeDecimals: dec ? dec.size : null,
|
|
1246
|
-
stepSize
|
|
1247
|
-
|
|
1379
|
+
// The lot grid of the size decimals (the public directory's stepSize, lighter-public.ts).
|
|
1380
|
+
stepSize: dec ? (dec.size === 0 ? "1" : `0.${"0".repeat(dec.size - 1)}1`) : null,
|
|
1381
|
+
// `fundingRate` is per 1 h (hourly settlement).
|
|
1382
|
+
fundingHours: 1,
|
|
1248
1383
|
marginTiers: null,
|
|
1249
1384
|
});
|
|
1250
1385
|
}
|
|
@@ -1256,8 +1391,11 @@ export class LighterAdapter {
|
|
|
1256
1391
|
exchange: this.name, remediation: "Retry later; the venue market data is incomplete",
|
|
1257
1392
|
});
|
|
1258
1393
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1394
|
+
// A marks-only listing (no funding read) is not cached as the full one.
|
|
1395
|
+
if (withFunding) {
|
|
1396
|
+
this._marketsCache = markets;
|
|
1397
|
+
this._marketsCacheTime = Date.now();
|
|
1398
|
+
}
|
|
1261
1399
|
return markets;
|
|
1262
1400
|
}
|
|
1263
1401
|
get accountCacheKey() {
|
|
@@ -1291,27 +1429,22 @@ export class LighterAdapter {
|
|
|
1291
1429
|
* Book from GET /orderBookOrders?market_id=<id> on this venue's host (Lighter RH: its own host
|
|
1292
1430
|
* and market ids, e.g. RH testnet TSLA = 112). Rule #2: an unknown symbol (SYMBOL_NOT_FOUND) or
|
|
1293
1431
|
* a failed read throws — it is never reported as an empty book. An empty `bids`/`asks` is the
|
|
1294
|
-
* venue's real answer (no liquidity).
|
|
1432
|
+
* venue's real answer (no liquidity). The endpoint lists individual ORDERS (up to 250 per side,
|
|
1433
|
+
* the documented max); they are aggregated into price levels (lighterBookLevels), as the WS
|
|
1434
|
+
* `order_book` channel publishes them.
|
|
1295
1435
|
*/
|
|
1296
|
-
async getOrderbook(symbol) {
|
|
1436
|
+
async getOrderbook(symbol, depth) {
|
|
1297
1437
|
await this.ensureMarketMap();
|
|
1298
1438
|
const marketId = this.getMarketIndex(symbol);
|
|
1299
1439
|
const res = await this.restGet("/orderBookOrders", {
|
|
1300
1440
|
market_id: String(marketId),
|
|
1301
|
-
limit:
|
|
1441
|
+
limit: String(LIGHTER_BOOK_ORDERS_LIMIT),
|
|
1302
1442
|
});
|
|
1303
|
-
|
|
1304
|
-
const size = l.remaining_base_amount ?? l.size;
|
|
1305
|
-
if (typeof l.price !== "string" || typeof size !== "string") {
|
|
1306
|
-
throw new PerpError("EXCHANGE_ERROR", `${this.name} orderBookOrders ${symbol}: level without price / size`, { exchange: this.name });
|
|
1307
|
-
}
|
|
1308
|
-
return [l.price, size];
|
|
1309
|
-
};
|
|
1310
|
-
return { bids: (res.bids ?? []).map(level), asks: (res.asks ?? []).map(level) };
|
|
1443
|
+
return lighterBook(this.name, res, symbol, depth);
|
|
1311
1444
|
}
|
|
1312
1445
|
async getBalance() {
|
|
1313
1446
|
if (!this._address)
|
|
1314
|
-
throw
|
|
1447
|
+
throw noAccountKeyError(this.name);
|
|
1315
1448
|
const acct = await this.fetchAccount();
|
|
1316
1449
|
// Rule #2: no account is not a zero balance — say so (like getMarketSettings).
|
|
1317
1450
|
if (!acct) {
|
|
@@ -1320,58 +1453,15 @@ export class LighterAdapter {
|
|
|
1320
1453
|
remediation: `A ${this.name} account is created by the first deposit; check the address / account index (perp -e ${this.name} wallet show) or deposit first`,
|
|
1321
1454
|
});
|
|
1322
1455
|
}
|
|
1323
|
-
|
|
1324
|
-
const available = parseFiniteVenueNumber(acct.available_balance, "available_balance", "lighter");
|
|
1325
|
-
const collateral = parseFiniteVenueNumber(acct.collateral, "collateral", "lighter");
|
|
1326
|
-
const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + parseFiniteVenueNumber(p.unrealized_pnl, "position.unrealized_pnl", "lighter"), 0) ?? 0;
|
|
1327
|
-
// Include spot collateral balance (USDC on Lighter, USDG on Lighter RH; separate from perp collateral)
|
|
1328
|
-
// An account without that asset holds none of it (0); an unreadable assets list / balance throws.
|
|
1329
|
-
let spotUsdc = 0;
|
|
1330
|
-
const collateralSymbol = this.collateralSymbol;
|
|
1331
|
-
if (!Array.isArray(acct.assets)) {
|
|
1332
|
-
throw new PerpError("EXCHANGE_ERROR", `${this.name} account has no assets list — spot ${collateralSymbol} balance unknown`, { exchange: this.name });
|
|
1333
|
-
}
|
|
1334
|
-
const usdcAsset = acct.assets.find(a => a?.symbol === collateralSymbol);
|
|
1335
|
-
if (usdcAsset !== undefined)
|
|
1336
|
-
spotUsdc = parseFiniteVenueNumber(usdcAsset.balance, `assets.${collateralSymbol}.balance`, "lighter");
|
|
1337
|
-
return {
|
|
1338
|
-
equity: String(totalAsset + spotUsdc),
|
|
1339
|
-
available: String(available),
|
|
1340
|
-
marginUsed: String(Math.max(0, collateral - available)),
|
|
1341
|
-
unrealizedPnl: String(unrealizedPnl),
|
|
1342
|
-
};
|
|
1456
|
+
return lighterBalanceOf(acct, this.name, this.collateralSymbol);
|
|
1343
1457
|
}
|
|
1344
1458
|
async getPositions() {
|
|
1345
1459
|
if (!this._address)
|
|
1346
|
-
throw
|
|
1460
|
+
throw noAccountKeyError(this.name);
|
|
1347
1461
|
const acct = await this.fetchAccount();
|
|
1348
1462
|
if (!acct)
|
|
1349
1463
|
return [];
|
|
1350
|
-
return (acct.positions
|
|
1351
|
-
.filter((p) => parseFiniteVenueNumber(p.position, "position", "lighter") !== 0)
|
|
1352
|
-
.map((p) => {
|
|
1353
|
-
const posSize = parseFiniteVenueNumber(p.position, "position", "lighter");
|
|
1354
|
-
const positionValue = parseFiniteVenueNumber(p.position_value, "position_value", "lighter");
|
|
1355
|
-
return {
|
|
1356
|
-
symbol: String(p.symbol || `Market-${p.market_id}`),
|
|
1357
|
-
side: (Number(p.sign) > 0 ? "long" : "short"),
|
|
1358
|
-
size: String(Math.abs(posSize)),
|
|
1359
|
-
entryPrice: String(p.avg_entry_price || "0"),
|
|
1360
|
-
markPrice: (() => {
|
|
1361
|
-
if (posSize === 0)
|
|
1362
|
-
return "0";
|
|
1363
|
-
const rawMark = positionValue / Math.abs(posSize);
|
|
1364
|
-
const priceDec = this._marketDecimals.get(String(p.symbol || "").toUpperCase())?.price;
|
|
1365
|
-
return priceDec !== undefined ? rawMark.toFixed(priceDec) : formatComputedPrice(rawMark);
|
|
1366
|
-
})(),
|
|
1367
|
-
// The venue reports "0" when the position has no liquidation price (e.g. cross margin
|
|
1368
|
-
// well covered): that is "none", not a price.
|
|
1369
|
-
liquidationPrice: Number(p.liquidation_price) > 0 ? String(p.liquidation_price) : null,
|
|
1370
|
-
unrealizedPnl: String(p.unrealized_pnl || "0"),
|
|
1371
|
-
// The leverage CONFIGURED for the market (what `trade leverage` set) — not notional / equity.
|
|
1372
|
-
...lighterPositionSettings(p, this.name),
|
|
1373
|
-
};
|
|
1374
|
-
});
|
|
1464
|
+
return lighterPositionRows(acct.positions, this.name, (sym) => this._marketDecimals.get(sym.toUpperCase())?.price);
|
|
1375
1465
|
}
|
|
1376
1466
|
/**
|
|
1377
1467
|
* Per-market leverage / margin mode the venue exposes: every `positions[]` entry of the
|
|
@@ -1380,7 +1470,7 @@ export class LighterAdapter {
|
|
|
1380
1470
|
*/
|
|
1381
1471
|
async getMarketSettings() {
|
|
1382
1472
|
if (!this._address)
|
|
1383
|
-
throw
|
|
1473
|
+
throw noAccountKeyError(this.name);
|
|
1384
1474
|
const acct = await this.fetchAccount();
|
|
1385
1475
|
if (acct === null)
|
|
1386
1476
|
throw new PerpError("EXCHANGE_ERROR", `${this.name}: no account found for ${this._address}`, { exchange: this.name });
|
|
@@ -1402,7 +1492,7 @@ export class LighterAdapter {
|
|
|
1402
1492
|
}
|
|
1403
1493
|
async getOpenOrders() {
|
|
1404
1494
|
if (!this._address)
|
|
1405
|
-
throw
|
|
1495
|
+
throw noAccountKeyError(this.name);
|
|
1406
1496
|
if (this._accountIndex < 0 || this._readOnly)
|
|
1407
1497
|
return [];
|
|
1408
1498
|
// ONE request for every perp market. Docs (apidocs.lighter.xyz/reference/accountactiveorders):
|
|
@@ -1474,8 +1564,9 @@ export class LighterAdapter {
|
|
|
1474
1564
|
try {
|
|
1475
1565
|
this.ensureSigner();
|
|
1476
1566
|
await this.ensureMarketMap();
|
|
1477
|
-
const nonce = await this.getNextNonce();
|
|
1478
1567
|
const marketIndex = this.getMarketIndex(symbol);
|
|
1568
|
+
this.assertOpenable(symbol, opts.reduceOnly);
|
|
1569
|
+
const nonce = await this.getNextNonce();
|
|
1479
1570
|
({ baseAmount } = this.toTicks(symbol, size, "0"));
|
|
1480
1571
|
const reference = await this.getReferencePrice(symbol);
|
|
1481
1572
|
const p = this.protectionFor(symbol, side, reference, opts.slippage, LIGHTER_REFERENCE_PRICE_SOURCE);
|
|
@@ -1538,8 +1629,9 @@ export class LighterAdapter {
|
|
|
1538
1629
|
throw unsupported(this.name, tif === "FOK" ? "fok" : "timeInForce", `time-in-force ${opts?.tif} is not offered`, "Use --tif GTC or IOC");
|
|
1539
1630
|
this.ensureSigner();
|
|
1540
1631
|
await this.ensureMarketMap();
|
|
1541
|
-
const nonce = await this.getNextNonce();
|
|
1542
1632
|
const marketIndex = this.getMarketIndex(symbol);
|
|
1633
|
+
this.assertOpenable(symbol, opts?.reduceOnly === true);
|
|
1634
|
+
const nonce = await this.getNextNonce();
|
|
1543
1635
|
const ticks = this.toTicks(symbol, size, price);
|
|
1544
1636
|
sentBase = ticks.baseAmount;
|
|
1545
1637
|
isIoc = timeInForce === LIGHTER_TIF.IOC;
|
|
@@ -1703,6 +1795,7 @@ export class LighterAdapter {
|
|
|
1703
1795
|
// The order must be an open order of this market, and not a trigger order: a modify is
|
|
1704
1796
|
// signed with triggerPrice 0, which would turn a stop / take-profit into a plain limit.
|
|
1705
1797
|
existing = await this.activeOrderRow(symbol, marketIndex, orderId, "modified");
|
|
1798
|
+
this.assertOpenable(symbol, existing.reduce_only === true || existing.reduce_only === 1);
|
|
1706
1799
|
const type = String(existing.type ?? "").toLowerCase();
|
|
1707
1800
|
if (/stop|take|trigger/.test(type) || Number(existing.trigger_price ?? 0) > 0) {
|
|
1708
1801
|
throw unsupported(this.name, "amend", "trigger orders cannot be modified", "cancel and re-place");
|
|
@@ -1872,8 +1965,9 @@ export class LighterAdapter {
|
|
|
1872
1965
|
}
|
|
1873
1966
|
this.ensureSigner();
|
|
1874
1967
|
await this.ensureMarketMap();
|
|
1875
|
-
const nonce = await this.getNextNonce();
|
|
1876
1968
|
const marketIndex = this.getMarketIndex(symbol);
|
|
1969
|
+
this.assertOpenable(symbol, opts?.reduceOnly === true);
|
|
1970
|
+
const nonce = await this.getNextNonce();
|
|
1877
1971
|
const { baseAmount } = this.toTicks(symbol, size, "0");
|
|
1878
1972
|
const { priceTicks: triggerTicks } = this.toTicks(symbol, "0", triggerPrice);
|
|
1879
1973
|
const triggerNum = parseFloat(triggerPrice);
|
|
@@ -2008,6 +2102,12 @@ export class LighterAdapter {
|
|
|
2008
2102
|
return this.updateLeverage(symbol, leverage, marginMode);
|
|
2009
2103
|
}
|
|
2010
2104
|
async getRecentTrades(symbol, limit = 20) {
|
|
2105
|
+
// /recentTrades `limit`: 1..100 (apidocs.lighter.xyz/reference/recenttrades; 101 → code 20001).
|
|
2106
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > LIGHTER_RECENT_TRADES_MAX) {
|
|
2107
|
+
throw new PerpError("INVALID_PARAMS", `${this.name} trades limit must be 1..${LIGHTER_RECENT_TRADES_MAX} (got ${limit})`, {
|
|
2108
|
+
exchange: this.name, remediation: `${this.name} serves at most the last ${LIGHTER_RECENT_TRADES_MAX} trades`,
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2011
2111
|
await this.ensureMarketMap();
|
|
2012
2112
|
const marketId = this.getMarketIndex(symbol);
|
|
2013
2113
|
const res = await this.restGet("/recentTrades", { market_id: String(marketId), limit: String(limit) });
|
|
@@ -2025,23 +2125,115 @@ export class LighterAdapter {
|
|
|
2025
2125
|
fee: null,
|
|
2026
2126
|
}));
|
|
2027
2127
|
}
|
|
2128
|
+
/**
|
|
2129
|
+
* Settled funding prints: GET /api/v1/fundings (apidocs.lighter.xyz/reference/fundings;
|
|
2130
|
+
* `count_back` rows from `start_timestamp`, max 750). Each row is one hourly settlement:
|
|
2131
|
+
* `rate` is PERCENT per 1 h, unsigned, with `direction` "long" (longs pay → positive) or
|
|
2132
|
+
* "short" (shorts pay → negative). Live 2026-10-19 BTC: rate "0.0012", value "0.99315" =
|
|
2133
|
+
* mark 82,763 × 0.000012, i.e. value / mark = rate / 100. NOT the /funding-rates unit (8 h).
|
|
2134
|
+
*/
|
|
2028
2135
|
async getFundingHistory(symbol, limit = 10) {
|
|
2136
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 750) {
|
|
2137
|
+
throw new PerpError("INVALID_PARAMS", `${this.name} funding history limit must be 1..750 (got ${limit})`, { exchange: this.name });
|
|
2138
|
+
}
|
|
2029
2139
|
await this.ensureMarketMap();
|
|
2030
|
-
this.getMarketIndex(symbol); // unlisted market: SYMBOL_NOT_FOUND, not an empty history
|
|
2031
|
-
const
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2140
|
+
const marketId = this.getMarketIndex(symbol); // unlisted market: SYMBOL_NOT_FOUND, not an empty history
|
|
2141
|
+
const end = Math.floor(Date.now() / 1000);
|
|
2142
|
+
const res = await this.restGet("/fundings", {
|
|
2143
|
+
market_id: String(marketId),
|
|
2144
|
+
resolution: "1h",
|
|
2145
|
+
// One spare hour covers the settlement in progress.
|
|
2146
|
+
start_timestamp: String(end - (limit + 1) * 3600),
|
|
2147
|
+
end_timestamp: String(end),
|
|
2148
|
+
count_back: String(limit),
|
|
2149
|
+
});
|
|
2150
|
+
if (!Array.isArray(res.fundings)) {
|
|
2151
|
+
throw new PerpError("EXCHANGE_ERROR", `${this.name} /fundings: missing fundings array (${JSON.stringify(res).slice(0, 120)})`, { exchange: this.name });
|
|
2152
|
+
}
|
|
2153
|
+
return res.fundings.map((f) => lighterFundingRow(this.name, f)).sort((a, b) => a.time - b.time).slice(-limit);
|
|
2037
2154
|
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Candles opening in [startTime, endTime] (ms). GET /api/v1/candles answers the
|
|
2157
|
+
* max(count_back, bars in the window) newest bars ending at `end_timestamp`, at most 500
|
|
2158
|
+
* (live 2026-10-19, BTC 1h: a 96-bar window with count_back 1 / 96 → those 96 bars; with
|
|
2159
|
+
* count_back 500 → 500 bars, 404 of them before start_timestamp; a future window → past bars).
|
|
2160
|
+
* So count_back is the window's bar count (≤ 500), rows outside the window are dropped, and a
|
|
2161
|
+
* longer window is paged backwards in 500-bar chunks by moving end_timestamp. `end_timestamp` is
|
|
2162
|
+
* exclusive at second resolution (live: end = a bar's open excludes it, open + 1 s includes it),
|
|
2163
|
+
* so end + 1 s is sent for an inclusive --end. Each answer's `r`
|
|
2164
|
+
* must be the requested resolution. Intervals outside LIGHTER_CANDLE_INTERVALS and windows over
|
|
2165
|
+
* LIGHTER_KLINE_MAX_PAGES pages are refused with INVALID_PARAMS before any request.
|
|
2166
|
+
*/
|
|
2038
2167
|
async getKlines(symbol, interval, startTime, endTime) {
|
|
2039
|
-
const
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2168
|
+
const ivMs = LIGHTER_CANDLE_INTERVALS[interval];
|
|
2169
|
+
if (ivMs === undefined) {
|
|
2170
|
+
throw new PerpError("INVALID_PARAMS", `${this.name} kline interval ${JSON.stringify(interval)} is not served (supported: ${Object.keys(LIGHTER_CANDLE_INTERVALS).join(", ")})`, {
|
|
2171
|
+
exchange: this.name, remediation: `Use one of: ${Object.keys(LIGHTER_CANDLE_INTERVALS).join(", ")}`,
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || startTime > endTime) {
|
|
2175
|
+
throw new PerpError("INVALID_PARAMS", `${this.name} kline window must be start ≤ end (unix ms; got ${startTime}..${endTime})`, { exchange: this.name });
|
|
2176
|
+
}
|
|
2177
|
+
// Bar opens in [start, end] (bars open on multiples of the interval since the epoch).
|
|
2178
|
+
const barsIn = (from, to) => Math.floor(to / ivMs) - Math.ceil(from / ivMs) + 1;
|
|
2179
|
+
const bars = barsIn(startTime, endTime);
|
|
2180
|
+
if (bars > LIGHTER_CANDLES_PER_CALL * LIGHTER_KLINE_MAX_PAGES) {
|
|
2181
|
+
throw new PerpError("INVALID_PARAMS", `${this.name} kline window spans ${bars} ${interval} bars; at most ${LIGHTER_CANDLES_PER_CALL * LIGHTER_KLINE_MAX_PAGES} are read (${LIGHTER_KLINE_MAX_PAGES} requests of ${LIGHTER_CANDLES_PER_CALL})`, {
|
|
2182
|
+
exchange: this.name, remediation: "Narrow --start / --end or use a longer interval",
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
if (bars <= 0) {
|
|
2186
|
+
// No bar opens in the window: nothing to ask for (count_back 0 is never sent); the symbol is still checked.
|
|
2187
|
+
await this.ensureMarketMap();
|
|
2188
|
+
this.getMarketIndex(symbol);
|
|
2189
|
+
return [];
|
|
2190
|
+
}
|
|
2191
|
+
const byTime = new Map();
|
|
2192
|
+
let cursorEnd = endTime;
|
|
2193
|
+
let prevEarliest = Infinity;
|
|
2194
|
+
let covered = false;
|
|
2195
|
+
for (let page = 0; page < LIGHTER_KLINE_MAX_PAGES; page++) {
|
|
2196
|
+
const left = barsIn(startTime, cursorEnd);
|
|
2197
|
+
if (left <= 0) {
|
|
2198
|
+
covered = true;
|
|
2199
|
+
break;
|
|
2200
|
+
} // every bar open in the window is read
|
|
2201
|
+
const want = Math.min(LIGHTER_CANDLES_PER_CALL, left);
|
|
2202
|
+
const res = await this.getCandles(symbol, interval, startTime, cursorEnd + 1000, want);
|
|
2203
|
+
assertCandleInterval(this.name, interval, res.r);
|
|
2204
|
+
// /candles returns the array under key `c` (top keys: code, r, c).
|
|
2205
|
+
if (!Array.isArray(res.c)) {
|
|
2206
|
+
throw new PerpError("EXCHANGE_ERROR", `${this.name} /candles: no candle list (${JSON.stringify(res).slice(0, 120)})`, { exchange: this.name });
|
|
2207
|
+
}
|
|
2208
|
+
const rows = res.c;
|
|
2209
|
+
let earliest = Infinity;
|
|
2210
|
+
for (const c of rows) {
|
|
2211
|
+
const t = Number(c.t);
|
|
2212
|
+
if (!Number.isFinite(t))
|
|
2213
|
+
throw new PerpError("EXCHANGE_ERROR", `${this.name} /candles: row without an open time: ${JSON.stringify(c).slice(0, 120)}`, { exchange: this.name });
|
|
2214
|
+
if (t < earliest)
|
|
2215
|
+
earliest = t;
|
|
2216
|
+
if (t >= startTime && t <= endTime)
|
|
2217
|
+
byTime.set(t, c);
|
|
2218
|
+
}
|
|
2219
|
+
// Done: the window start is covered, or the venue has no older bars (a short answer).
|
|
2220
|
+
if (rows.length === 0 || earliest <= startTime || rows.length < want) {
|
|
2221
|
+
covered = true;
|
|
2222
|
+
break;
|
|
2223
|
+
}
|
|
2224
|
+
if (earliest >= prevEarliest) {
|
|
2225
|
+
throw new PerpError("EXCHANGE_ERROR", `${this.name} /candles: paging made no progress (oldest bar ${earliest} not before the previous page's ${prevEarliest})`, { exchange: this.name });
|
|
2226
|
+
}
|
|
2227
|
+
prevEarliest = earliest;
|
|
2228
|
+
cursorEnd = earliest - 1000; // sent as `earliest`: the bars strictly before the oldest one read
|
|
2229
|
+
}
|
|
2230
|
+
if (!covered) {
|
|
2231
|
+
// Every page was full and the window start is still not reached: never a silently short window.
|
|
2232
|
+
throw new PerpError("EXCHANGE_ERROR", `${this.name} /candles: ${LIGHTER_KLINE_MAX_PAGES} full pages did not reach the window start ${startTime} (oldest ${prevEarliest})`, { exchange: this.name });
|
|
2233
|
+
}
|
|
2234
|
+
const candles = [...byTime.values()].sort((a, b) => Number(a.t) - Number(b.t));
|
|
2043
2235
|
return candles.map((c) => ({
|
|
2044
|
-
time: Number(c.
|
|
2236
|
+
time: Number(c.t),
|
|
2045
2237
|
open: String(c.open ?? c.o ?? "0"),
|
|
2046
2238
|
high: String(c.high ?? c.h ?? "0"),
|
|
2047
2239
|
low: String(c.low ?? c.l ?? "0"),
|
|
@@ -2109,7 +2301,7 @@ export class LighterAdapter {
|
|
|
2109
2301
|
*/
|
|
2110
2302
|
async getOrderHistory(limit = 30) {
|
|
2111
2303
|
if (!this._address)
|
|
2112
|
-
throw
|
|
2304
|
+
throw noAccountKeyError(this.name);
|
|
2113
2305
|
const orders = await this.historyRows("/accountInactiveOrders", "orders", { market_type: "perp" }, limit, true);
|
|
2114
2306
|
// Every field is required: a missing price / size / status / type is an unreadable row, never "0" / "done" / "limit".
|
|
2115
2307
|
const req = (o, k) => lighterRowField(o, k, this.name, "order history");
|
|
@@ -2139,7 +2331,7 @@ export class LighterAdapter {
|
|
|
2139
2331
|
*/
|
|
2140
2332
|
async getTradeHistory(limit = 30) {
|
|
2141
2333
|
if (!this._address)
|
|
2142
|
-
throw
|
|
2334
|
+
throw noAccountKeyError(this.name);
|
|
2143
2335
|
const trades = await this.historyRows("/trades", "trades", { sort_by: "timestamp", market_type: "perp" }, limit, false);
|
|
2144
2336
|
return trades.map((t) => {
|
|
2145
2337
|
const sold = t.ask_account_id === this._accountIndex;
|
|
@@ -2174,7 +2366,7 @@ export class LighterAdapter {
|
|
|
2174
2366
|
*/
|
|
2175
2367
|
async getFundingPayments(limit = 200) {
|
|
2176
2368
|
if (!this._address)
|
|
2177
|
-
throw
|
|
2369
|
+
throw noAccountKeyError(this.name);
|
|
2178
2370
|
const items = await this.historyRows("/positionFunding", "position_fundings", { side: "all" }, limit, true);
|
|
2179
2371
|
return items.map((f) => {
|
|
2180
2372
|
if (typeof f.timestamp !== "number" || typeof f.change !== "string") {
|
|
@@ -2184,8 +2376,9 @@ export class LighterAdapter {
|
|
|
2184
2376
|
});
|
|
2185
2377
|
}
|
|
2186
2378
|
/**
|
|
2187
|
-
*
|
|
2188
|
-
*
|
|
2379
|
+
* GET /api/v1/funding-rates: the venue's PREDICTED rate × 8, a fraction per 8 h — not the venue
|
|
2380
|
+
* basis (1 h; market rows carry the settled hourly rate, fundingStats). Not used for funding
|
|
2381
|
+
* comparisons; normalize with 8 h if read.
|
|
2189
2382
|
*/
|
|
2190
2383
|
async getFundingRates() {
|
|
2191
2384
|
const map = new Map();
|