diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index da0aea0..25b8464 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -1,6 +1,13 @@ import type { ExchangeAdapter } from "../exchanges/adapter"; -import type { AsterOrder, CreateOrderParams } from "../exchanges/types"; -import { roundDownToTick, roundQtyDownToStep, formatPriceToString } from "../utils/math"; +import type { AsterOrder } from "../exchanges/types"; +import { + routeCloseOrder, + routeLimitOrder, + routeMarketOrder, + routeStopOrder, + routeTrailingStopOrder, +} from "../exchanges/order-router"; +import { roundDownToTick, roundQtyDownToStep } from "../utils/math"; import { isUnknownOrderError } from "../utils/errors"; import { isOrderPriceAllowedByMark } from "../utils/strategy"; @@ -146,25 +153,30 @@ export async function placeOrder( if (isOperating(locks, type)) return; const priceNum = Number(price); if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return; - const priceTick = opts?.priceTick ?? 0.1; const qtyStep = opts?.qtyStep ?? 0.001; - const params: CreateOrderParams = { - symbol, - side, - type, - quantity: roundQtyDownToStep(amount, qtyStep), - price: priceNum, // 直接使用字符串转换的数字,不再格式化 - timeInForce: "GTX", - }; - if (reduceOnly) params.reduceOnly = "true"; + const rawQuantity = Math.abs(amount); + const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep); + const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity; + if (quantity <= 0) { + log("error", "限价单数量无效,跳过下单"); + return; + } if (!opts?.skipDedupe) { await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); } lockOperating(locks, timers, pendings, type, log); try { - const order = await adapter.createOrder(params); + const order = await routeLimitOrder({ + adapter, + symbol, + side, + quantity, + price: priceNum, + timeInForce: "GTX", + reduceOnly: reduceOnly ? true : undefined, + }); pendings[type] = String(order.orderId); - log("order", `挂限价单: ${side} @ ${params.price} 数量 ${params.quantity} reduceOnly=${reduceOnly}`); + log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`); return order; } catch (err) { unlockOperating(locks, timers, pendings, type); @@ -194,19 +206,25 @@ export async function placeMarketOrder( if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return; const qtyStep = opts?.qtyStep ?? 0.001; - const params: CreateOrderParams = { - symbol, - side, - type, - quantity: roundQtyDownToStep(amount, qtyStep), - }; - if (reduceOnly) params.reduceOnly = "true"; + const rawQuantity = Math.abs(amount); + const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep); + const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity; + if (quantity <= 0) { + log("error", "市价单数量无效,跳过下单"); + return; + } await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); lockOperating(locks, timers, pendings, type, log); try { - const order = await adapter.createOrder(params); + const order = await routeMarketOrder({ + adapter, + symbol, + side, + quantity, + reduceOnly: reduceOnly ? true : undefined, + }); pendings[type] = String(order.orderId); - log("order", `市价单: ${side} 数量 ${params.quantity} reduceOnly=${reduceOnly}`); + log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`); return order; } catch (err) { unlockOperating(locks, timers, pendings, type); @@ -248,29 +266,32 @@ export async function placeStopLossOrder( } const priceTick = opts?.priceTick ?? 0.1; const qtyStep = opts?.qtyStep ?? 0.001; - - const params: CreateOrderParams = { - symbol, - side, - type, - quantity: roundQtyDownToStep(quantity, qtyStep), - stopPrice: roundDownToTick(stopPrice, priceTick), - // Always mark reduce-only semantics; some exchanges (e.g. Aster) ignore this on STOP - reduceOnly: "true", - // Some exchanges prefer explicit close-position semantics; gateways will normalize - closePosition: "true", - timeInForce: "GTC", - // GRVT requires triggerType to match side semantics: BUY -> TAKE_PROFIT, SELL -> STOP_LOSS - triggerType: side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS", - }; + const normalizedStop = roundDownToTick(stopPrice, priceTick); + const rawQuantity = Math.abs(quantity); + const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep); + const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity; + if (normalizedQty <= 0) { + log("error", "止损单数量无效,跳过下单"); + return; + } // Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); lockOperating(locks, timers, pendings, type, log); try { - const order = await adapter.createOrder(params); + const order = await routeStopOrder({ + adapter, + symbol, + side, + quantity: normalizedQty, + stopPrice: normalizedStop, + timeInForce: "GTC", + reduceOnly: true, + closePosition: true, + triggerType: side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS", + }); pendings[type] = String(order.orderId); - log("stop", `挂止损单: ${side} STOP_MARKET @ ${params.stopPrice}`); + log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`); return order; } catch (err) { unlockOperating(locks, timers, pendings, type); @@ -299,27 +320,38 @@ export async function placeTrailingStopOrder( ): Promise { const type = "TRAILING_STOP_MARKET"; if (isOperating(locks, type)) return; + if (!adapter.supportsTrailingStops()) { + log("error", "当前交易所不支持动态止盈单"); + return; + } if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return; const priceTick = opts?.priceTick ?? 0.1; const qtyStep = opts?.qtyStep ?? 0.001; - const params: CreateOrderParams = { - symbol, - side, - type, - quantity, - reduceOnly: "true", - activationPrice: roundDownToTick(activationPrice, priceTick), - callbackRate, - timeInForce: "GTC", - }; + const normalizedActivation = roundDownToTick(activationPrice, priceTick); + const rawQuantity = Math.abs(quantity); + const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep); + const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity; + if (normalizedQty <= 0) { + log("error", "动态止盈单数量无效,跳过下单"); + return; + } await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); lockOperating(locks, timers, pendings, type, log); try { - const order = await adapter.createOrder(params); + const order = await routeTrailingStopOrder({ + adapter, + symbol, + side, + quantity: normalizedQty, + activationPrice: normalizedActivation, + callbackRate, + timeInForce: "GTC", + reduceOnly: true, + }); pendings[type] = String(order.orderId); log( "order", - `挂动态止盈单: ${side} activation=${params.activationPrice} callbackRate=${callbackRate}` + `挂动态止盈单: ${side} activation=${normalizedActivation} callbackRate=${callbackRate}` ); return order; } catch (err) { @@ -349,18 +381,26 @@ export async function marketClose( if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return; - const params: CreateOrderParams = { - symbol, - side, - type, - quantity, - reduceOnly: "true", - }; - + const qtyStep = opts?.qtyStep; + const rawQuantity = Math.abs(quantity); + const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity; + const normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity; + if (normalizedQty <= 0) { + log("error", "市价平仓数量无效,跳过下单"); + return; + } + await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); lockOperating(locks, timers, pendings, type, log); try { - const order = await adapter.createOrder(params); + const order = await routeCloseOrder({ + adapter, + symbol, + side, + quantity: normalizedQty, + reduceOnly: true, + closePosition: true, + }); pendings[type] = String(order.orderId); log("close", `市价平仓: ${side}`); } catch (err) { diff --git a/src/exchanges/aster/order.ts b/src/exchanges/aster/order.ts new file mode 100644 index 0000000..fdad4e7 --- /dev/null +++ b/src/exchanges/aster/order.ts @@ -0,0 +1,101 @@ +import type { AsterOrder, CreateOrderParams } from "../types"; +import type { + BaseOrderIntent, + ClosePositionIntent, + LimitOrderIntent, + MarketOrderIntent, + StopOrderIntent, + TrailingStopOrderIntent, +} from "../order-schema"; +import { toStringBoolean } from "../order-schema"; + +function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams { + if (params.quantity === undefined) { + params.quantity = intent.quantity; + } + if (params.timeInForce === undefined && intent.timeInForce) { + params.timeInForce = intent.timeInForce; + } + if (intent.reduceOnly !== undefined) { + params.reduceOnly = toStringBoolean(intent.reduceOnly); + } + if (intent.closePosition !== undefined) { + params.closePosition = toStringBoolean(intent.closePosition); + } + return params; +} + +export async function createLimitOrder(intent: LimitOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "LIMIT", + quantity: intent.quantity, + price: intent.price, + timeInForce: intent.timeInForce ?? "GTX", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createMarketOrder(intent: MarketOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createStopOrder(intent: StopOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "STOP_MARKET", + quantity: intent.quantity, + stopPrice: intent.stopPrice, + timeInForce: intent.timeInForce ?? "GTC", + triggerType: intent.triggerType, + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createTrailingStopOrder(intent: TrailingStopOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "TRAILING_STOP_MARKET", + quantity: intent.quantity, + activationPrice: intent.activationPrice, + callbackRate: intent.callbackRate, + timeInForce: intent.timeInForce ?? "GTC", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createClosePositionOrder(intent: ClosePositionIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + reduceOnly: "true", + }, + intent + ); + return intent.adapter.createOrder(params); +} + diff --git a/src/exchanges/backpack/order.ts b/src/exchanges/backpack/order.ts new file mode 100644 index 0000000..0e31e8e --- /dev/null +++ b/src/exchanges/backpack/order.ts @@ -0,0 +1,88 @@ +import type { AsterOrder, CreateOrderParams } from "../types"; +import type { + BaseOrderIntent, + ClosePositionIntent, + LimitOrderIntent, + MarketOrderIntent, + StopOrderIntent, + TrailingStopOrderIntent, +} from "../order-schema"; +import { toStringBoolean } from "../order-schema"; + +function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams { + if (params.quantity === undefined) { + params.quantity = intent.quantity; + } + if (params.timeInForce === undefined && intent.timeInForce) { + params.timeInForce = intent.timeInForce; + } + if (intent.reduceOnly !== undefined) { + params.reduceOnly = toStringBoolean(intent.reduceOnly); + } + if (intent.closePosition !== undefined) { + params.closePosition = toStringBoolean(intent.closePosition); + } + return params; +} + +export async function createLimitOrder(intent: LimitOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "LIMIT", + quantity: intent.quantity, + price: intent.price, + timeInForce: intent.timeInForce ?? "GTX", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createMarketOrder(intent: MarketOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createStopOrder(intent: StopOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "STOP_MARKET", + quantity: intent.quantity, + stopPrice: intent.stopPrice, + timeInForce: intent.timeInForce ?? "GTC", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise { + throw new Error("Backpack exchange does not support trailing stop orders"); +} + +export async function createClosePositionOrder(intent: ClosePositionIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + reduceOnly: "true", + }, + intent + ); + return intent.adapter.createOrder(params); +} + diff --git a/src/exchanges/grvt/order.ts b/src/exchanges/grvt/order.ts new file mode 100644 index 0000000..ac875b3 --- /dev/null +++ b/src/exchanges/grvt/order.ts @@ -0,0 +1,92 @@ +import type { AsterOrder, CreateOrderParams } from "../types"; +import type { + BaseOrderIntent, + ClosePositionIntent, + LimitOrderIntent, + MarketOrderIntent, + StopOrderIntent, + TrailingStopOrderIntent, +} from "../order-schema"; +import { toStringBoolean } from "../order-schema"; + +function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams { + if (params.quantity === undefined) { + params.quantity = intent.quantity; + } + if (params.timeInForce === undefined && intent.timeInForce) { + params.timeInForce = intent.timeInForce; + } + if (intent.reduceOnly !== undefined) { + params.reduceOnly = toStringBoolean(intent.reduceOnly); + } + if (intent.closePosition !== undefined) { + params.closePosition = toStringBoolean(intent.closePosition); + } + return params; +} + +export async function createLimitOrder(intent: LimitOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "LIMIT", + quantity: intent.quantity, + price: intent.price, + timeInForce: intent.timeInForce ?? "GTX", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createMarketOrder(intent: MarketOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createStopOrder(intent: StopOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "STOP_MARKET", + quantity: intent.quantity, + stopPrice: intent.stopPrice, + timeInForce: intent.timeInForce ?? "GTC", + triggerType: intent.triggerType ?? (intent.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"), + closePosition: toStringBoolean(intent.closePosition ?? true), + reduceOnly: toStringBoolean(intent.reduceOnly ?? true), + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise { + throw new Error("GRVT exchange does not support trailing stop orders"); +} + +export async function createClosePositionOrder(intent: ClosePositionIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + reduceOnly: "true", + closePosition: toStringBoolean(intent.closePosition ?? true), + }, + intent + ); + return intent.adapter.createOrder(params); +} + diff --git a/src/exchanges/lighter/order.ts b/src/exchanges/lighter/order.ts new file mode 100644 index 0000000..899b157 --- /dev/null +++ b/src/exchanges/lighter/order.ts @@ -0,0 +1,93 @@ +import type { AsterOrder, CreateOrderParams } from "../types"; +import type { + BaseOrderIntent, + ClosePositionIntent, + LimitOrderIntent, + MarketOrderIntent, + StopOrderIntent, + TrailingStopOrderIntent, +} from "../order-schema"; +import { toStringBoolean } from "../order-schema"; + +function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams { + if (params.quantity === undefined) { + params.quantity = intent.quantity; + } + if (params.timeInForce === undefined && intent.timeInForce) { + params.timeInForce = intent.timeInForce; + } + if (intent.reduceOnly !== undefined) { + params.reduceOnly = toStringBoolean(intent.reduceOnly); + } + if (intent.closePosition !== undefined) { + params.closePosition = toStringBoolean(intent.closePosition); + } + return params; +} + +export async function createLimitOrder(intent: LimitOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "LIMIT", + quantity: intent.quantity, + price: intent.price, + timeInForce: intent.timeInForce ?? "GTC", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createMarketOrder(intent: MarketOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + timeInForce: intent.timeInForce ?? "IOC", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createStopOrder(intent: StopOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "STOP_MARKET", + quantity: intent.quantity, + stopPrice: intent.stopPrice, + timeInForce: intent.timeInForce ?? "GTC", + reduceOnly: toStringBoolean(intent.reduceOnly ?? true), + closePosition: toStringBoolean(intent.closePosition ?? true), + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise { + throw new Error("Lighter exchange does not support trailing stop orders"); +} + +export async function createClosePositionOrder(intent: ClosePositionIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + reduceOnly: "true", + closePosition: toStringBoolean(intent.closePosition ?? true), + timeInForce: intent.timeInForce ?? "IOC", + }, + intent + ); + return intent.adapter.createOrder(params); +} + diff --git a/src/exchanges/order-router.ts b/src/exchanges/order-router.ts new file mode 100644 index 0000000..ef3395d --- /dev/null +++ b/src/exchanges/order-router.ts @@ -0,0 +1,118 @@ +import type { ExchangeAdapter } from "./adapter"; +import type { AsterOrder } from "./types"; +import type { + BaseOrderIntent, + ClosePositionIntent, + LimitOrderIntent, + MarketOrderIntent, + StopOrderIntent, + TrailingStopOrderIntent, +} from "./order-schema"; +import * as asterOrders from "./aster/order"; +import * as backpackOrders from "./backpack/order"; +import * as grvtOrders from "./grvt/order"; +import * as lighterOrders from "./lighter/order"; +import * as paradexOrders from "./paradex/order"; + +type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex"; + +interface ExchangeOrderHandlers { + limit(intent: LimitOrderIntent): Promise; + market(intent: MarketOrderIntent): Promise; + stop(intent: StopOrderIntent): Promise; + trailingStop?: (intent: TrailingStopOrderIntent) => Promise; + close(intent: ClosePositionIntent): Promise; +} + +const handlerMap: Record = { + aster: { + limit: asterOrders.createLimitOrder, + market: asterOrders.createMarketOrder, + stop: asterOrders.createStopOrder, + trailingStop: asterOrders.createTrailingStopOrder, + close: asterOrders.createClosePositionOrder, + }, + backpack: { + limit: backpackOrders.createLimitOrder, + market: backpackOrders.createMarketOrder, + stop: backpackOrders.createStopOrder, + trailingStop: backpackOrders.createTrailingStopOrder, + close: backpackOrders.createClosePositionOrder, + }, + grvt: { + limit: grvtOrders.createLimitOrder, + market: grvtOrders.createMarketOrder, + stop: grvtOrders.createStopOrder, + trailingStop: grvtOrders.createTrailingStopOrder, + close: grvtOrders.createClosePositionOrder, + }, + lighter: { + limit: lighterOrders.createLimitOrder, + market: lighterOrders.createMarketOrder, + stop: lighterOrders.createStopOrder, + trailingStop: lighterOrders.createTrailingStopOrder, + close: lighterOrders.createClosePositionOrder, + }, + paradex: { + limit: paradexOrders.createLimitOrder, + market: paradexOrders.createMarketOrder, + stop: paradexOrders.createStopOrder, + trailingStop: paradexOrders.createTrailingStopOrder, + close: paradexOrders.createClosePositionOrder, + }, +}; + +const knownExchanges: ExchangeKey[] = ["aster", "backpack", "grvt", "lighter", "paradex"]; + +function normalizeExchangeId(value: string | undefined | null): string | undefined { + if (!value) return undefined; + return value.trim().toLowerCase(); +} + +function resolveExchangeKey(adapter: ExchangeAdapter): ExchangeKey { + const fromEnv = normalizeExchangeId(process.env.TRADE_EXCHANGE ?? process.env.EXCHANGE); + const candidates = [fromEnv, normalizeExchangeId(adapter.id)]; + for (const candidate of candidates) { + if (!candidate) continue; + if ((knownExchanges as string[]).includes(candidate)) { + return candidate as ExchangeKey; + } + } + throw new Error( + `Unsupported exchange for order routing: ${candidates.filter(Boolean).join(", ") || "unknown"}` + ); +} + +function getHandlers(intent: BaseOrderIntent): ExchangeOrderHandlers { + const exchangeKey = resolveExchangeKey(intent.adapter); + const handlers = handlerMap[exchangeKey]; + if (!handlers) { + throw new Error(`Order handlers not implemented for exchange: ${exchangeKey}`); + } + return handlers; +} + +export function routeLimitOrder(intent: LimitOrderIntent): Promise { + return getHandlers(intent).limit(intent); +} + +export function routeMarketOrder(intent: MarketOrderIntent): Promise { + return getHandlers(intent).market(intent); +} + +export function routeStopOrder(intent: StopOrderIntent): Promise { + return getHandlers(intent).stop(intent); +} + +export function routeTrailingStopOrder(intent: TrailingStopOrderIntent): Promise { + const handlers = getHandlers(intent); + if (!handlers.trailingStop) { + throw new Error("Trailing stop orders are not supported by the current exchange"); + } + return handlers.trailingStop(intent); +} + +export function routeCloseOrder(intent: ClosePositionIntent): Promise { + return getHandlers(intent).close(intent); +} + diff --git a/src/exchanges/order-schema.ts b/src/exchanges/order-schema.ts new file mode 100644 index 0000000..5712af4 --- /dev/null +++ b/src/exchanges/order-schema.ts @@ -0,0 +1,42 @@ +import type { ExchangeAdapter } from "./adapter"; +import type { OrderSide, TimeInForce } from "./types"; + +export interface BaseOrderIntent { + adapter: ExchangeAdapter; + symbol: string; + side: OrderSide; + quantity: number; + reduceOnly?: boolean; + closePosition?: boolean; + timeInForce?: TimeInForce | "GTX"; +} + +export interface LimitOrderIntent extends BaseOrderIntent { + price: number; +} + +export interface MarketOrderIntent extends BaseOrderIntent { + expectedPrice?: number | null; +} + +export interface StopOrderIntent extends BaseOrderIntent { + stopPrice: number; + triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS"; +} + +export interface TrailingStopOrderIntent extends BaseOrderIntent { + activationPrice: number; + callbackRate: number; +} + +export interface ClosePositionIntent extends BaseOrderIntent { + expectedPrice?: number | null; +} + +export type ExchangeOrderType = "limit" | "market" | "stop" | "trailingStop" | "close"; + +export function toStringBoolean(value: boolean | undefined): "true" | "false" | undefined { + if (value === undefined) return undefined; + return value ? "true" : "false"; +} + diff --git a/src/exchanges/paradex/gateway.ts b/src/exchanges/paradex/gateway.ts index b164bce..236bfd0 100644 --- a/src/exchanges/paradex/gateway.ts +++ b/src/exchanges/paradex/gateway.ts @@ -602,6 +602,9 @@ export class ParadexGateway { // Only omit amount for MARKET close-position orders; STOP requires explicit size const shouldOmitAmount = isClosePosition && type === "market"; const amountArg: any = shouldOmitAmount ? undefined : amount; + if (!shouldOmitAmount && amountArg != null && extraParams.size === undefined) { + extraParams.size = amountArg.toString(); + } const order = (await this.exchange.createOrder( symbol, type, diff --git a/src/exchanges/paradex/order.ts b/src/exchanges/paradex/order.ts new file mode 100644 index 0000000..351a23b --- /dev/null +++ b/src/exchanges/paradex/order.ts @@ -0,0 +1,93 @@ +import type { AsterOrder, CreateOrderParams } from "../types"; +import type { + BaseOrderIntent, + ClosePositionIntent, + LimitOrderIntent, + MarketOrderIntent, + StopOrderIntent, + TrailingStopOrderIntent, +} from "../order-schema"; +import { toStringBoolean } from "../order-schema"; + +function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams { + if (params.quantity === undefined) { + params.quantity = intent.quantity; + } + if (params.timeInForce === undefined && intent.timeInForce) { + params.timeInForce = intent.timeInForce; + } + if (intent.reduceOnly !== undefined) { + params.reduceOnly = toStringBoolean(intent.reduceOnly); + } + if (intent.closePosition !== undefined) { + params.closePosition = toStringBoolean(intent.closePosition); + } + return params; +} + +export async function createLimitOrder(intent: LimitOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "LIMIT", + quantity: intent.quantity, + price: intent.price, + timeInForce: intent.timeInForce ?? "GTC", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createMarketOrder(intent: MarketOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + timeInForce: intent.timeInForce, + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createStopOrder(intent: StopOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "STOP_MARKET", + quantity: intent.quantity, + stopPrice: intent.stopPrice, + price: intent.stopPrice, + timeInForce: intent.timeInForce ?? "GTC", + reduceOnly: toStringBoolean(intent.reduceOnly ?? true), + closePosition: toStringBoolean(intent.closePosition ?? true), + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise { + throw new Error("Paradex exchange does not support trailing stop orders"); +} + +export async function createClosePositionOrder(intent: ClosePositionIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + reduceOnly: "true", + closePosition: toStringBoolean(intent.closePosition ?? true), + timeInForce: intent.timeInForce, + }, + intent + ); + return intent.adapter.createOrder(params); +} diff --git a/tests/order-coordinator.test.ts b/tests/order-coordinator.test.ts index 4104b97..667d5d4 100644 --- a/tests/order-coordinator.test.ts +++ b/tests/order-coordinator.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterAll } from "vitest"; import type { ExchangeAdapter } from "../src/exchanges/adapter"; import type { AsterOrder } from "../src/exchanges/types"; import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator"; @@ -12,6 +12,9 @@ import { unlockOperating, } from "../src/core/order-coordinator"; +const originalTradeExchange = process.env.TRADE_EXCHANGE; +const originalExchange = process.env.EXCHANGE; + const baseOrder: AsterOrder = { orderId: 1, clientOrderId: "client", @@ -47,6 +50,16 @@ function createMockExchange(overrides: Partial = {}): ExchangeA } describe("order-coordinator", () => { + beforeEach(() => { + process.env.TRADE_EXCHANGE = "aster"; + process.env.EXCHANGE = undefined; + }); + + afterAll(() => { + process.env.TRADE_EXCHANGE = originalTradeExchange; + process.env.EXCHANGE = originalExchange; + }); + it("deduplicates orders by type and side", async () => { const adapter = createMockExchange(); const locks: OrderLockMap = {};