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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Network, type PublicBalanceResult, type PublicPositionsOptions } from "./venue.js";
|
|
2
|
+
import type { LighterVenueId } from "./venues/lighter-family.js";
|
|
3
|
+
/**
|
|
4
|
+
* Hyperliquid: info `clearinghouseState` (native perp dex), `userAbstraction` (the mode decides
|
|
5
|
+
* the equity source, exactly as the keyed getBalance) and `spotClearinghouseState` (spot rows;
|
|
6
|
+
* in unified / portfolio mode also the collateral), plus `meta.collateralToken` when the mode needs
|
|
7
|
+
* it. HIP-3: `clearinghouseState {dex}` for every dex of `perpDexs` (paced: hlDexFanOut); a dex
|
|
8
|
+
* balance is that dex's own perp clearinghouse, listed when its account value is non-zero. A dex
|
|
9
|
+
* (or the dex list) that fails is an `errors` entry ("hip3:<dex>") — unknown, never zero; the
|
|
10
|
+
* native, mode or spot read failing fails the venue.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hyperliquidBalanceByAddress(net: Network, address: string): Promise<PublicBalanceResult>;
|
|
13
|
+
/**
|
|
14
|
+
* Lighter / Lighter RH: `GET /account?by=l1_address` lists every account of the address; each
|
|
15
|
+
* account's balance (lighterBalanceOf) and spot assets (lighterSpotBalanceRows) are one
|
|
16
|
+
* `accounts` row. `accountIndex` narrows to one account (not one of the address's →
|
|
17
|
+
* INVALID_PARAMS); no account → empty `accounts` + note.
|
|
18
|
+
*/
|
|
19
|
+
export declare function lighterBalanceByAddress(venue: LighterVenueId, net: Network, address: string, opts: PublicPositionsOptions): Promise<PublicBalanceResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Pacifica: `GET /account?account=` (balance + spot_balances), `/positions?account=` and
|
|
22
|
+
* `/info/prices` — pacificaBalanceOf with the keyed extras (usdcBalance, borrowedUsdc,
|
|
23
|
+
* pendingInterest, availableToWithdraw). The address is validated as base58 32 bytes by the caller.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pacificaBalanceByAddress(net: Network, address: string): Promise<PublicBalanceResult>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Balances of ANY account by its public address, keyless (`perp account balance --address`, MCP
|
|
3
|
+
* get_balance_by_address). Wired into the registry through VenuePublicData.fetchBalanceByAddress;
|
|
4
|
+
* the balance comes from the same functions as the keyed adapters' getBalance (hlBalanceOf /
|
|
5
|
+
* lighterBalanceOf / pacificaBalanceOf) and the spot rows from the keyed spot mappers
|
|
6
|
+
* (hlSpotBalanceRows / lighterSpotBalanceRows / pacificaSpotBalanceRows). Read-only GETs / info
|
|
7
|
+
* POSTs; nothing is signed, no key is loaded.
|
|
8
|
+
*/
|
|
9
|
+
import { classifyError, PerpError } from "../errors.js";
|
|
10
|
+
import { getVenue, networkConfig } from "./venue.js";
|
|
11
|
+
import { chooseLighterAccounts, hlDexFanOut, hlDexNamesOf, lighterAccountIndexOf } from "./public-positions.js";
|
|
12
|
+
function errorOf(e, venue) {
|
|
13
|
+
const s = classifyError(e, venue);
|
|
14
|
+
return { code: s.code, message: s.message };
|
|
15
|
+
}
|
|
16
|
+
/** A venue read failure as a typed error (the public HTTP helpers throw plain Errors on HTTP status). */
|
|
17
|
+
async function typed(venue, read) {
|
|
18
|
+
try {
|
|
19
|
+
return await read();
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
if (e instanceof PerpError)
|
|
23
|
+
throw e;
|
|
24
|
+
const s = classifyError(e, venue);
|
|
25
|
+
throw new PerpError(s.code === "UNKNOWN" ? "EXCHANGE_ERROR" : s.code, s.message, { exchange: venue });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const EMPTY = { balance: null, spot: null, accounts: null, dexes: null, accountMode: null };
|
|
29
|
+
/** spotClearinghouseState `balances`, every row with a coin and numeric total / hold (unreadable → EXCHANGE_ERROR, never "0"). */
|
|
30
|
+
function hlSpotBalancesOf(state) {
|
|
31
|
+
const balances = state?.balances;
|
|
32
|
+
if (!Array.isArray(balances))
|
|
33
|
+
throw new PerpError("EXCHANGE_ERROR", "hyperliquid spotClearinghouseState: no balances array", { exchange: "hyperliquid" });
|
|
34
|
+
for (const b of balances) {
|
|
35
|
+
const num = (v) => typeof v === "string" && v !== "" && Number.isFinite(Number(v));
|
|
36
|
+
if (typeof b?.coin !== "string" || !num(b.total) || !num(b.hold)) {
|
|
37
|
+
throw new PerpError("EXCHANGE_ERROR", `hyperliquid spotClearinghouseState: unreadable balance row ${JSON.stringify(b).slice(0, 160)}`, { exchange: "hyperliquid" });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return balances;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Hyperliquid: info `clearinghouseState` (native perp dex), `userAbstraction` (the mode decides
|
|
44
|
+
* the equity source, exactly as the keyed getBalance) and `spotClearinghouseState` (spot rows;
|
|
45
|
+
* in unified / portfolio mode also the collateral), plus `meta.collateralToken` when the mode needs
|
|
46
|
+
* it. HIP-3: `clearinghouseState {dex}` for every dex of `perpDexs` (paced: hlDexFanOut); a dex
|
|
47
|
+
* balance is that dex's own perp clearinghouse, listed when its account value is non-zero. A dex
|
|
48
|
+
* (or the dex list) that fails is an `errors` entry ("hip3:<dex>") — unknown, never zero; the
|
|
49
|
+
* native, mode or spot read failing fails the venue.
|
|
50
|
+
*/
|
|
51
|
+
export async function hyperliquidBalanceByAddress(net, address) {
|
|
52
|
+
const { legacyPublicJson } = await import("../funding/legacy-public.js");
|
|
53
|
+
const { legacyPublicBaseUrl } = await import("../api/public/urls.js");
|
|
54
|
+
const { hlAbstractionModeOf, hlBalanceOf, hlSpotUsdPricesOf } = await import("./hyperliquid.js");
|
|
55
|
+
const { hlSpotBalanceRows } = await import("./hyperliquid-spot.js");
|
|
56
|
+
const url = `${legacyPublicBaseUrl("hyperliquid", net)}/info`;
|
|
57
|
+
const post = (body, label) => legacyPublicJson("hyperliquid", url, label, {
|
|
58
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
59
|
+
});
|
|
60
|
+
const errors = {};
|
|
61
|
+
const [native, modeRaw, spotState, dexList] = await Promise.all([
|
|
62
|
+
typed("hyperliquid", () => post({ type: "clearinghouseState", user: address }, "info clearinghouseState")),
|
|
63
|
+
typed("hyperliquid", () => post({ type: "userAbstraction", user: address }, "info userAbstraction")),
|
|
64
|
+
typed("hyperliquid", () => post({ type: "spotClearinghouseState", user: address }, "info spotClearinghouseState")),
|
|
65
|
+
post({ type: "perpDexs" }, "info perpDexs").then(hlDexNamesOf, (e) => { errors.hip3 = errorOf(e, "hyperliquid"); return []; }),
|
|
66
|
+
]);
|
|
67
|
+
const mode = hlAbstractionModeOf(modeRaw);
|
|
68
|
+
const balances = hlSpotBalancesOf(spotState);
|
|
69
|
+
let token = null;
|
|
70
|
+
const collateralToken = () => (token ??= typed("hyperliquid", () => post({ type: "meta" }, "info meta")).then((meta) => {
|
|
71
|
+
const idx = meta?.collateralToken;
|
|
72
|
+
if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0) {
|
|
73
|
+
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid meta has no collateralToken (got ${JSON.stringify(idx)})`, { exchange: "hyperliquid" });
|
|
74
|
+
}
|
|
75
|
+
return idx;
|
|
76
|
+
}));
|
|
77
|
+
const nativeState = native;
|
|
78
|
+
const { balance, notes } = await hlBalanceOf(nativeState, mode, "", {
|
|
79
|
+
collateralToken,
|
|
80
|
+
spotCollateralRow: async () => {
|
|
81
|
+
const t = await collateralToken();
|
|
82
|
+
return { row: balances.find(b => Number(b.token) === t) ?? null, balances, token: t, portfolioMarginRatio: spotState.portfolioMarginRatio };
|
|
83
|
+
},
|
|
84
|
+
spotUsdPrices: async () => hlSpotUsdPricesOf(await typed("hyperliquid", () => post({ type: "spotMetaAndAssetCtxs" }, "info spotMetaAndAssetCtxs"))),
|
|
85
|
+
});
|
|
86
|
+
const unified = mode === "unified" || mode === "portfolio";
|
|
87
|
+
const out = notes.map(n => n.trim());
|
|
88
|
+
if (unified) {
|
|
89
|
+
const av = nativeState.marginSummary?.accountValue;
|
|
90
|
+
const wd = nativeState.withdrawable;
|
|
91
|
+
const perp = [typeof av === "string" ? `accountValue ${av}` : null, typeof wd === "string" ? `withdrawable ${wd}` : null].filter(x => x !== null).join(", ");
|
|
92
|
+
out.push(`account mode ${mode}: equity / available come from the spot clearinghouse (the collateral balance already moves with perp PnL), as in the keyed balance; the native perp clearinghouse${perp !== "" ? ` (${perp})` : ""} is a per-dex view and is not added`);
|
|
93
|
+
}
|
|
94
|
+
// HIP-3 dex balances: the dex's own perp clearinghouse (standard semantics — the mode-dependent
|
|
95
|
+
// spot collateral is the top-level balance, never repeated per dex).
|
|
96
|
+
const dexes = {};
|
|
97
|
+
const noSpotRead = () => { throw new PerpError("EXCHANGE_ERROR", "internal: a HIP-3 dex balance read asked for spot state", { exchange: "hyperliquid" }); };
|
|
98
|
+
const perDex = await hlDexFanOut(net, dexList, dex => post({ type: "clearinghouseState", user: address, dex }, `info clearinghouseState dex ${dex}`));
|
|
99
|
+
for (const [i, res] of perDex.entries()) {
|
|
100
|
+
const dex = dexList[i];
|
|
101
|
+
if (res.status === "rejected") {
|
|
102
|
+
errors[`hip3:${dex}`] = errorOf(res.reason, "hyperliquid");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const b = (await hlBalanceOf(res.value, "standard", dex, { collateralToken: noSpotRead, spotCollateralRow: noSpotRead, spotUsdPrices: noSpotRead })).balance;
|
|
107
|
+
if (Number(b.equity) !== 0)
|
|
108
|
+
dexes[dex] = b;
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
errors[`hip3:${dex}`] = errorOf(e, "hyperliquid");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { balance, spot: hlSpotBalanceRows({ balances }, mode), accounts: null, dexes, accountMode: mode, errors, notes: out };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Lighter / Lighter RH: `GET /account?by=l1_address` lists every account of the address; each
|
|
118
|
+
* account's balance (lighterBalanceOf) and spot assets (lighterSpotBalanceRows) are one
|
|
119
|
+
* `accounts` row. `accountIndex` narrows to one account (not one of the address's →
|
|
120
|
+
* INVALID_PARAMS); no account → empty `accounts` + note.
|
|
121
|
+
*/
|
|
122
|
+
export async function lighterBalanceByAddress(venue, net, address, opts) {
|
|
123
|
+
const { fetchLighterAccountsByL1 } = await import("./lighter-public.js");
|
|
124
|
+
const { lighterBalanceOf } = await import("./lighter.js");
|
|
125
|
+
const { lighterSpotBalanceRows } = await import("./lighter-spot.js");
|
|
126
|
+
const collateral = networkConfig(getVenue(venue), net).collateral[0];
|
|
127
|
+
if (collateral === undefined)
|
|
128
|
+
throw new PerpError("INVALID_PARAMS", `${venue} network config has no collateral`);
|
|
129
|
+
const accounts = await fetchLighterAccountsByL1(venue, net, address);
|
|
130
|
+
if (accounts === null || accounts.length === 0) {
|
|
131
|
+
return { ...EMPTY, accounts: [], errors: {}, notes: [`no ${venue === "lighter" ? "Lighter" : "Lighter RH"} account for this address`] };
|
|
132
|
+
}
|
|
133
|
+
const rows = chooseLighterAccounts(venue, accounts, opts.accountIndex).map(a => ({
|
|
134
|
+
accountIndex: lighterAccountIndexOf(a),
|
|
135
|
+
balance: lighterBalanceOf(a, venue, collateral),
|
|
136
|
+
spot: lighterSpotBalanceRows(a, venue, collateral),
|
|
137
|
+
}));
|
|
138
|
+
return { ...EMPTY, accounts: rows, errors: {}, notes: [] };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Pacifica: `GET /account?account=` (balance + spot_balances), `/positions?account=` and
|
|
142
|
+
* `/info/prices` — pacificaBalanceOf with the keyed extras (usdcBalance, borrowedUsdc,
|
|
143
|
+
* pendingInterest, availableToWithdraw). The address is validated as base58 32 bytes by the caller.
|
|
144
|
+
*/
|
|
145
|
+
export async function pacificaBalanceByAddress(net, address) {
|
|
146
|
+
const { PacificaClient } = await import("../pacifica/client.js");
|
|
147
|
+
const { pacificaBalanceOf } = await import("./pacifica.js");
|
|
148
|
+
const { pacificaSpotBalanceRows } = await import("./pacifica-spot.js");
|
|
149
|
+
const client = new PacificaClient({ network: net });
|
|
150
|
+
const [info, positions, prices] = await typed("pacifica", () => Promise.all([client.getAccount(address), client.getPositions(address), client.getPrices()]));
|
|
151
|
+
return {
|
|
152
|
+
...EMPTY,
|
|
153
|
+
balance: pacificaBalanceOf(info, positions, prices),
|
|
154
|
+
spot: pacificaSpotBalanceRows(info),
|
|
155
|
+
errors: {},
|
|
156
|
+
notes: [],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Network, type PublicPositionsOptions, type PublicPositionsResult } from "./venue.js";
|
|
2
|
+
import type { LighterVenueId } from "./venues/lighter-family.js";
|
|
3
|
+
/** Requests of a HIP-3 per-dex fan-out in flight at once. */
|
|
4
|
+
export declare const HL_DEX_FANOUT_CONCURRENCY = 4;
|
|
5
|
+
/** Burst of the fan-out's token bucket, in HL weight units (clearinghouseState weighs 2 → 10 requests). */
|
|
6
|
+
export declare const HL_DEX_FANOUT_BURST = 20;
|
|
7
|
+
/**
|
|
8
|
+
* One `read` per HIP-3 dex, paced: at most HL_DEX_FANOUT_CONCURRENCY in flight, and every request
|
|
9
|
+
* takes its weight from a token bucket refilled at the venue's documented rate
|
|
10
|
+
* (hyperliquidVenue.rateLimit: 1200 weight / min = 20 / s; clearinghouseState weight 2) with a
|
|
11
|
+
* small burst (HL_DEX_FANOUT_BURST) instead of the full minute's budget. HL mainnet lists ~10
|
|
12
|
+
* dexes (no wait); testnet ~272, where an unpaced burst got HTTP 429 on 62 of 272 reads at
|
|
13
|
+
* concurrency 4 — paced, the fan-out takes ~25 s instead. A failed read stays that dex's
|
|
14
|
+
* (settled per dex); nothing is retried.
|
|
15
|
+
*/
|
|
16
|
+
export declare function hlDexFanOut<T>(net: Network, dexes: readonly string[], read: (dex: string) => Promise<T>): Promise<PromiseSettledResult<T>[]>;
|
|
17
|
+
/** Hyperliquid `perpDexs` → HIP-3 dex names (the first entry, null, is the native dex). */
|
|
18
|
+
export declare function hlDexNamesOf(j: unknown): string[];
|
|
19
|
+
/** Account index of a Lighter account object. */
|
|
20
|
+
export declare function lighterAccountIndexOf(a: Record<string, unknown>): number;
|
|
21
|
+
/** The accounts of an address a read covers: every one, or the one of `accountIndex` (not one of the address's → INVALID_PARAMS). */
|
|
22
|
+
export declare function chooseLighterAccounts(venue: LighterVenueId, accounts: Array<Record<string, unknown>>, accountIndex: number | undefined): Array<Record<string, unknown>>;
|
|
23
|
+
/**
|
|
24
|
+
* Hyperliquid: info `clearinghouseState` {user} for the native perp dex plus {user, dex} for every
|
|
25
|
+
* HIP-3 dex of `perpDexs` (one request each, paced: hlDexFanOut). Native rows carry `dex: null`, HIP-3 rows their dex
|
|
26
|
+
* and the prefixed coin ("xyz:GOLD"), as the HIP-3 market rows. A dex (or the dex list) that
|
|
27
|
+
* fails is an `errors` entry ("hip3:<dex>") — its rows are unknown, never silently dropped; the
|
|
28
|
+
* native read failing fails the venue.
|
|
29
|
+
*/
|
|
30
|
+
export declare function hyperliquidPositionsByAddress(net: Network, address: string): Promise<PublicPositionsResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Lighter / Lighter RH: `GET /account?by=l1_address` lists every account of the address
|
|
33
|
+
* (sub-accounts); each account's rows are tagged with its `accountIndex`. The venue keeps a
|
|
34
|
+
* zero-size row for every market an account ever touched: those are dropped by the mapper.
|
|
35
|
+
* `accountIndex` narrows to one account (not one of the address's → INVALID_PARAMS).
|
|
36
|
+
*/
|
|
37
|
+
export declare function lighterPositionsByAddress(venue: LighterVenueId, net: Network, address: string, opts: PublicPositionsOptions): Promise<PublicPositionsResult>;
|
|
38
|
+
/**
|
|
39
|
+
* Pacifica: `GET /positions?account=` marked with `/info/prices`; unresolved leverage from the
|
|
40
|
+
* account's public `/account/settings`, then the market max (`/info`). The address is validated
|
|
41
|
+
* as base58 32 bytes by the caller (an EVM address would be answered `success: true, []`).
|
|
42
|
+
*/
|
|
43
|
+
export declare function pacificaPositionsByAddress(net: Network, address: string): Promise<PublicPositionsResult>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open positions of ANY account by its public address, keyless (`perp account positions
|
|
3
|
+
* --address`, MCP get_positions_by_address). Wired into the registry through
|
|
4
|
+
* VenuePublicData.fetchPositionsByAddress (+ addressKind); the rows come from the same mappers as
|
|
5
|
+
* the keyed adapters' getPositions (hlPositionRows / lighterPositionRows / pacificaPositionRows),
|
|
6
|
+
* with plain symbols. Read-only GETs / info POSTs; nothing is signed, no key is loaded.
|
|
7
|
+
*/
|
|
8
|
+
import { classifyError, PerpError } from "../errors.js";
|
|
9
|
+
import { getVenue } from "./venue.js";
|
|
10
|
+
import { acquireRate } from "./rate-limiter.js";
|
|
11
|
+
function errorOf(e, venue) {
|
|
12
|
+
const s = classifyError(e, venue);
|
|
13
|
+
return { code: s.code, message: s.message };
|
|
14
|
+
}
|
|
15
|
+
/** A venue read failure as a typed error (the public HTTP helpers throw plain Errors on HTTP status). */
|
|
16
|
+
async function typed(venue, read) {
|
|
17
|
+
try {
|
|
18
|
+
return await read();
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
if (e instanceof PerpError)
|
|
22
|
+
throw e;
|
|
23
|
+
const s = classifyError(e, venue);
|
|
24
|
+
throw new PerpError(s.code === "UNKNOWN" ? "EXCHANGE_ERROR" : s.code, s.message, { exchange: venue });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Requests of a HIP-3 per-dex fan-out in flight at once. */
|
|
28
|
+
export const HL_DEX_FANOUT_CONCURRENCY = 4;
|
|
29
|
+
/** Burst of the fan-out's token bucket, in HL weight units (clearinghouseState weighs 2 → 10 requests). */
|
|
30
|
+
export const HL_DEX_FANOUT_BURST = 20;
|
|
31
|
+
/**
|
|
32
|
+
* One `read` per HIP-3 dex, paced: at most HL_DEX_FANOUT_CONCURRENCY in flight, and every request
|
|
33
|
+
* takes its weight from a token bucket refilled at the venue's documented rate
|
|
34
|
+
* (hyperliquidVenue.rateLimit: 1200 weight / min = 20 / s; clearinghouseState weight 2) with a
|
|
35
|
+
* small burst (HL_DEX_FANOUT_BURST) instead of the full minute's budget. HL mainnet lists ~10
|
|
36
|
+
* dexes (no wait); testnet ~272, where an unpaced burst got HTTP 429 on 62 of 272 reads at
|
|
37
|
+
* concurrency 4 — paced, the fan-out takes ~25 s instead. A failed read stays that dex's
|
|
38
|
+
* (settled per dex); nothing is retried.
|
|
39
|
+
*/
|
|
40
|
+
export async function hlDexFanOut(net, dexes, read) {
|
|
41
|
+
const venuePolicy = getVenue("hyperliquid").rateLimit;
|
|
42
|
+
const policy = { capacity: HL_DEX_FANOUT_BURST, refillPerSec: venuePolicy.refillPerSec, weights: venuePolicy.weights };
|
|
43
|
+
const out = new Array(dexes.length);
|
|
44
|
+
let next = 0;
|
|
45
|
+
const worker = async () => {
|
|
46
|
+
while (next < dexes.length) {
|
|
47
|
+
const i = next++;
|
|
48
|
+
try {
|
|
49
|
+
await acquireRate(`hyperliquid:hip3-fanout:${net}`, policy, "clearinghouseState");
|
|
50
|
+
out[i] = { status: "fulfilled", value: await read(dexes[i]) };
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
out[i] = { status: "rejected", reason: e };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
await Promise.all(Array.from({ length: Math.min(HL_DEX_FANOUT_CONCURRENCY, dexes.length) }, worker));
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** Hyperliquid `perpDexs` → HIP-3 dex names (the first entry, null, is the native dex). */
|
|
61
|
+
export function hlDexNamesOf(j) {
|
|
62
|
+
if (!Array.isArray(j))
|
|
63
|
+
throw new PerpError("EXCHANGE_ERROR", "hyperliquid perpDexs: expected an array", { exchange: "hyperliquid" });
|
|
64
|
+
return j.flatMap(d => (d !== null && typeof d === "object" && typeof d.name === "string" ? [d.name] : []));
|
|
65
|
+
}
|
|
66
|
+
/** Account index of a Lighter account object. */
|
|
67
|
+
export function lighterAccountIndexOf(a) {
|
|
68
|
+
return Number(a.account_index ?? a.index);
|
|
69
|
+
}
|
|
70
|
+
/** The accounts of an address a read covers: every one, or the one of `accountIndex` (not one of the address's → INVALID_PARAMS). */
|
|
71
|
+
export function chooseLighterAccounts(venue, accounts, accountIndex) {
|
|
72
|
+
if (accountIndex === undefined)
|
|
73
|
+
return accounts;
|
|
74
|
+
const chosen = accounts.filter(a => lighterAccountIndexOf(a) === accountIndex);
|
|
75
|
+
if (chosen.length === 0) {
|
|
76
|
+
throw new PerpError("INVALID_PARAMS", `${venue}: account index ${accountIndex} is not an account of this address (accounts: ${accounts.map(lighterAccountIndexOf).join(", ")})`, {
|
|
77
|
+
exchange: venue, remediation: `Use one of: ${accounts.map(lighterAccountIndexOf).join(", ")} — or drop --account-index for every account`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return chosen;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Hyperliquid: info `clearinghouseState` {user} for the native perp dex plus {user, dex} for every
|
|
84
|
+
* HIP-3 dex of `perpDexs` (one request each, paced: hlDexFanOut). Native rows carry `dex: null`, HIP-3 rows their dex
|
|
85
|
+
* and the prefixed coin ("xyz:GOLD"), as the HIP-3 market rows. A dex (or the dex list) that
|
|
86
|
+
* fails is an `errors` entry ("hip3:<dex>") — its rows are unknown, never silently dropped; the
|
|
87
|
+
* native read failing fails the venue.
|
|
88
|
+
*/
|
|
89
|
+
export async function hyperliquidPositionsByAddress(net, address) {
|
|
90
|
+
const { legacyPublicJson } = await import("../funding/legacy-public.js");
|
|
91
|
+
const { legacyPublicBaseUrl } = await import("../api/public/urls.js");
|
|
92
|
+
const { hlPositionRows } = await import("./hyperliquid.js");
|
|
93
|
+
const url = `${legacyPublicBaseUrl("hyperliquid", net)}/info`;
|
|
94
|
+
const post = (body, label) => legacyPublicJson("hyperliquid", url, label, {
|
|
95
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
96
|
+
});
|
|
97
|
+
const errors = {};
|
|
98
|
+
const [native, dexList] = await Promise.all([
|
|
99
|
+
typed("hyperliquid", () => post({ type: "clearinghouseState", user: address }, "info clearinghouseState")),
|
|
100
|
+
post({ type: "perpDexs" }, "info perpDexs").then(hlDexNamesOf, (e) => { errors.hip3 = errorOf(e, "hyperliquid"); return []; }),
|
|
101
|
+
]);
|
|
102
|
+
const positions = hlPositionRows(native, coin => coin).map(r => ({ ...r, dex: null }));
|
|
103
|
+
const perDex = await hlDexFanOut(net, dexList, dex => post({ type: "clearinghouseState", user: address, dex }, `info clearinghouseState dex ${dex}`));
|
|
104
|
+
perDex.forEach((res, i) => {
|
|
105
|
+
const dex = dexList[i];
|
|
106
|
+
if (res.status === "rejected") {
|
|
107
|
+
errors[`hip3:${dex}`] = errorOf(res.reason, "hyperliquid");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
positions.push(...hlPositionRows(res.value, coin => (coin.includes(":") ? coin : `${dex}:${coin}`)).map(r => ({ ...r, dex })));
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
errors[`hip3:${dex}`] = errorOf(e, "hyperliquid");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return { positions, errors, notes: [] };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Lighter / Lighter RH: `GET /account?by=l1_address` lists every account of the address
|
|
121
|
+
* (sub-accounts); each account's rows are tagged with its `accountIndex`. The venue keeps a
|
|
122
|
+
* zero-size row for every market an account ever touched: those are dropped by the mapper.
|
|
123
|
+
* `accountIndex` narrows to one account (not one of the address's → INVALID_PARAMS).
|
|
124
|
+
*/
|
|
125
|
+
export async function lighterPositionsByAddress(venue, net, address, opts) {
|
|
126
|
+
const { fetchLighterAccountsByL1, fetchLighterPriceDecimals } = await import("./lighter-public.js");
|
|
127
|
+
const { lighterPositionRows } = await import("./lighter.js");
|
|
128
|
+
const accounts = await fetchLighterAccountsByL1(venue, net, address);
|
|
129
|
+
if (accounts === null || accounts.length === 0)
|
|
130
|
+
return { positions: [], errors: {}, notes: [`no ${venue === "lighter" ? "Lighter" : "Lighter RH"} account for this address`] };
|
|
131
|
+
const chosen = chooseLighterAccounts(venue, accounts, opts.accountIndex);
|
|
132
|
+
const open = (a) => (Array.isArray(a.positions) ? a.positions : []).some(p => Number(p.position) !== 0);
|
|
133
|
+
// Price decimals only when there is a mark to format (an account without positions costs one read).
|
|
134
|
+
const decimals = chosen.some(open) ? await fetchLighterPriceDecimals(venue, net) : new Map();
|
|
135
|
+
const positions = chosen.flatMap(a => lighterPositionRows(a.positions, venue, sym => decimals.get(sym.toUpperCase())).map(r => ({ ...r, accountIndex: lighterAccountIndexOf(a) })));
|
|
136
|
+
return { positions, errors: {}, notes: [] };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Pacifica: `GET /positions?account=` marked with `/info/prices`; unresolved leverage from the
|
|
140
|
+
* account's public `/account/settings`, then the market max (`/info`). The address is validated
|
|
141
|
+
* as base58 32 bytes by the caller (an EVM address would be answered `success: true, []`).
|
|
142
|
+
*/
|
|
143
|
+
export async function pacificaPositionsByAddress(net, address) {
|
|
144
|
+
const { PacificaClient } = await import("../pacifica/client.js");
|
|
145
|
+
const { pacificaPositionRows } = await import("./pacifica.js");
|
|
146
|
+
const { legacyPublicData } = await import("../funding/legacy-public.js");
|
|
147
|
+
const client = new PacificaClient({ network: net });
|
|
148
|
+
const [rows, prices] = await typed("pacifica", () => Promise.all([client.getPositions(address), client.getPrices()]));
|
|
149
|
+
const positions = await pacificaPositionRows(rows, prices, {
|
|
150
|
+
accountSettings: () => client.getAccountSettings(address),
|
|
151
|
+
maxLeverage: async () => new Map((await legacyPublicData("pacifica").fetchMarkets(net)).map(m => [m.symbol, m.maxLeverage])),
|
|
152
|
+
});
|
|
153
|
+
return { positions, errors: {}, notes: [] };
|
|
154
|
+
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { PerpError } from "../../errors.js";
|
|
42
42
|
import type { WithdrawGate } from "../../settings.js";
|
|
43
43
|
import { type BuilderFeeGate } from "../builder-fee.js";
|
|
44
|
-
import type { ExchangeAdapter, ExchangeBalance, ExchangeFundingPayment, ExchangeKline, ExchangeMarketInfo, ExchangeOrder, ExchangePosition, ExchangeTrade, ExchangePublicTrade } from "../interface.js";
|
|
44
|
+
import type { ExchangeAdapter, ExchangeBalance, ExchangeFundingPayment, ExchangeKline, ExchangeMarketInfo, ExchangeOrder, ExchangePosition, ExchangeTrade, ExchangePublicTrade, FundingHistoryRow } from "../interface.js";
|
|
45
45
|
import type { IocOutcome } from "../ioc-outcome.js";
|
|
46
46
|
import type { ResolvedSlippage } from "../slippage.js";
|
|
47
47
|
import type { TradingV2 } from "../trading-v2.js";
|
|
@@ -77,6 +77,22 @@ export declare function qfexClientOrderId(): string;
|
|
|
77
77
|
* quantity) are quantized to the instrument tick / lot instead (fromWireNumber).
|
|
78
78
|
*/
|
|
79
79
|
export declare function plainNumber(n: unknown, what: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* A QFEX `market list` / `market info` row: the legacy shape plus listing state and quote currency.
|
|
82
|
+
* Prices are in `quoteAsset`; `volume24h` is USD (null for a non-USD quote — no FX rate published),
|
|
83
|
+
* `volume24hQuote` the venue's 24h notional in `quoteAsset`.
|
|
84
|
+
*/
|
|
85
|
+
export type QfexMarketRow = ExchangeMarketInfo & {
|
|
86
|
+
tradable: boolean;
|
|
87
|
+
status: QfexListedStatus;
|
|
88
|
+
quoteAsset: string;
|
|
89
|
+
volume24hQuote: string | null;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Why a LISTED (ACTIVE) market is or is not tradable through perp-cli: "active" = tradable;
|
|
93
|
+
* "nonUsdQuote" = quoted in KRW / JPY / CNY / SEK / HKD — readable, never ordered (USD sizing).
|
|
94
|
+
*/
|
|
95
|
+
export type QfexListedStatus = "active" | "nonUsdQuote";
|
|
80
96
|
export interface QfexAddOrderWire {
|
|
81
97
|
symbol: string;
|
|
82
98
|
side: "BUY" | "SELL";
|
|
@@ -143,6 +159,12 @@ export declare class QfexAdapter implements ExchangeAdapter, TradingV2 {
|
|
|
143
159
|
private session;
|
|
144
160
|
private instrumentMap;
|
|
145
161
|
private instrumentOf;
|
|
162
|
+
/**
|
|
163
|
+
* Instrument of a per-symbol read (book / funding / kline / info). Unknown → SYMBOL_NOT_FOUND; a
|
|
164
|
+
* listed market whose /refdata status is not ACTIVE (DELISTED, INACTIVE) → SYMBOL_NOT_FOUND with
|
|
165
|
+
* that status: it is not a live market. A non-USD ACTIVE market is readable (only orders refuse it).
|
|
166
|
+
*/
|
|
167
|
+
private readableInstrument;
|
|
146
168
|
/** Refuse a market perp-cli must not send orders to, and an order type / TIF the symbol does not list. */
|
|
147
169
|
private requireOrderable;
|
|
148
170
|
/** Mark of ONE symbol from a single-symbol MDS subscription; PRICE_STALE when absent or older than QFEX_MARK_MAX_AGE_MS. */
|
|
@@ -261,19 +283,40 @@ export declare class QfexAdapter implements ExchangeAdapter, TradingV2 {
|
|
|
261
283
|
private settleUnknownStopCancel;
|
|
262
284
|
private settledByHistory;
|
|
263
285
|
applyLeverage(symbol: string, leverage: number, marginMode: "cross" | "isolated" | null): Promise<LeverageResult>;
|
|
264
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Legacy rows plus `tradable` (ACTIVE and USD-quoted, qfexTradable), the /refdata `status`
|
|
288
|
+
* (ACTIVE / INACTIVE / DELISTED) and `quoteAsset` (prices of KRW / JPY / CNY / SEK / HKD markets
|
|
289
|
+
* are in that currency, not USD).
|
|
290
|
+
*/
|
|
291
|
+
getMarkets(): Promise<QfexMarketRow[]>;
|
|
292
|
+
private marketRow;
|
|
293
|
+
/**
|
|
294
|
+
* `market info` of one ACTIVE market. fundingRate is the CURRENT /md/contracts `funding_rate`
|
|
295
|
+
* (the rate `market funding` / `arb scan --rates --include-unverified` show), labelled fundingVerified false: QFEX funding
|
|
296
|
+
* is unverified (Q4, display only). Market lists keep fundingRate null so no scan / bot reads it.
|
|
297
|
+
*/
|
|
298
|
+
getMarketInfo(symbol: string): Promise<QfexMarketRow & {
|
|
299
|
+
fundingVerified: false;
|
|
300
|
+
fundingNote: string;
|
|
301
|
+
marketClosed: boolean | null;
|
|
302
|
+
}>;
|
|
303
|
+
/** `quoteAsset`: level prices are in the market's quote currency (KRW for SAMSUNG-KRW). */
|
|
265
304
|
getOrderbook(symbol: string): Promise<{
|
|
266
305
|
bids: [string, string][];
|
|
267
306
|
asks: [string, string][];
|
|
307
|
+
quoteAsset: string;
|
|
268
308
|
}>;
|
|
269
309
|
getRecentTrades(): Promise<ExchangePublicTrade[]>;
|
|
270
|
-
/**
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
310
|
+
/**
|
|
311
|
+
* Hourly funding windows (GET /funding/{symbol}, intervalMinutes 60). `limit` = hours back (default 24).
|
|
312
|
+
* A window outside the market's funding hours (qfexFundingWindowClosed) is `rate: null,
|
|
313
|
+
* marketClosed: true` — the venue prints 0 there, which is "no funding", not a 0 rate.
|
|
314
|
+
*/
|
|
315
|
+
getFundingHistory(symbol: string, limit?: number): Promise<FundingHistoryRow[]>;
|
|
316
|
+
/** Rows carry `quoteAsset`: OHLC prices are in the market's quote currency (KRW for SAMSUNG-KRW). */
|
|
317
|
+
getKlines(symbol: string, interval: string, startTime: number, endTime: number): Promise<Array<ExchangeKline & {
|
|
318
|
+
quoteAsset: string;
|
|
319
|
+
}>>;
|
|
277
320
|
private userPositions;
|
|
278
321
|
/** Equity is the venue's own figure (GET /user/subaccounts/equity) — never recomputed from balance fields. */
|
|
279
322
|
getBalance(): Promise<ExchangeBalance>;
|
|
@@ -50,7 +50,7 @@ import { QFEX_BANKER_NETWORK, QFEX_ENDPOINTS, QFEX_WITHDRAW_DISABLED, QFEX_WITHD
|
|
|
50
50
|
import { loadQfexCredentials, QFEX_IMPORT_REMEDIATION } from "./agent.js";
|
|
51
51
|
import { QfexClient } from "./client.js";
|
|
52
52
|
import { qfexStatusClass } from "./errors.js";
|
|
53
|
-
import { fetchQfexInstruments, qfexPlainSymbol, qfexPublicData, qfexTradable, qfexVenueSymbol, } from "./public.js";
|
|
53
|
+
import { buildQfexFundingRates, buildQfexMarkets, fetchQfexInstruments, fetchQfexMarketsWithInstruments, qfexFundingWindowClosed, qfexIndexPrices, qfexListedStatus, qfexPlainSymbol, qfexPublicData, qfexTradable, qfexVenueSymbol, } from "./public.js";
|
|
54
54
|
import { QFEX_AUTOMATION_VERIFIED, QFEX_MODIFY_KEEPS_CLIENT_ID, QFEX_REDUCE_ONLY_WIRE, QFEX_STOP_CLIENT_ID_ECHO, decimalsEqual, fromWireNumber, isZeroDecimal, qfexAutomationGate, qfexReduceOnlyGate, reduceOnlyWireValue, subDecimals, sumDecimals, toWireNumber, weightedAveragePrice, } from "./signing.js";
|
|
55
55
|
import { QFEX_IDLE_CLOSE_MS, QFEX_MDS_TIMEOUT_MS, QFEX_TIMEOUTS, fetchQfexMarketStats, qfexPendingRemediation, qfexTradeSession, } from "./ws-transport.js";
|
|
56
56
|
/** A mark price entry older than this (its own venue `time`) is not used as an order reference. */
|
|
@@ -283,6 +283,20 @@ export class QfexAdapter {
|
|
|
283
283
|
}
|
|
284
284
|
return i;
|
|
285
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Instrument of a per-symbol read (book / funding / kline / info). Unknown → SYMBOL_NOT_FOUND; a
|
|
288
|
+
* listed market whose /refdata status is not ACTIVE (DELISTED, INACTIVE) → SYMBOL_NOT_FOUND with
|
|
289
|
+
* that status: it is not a live market. A non-USD ACTIVE market is readable (only orders refuse it).
|
|
290
|
+
*/
|
|
291
|
+
async readableInstrument(symbol) {
|
|
292
|
+
const i = await this.instrumentOf(symbol);
|
|
293
|
+
if (i.status !== "ACTIVE") {
|
|
294
|
+
throw new PerpError("SYMBOL_NOT_FOUND", `QFEX ${i.venueSymbol} is ${i.status.toLowerCase()} (status ${i.status}) — not a live market`, {
|
|
295
|
+
exchange: "qfex", status: i.status, remediation: "List tradable markets with: perp market list -e qfex",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return i;
|
|
299
|
+
}
|
|
286
300
|
/** Refuse a market perp-cli must not send orders to, and an order type / TIF the symbol does not list. */
|
|
287
301
|
requireOrderable(i, orderType, tif) {
|
|
288
302
|
if (i.status !== "ACTIVE") {
|
|
@@ -1111,26 +1125,70 @@ export class QfexAdapter {
|
|
|
1111
1125
|
return { venue: "qfex", symbol: i.symbol, leverage: raw.leverage, marginMode: "cross", venueRaw: raw };
|
|
1112
1126
|
}
|
|
1113
1127
|
// ── Legacy ExchangeAdapter: market data ──
|
|
1128
|
+
/**
|
|
1129
|
+
* Legacy rows plus `tradable` (ACTIVE and USD-quoted, qfexTradable), the /refdata `status`
|
|
1130
|
+
* (ACTIVE / INACTIVE / DELISTED) and `quoteAsset` (prices of KRW / JPY / CNY / SEK / HKD markets
|
|
1131
|
+
* are in that currency, not USD).
|
|
1132
|
+
*/
|
|
1114
1133
|
async getMarkets() {
|
|
1115
|
-
|
|
1134
|
+
const { markets, instruments, volumeNotional } = await fetchQfexMarketsWithInstruments(this.network);
|
|
1135
|
+
const bySymbol = new Map(instruments.map(i => [i.venueSymbol, i]));
|
|
1136
|
+
return markets.map(m => this.marketRow(m, bySymbol.get(m.venueSymbol), volumeNotional));
|
|
1116
1137
|
}
|
|
1138
|
+
marketRow(m, i, volumeNotional) {
|
|
1139
|
+
if (i === undefined)
|
|
1140
|
+
throw new PerpError("EXCHANGE_ERROR", `QFEX ${m.venueSymbol} is missing from /refdata`, { exchange: "qfex" });
|
|
1141
|
+
return {
|
|
1142
|
+
...toLegacyMarketInfo(m), tradable: m.tradable, status: qfexListedStatus(i), quoteAsset: i.quoteAsset,
|
|
1143
|
+
volume24hQuote: volumeNotional.get(i.venueSymbol) ?? null,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* `market info` of one ACTIVE market. fundingRate is the CURRENT /md/contracts `funding_rate`
|
|
1148
|
+
* (the rate `market funding` / `arb scan --rates --include-unverified` show), labelled fundingVerified false: QFEX funding
|
|
1149
|
+
* is unverified (Q4, display only). Market lists keep fundingRate null so no scan / bot reads it.
|
|
1150
|
+
*/
|
|
1151
|
+
async getMarketInfo(symbol) {
|
|
1152
|
+
const i = await this.readableInstrument(symbol);
|
|
1153
|
+
const snap = await fetchQfexMarketStats(QFEX_ENDPOINTS[this.network].mdsWs, i.venueSymbol, QFEX_MDS_TIMEOUT_MS);
|
|
1154
|
+
const contracts = await this.client.publicGet("/md/contracts", null);
|
|
1155
|
+
const { markets } = buildQfexMarkets([i], snap, Date.now(), qfexIndexPrices(listOf(contracts.data, "/md/contracts data")));
|
|
1156
|
+
if (markets.length === 0)
|
|
1157
|
+
throw new PerpError("PRICE_STALE", `QFEX ${i.venueSymbol}: no mark price in the market_stats snapshot`, { exchange: "qfex" });
|
|
1158
|
+
const { rates } = buildQfexFundingRates([i], listOf(contracts.data, "/md/contracts data").filter(c => c.ticker_id === i.venueSymbol), snap.marks, Date.now());
|
|
1159
|
+
return {
|
|
1160
|
+
...this.marketRow(markets[0], i, snap.volumeNotional),
|
|
1161
|
+
fundingRate: rates.length === 0 ? null : rates[0].rate,
|
|
1162
|
+
// true = the window in progress is outside the market's funding hours (fundingRate null).
|
|
1163
|
+
marketClosed: rates.length === 0 ? null : rates[0].marketClosed === true,
|
|
1164
|
+
fundingVerified: false,
|
|
1165
|
+
fundingNote: "QFEX funding rate UNVERIFIED (not confirmed against settled payments) — display only, never a basis for trading",
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
/** `quoteAsset`: level prices are in the market's quote currency (KRW for SAMSUNG-KRW). */
|
|
1117
1169
|
async getOrderbook(symbol) {
|
|
1118
1170
|
// Resolved against refdata first: an unknown symbol is SYMBOL_NOT_FOUND, not the venue's
|
|
1119
1171
|
// HTTP 404 surfacing as a retryable EXCHANGE_ERROR.
|
|
1120
|
-
const
|
|
1172
|
+
const instrument = await this.readableInstrument(symbol);
|
|
1173
|
+
const venueSymbol = instrument.venueSymbol;
|
|
1121
1174
|
const book = await this.client.publicGet(`/md/orderbook/${encodeURIComponent(venueSymbol)}`, null);
|
|
1122
1175
|
// The venue lists empty price levels (quantity "0"); they are not part of the book.
|
|
1123
1176
|
const side = (v, what) => listOf(v, what)
|
|
1124
1177
|
.filter(l => Array.isArray(l) && typeof l[0] === "string" && typeof l[1] === "string" && Number(l[1]) > 0)
|
|
1125
1178
|
.map(l => [l[0], l[1]]);
|
|
1126
|
-
return { bids: side(book.bids, "orderbook bids"), asks: side(book.asks, "orderbook asks") };
|
|
1179
|
+
return { bids: side(book.bids, "orderbook bids"), asks: side(book.asks, "orderbook asks"), quoteAsset: instrument.quoteAsset };
|
|
1127
1180
|
}
|
|
1128
1181
|
async getRecentTrades() {
|
|
1129
1182
|
throw unsupported("qfex", "recentTrades", "the documented public trade endpoint (GET /public/trades) answers 404", "Use the order book: perp market book <symbol> -e qfex");
|
|
1130
1183
|
}
|
|
1131
|
-
/**
|
|
1184
|
+
/**
|
|
1185
|
+
* Hourly funding windows (GET /funding/{symbol}, intervalMinutes 60). `limit` = hours back (default 24).
|
|
1186
|
+
* A window outside the market's funding hours (qfexFundingWindowClosed) is `rate: null,
|
|
1187
|
+
* marketClosed: true` — the venue prints 0 there, which is "no funding", not a 0 rate.
|
|
1188
|
+
*/
|
|
1132
1189
|
async getFundingHistory(symbol, limit) {
|
|
1133
|
-
const
|
|
1190
|
+
const instrument = await this.readableInstrument(symbol);
|
|
1191
|
+
const venueSymbol = instrument.venueSymbol;
|
|
1134
1192
|
const hours = limit === undefined ? 24 : limit;
|
|
1135
1193
|
if (!Number.isInteger(hours) || hours < 1)
|
|
1136
1194
|
throw new PerpError("INVALID_PARAMS", `funding history limit must be a positive integer of hours (got ${hours})`);
|
|
@@ -1145,16 +1203,20 @@ export class QfexAdapter {
|
|
|
1145
1203
|
const time = typeof r.windowStart === "string" ? Date.parse(r.windowStart) : NaN;
|
|
1146
1204
|
if (!Number.isFinite(time))
|
|
1147
1205
|
throw new PerpError("EXCHANGE_ERROR", `QFEX funding history: invalid windowStart ${JSON.stringify(r.windowStart)}`);
|
|
1148
|
-
|
|
1206
|
+
const rate = plainNumber(r.rate, "funding rate");
|
|
1207
|
+
const marketClosed = qfexFundingWindowClosed(instrument.marketHours, time, Number(rate));
|
|
1208
|
+
out.push({ time, rate: marketClosed ? null : rate, price: null, intervalHours: 1, rateKind: marketClosed ? null : "lastSettled", marketClosed });
|
|
1149
1209
|
}
|
|
1150
1210
|
return out.sort((a, b) => a.time - b.time);
|
|
1151
1211
|
}
|
|
1212
|
+
/** Rows carry `quoteAsset`: OHLC prices are in the market's quote currency (KRW for SAMSUNG-KRW). */
|
|
1152
1213
|
async getKlines(symbol, interval, startTime, endTime) {
|
|
1153
1214
|
const resolution = KLINE_RESOLUTION[interval.trim()];
|
|
1154
1215
|
if (resolution === undefined) {
|
|
1155
1216
|
throw new PerpError("INVALID_PARAMS", `Unsupported kline interval "${interval}" on QFEX`, { remediation: `Use one of: ${Object.keys(KLINE_RESOLUTION).join(", ")}` });
|
|
1156
1217
|
}
|
|
1157
|
-
const
|
|
1218
|
+
const instrument = await this.readableInstrument(symbol);
|
|
1219
|
+
const venueSymbol = instrument.venueSymbol;
|
|
1158
1220
|
const body = await this.client.publicGet(`/candles/${encodeURIComponent(venueSymbol)}`, {
|
|
1159
1221
|
resolution, fromISO: new Date(startTime).toISOString(), toISO: new Date(endTime).toISOString(),
|
|
1160
1222
|
});
|
|
@@ -1162,7 +1224,10 @@ export class QfexAdapter {
|
|
|
1162
1224
|
const time = Date.parse(c.startedAt);
|
|
1163
1225
|
if (!Number.isFinite(time))
|
|
1164
1226
|
throw new PerpError("EXCHANGE_ERROR", `QFEX candles: invalid startedAt "${c.startedAt}"`);
|
|
1165
|
-
|
|
1227
|
+
const v = qfexCandleVolumes(c);
|
|
1228
|
+
// `usdVolume` is Σ price × qty in the QUOTE currency (live 2026-10-19: SAMSUNG-KRW 1m candles
|
|
1229
|
+
// equal the KRW trade tape exactly); quoteVolume is USD → null for a non-USD quote.
|
|
1230
|
+
return { time, open: c.open, high: c.high, low: c.low, close: c.close, ...v, quoteVolume: instrument.quoteAsset === "USD" ? v.quoteVolume : null, trades: null, quoteAsset: instrument.quoteAsset };
|
|
1166
1231
|
}).sort((a, b) => a.time - b.time);
|
|
1167
1232
|
}
|
|
1168
1233
|
// ── Legacy ExchangeAdapter: account ──
|