Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions packages/backtesting/src/exchange/memory-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@ import type {
IGetMarketPriceResponse,
ICancelLimitOrderRequest,
ICancelLimitOrderResponse,
IGetLimitOrderRequest,
IGetLimitOrderResponse,
IPlaceOrderRequest,
IPlaceOrderResponse,
IPlaceLimitOrderRequest,
IPlaceLimitOrderResponse,
IPlaceMarketOrderRequest,
IPlaceMarketOrderResponse,
IPlaceStopOrderRequest,
IPlaceStopOrderResponse,
IGetLimitOrderRequest,
IGetLimitOrderResponse,
IGetSymbolInfoRequest,
ISymbolInfo,
IWatchOrdersRequest,
IWatchOrdersResponse,
IPlaceStopOrderRequest,
IPlaceStopOrderResponse,
IWatchCandlesRequest,
IWatchCandlesResponse,
IPlaceMarketOrderRequest,
IPlaceMarketOrderResponse,
ITrade,
IOrderbook,
ITicker,
Expand Down Expand Up @@ -65,6 +67,13 @@ export class MemoryExchange implements IExchange {
};
}

async placeOrder(_body: IPlaceOrderRequest): Promise<IPlaceOrderResponse> {
return {
orderId: "",
clientOrderId: "",
};
}

