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,
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection,
} from './dxtrade-helpers';
import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed';
// ── timings ──────────────────────────────────────────────────────────────────
@@ -176,7 +177,10 @@ function parseOrder(raw: unknown): DxOrder | null {
instrument: String(o.instrument ?? leg.instrument ?? ''),
positionCode: typeof leg.positionCode === 'string' ? leg.positionCode : undefined,
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),
rejectReason: rejected ? String(rejected.rejectReason) : undefined,
};
@@ -1067,12 +1071,49 @@ export class DXTradeClient implements PlatformClient {
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 }>> {
const events = await this.marketData(account, symbols, { type: 'Quote', format: 'COMPACT' });
const out = new Map<string, { bid?: number; ask?: number; time: number }>();
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) });
if (!this.dxMarketDataDenied) {
try {
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;
}
@@ -1081,20 +1122,36 @@ export class DXTradeClient implements PlatformClient {
const now = Date.now();
const missing = symbols.filter((s) => !(now - (this.candleCache.get(s)?.at ?? 0) < CANDLE_TTL_MS));
if (missing.length > 0) {
const events = await this.marketData(account, missing, {
type: 'Candle', format: 'COMPACT', candleType: '1h',
fromTime: new Date(now - (CANDLE_HOURS + 1) * 3_600_000).toISOString(),
toTime: new Date(now).toISOString(),
});
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 }]);
const closes = new Map<string, number[]>();
let fromDx = false;
if (!this.dxMarketDataDenied) {
try {
const events = await this.marketData(account, missing, {
type: 'Candle', format: 'COMPACT', candleType: '1h',
fromTime: new Date(now - (CANDLE_HOURS + 1) * 3_600_000).toISOString(),
toTime: new Date(now).toISOString(),
});
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) {
const candles = (bySymbol.get(s) ?? []).sort((a, b) => a.time - b.time);
this.candleCache.set(s, { vol: realizedVolPerHour(candles.map((c) => c.close)), candles: candles.length, at: now });
const list = closes.get(s) ?? [];
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 }]));
@@ -1286,7 +1343,11 @@ export class DXTradeClient implements PlatformClient {
orderCode: o.code,
type: o.type,
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,
...(o.positionCode ? { positionCode: o.positionCode } : {}),
side: o.side,