Skip to content

Commit d359ee5

Browse files
replace
1 parent 327d41b commit d359ee5

8 files changed

Lines changed: 295 additions & 2 deletions

File tree

src/apps/cb/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Notes:
131131
- `cb order get <order_id>`
132132
- `cb order list [product]`
133133
- `cb order cancel <order_id>`
134+
- `cb order replace <order_id>` (re-places a cancelled sell order with the same prices when enough base asset is available)
134135
- `cb order modify <order_id> [--baseSize <baseSize>] [--limitPrice <limitPrice>] [--stopPrice <stopPrice>] [--takeProfitPrice <takeProfitPrice>]` (supports limit, stop-limit, bracket, and TP/SL orders)
135136
- `cb order breakeven <order_id> --buyPrice <buyPrice> [--limitPrice <limitPrice>]`
136137

src/apps/cb/commands/order-handlers.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { cancelOrder, getOpenOrders, getOrder } from "../../../shared/coinbase/i
22
import { logger, printOrder } from "../../../shared/log/index.js";
33
import type { ProductId } from "../../../shared/schemas/shared-primitives.js";
44
import type { BreakEvenStopOptions, ModifyOptions } from "./schemas/command-options.js";
5-
import { placeBreakEvenStopOrder, placeModifyOrder } from "../service/order-service.js";
5+
import {
6+
placeBreakEvenStopOrder,
7+
placeModifyOrder,
8+
replaceCancelledSellOrder,
9+
} from "../service/order-service.js";
610

