Skip to content

Commit

Permalink
Merge pull request #84 from bludnic/feat/exchange-place-order-method
Browse files Browse the repository at this point in the history
Specify price for Market buy orders when required by exchange
  • Loading branch information
bludnic authored Dec 14, 2024
2 parents 5c52a20 + 4a5a9e6 commit 4c8c37c
Show file tree
Hide file tree
Showing 10 changed files with 204 additions and 21 deletions.
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

0 comments on commit 4c8c37c

Please sign in to comment.