Skip to content

Commit 1bc7df1

Browse files
committed
Honor passive intent on typed order placement
1 parent 0d0b70b commit 1bc7df1

6 files changed

Lines changed: 321 additions & 1 deletion

File tree

src/handlers/execute-action/orders.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
emitOrderExecutionTelemetryInBackground,
1111
extractOrderTelemetryIds,
1212
} from "../../helpers/order-telemetry";
13+
import { classifyPassiveOrderError } from "../../helpers/passive-order";
1314
import {
1415
safeLogError,
1516
safeLogRedactedError,
@@ -47,11 +48,23 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise<void> {
4748

4849
const orderValue = parsePayloadForAction(ctx, CreateOrderPayloadSchema);
4950
if (orderValue === null) return;
51+
const isPassiveOrder = orderValue.orderIntent === "passive_only";
52+
if (isPassiveOrder && orderValue.orderType !== "limit") {
53+
return ctx.wrappedCallback(
54+
{
55+
code: grpc.status.INVALID_ARGUMENT,
56+
message:
57+
"ValidationError: passive_only order intent requires a limit order",
58+
},
59+
null,
60+
);
61+
}
5062
const createOrderParams = {
5163
...orderValue.params,
5264
...(orderValue.clientOrderId !== undefined && {
5365
clientOrderId: orderValue.clientOrderId,
5466
}),
67+
...(isPassiveOrder && { postOnly: true }),
5568
};
5669
let resolvedOrderTelemetry: {
5770
symbol?: string;
@@ -149,7 +162,14 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise<void> {
149162
undefined,
150163
{ marketMetadataHash },
151164
);
152-
ctx.wrappedCallback(null, { result: JSON.stringify({ ...order }) });
165+
ctx.wrappedCallback(null, {
166+
result: JSON.stringify({
167+
...order,
168+
...(isPassiveOrder && {
169+
passivePlacementOutcome: "accepted_passive",
170+
}),
171+
}),
172+
});
153173
} catch (error) {
154174
rethrowArchiveDurabilityError(error);
155175
safeLogRedactedError("Order Creation failed", error);
@@ -178,6 +198,13 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise<void> {
178198
error,
179199
{ marketMetadataHash },
180200
);
201+
if (isPassiveOrder) {
202+
const passiveErrorCode = classifyPassiveOrderError(error);
203+
return rejectWithGrpcError(ctx, error, {
204+
message: `${passiveErrorCode}: ${sanitizeErrorDetail(error)}`,
205+
preferStableMessageOnly: true,
206+
});
207+
}
181208
ctx.wrappedCallback(
182209
{
183210
code: grpc.status.INTERNAL,

src/helpers/grpc/status.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ export function stableGrpcErrorCode(message: string): grpc.status | undefined {
1818
if (message.startsWith("deposit_amount_mismatch:")) {
1919
return grpc.status.FAILED_PRECONDITION;
2020
}
21+
if (message.startsWith("passive_order_unsupported:")) {
22+
return grpc.status.UNIMPLEMENTED;
23+
}
24+
if (
25+
message.startsWith("passive_order_rejected:") ||
26+
message.startsWith("passive_order_would_cross:")
27+
) {
28+
return grpc.status.FAILED_PRECONDITION;
29+
}
2130
if (
2231
message.startsWith("policy_withdrawal_denied:") ||
2332
message.startsWith("policy_deposit_denied:")

src/helpers/passive-order.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import ccxt from "@usherlabs/ccxt";
2+
import { getErrorMessage } from "./shared/errors";
3+
4+
export const PASSIVE_ORDER_ERROR_CODES = {
5+
unsupported: "passive_order_unsupported",
6+
rejected: "passive_order_rejected",
7+
wouldCross: "passive_order_would_cross",
8+
} as const;
9+
10+
export type PassiveOrderErrorCode =
11+
(typeof PASSIVE_ORDER_ERROR_CODES)[keyof typeof PASSIVE_ORDER_ERROR_CODES];
12+
13+
function identifiesWouldCross(message: string): boolean {
14+
const normalized = message.toLowerCase();
15+
// Post-only venues reject a crossing limit instead of resting it. Binance
16+
// says it "would immediately match and take"; Hyperliquid and other venues
17+
// use equivalent explicit immediate-execution wording.
18+
return (
19+
normalized.includes("would immediately match and take") ||
20+
/post[\s-]?only\b.*\bwould\b.*\bimmediately\b.*\b(?:execute|fill|match)/.test(
21+
normalized,
22+
) ||
23+
/post[\s-]?only\b.*\bwould\b.*\b(?:execute|fill|match)\w*\b.*\bimmediately/.test(
24+
normalized,
25+
)
26+
);
27+
}
28+
29+
function identifiesUnsupported(message: string): boolean {
30+
const normalized = message.toLowerCase();
31+
return (
32+
/post[\s-]?only\b.*\b(?:not supported|unsupported|does not support)\b/.test(
33+
normalized,
34+
) ||
35+
/\b(?:not supported|unsupported|does not support)\b.*\bpost[\s-]?only\b/.test(
36+
normalized,
37+
)
38+
);
39+
}
40+
41+
export function classifyPassiveOrderError(
42+
error: unknown,
43+
): PassiveOrderErrorCode {
44+
const message = getErrorMessage(error);
45+
if (
46+
error instanceof ccxt.OrderImmediatelyFillable ||
47+
identifiesWouldCross(message)
48+
) {
49+
return PASSIVE_ORDER_ERROR_CODES.wouldCross;
50+
}
51+
if (error instanceof ccxt.NotSupported || identifiesUnsupported(message)) {
52+
return PASSIVE_ORDER_ERROR_CODES.unsupported;
53+
}
54+
return PASSIVE_ORDER_ERROR_CODES.rejected;
55+
}

src/schemas/action-payloads.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const unknownParamsSchema = z.preprocess(
7777

7878
export const CreateOrderPayloadSchema = z.object({
7979
orderType: z.enum(["market", "limit"]).default("limit"),
80+
orderIntent: z.enum(["passive_only"]).optional(),
8081
amount: z.coerce.number().positive(),
8182
fromToken: z.string().min(1),
8283
toToken: z.string().min(1),

test/grpc-status.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ describe("grpc status", () => {
2121
expect(stableGrpcErrorCode("policy_deposit_denied: x")).toBe(
2222
grpc.status.PERMISSION_DENIED,
2323
);
24+
expect(stableGrpcErrorCode("passive_order_unsupported: x")).toBe(
25+
grpc.status.UNIMPLEMENTED,
26+
);
27+
expect(stableGrpcErrorCode("passive_order_rejected: x")).toBe(
28+
grpc.status.FAILED_PRECONDITION,
29+
);
30+
expect(stableGrpcErrorCode("passive_order_would_cross: x")).toBe(
31+
grpc.status.FAILED_PRECONDITION,
32+
);
2433
});
2534

2635
test("mapCcxtErrorToGrpcStatus maps authentication errors", () => {

test/passive-order.test.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { describe, expect, test } from "bun:test";
2+
import * as grpc from "@grpc/grpc-js";
3+
import ccxt, { type Exchange } from "@usherlabs/ccxt";
4+
import type { ExecuteActionContext } from "../src/handlers/execute-action/context";
5+
import { handleOrders } from "../src/handlers/execute-action/orders";
6+
import { Action } from "../src/helpers/constants";
7+
import type { PolicyConfig } from "../src/types";
8+
9+
type CallbackError = { code?: number; message?: string };
10+
type CallbackResponse = { result?: string };
11+
12+
function createFixture(createOrderResult: unknown = { id: "order-1" }) {
13+
const createOrderCalls: unknown[][] = [];
14+
const broker = {
15+
loadMarkets: async () => {},
16+
markets: {
17+
"USDC/USDT": {
18+
symbol: "USDC/USDT",
19+
base: "USDC",
20+
quote: "USDT",
21+
spot: true,
22+
type: "spot",
23+
},
24+
},
25+
createOrder: async (...args: unknown[]) => {
26+
createOrderCalls.push(args);
27+
if (createOrderResult instanceof Error) {
28+
throw createOrderResult;
29+
}
30+
return createOrderResult;
31+
},
32+
} as unknown as Exchange;
33+
34+
let callbackError: CallbackError | null = null;
35+
let callbackResponse: CallbackResponse | null = null;
36+
const ctx = {
37+
action: Action.CreateOrder,
38+
call: { request: { payload: {} } },
39+
wrappedCallback: (
40+
error: CallbackError | null,
41+
response: CallbackResponse | null,
42+
) => {
43+
callbackError = error;
44+
callbackResponse = response;
45+
},
46+
cex: "binance",
47+
normalizedCex: "binance",
48+
symbol: "USDC/USDT",
49+
broker,
50+
verity: { proof: "" },
51+
policy: {
52+
order: { rule: { markets: ["*"], limits: [] } },
53+
} as unknown as PolicyConfig,
54+
brokers: {},
55+
} as unknown as ExecuteActionContext;
56+
57+
return {
58+
ctx,
59+
createOrderCalls,
60+
getError: () => callbackError,
61+
getResponse: () => callbackResponse,
62+
};
63+
}
64+
65+
function createOrderPayload(
66+
overrides: Record<string, string> = {},
67+
): Record<string, string> {
68+
return {
69+
orderType: "limit",
70+
amount: "10",
71+
fromToken: "USDC",
72+
toToken: "USDT",
73+
price: "1",
74+
marketType: "spot",
75+
...overrides,
76+
};
77+
}
78+
79+
describe("passive CreateOrder", () => {
80+
test("keeps the existing ccxt request and response byte-for-byte when intent is absent", async () => {
81+
const order = { id: "ordinary-1", status: "open" };
82+
const fixture = createFixture(order);
83+
fixture.ctx.call.request.payload = createOrderPayload({
84+
clientOrderId: "client-1",
85+
params: JSON.stringify({ timeInForce: "IOC", strategyId: 7 }),
86+
});
87+
88+
await handleOrders(fixture.ctx);
89+
90+
expect(fixture.createOrderCalls).toEqual([
91+
[
92+
"USDC/USDT",
93+
"limit",
94+
"sell",
95+
10,
96+
1,
97+
{
98+
timeInForce: "IOC",
99+
strategyId: 7,
100+
clientOrderId: "client-1",
101+
},
102+
],
103+
]);
104+
expect(fixture.getError()).toBeNull();
105+
expect(fixture.getResponse()?.result).toBe(JSON.stringify(order));
106+
});
107+
108+
test("adds postOnly without clobbering params and reports accepted passive placement", async () => {
109+
const order = { id: "passive-1", status: "open" };
110+
const fixture = createFixture(order);
111+
fixture.ctx.call.request.payload = createOrderPayload({
112+
orderIntent: "passive_only",
113+
params: JSON.stringify({ timeInForce: "GTC", strategyId: 9 }),
114+
});
115+
116+
await handleOrders(fixture.ctx);
117+
118+
expect(fixture.createOrderCalls[0]?.[5]).toEqual({
119+
timeInForce: "GTC",
120+
strategyId: 9,
121+
postOnly: true,
122+
});
123+
expect(JSON.parse(fixture.getResponse()?.result ?? "{}")).toEqual({
124+
...order,
125+
passivePlacementOutcome: "accepted_passive",
126+
});
127+
});
128+
129+
test("overrides a conflicting caller postOnly value to preserve passive intent", async () => {
130+
const fixture = createFixture();
131+
fixture.ctx.call.request.payload = createOrderPayload({
132+
orderIntent: "passive_only",
133+
params: JSON.stringify({ postOnly: 0, strategyId: 9 }),
134+
});
135+
136+
await handleOrders(fixture.ctx);
137+
138+
expect(fixture.createOrderCalls[0]?.[5]).toEqual({
139+
postOnly: true,
140+
strategyId: 9,
141+
});
142+
});
143+
144+
test("rejects passive market orders as invalid before calling ccxt", async () => {
145+
const fixture = createFixture();
146+
fixture.ctx.call.request.payload = createOrderPayload({
147+
orderType: "market",
148+
orderIntent: "passive_only",
149+
});
150+
151+
await handleOrders(fixture.ctx);
152+
153+
expect(fixture.createOrderCalls).toHaveLength(0);
154+
expect(fixture.getError()).toEqual({
155+
code: grpc.status.INVALID_ARGUMENT,
156+
message:
157+
"ValidationError: passive_only order intent requires a limit order",
158+
});
159+
});
160+
161+
test("rejects unknown order intents in the payload schema", async () => {
162+
const fixture = createFixture();
163+
fixture.ctx.call.request.payload = createOrderPayload({
164+
orderIntent: "maker_if_possible",
165+
});
166+
167+
await handleOrders(fixture.ctx);
168+
169+
expect(fixture.createOrderCalls).toHaveLength(0);
170+
expect(fixture.getError()?.code).toBe(grpc.status.INVALID_ARGUMENT);
171+
expect(fixture.getError()?.message).toContain("orderIntent");
172+
});
173+
174+
test("maps an immediately fillable venue rejection to would-cross", async () => {
175+
const fixture = createFixture(
176+
new ccxt.InvalidOrder("binance Order would immediately match and take."),
177+
);
178+
fixture.ctx.call.request.payload = createOrderPayload({
179+
orderIntent: "passive_only",
180+
});
181+
182+
await handleOrders(fixture.ctx);
183+
184+
expect(fixture.getError()?.code).toBe(grpc.status.FAILED_PRECONDITION);
185+
expect(fixture.getError()?.message).toStartWith(
186+
"passive_order_would_cross:",
187+
);
188+
});
189+
190+
test("maps missing ccxt post-only support to unsupported", async () => {
191+
const fixture = createFixture(
192+
new ccxt.NotSupported("binance post-only orders are not supported"),
193+
);
194+
fixture.ctx.call.request.payload = createOrderPayload({
195+
orderIntent: "passive_only",
196+
});
197+
198+
await handleOrders(fixture.ctx);
199+
200+
expect(fixture.getError()?.code).toBe(grpc.status.UNIMPLEMENTED);
201+
expect(fixture.getError()?.message).toStartWith(
202+
"passive_order_unsupported:",
203+
);
204+
});
205+
206+
test("maps any other passive venue rejection to rejected", async () => {
207+
const fixture = createFixture(
208+
new ccxt.InvalidOrder("binance passive order rejected: invalid price"),
209+
);
210+
fixture.ctx.call.request.payload = createOrderPayload({
211+
orderIntent: "passive_only",
212+
});
213+
214+
await handleOrders(fixture.ctx);
215+
216+
expect(fixture.getError()?.code).toBe(grpc.status.FAILED_PRECONDITION);
217+
expect(fixture.getError()?.message).toStartWith("passive_order_rejected:");
218+
});
219+
});

0 commit comments

Comments
 (0)