async placeLimitOrder(_body: IPlaceLimitOrderRequest): Promise<IPlaceLimitOrderResponse> {
return {
orderId: "",
Expand Down Expand Up @@ -92,6 +101,21 @@ export class MemoryExchange implements IExchange {
};
}

async getTicker(symbol: string): Promise<ITicker> {
const candlestick = this.marketSimulator.currentCandle;
const assetPrice = candlestick.close;

return {
symbol,
bid: assetPrice,
ask: assetPrice,
last: assetPrice,
baseVolume: 0,
quoteVolume: 0,
timestamp: this.marketSimulator.currentCandle.timestamp,
};
}

async getMarketPrice(params: IGetMarketPriceRequest): Promise<IGetMarketPriceResponse> {
const candlestick = this.marketSimulator.currentCandle;
const assetPrice = candlestick.close;
Expand Down
20 changes: 18 additions & 2 deletions packages/exchanges/src/exchanges/ccxt/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import type {
IGetOpenOrdersRequest,
IGetOpenOrdersResponse,
IGetSymbolInfoRequest,
IPlaceOrderRequest,
IPlaceOrderResponse,
IPlaceMarketOrderRequest,
IPlaceMarketOrderResponse,
IPlaceLimitOrderRequest,
IPlaceLimitOrderResponse,
IPlaceStopOrderRequest,
Expand All @@ -39,8 +43,6 @@ import type {
IWatchCandlesResponse,
IWatchOrdersRequest,
IWatchOrdersResponse,
IPlaceMarketOrderRequest,
IPlaceMarketOrderResponse,
ExchangeCode,
IWatchTradesRequest,
IWatchTradesResponse,
Expand Down Expand Up @@ -107,6 +109,13 @@ export class CCXTExchange implements IExchange {
return normalize.getLimitOrder.response(data);
}

async placeOrder(params: IPlaceOrderRequest): Promise<IPlaceOrderResponse> {
const args = normalize.placeOrder.request(params);
const data = await this.ccxt.createOrder(...args);

return normalize.placeOrder.response(data);
}

async placeLimitOrder(params: IPlaceLimitOrderRequest): Promise<IPlaceLimitOrderResponse> {
if ("clientOrderId" in params) {
throw new Error("Fetch limit order by `clientOrderId` is not supported yet");
Expand Down Expand Up @@ -162,6 +171,13 @@ export class CCXTExchange implements IExchange {
};
}

async getTicker(symbol: string): Promise<ITicker> {
const args = normalize.getTicker.request(symbol);
const data = await this.ccxt.fetchTicker(...args);

return normalize.getTicker.response(data);
}

async getMarketPrice(params: IGetMarketPriceRequest): Promise<IGetMarketPriceResponse> {
const args = normalize.getMarketPrice.request(params);
const data = await this.ccxt.fetchTicker(...args);
Expand Down
43 changes: 42 additions & 1 deletion packages/exchanges/src/exchanges/ccxt/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type OrderType } from "ccxt";
import { composeSymbolIdFromPair, getExponentAbs } from "@opentrader/tools";
import { OrderSide } from "@opentrader/types";
import { OrderSide, XOrderType } from "@opentrader/types";
import type { Normalize } from "../../types/normalize.interface.js";
import { normalizeOrderStatus } from "../../utils/normalizeOrderStatus.js";

Expand Down Expand Up @@ -36,6 +37,24 @@ const getLimitOrder: Normalize["getLimitOrder"] = {
}),
};

const placeOrder: Normalize["placeOrder"] = {
request: (params) => {
const orderType: OrderType = params.type === XOrderType.Market ? "market" : "limit";

// Some exchanges require price for Market orders to calculate the total cost of the order in the quote currency.
// https://docs.ccxt.com/#/?id=market-buys
if (params.price !== undefined) {
return [params.symbol, orderType, params.side, params.quantity, params.price];
}

return [params.symbol, orderType, params.side, params.quantity];
},
response: (order) => ({
orderId: order.id,
clientOrderId: order.clientOrderId,
}),
};

const placeLimitOrder: Normalize["placeLimitOrder"] = {
request: (params) => [params.symbol, params.side, params.quantity, params.price],
response: (order) => ({
Expand Down Expand Up @@ -112,6 +131,26 @@ const getClosedOrders: Normalize["getClosedOrders"] = {
})),
};

const getTicker: Normalize["getTicker"] = {
request: (symbol) => [symbol],
response: (ticker) => ({
symbol: ticker.symbol!,
timestamp: ticker.timestamp!,

bid: ticker.bid!,
ask: ticker.ask!,
last: ticker.last!,

open: ticker.open,
high: ticker.high,
low: ticker.low,
close: ticker.close,

baseVolume: ticker.baseVolume!,
quoteVolume: ticker.quoteVolume!,
}),
};

const getMarketPrice: Normalize["getMarketPrice"] = {
request: (params) => [params.symbol],
response: (ticker) => ({
Expand Down Expand Up @@ -238,12 +277,14 @@ const watchTicker: Normalize["watchTicker"] = {
export const normalize: Normalize = {
accountAssets,
getLimitOrder,
placeOrder,
placeLimitOrder,
placeMarketOrder,
placeStopOrder,
cancelLimitOrder,
getOpenOrders,
getClosedOrders,
getTicker,
getMarketPrice,
getCandlesticks,
getSymbol,
Expand Down
13 changes: 13 additions & 0 deletions packages/exchanges/src/exchanges/ccxt/paper-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import {
OrderSide,
OrderStatus,
OrderType,
IPlaceOrderRequest,
IPlaceOrderResponse,
} from "@opentrader/types";
import { PaperOrder, xprisma } from "@opentrader/db";
import { CCXTExchange } from "./exchange.js";
Expand Down Expand Up @@ -206,6 +208,17 @@ export class PaperExchange extends CCXTExchange {
};
}

async placeOrder(params: IPlaceOrderRequest): Promise<IPlaceOrderResponse> {
if (params.type === OrderType.Market) {
const price = params.price;
if (price === undefined) throw new Error("PaperExchange: Limit orders require a price param");

return this.placeLimitOrder({ ...params, price });
} else {
return this.placeMarketOrder(params);
}
}

/**
* @override
*/
Expand Down
16 changes: 10 additions & 6 deletions packages/exchanges/src/types/exchange.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,24 @@ import {
ICancelLimitOrderResponse,
IGetLimitOrderRequest,
IGetLimitOrderResponse,
IPlaceLimitOrderRequest,
IPlaceLimitOrderResponse,
ISymbolInfo,
IGetSymbolInfoRequest,
IGetOpenOrdersRequest,
IGetOpenOrdersResponse,
IGetClosedOrdersRequest,
IGetClosedOrdersResponse,
IWatchOrdersRequest,
IWatchOrdersResponse,
IPlaceOrderRequest,
IPlaceOrderResponse,
IPlaceLimitOrderRequest,
IPlaceLimitOrderResponse,
IPlaceMarketOrderRequest,
IPlaceMarketOrderResponse,
IPlaceStopOrderRequest,
IPlaceStopOrderResponse,
IWatchOrdersRequest,
IWatchOrdersResponse,
ExchangeCode,
IWatchCandlesRequest,
IPlaceMarketOrderRequest,
IPlaceMarketOrderResponse,
IWatchTradesRequest,
IWatchTradesResponse,
IOrderbook,
Expand All @@ -43,12 +45,14 @@ export interface IExchange {

accountAssets: () => Promise<IAccountAsset[]>;
getLimitOrder: (body: IGetLimitOrderRequest) => Promise<IGetLimitOrderResponse>;
placeOrder: (body: IPlaceOrderRequest) => Promise<IPlaceOrderResponse>;
placeLimitOrder: (body: IPlaceLimitOrderRequest) => Promise<IPlaceLimitOrderResponse>;
placeMarketOrder: (boyd: IPlaceMarketOrderRequest) => Promise<IPlaceMarketOrderResponse>;
cancelLimitOrder: (body: ICancelLimitOrderRequest) => Promise<ICancelLimitOrderResponse>;
placeStopOrder: (body: IPlaceStopOrderRequest) => Promise<IPlaceStopOrderResponse>;
getOpenOrders: (body: IGetOpenOrdersRequest) => Promise<IGetOpenOrdersResponse>;
getClosedOrders: (body: IGetClosedOrdersRequest) => Promise<IGetClosedOrdersResponse>;
getTicker: (symbol: string) => Promise<ITicker>;
getMarketPrice: (params: IGetMarketPriceRequest) => Promise<IGetMarketPriceResponse>;
getCandlesticks: (params: IGetCandlesticksRequest) => Promise<ICandlestick[]>;
getSymbols: () => Promise<ISymbolInfo[]>;
Expand Down
16 changes: 14 additions & 2 deletions packages/exchanges/src/types/normalize.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ import type {
IGetMarketPriceRequest,
IGetMarketPriceResponse,
IGetSymbolInfoRequest,
IPlaceOrderRequest,
IPlaceOrderResponse,
IPlaceLimitOrderRequest,
IPlaceLimitOrderResponse,
IPlaceMarketOrderRequest,
IPlaceMarketOrderResponse,
ISymbolInfo,
IWatchOrdersRequest,
IWatchOrdersResponse,
IPlaceStopOrderRequest,
IPlaceStopOrderResponse,
IWatchCandlesRequest,
IWatchCandlesResponse,
IPlaceMarketOrderRequest,
IPlaceMarketOrderResponse,
ExchangeCode,
IWatchTradesRequest,
IWatchTradesResponse,
Expand All @@ -43,6 +45,11 @@ export type Normalize = {
response: (data: Order) => IGetLimitOrderResponse;
};

placeOrder: {
request: (params: IPlaceOrderRequest) => Parameters<Exchange["createOrder"]>;
response: (data: Order) => IPlaceOrderResponse;
};

placeLimitOrder: {
request: (params: IPlaceLimitOrderRequest) => Parameters<Exchange["createLimitOrder"]>;
response: (data: Order) => IPlaceLimitOrderResponse;
Expand Down Expand Up @@ -75,6 +82,11 @@ export type Normalize = {
response: (data: Order[]) => IGetClosedOrdersResponse;
};

getTicker: {
request: (symbol: string) => Parameters<Exchange["fetchTicker"]>;
response: (data: Ticker) => ITicker;
};

getMarketPrice: {
request: (params: IGetMarketPriceRequest) => Parameters<Exchange["fetchTicker"]>;
response: (data: Ticker) => IGetMarketPriceResponse;
Expand Down
40 changes: 37 additions & 3 deletions packages/processing/src/executors/order/order.executor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IExchange } from "@opentrader/exchanges";
import { XOrderStatus } from "@opentrader/types";
import { XOrderSide, XOrderStatus, XOrderType } from "@opentrader/types";
import type { Order } from "@prisma/client";
import { logger } from "@opentrader/logger";
import type { OrderEntity } from "@opentrader/db";
Expand Down Expand Up @@ -62,10 +62,44 @@ export class OrderExecutor {

return true;
} else if (this.order.type === "Market") {
const exchangeOrder = await this.exchange.placeMarketOrder({
// Some exchanges require price for Market orders.
// https://docs.ccxt.com/#/?id=market-buys
const marketBuyRequiresPrice: boolean | undefined =
this.exchange.ccxt.features?.spot?.createOrder?.marketBuyRequiresPrice;

/**
* Estimates the price for a market order based on the current ticker.
* Additionally, a slippage multiplier is applied to the best bid or ask price to
* guarantee the order fulfillment on the exchange.
*/
const estimateMarketOrderPrice = async () => {
const MAX_PRICE_SLIPPAGE = 0.01; // 1%

try {
const ticker = await this.exchange.getTicker(this.symbol);
const estimatedPrice =
this.order.side === XOrderSide.Buy
? ticker.ask + ticker.ask * MAX_PRICE_SLIPPAGE
: ticker.bid - ticker.bid * MAX_PRICE_SLIPPAGE;
logger.debug(
`${this.exchange.exchangeCode} requires a price for market orders. The estimated price for ${this.symbol} is ${estimatedPrice}.`,
);

return estimatedPrice;
} catch (err) {
logger.warn(err, `Failed to estimate market order price. Reason: Cannot retrieve ticker for ${this.symbol}.`);

return undefined;
}
};

const side = this.order.side === XOrderSide.Buy ? "buy" : "sell";
const exchangeOrder = await this.exchange.placeOrder({
type: XOrderType.Market,
symbol: this.symbol,
side: this.order.side === "Buy" ? "buy" : "sell",
side,
quantity: this.order.quantity,
price: marketBuyRequiresPrice && side === "buy" ? await estimateMarketOrderPrice() : undefined,
});

await xprisma.order.update({
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/exchange/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from "./public-data/get-symbols-info.js";
export * from "./trade/common/enums.js";
export * from "./trade/cancel-limit-order.js";
export * from "./trade/get-limit-order.js";
export * from "./trade/place-order.js";
export * from "./trade/place-limit-order.js";
export * from "./trade/place-market-order.js";
export * from "./trade/place-stop-order.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/exchange/trade/place-market-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { OrderSide } from "./common/enums.js";

export interface IPlaceMarketOrderRequest {
/**
* Instrument ID, e.g `ADA-USDT`.
* Instrument ID, e.g `BTC/USDT`.
*/
symbol: string;
/**
Expand Down
Loading
Loading