DXtrade: Hyperliquid prices when /marketdata is denied; closing stop/limit without quantity

Tradeify247's API user may not read DXtrade market data (code 34), which
skipped every batch at the instrument pick. Quotes and hourly candles then
come from Hyperliquid's public API (BTC, ETH on the main dex; XYZ100, SP500
and tokenized stocks on trade[XYZ]) — for the pick and sizing only; orders,
fills and exit prices stay DXtrade's.

Closing STOP/LIMIT orders close the whole position by positionCode and must
not carry a quantity (code 33) — the exit bracket was refused, and the
daily-loss guard flattened both accounts. A close's fill price also falls
back to its executions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 17:35:23 -05:00
co-authored by Claude Opus 5.5
parent d01f03b293
commit 9fc1ed6908
3 changed files with 190 additions and 18 deletions
+79 -18
View File
@@ -46,6 +46,7 @@ import {
lastUtcReset, ctTradeDayStart, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode, lastUtcReset, ctTradeDayStart, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection,
} from './dxtrade-helpers'; } from './dxtrade-helpers';
import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed';
// ── timings ────────────────────────────────────────────────────────────────── // ── timings ──────────────────────────────────────────────────────────────────
@@ -176,7 +177,10 @@ function parseOrder(raw: unknown): DxOrder | null {
instrument: String(o.instrument ?? leg.instrument ?? ''), instrument: String(o.instrument ?? leg.instrument ?? ''),
positionCode: typeof leg.positionCode === 'string' ? leg.positionCode : undefined, positionCode: typeof leg.positionCode === 'string' ? leg.positionCode : undefined,
filledQuantity: ciNum(leg, 'filledQuantity') ?? 0, filledQuantity: ciNum(leg, 'filledQuantity') ?? 0,
averagePrice: ciNum(leg, 'averagePrice'), // The leg's average, else the last execution's (a filled close may
// carry it only there).
averagePrice: ciNum(leg, 'averagePrice')
?? [...executions].reverse().map((e) => ciNum(e, 'averagePrice')).find((p) => p !== undefined && p > 0),
issueTime: timeOf(o.issueTime), issueTime: timeOf(o.issueTime),
rejectReason: rejected ? String(rejected.rejectReason) : undefined, rejectReason: rejected ? String(rejected.rejectReason) : undefined,
}; };
@@ -1067,12 +1071,49 @@ export class DXTradeClient implements PlatformClient {
return data?.events ?? []; return data?.events ?? [];
} }
/** Whether DXtrade's own /marketdata is off limits to this login (it
* answers code 34 "No proper permission is configured"), in which case
* quotes and candles come from Hyperliquid instead (hyperliquid-feed). */
private dxMarketDataDenied = false;
private isPermissionDenied(err: unknown): boolean {
return err instanceof DxApiError && (err.code === 34 || err.status === 403);
}
private noteMarketDataDenied(err: unknown): void {
if (this.dxMarketDataDenied) return;
this.dxMarketDataDenied = true;
console.warn(`${this.tag} DXtrade market data not permitted (${errorMessage(err)}) — using Hyperliquid's public prices for the instrument pick`);
}
/** DXtrade symbol → Hyperliquid coin for the symbols given (unmapped left out). */
private async hyperliquidCoins(symbols: string[]): Promise<Map<string, string>> {
const coins = await hyperliquidCoins();
const out = new Map<string, string>();
for (const s of symbols) {
const coin = hyperliquidCoinFor(s, coins);
if (coin) out.set(s, coin);
}
return out;
}
private async quotes(account: string, symbols: string[]): Promise<Map<string, { bid?: number; ask?: number; time: number }>> { private async quotes(account: string, symbols: string[]): Promise<Map<string, { bid?: number; ask?: number; time: number }>> {
const events = await this.marketData(account, symbols, { type: 'Quote', format: 'COMPACT' });
const out = new Map<string, { bid?: number; ask?: number; time: number }>(); const out = new Map<string, { bid?: number; ask?: number; time: number }>();
for (const e of events) { if (!this.dxMarketDataDenied) {
if (typeof e.symbol !== 'string') continue; try {
out.set(e.symbol, { bid: ciNum(e, 'bid'), ask: ciNum(e, 'ask'), time: timeOf(e.time ?? e.timestamp) }); const events = await this.marketData(account, symbols, { type: 'Quote', format: 'COMPACT' });
for (const e of events) {
if (typeof e.symbol !== 'string') continue;
out.set(e.symbol, { bid: ciNum(e, 'bid'), ask: ciNum(e, 'ask'), time: timeOf(e.time ?? e.timestamp) });
}
return out;
} catch (err) {
if (!this.isPermissionDenied(err)) throw err;
this.noteMarketDataDenied(err);
}
}
for (const [symbol, coin] of await this.hyperliquidCoins(symbols)) {
out.set(symbol, await hyperliquidQuote(coin));
} }
return out; return out;
} }
@@ -1081,20 +1122,36 @@ export class DXTradeClient implements PlatformClient {
const now = Date.now(); const now = Date.now();
const missing = symbols.filter((s) => !(now - (this.candleCache.get(s)?.at ?? 0) < CANDLE_TTL_MS)); const missing = symbols.filter((s) => !(now - (this.candleCache.get(s)?.at ?? 0) < CANDLE_TTL_MS));
if (missing.length > 0) { if (missing.length > 0) {
const events = await this.marketData(account, missing, { const closes = new Map<string, number[]>();
type: 'Candle', format: 'COMPACT', candleType: '1h', let fromDx = false;
fromTime: new Date(now - (CANDLE_HOURS + 1) * 3_600_000).toISOString(), if (!this.dxMarketDataDenied) {
toTime: new Date(now).toISOString(), try {
}); const events = await this.marketData(account, missing, {
const bySymbol = new Map<string, { time: number; close: number }[]>(); type: 'Candle', format: 'COMPACT', candleType: '1h',
for (const e of events) { fromTime: new Date(now - (CANDLE_HOURS + 1) * 3_600_000).toISOString(),
const close = ciNum(e, 'close'); toTime: new Date(now).toISOString(),
if (typeof e.symbol !== 'string' || close === undefined) continue; });
bySymbol.set(e.symbol, [...(bySymbol.get(e.symbol) ?? []), { time: timeOf(e.time ?? e.timestamp), close }]); const bySymbol = new Map<string, { time: number; close: number }[]>();
for (const e of events) {
const close = ciNum(e, 'close');
if (typeof e.symbol !== 'string' || close === undefined) continue;
bySymbol.set(e.symbol, [...(bySymbol.get(e.symbol) ?? []), { time: timeOf(e.time ?? e.timestamp), close }]);
}
for (const [s, list] of bySymbol) closes.set(s, list.sort((a, b) => a.time - b.time).map((c) => c.close));
fromDx = true;
} catch (err) {
if (!this.isPermissionDenied(err)) throw err;
this.noteMarketDataDenied(err);
}
}
if (!fromDx) {
for (const [symbol, coin] of await this.hyperliquidCoins(missing)) {
closes.set(symbol, await hyperliquidHourlyCloses(coin, CANDLE_HOURS));
}
} }
for (const s of missing) { for (const s of missing) {
const candles = (bySymbol.get(s) ?? []).sort((a, b) => a.time - b.time); const list = closes.get(s) ?? [];
this.candleCache.set(s, { vol: realizedVolPerHour(candles.map((c) => c.close)), candles: candles.length, at: now }); this.candleCache.set(s, { vol: realizedVolPerHour(list), candles: list.length, at: now });
} }
} }
return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }])); return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }]));
@@ -1286,7 +1343,11 @@ export class DXTradeClient implements PlatformClient {
orderCode: o.code, orderCode: o.code,
type: o.type, type: o.type,
instrument: symbol, instrument: symbol,
quantity: o.quantity, // A closing STOP or LIMIT closes the whole position by its code and
// must not name a quantity (DXtrade: "Closing STOP/LIMIT orders
// should not have specified quantity", code 33). A MARKET close
// may, and the flatten path relies on it.
...(o.positionEffect === 'CLOSE' && o.type !== 'MARKET' ? {} : { quantity: o.quantity }),
positionEffect: o.positionEffect, positionEffect: o.positionEffect,
...(o.positionCode ? { positionCode: o.positionCode } : {}), ...(o.positionCode ? { positionCode: o.positionCode } : {}),
side: o.side, side: o.side,
+89
View File
@@ -0,0 +1,89 @@
/**
* Public Hyperliquid market data — the price source for DXtrade firms whose
* API user isn't allowed DXtrade's own /marketdata (Tradeify247 answers
* "No proper permission is configured", code 34). Tradeify247's instruments
* track Hyperliquid's: crypto on the main dex (BTC, ETH), indices and
* tokenized stocks on the trade[XYZ] HIP-3 dex (xyz:XYZ100, xyz:SP500…).
*
* Only the instrument pick uses it — spread for the cost score, hourly closes
* for volatility, the mid for sizing. Orders, fills, balances and every
* take-profit/stop price still come from DXtrade and its own fill prices.
*
* No key, no account: POST https://api.hyperliquid.xyz/info.
*/
import axios from 'axios';
const INFO_URL = 'https://api.hyperliquid.xyz/info';
const TIMEOUT_MS = 10_000;
const META_TTL_MS = 60 * 60_000;
/** Dexes whose coins a DXtrade symbol may map to, main dex first. */
const DEXES = ['', 'xyz'];
async function info<T>(body: Record<string, unknown>): Promise<T> {
const res = await axios.post(INFO_URL, body, {
timeout: TIMEOUT_MS,
signal: AbortSignal.timeout(TIMEOUT_MS + 5_000),
headers: { 'Content-Type': 'application/json' },
});
return res.data as T;
}
const g = globalThis as typeof globalThis & { __hlCoins?: { coins: Set<string>; at: number } };
/** Every coin on the dexes above ("BTC", "xyz:XYZ100", …), cached an hour. */
export async function hyperliquidCoins(): Promise<Set<string>> {
if (g.__hlCoins && Date.now() - g.__hlCoins.at < META_TTL_MS) return g.__hlCoins.coins;
const coins = new Set<string>();
for (const dex of DEXES) {
const meta = await info<{ universe?: { name?: unknown; isDelisted?: unknown }[] }>(dex ? { type: 'meta', dex } : { type: 'meta' });
for (const u of meta?.universe ?? []) {
if (typeof u.name === 'string' && u.isDelisted !== true) coins.add(u.name);
}
}
g.__hlCoins = { coins, at: Date.now() };
return coins;
}
/**
* The Hyperliquid coin for a DXtrade symbol, or null. "BTC/USD" → "BTC" on
* the main dex; "XYZ100" or "AAPL/USD" → "xyz:XYZ100" / "xyz:AAPL" on the
* trade[XYZ] dex when the main dex doesn't list it.
*/
export function hyperliquidCoinFor(symbol: string, coins: Set<string>): string | null {
const base = symbol.toUpperCase().replace(/[/_-]?(USDT|USDC|USD)$/, '');
if (!base) return null;
for (const candidate of [base, `xyz:${base}`]) {
if (coins.has(candidate)) return candidate;
}
return null;
}
/** Best bid/ask of an l2Book answer; undefined sides when the book is empty. */
export function parseL2Book(book: unknown): { bid?: number; ask?: number; time: number } {
const b = book as { levels?: { px?: unknown }[][]; time?: unknown };
const px = (side: number) => {
const v = Number(b?.levels?.[side]?.[0]?.px);
return Number.isFinite(v) && v > 0 ? v : undefined;
};
const time = Number(b?.time);
return { bid: px(0), ask: px(1), time: Number.isFinite(time) ? time : NaN };
}
export async function hyperliquidQuote(coin: string): Promise<{ bid?: number; ask?: number; time: number }> {
return parseL2Book(await info({ type: 'l2Book', coin }));
}
/** Hourly closes, oldest first, over the last `hours`. */
export async function hyperliquidHourlyCloses(coin: string, hours: number): Promise<number[]> {
const endTime = Date.now();
const candles = await info<{ t?: unknown; c?: unknown }[]>({
type: 'candleSnapshot',
req: { coin, interval: '1h', startTime: endTime - (hours + 1) * 3_600_000, endTime },
});
return (Array.isArray(candles) ? candles : [])
.map((c) => ({ t: Number(c.t), c: Number(c.c) }))
.filter((c) => Number.isFinite(c.t) && Number.isFinite(c.c) && c.c > 0)
.sort((a, b) => a.t - b.t)
.map((c) => c.c);
}
+22
View File
@@ -484,3 +484,25 @@ test('the selector drops what cannot trade right now', () => {
assert.match(candidateRejection({ ...ok, candles: 10 }), /candles/); assert.match(candidateRejection({ ...ok, candles: 10 }), /candles/);
assert.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/); assert.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/);
}); });
// ── Hyperliquid price fallback (lib/hyperliquid-feed.ts) ─────────────────────
import { hyperliquidCoinFor, parseL2Book } from '../lib/hyperliquid-feed.ts';
test('DXtrade symbols map to Hyperliquid coins', () => {
const coins = new Set(['BTC', 'ETH', 'xyz:XYZ100', 'xyz:SP500', 'xyz:AAPL']);
assert.equal(hyperliquidCoinFor('BTC/USD', coins), 'BTC');
assert.equal(hyperliquidCoinFor('ETH/USD', coins), 'ETH');
assert.equal(hyperliquidCoinFor('XYZ100', coins), 'xyz:XYZ100');
assert.equal(hyperliquidCoinFor('SP500', coins), 'xyz:SP500');
assert.equal(hyperliquidCoinFor('AAPL/USD', coins), 'xyz:AAPL');
assert.equal(hyperliquidCoinFor('DOGE/USD', coins), null);
});
test('l2Book best bid/ask', () => {
const q = parseL2Book({ levels: [[{ px: '84310.0' }, { px: '84309' }], [{ px: '84311.0' }]], time: 1790461870878 });
assert.deepEqual(q, { bid: 84310, ask: 84311, time: 1790461870878 });
const empty = parseL2Book({ levels: [[], []] });
assert.equal(empty.bid, undefined);
assert.equal(empty.ask, undefined);
});