diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index a60b52c..667089d 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -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> { + const coins = await hyperliquidCoins(); + const out = new Map(); + 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> { - const events = await this.marketData(account, symbols, { type: 'Quote', format: 'COMPACT' }); const out = new Map(); - 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(); - 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(); + 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(); + 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, diff --git a/lib/hyperliquid-feed.ts b/lib/hyperliquid-feed.ts new file mode 100644 index 0000000..c292321 --- /dev/null +++ b/lib/hyperliquid-feed.ts @@ -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(body: Record): Promise { + 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; at: number } }; + +/** Every coin on the dexes above ("BTC", "xyz:XYZ100", …), cached an hour. */ +export async function hyperliquidCoins(): Promise> { + if (g.__hlCoins && Date.now() - g.__hlCoins.at < META_TTL_MS) return g.__hlCoins.coins; + const coins = new Set(); + 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 | 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 { + 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); +} diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index c354a11..d933cdb 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -484,3 +484,25 @@ test('the selector drops what cannot trade right now', () => { assert.match(candidateRejection({ ...ok, candles: 10 }), /candles/); 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); +});