711
export async function handleOrderAction(orderId: string): Promise<void> {
812
const order = await getOrder(orderId);
@@ -36,3 +40,7 @@ export async function handleBreakEvenStopAction(
3640
): Promise<void> {
3741
await placeBreakEvenStopOrder(orderId, options);
3842
}
43+
44+
export async function handleReplaceAction(orderId: string): Promise<void> {
45+
await replaceCancelledSellOrder(orderId);
46+
}

src/apps/cb/commands/register/register-orders.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
handleModifyAction,
66
handleOrderAction,
77
handleOrdersAction,
8+
handleReplaceAction,
89
} from "../order-handlers.js";
910
import { BreakEvenStopOptionsSchema, ModifyOptionsSchema } from "../schemas/command-options.js";
1011
import {
@@ -34,6 +35,11 @@ export function registerOrderCommands(program: Command) {
3435
.description("Cancel an open order by order ID")
3536
.action(withAction("order cancel", parseArg(OrderIdSchema), handleCancelAction));
3637

38+
order
39+
.command("replace <order_id>")
40+
.description("Re-place a cancelled sell order with the same prices when funds are available")
41+
.action(withAction("order replace", parseArg(OrderIdSchema), handleReplaceAction));
42+
3743
order
3844
.command("modify <order_id>")
3945
.description("Modify an existing limit/stop-limit/bracket/TP-SL order")

src/apps/cb/service/order-builders.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,15 @@ export type AttachedTpSlValues = {
233233
stopPrice: string;
234234
};
235235

236+
export type ReplaceableSellOrderValues = {
237+
orderType: CoinbaseOrder["order_type"];
238+
productId: string;
239+
baseSize: string;
240+
limitPrice: string;
241+
stopPrice?: string;
242+
postOnly?: boolean;
243+
};
244+
236245
export function getModifiableOrderValues(order: CoinbaseOrder): ModifiableOrderValues {
237246
switch (order.order_type) {
238247
case ORDER_TYPES.LIMIT: {
@@ -278,6 +287,51 @@ export function getAttachedTpSlValues(order: CoinbaseOrder): AttachedTpSlValues
278287
};
279288
}
280289

290+
export function getReplaceableSellOrderValues(order: CoinbaseOrder): ReplaceableSellOrderValues {
291+
if (order.status !== "CANCELLED") {
292+
throw new Error(`Order ${order.order_id} must be CANCELLED before it can be replaced.`);
293+
}
294+
if (order.side !== ORDER_SIDE.SELL) {
295+
throw new Error(`Order ${order.order_id} must be a SELL order before it can be replaced.`);
296+
}
297+
298+
switch (order.order_type) {
299+
case ORDER_TYPES.LIMIT: {
300+
const config = order.order_configuration.limit_limit_gtc;
301+
return {
302+
orderType: order.order_type,
303+
productId: order.product_id,
304+
baseSize: config.base_size,
305+
limitPrice: config.limit_price,
306+
postOnly: config.post_only,
307+
};
308+
}
309+
case ORDER_TYPES.STOP_LIMIT: {
310+
const config = order.order_configuration.stop_limit_stop_limit_gtc;
311+
return {
312+
orderType: order.order_type,
313+
productId: order.product_id,
314+
baseSize: config.base_size,
315+
limitPrice: config.limit_price,
316+
stopPrice: config.stop_price,
317+
};
318+
}
319+
case ORDER_TYPES.BRACKET:
320+
case ORDER_TYPES.TAKE_PROFIT_STOP_LOSS: {
321+
const config = order.order_configuration.trigger_bracket_gtc;
322+
return {
323+
orderType: order.order_type,
324+
productId: order.product_id,
325+
baseSize: config.base_size,
326+
limitPrice: config.limit_price,
327+
stopPrice: config.stop_trigger_price,
328+
};
329+
}
330+
case ORDER_TYPES.MARKET:
331+
throw new Error("Only priced sell orders can be replaced.");
332+
}
333+
}
334+
281335
export function buildModifyOrderValues(
282336
options: ModifyOptions,
283337
existing: ModifiableOrderValues,

src/apps/cb/service/order-service.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getTransactionSummary,
2020
ORDER_SIDE,
2121
ORDER_TYPES,
22+
requestAccounts,
2223
requestBestBidAsk,
2324
} from "../../../shared/coinbase/index.js";
2425
import type { EditOrderRequest } from "../../../shared/coinbase/schemas/coinbase-rest-schemas.js";
@@ -32,9 +33,18 @@ import {
3233
buildStopLimitOrderValues,
3334
getAttachedTpSlValues,
3435
getModifiableOrderValues,
36+
getReplaceableSellOrderValues,
3537
} from "./order-builders.js";
3638
import { confirmOrder, confirmOrderChange } from "./order-prompts.js";
3739

40+
function getBaseCurrency(productId: string): string {
41+
const [baseCurrency] = productId.split("-");
42+
if (!baseCurrency) {
43+
throw new Error(`Could not determine base currency for product ${productId}.`);
44+
}
45+
return baseCurrency;
46+
}
47+
3848
/**
3949
* Places a market order after validation and confirmation.
4050
* @param {string} productId
@@ -325,3 +335,84 @@ export async function placeBreakEvenStopOrder(
325335
stop_price: stopPrice,
326336
});
327337
}
338+
339+
export async function replaceCancelledSellOrder(orderId: string): Promise<void> {
340+
const order = await getOrder(orderId);
341+
const values = getReplaceableSellOrderValues(order);
342+
const accounts = await requestAccounts();
343+
const baseCurrency = getBaseCurrency(values.productId);
344+
const baseAccount = accounts.find((account) => account.currency === baseCurrency);
345+
346+
if (!baseAccount) {
347+
throw new Error(`Could not find ${baseCurrency} account for ${values.productId}.`);
348+
}
349+
350+
const available = parseFloat(baseAccount.available_balance.value);
351+
const required = parseFloat(values.baseSize);
352+
if (!Number.isFinite(available)) {
353+
throw new Error(`Invalid available balance for ${baseCurrency} account.`);
354+
}
355+
if (!Number.isFinite(required) || required <= 0) {
356+
throw new Error(`Invalid base size on order ${orderId}.`);
357+
}
358+
if (available < required) {
359+
throw new Error(
360+
`Insufficient available ${baseCurrency} balance to replace order ${orderId}: `
361+
+ `need ${values.baseSize}, have ${baseAccount.available_balance.value}.`,
362+
);
363+
}
364+
365+
const confirmationPrice = values.stopPrice
366+
? `${values.limitPrice}/${values.stopPrice}`
367+
: values.limitPrice;
368+
const confirmationValue = values.stopPrice
369+
? `${(required * parseFloat(values.limitPrice)).toFixed(2)}/${(required * parseFloat(values.stopPrice)).toFixed(2)}`
370+
: (required * parseFloat(values.limitPrice)).toFixed(2);
371+
372+
if (
373+
!confirmOrder(
374+
values.orderType,
375+
ORDER_SIDE.SELL,
376+
values.productId,
377+
values.baseSize,
378+
confirmationPrice,
379+
confirmationValue,
380+
)
381+
) {
382+
console.log("Action canceled.");
383+
return;
384+
}
385+
386+
switch (values.orderType) {
387+
case ORDER_TYPES.LIMIT:
388+
await createLimitOrder(
389+
values.productId,
390+
ORDER_SIDE.SELL,
391+
values.baseSize,
392+
values.limitPrice,
393+
values.postOnly ?? true,
394+
);
395+
return;
396+
case ORDER_TYPES.STOP_LIMIT:
397+
await createStopLimitOrder(
398+
values.productId,
399+
ORDER_SIDE.SELL,
400+
values.baseSize,
401+
values.limitPrice,
402+
values.stopPrice!,
403+
);
404+
return;
405+
case ORDER_TYPES.BRACKET:
406+
case ORDER_TYPES.TAKE_PROFIT_STOP_LOSS:
407+
await createBracketOrder(
408+
values.productId,
409+
ORDER_SIDE.SELL,
410+
values.baseSize,
411+
values.limitPrice,
412+
values.stopPrice!,
413+
);
414+
return;
415+
case ORDER_TYPES.MARKET:
416+
throw new Error("Only priced sell orders can be replaced.");
417+
}
418+
}

test/src/apps/cb/commands/order-handlers.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const {
88
getOrderMock,
99
cancelOrderMock,
1010
placeModifyOrderMock,
11+
replaceCancelledSellOrderMock,
1112
printOrderMock,
1213
} = vi.hoisted(() => ({
1314
placeBreakEvenStopOrderMock: vi.fn(() => Promise.resolve(undefined)),
@@ -16,6 +17,7 @@ const {
1617
getOrderMock: vi.fn(() => Promise.resolve({ order_id: "order-1" })),
1718
cancelOrderMock: vi.fn(() => Promise.resolve(true)),
1819
placeModifyOrderMock: vi.fn(() => Promise.resolve(undefined)),
20+
replaceCancelledSellOrderMock: vi.fn(() => Promise.resolve(undefined)),
1921
printOrderMock: vi.fn(),
2022
}));
2123

@@ -38,6 +40,7 @@ vi.mock("../../../../../src/shared/log/orders.js", () => ({
3840
vi.mock("../../../../../src/apps/cb/service/order-service.js", () => ({
3941
placeBreakEvenStopOrder: placeBreakEvenStopOrderMock,
4042
placeModifyOrder: placeModifyOrderMock,
43+
replaceCancelledSellOrder: replaceCancelledSellOrderMock,
4144
}));
4245

4346
import {
@@ -46,6 +49,7 @@ import {
4649
handleModifyAction,
4750
handleOrderAction,
4851
handleOrdersAction,
52+
handleReplaceAction,
4953
} from "../../../../../src/apps/cb/commands/order-handlers.js";
5054

5155
describe("orders command handlers", () => {
@@ -115,4 +119,10 @@ describe("orders command handlers", () => {
115119
limitPrice: "101.50",
116120
});
117121
});
122+
123+
it("delegates replace action to service", async () => {
124+
await handleReplaceAction(orderId);
125+
126+
expect(replaceCancelledSellOrderMock).toHaveBeenCalledWith(orderId);
127+
});
118128
});

test/src/apps/cb/commands/register/orders-topology.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ const {
88
handleModifyActionMock,
99
handleOrderActionMock,
1010
handleOrdersActionMock,
11+
handleReplaceActionMock,
1112
} = vi.hoisted(() => ({
1213
handleBreakEvenStopActionMock: vi.fn(() => Promise.resolve(undefined)),
1314
handleCancelActionMock: vi.fn(() => Promise.resolve(undefined)),
1415
handleModifyActionMock: vi.fn(() => Promise.resolve(undefined)),
1516
handleOrderActionMock: vi.fn(() => Promise.resolve(undefined)),
1617
handleOrdersActionMock: vi.fn(() => Promise.resolve(undefined)),
18+
handleReplaceActionMock: vi.fn(() => Promise.resolve(undefined)),
1719
}));
1820

1921
vi.mock("../../../../../../src/apps/cb/commands/order-handlers.js", () => ({
@@ -22,6 +24,7 @@ vi.mock("../../../../../../src/apps/cb/commands/order-handlers.js", () => ({
2224
handleModifyAction: handleModifyActionMock,
2325
handleOrderAction: handleOrderActionMock,
2426
handleOrdersAction: handleOrdersActionMock,
27+
handleReplaceAction: handleReplaceActionMock,
2528
}));
2629

2730
import { registerOrderCommands } from "../../../../../../src/apps/cb/commands/register/register-orders.js";
@@ -40,17 +43,19 @@ describe("order command topology", () => {
4043
vi.clearAllMocks();
4144
});
4245

43-
it("supports nested order get/list/cancel/modify commands", async () => {
46+
it("supports nested order get/list/cancel/replace/modify commands", async () => {
4447
await run(["order", "get", VALID_UUID]);
4548
await run(["order", "list"]);
4649
await run(["order", "cancel", VALID_UUID]);
50+
await run(["order", "replace", VALID_UUID]);
4751
await run(["order", "modify", VALID_UUID, "--limitPrice", "101.50"]);
4852
await run(["order", "modify", VALID_UUID, "--takeProfitPrice", "121.50"]);
4953
await run(["order", "breakeven", VALID_UUID, "--buyPrice", "100", "--limitPrice", "101.50"]);
5054

5155
expect(handleOrderActionMock).toHaveBeenCalledWith(VALID_UUID);
5256
expect(handleOrdersActionMock).toHaveBeenCalledWith(null);
5357
expect(handleCancelActionMock).toHaveBeenCalledWith(VALID_UUID);
58+
expect(handleReplaceActionMock).toHaveBeenCalledWith(VALID_UUID);
5459
expect(handleModifyActionMock).toHaveBeenCalledWith(VALID_UUID, { limitPrice: "101.50" });
5560
expect(handleModifyActionMock).toHaveBeenCalledWith(VALID_UUID, { takeProfitPrice: "121.50" });
5661
expect(handleBreakEvenStopActionMock).toHaveBeenCalledWith(VALID_UUID, {
@@ -71,6 +76,7 @@ describe("order command topology", () => {
7176
expect(handleOrderActionMock).not.toHaveBeenCalled();
7277
expect(handleOrdersActionMock).not.toHaveBeenCalled();
7378
expect(handleCancelActionMock).not.toHaveBeenCalled();
79+
expect(handleReplaceActionMock).not.toHaveBeenCalled();
7480
expect(handleModifyActionMock).not.toHaveBeenCalled();
7581
expect(handleBreakEvenStopActionMock).not.toHaveBeenCalled();
7682
});

0 commit comments

Comments
 (0)