create-polymarket-strategy 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +252 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/worker/package.json +1 -1
- package/templates/worker/src/durable-objects/positions.ts +189 -2
- package/templates/worker/src/durable-objects/scheduler.ts +108 -0
- package/templates/worker/src/lib/quote-manager.ts +358 -0
- package/templates/worker/src/scanner.ts +55 -3
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
* Positions Durable Object
|
|
3
3
|
*
|
|
4
4
|
* SQL-backed persistent storage for position tracking.
|
|
5
|
-
* Tracks open positions
|
|
5
|
+
* Tracks open positions, calculates P&L, and manages inventory.
|
|
6
|
+
*
|
|
7
|
+
* Inventory tracking is for market making - tracks net position per token.
|
|
8
|
+
* Position tracking is for directional strategies - tracks individual entries.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
import type { Position } from "polymarket-trading-sdk";
|
|
11
|
+
import type { Position, TokenInventory, InventoryFill } from "polymarket-trading-sdk";
|
|
12
|
+
import { applyFillToInventory, createEmptyInventory } from "polymarket-trading-sdk";
|
|
9
13
|
|
|
10
14
|
interface NewPosition {
|
|
11
15
|
orderId: string;
|
|
@@ -54,6 +58,18 @@ export class PositionsDO implements DurableObject {
|
|
|
54
58
|
|
|
55
59
|
CREATE INDEX IF NOT EXISTS idx_positions_status ON positions(status);
|
|
56
60
|
CREATE INDEX IF NOT EXISTS idx_positions_order_id ON positions(order_id);
|
|
61
|
+
|
|
62
|
+
-- Inventory table for market making (net position per token)
|
|
63
|
+
CREATE TABLE IF NOT EXISTS inventory (
|
|
64
|
+
token_id TEXT PRIMARY KEY,
|
|
65
|
+
net_shares REAL NOT NULL DEFAULT 0,
|
|
66
|
+
avg_entry_price REAL,
|
|
67
|
+
realized_pnl REAL NOT NULL DEFAULT 0,
|
|
68
|
+
cost_basis REAL NOT NULL DEFAULT 0,
|
|
69
|
+
last_updated INTEGER NOT NULL
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_inventory_updated ON inventory(last_updated);
|
|
57
73
|
`);
|
|
58
74
|
}
|
|
59
75
|
|
|
@@ -62,6 +78,7 @@ export class PositionsDO implements DurableObject {
|
|
|
62
78
|
|
|
63
79
|
try {
|
|
64
80
|
switch (url.pathname) {
|
|
81
|
+
// Position tracking endpoints
|
|
65
82
|
case "/create":
|
|
66
83
|
return this.handleCreate(request);
|
|
67
84
|
case "/list-open":
|
|
@@ -74,6 +91,19 @@ export class PositionsDO implements DurableObject {
|
|
|
74
91
|
return this.handleGet(url);
|
|
75
92
|
case "/stats":
|
|
76
93
|
return this.handleStats();
|
|
94
|
+
|
|
95
|
+
// Inventory tracking endpoints (for market making)
|
|
96
|
+
case "/inventory":
|
|
97
|
+
return this.handleGetInventory(url);
|
|
98
|
+
case "/inventory/all":
|
|
99
|
+
return this.handleGetAllInventory();
|
|
100
|
+
case "/inventory/fill":
|
|
101
|
+
return this.handleRecordFill(request);
|
|
102
|
+
case "/inventory/reset":
|
|
103
|
+
return this.handleResetInventory(request);
|
|
104
|
+
case "/inventory/summary":
|
|
105
|
+
return this.handleInventorySummary();
|
|
106
|
+
|
|
77
107
|
default:
|
|
78
108
|
return new Response("Not found", { status: 404 });
|
|
79
109
|
}
|
|
@@ -244,4 +274,161 @@ export class PositionsDO implements DurableObject {
|
|
|
244
274
|
closedAt: row.closed_at as string | undefined,
|
|
245
275
|
};
|
|
246
276
|
}
|
|
277
|
+
|
|
278
|
+
// ===========================================================================
|
|
279
|
+
// Inventory Methods (for market making)
|
|
280
|
+
// ===========================================================================
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get inventory for a single token.
|
|
284
|
+
*/
|
|
285
|
+
private handleGetInventory(url: URL): Response {
|
|
286
|
+
const tokenId = url.searchParams.get("token_id");
|
|
287
|
+
|
|
288
|
+
if (!tokenId) {
|
|
289
|
+
return Response.json({ error: "token_id required" }, { status: 400 });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const result = this.sql.exec(
|
|
293
|
+
`SELECT * FROM inventory WHERE token_id = ?`,
|
|
294
|
+
tokenId
|
|
295
|
+
);
|
|
296
|
+
const rows = result.toArray();
|
|
297
|
+
|
|
298
|
+
if (rows.length === 0) {
|
|
299
|
+
// Return empty inventory
|
|
300
|
+
return Response.json(createEmptyInventory(tokenId));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return Response.json(this.rowToInventory(rows[0]));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Get all inventories with non-zero positions.
|
|
308
|
+
*/
|
|
309
|
+
private handleGetAllInventory(): Response {
|
|
310
|
+
const result = this.sql.exec(
|
|
311
|
+
`SELECT * FROM inventory WHERE net_shares != 0 ORDER BY last_updated DESC`
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const inventories = result.toArray().map((row) => this.rowToInventory(row));
|
|
315
|
+
return Response.json(inventories);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Record a fill and update inventory.
|
|
320
|
+
*/
|
|
321
|
+
private async handleRecordFill(request: Request): Promise<Response> {
|
|
322
|
+
const fill = (await request.json()) as InventoryFill;
|
|
323
|
+
|
|
324
|
+
if (!fill.tokenId || !fill.side || fill.size == null || fill.price == null) {
|
|
325
|
+
return Response.json(
|
|
326
|
+
{ error: "tokenId, side, size, and price required" },
|
|
327
|
+
{ status: 400 }
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Get current inventory
|
|
332
|
+
const result = this.sql.exec(
|
|
333
|
+
`SELECT * FROM inventory WHERE token_id = ?`,
|
|
334
|
+
fill.tokenId
|
|
335
|
+
);
|
|
336
|
+
const rows = result.toArray();
|
|
337
|
+
|
|
338
|
+
const current: TokenInventory | null =
|
|
339
|
+
rows.length > 0 ? this.rowToInventory(rows[0]) : null;
|
|
340
|
+
|
|
341
|
+
// Apply fill using SDK function
|
|
342
|
+
const updated = applyFillToInventory(current, fill);
|
|
343
|
+
|
|
344
|
+
// Upsert into database
|
|
345
|
+
this.sql.exec(
|
|
346
|
+
`INSERT INTO inventory (token_id, net_shares, avg_entry_price, realized_pnl, cost_basis, last_updated)
|
|
347
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
348
|
+
ON CONFLICT(token_id) DO UPDATE SET
|
|
349
|
+
net_shares = excluded.net_shares,
|
|
350
|
+
avg_entry_price = excluded.avg_entry_price,
|
|
351
|
+
realized_pnl = excluded.realized_pnl,
|
|
352
|
+
cost_basis = excluded.cost_basis,
|
|
353
|
+
last_updated = excluded.last_updated`,
|
|
354
|
+
updated.tokenId,
|
|
355
|
+
updated.netShares,
|
|
356
|
+
updated.avgEntryPrice,
|
|
357
|
+
updated.realizedPnL,
|
|
358
|
+
updated.costBasis,
|
|
359
|
+
updated.lastUpdated
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return Response.json({
|
|
363
|
+
success: true,
|
|
364
|
+
inventory: updated,
|
|
365
|
+
fill,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Reset inventory for a token (or all tokens).
|
|
371
|
+
*/
|
|
372
|
+
private async handleResetInventory(request: Request): Promise<Response> {
|
|
373
|
+
const body = (await request.json()) as { tokenId?: string };
|
|
374
|
+
|
|
375
|
+
if (body.tokenId) {
|
|
376
|
+
this.sql.exec(`DELETE FROM inventory WHERE token_id = ?`, body.tokenId);
|
|
377
|
+
return Response.json({ success: true, reset: body.tokenId });
|
|
378
|
+
} else {
|
|
379
|
+
this.sql.exec(`DELETE FROM inventory`);
|
|
380
|
+
return Response.json({ success: true, reset: "all" });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Get inventory summary (totals across all tokens).
|
|
386
|
+
*/
|
|
387
|
+
private handleInventorySummary(): Response {
|
|
388
|
+
const result = this.sql.exec(`
|
|
389
|
+
SELECT
|
|
390
|
+
SUM(CASE WHEN net_shares > 0 THEN net_shares * avg_entry_price ELSE 0 END) as long_exposure,
|
|
391
|
+
SUM(CASE WHEN net_shares < 0 THEN ABS(net_shares) * avg_entry_price ELSE 0 END) as short_exposure,
|
|
392
|
+
SUM(realized_pnl) as total_realized_pnl,
|
|
393
|
+
COUNT(CASE WHEN net_shares != 0 THEN 1 END) as active_tokens
|
|
394
|
+
FROM inventory
|
|
395
|
+
`);
|
|
396
|
+
|
|
397
|
+
const rows = result.toArray();
|
|
398
|
+
if (rows.length === 0) {
|
|
399
|
+
return Response.json({
|
|
400
|
+
longExposure: 0,
|
|
401
|
+
shortExposure: 0,
|
|
402
|
+
netExposure: 0,
|
|
403
|
+
totalRealizedPnL: 0,
|
|
404
|
+
activeTokens: 0,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const row = rows[0];
|
|
409
|
+
const longExposure = (row.long_exposure as number) || 0;
|
|
410
|
+
const shortExposure = (row.short_exposure as number) || 0;
|
|
411
|
+
|
|
412
|
+
return Response.json({
|
|
413
|
+
longExposure,
|
|
414
|
+
shortExposure,
|
|
415
|
+
netExposure: longExposure - shortExposure,
|
|
416
|
+
totalRealizedPnL: (row.total_realized_pnl as number) || 0,
|
|
417
|
+
activeTokens: (row.active_tokens as number) || 0,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Convert database row to TokenInventory.
|
|
423
|
+
*/
|
|
424
|
+
private rowToInventory(row: Record<string, unknown>): TokenInventory {
|
|
425
|
+
return {
|
|
426
|
+
tokenId: row.token_id as string,
|
|
427
|
+
netShares: row.net_shares as number,
|
|
428
|
+
avgEntryPrice: (row.avg_entry_price as number) || 0,
|
|
429
|
+
realizedPnL: row.realized_pnl as number,
|
|
430
|
+
costBasis: row.cost_basis as number,
|
|
431
|
+
lastUpdated: row.last_updated as number,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
247
434
|
}
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Uses DO alarms for granular, self-contained scheduling.
|
|
5
5
|
* Manages periodic scanning with configurable intervals.
|
|
6
|
+
* Also handles fill reconciliation - polling executor for fills
|
|
7
|
+
* and updating inventory.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import type { Env } from "../types.js";
|
|
11
|
+
import { createOrderManagerFromEnv, type Order } from "polymarket-trading-sdk";
|
|
9
12
|
|
|
10
13
|
interface SchedulerConfig {
|
|
11
14
|
interval: number; // milliseconds
|
|
@@ -90,7 +93,12 @@ export class SchedulerDO implements DurableObject {
|
|
|
90
93
|
return;
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
// Run fill reconciliation first (update inventory from fills)
|
|
97
|
+
await this.reconcileFills();
|
|
98
|
+
|
|
99
|
+
// Then run the scan
|
|
93
100
|
await this.runScan();
|
|
101
|
+
|
|
94
102
|
await this.scheduleNext(config.interval);
|
|
95
103
|
}
|
|
96
104
|
|
|
@@ -288,4 +296,104 @@ export class SchedulerDO implements DurableObject {
|
|
|
288
296
|
return { signalsFound: 0, ordersPlaced: 0, error: errorMsg };
|
|
289
297
|
}
|
|
290
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Reconcile fills by polling the executor and updating inventory.
|
|
302
|
+
*
|
|
303
|
+
* Process:
|
|
304
|
+
* 1. Get pending orders from OrdersDO
|
|
305
|
+
* 2. Get open orders from executor (CLOB)
|
|
306
|
+
* 3. Any order that's pending locally but not open on CLOB = filled
|
|
307
|
+
* 4. Update inventory via PositionsDO
|
|
308
|
+
* 5. Mark orders as filled in OrdersDO
|
|
309
|
+
*/
|
|
310
|
+
private async reconcileFills(): Promise<{
|
|
311
|
+
fillsDetected: number;
|
|
312
|
+
errors: string[];
|
|
313
|
+
}> {
|
|
314
|
+
const result = { fillsDetected: 0, errors: [] as string[] };
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
// Skip if not in live mode or executor not configured
|
|
318
|
+
const orderManager = createOrderManagerFromEnv(this.env);
|
|
319
|
+
if (!orderManager.isLiveEnabled()) {
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Get pending orders from local DO
|
|
324
|
+
const ordersId = this.env.ORDERS.idFromName("main");
|
|
325
|
+
const ordersStub = this.env.ORDERS.get(ordersId);
|
|
326
|
+
|
|
327
|
+
const pendingResponse = await ordersStub.fetch(
|
|
328
|
+
new Request("http://do/list-pending")
|
|
329
|
+
);
|
|
330
|
+
const pendingOrders = (await pendingResponse.json()) as Order[];
|
|
331
|
+
|
|
332
|
+
if (pendingOrders.length === 0) {
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Get open orders from executor/CLOB
|
|
337
|
+
const clobOrders = await orderManager.getOpenOrders();
|
|
338
|
+
const clobOrderIds = new Set(clobOrders.map((o) => o.clobOrderId));
|
|
339
|
+
|
|
340
|
+
// Get positions DO for inventory updates
|
|
341
|
+
const positionsId = this.env.POSITIONS.idFromName("main");
|
|
342
|
+
const positionsStub = this.env.POSITIONS.get(positionsId);
|
|
343
|
+
|
|
344
|
+
// Check each pending order
|
|
345
|
+
for (const order of pendingOrders) {
|
|
346
|
+
try {
|
|
347
|
+
// If no CLOB order ID, skip (might be paper order)
|
|
348
|
+
if (!order.clobOrderId) continue;
|
|
349
|
+
|
|
350
|
+
// If order is still open on CLOB, skip
|
|
351
|
+
if (clobOrderIds.has(order.clobOrderId)) continue;
|
|
352
|
+
|
|
353
|
+
// Order is not open on CLOB = filled (or cancelled)
|
|
354
|
+
// For now, assume filled at limit price
|
|
355
|
+
// TODO: Could query executor for actual fill details
|
|
356
|
+
|
|
357
|
+
// Update inventory
|
|
358
|
+
await positionsStub.fetch(
|
|
359
|
+
new Request("http://do/inventory/fill", {
|
|
360
|
+
method: "POST",
|
|
361
|
+
headers: { "Content-Type": "application/json" },
|
|
362
|
+
body: JSON.stringify({
|
|
363
|
+
tokenId: order.tokenId,
|
|
364
|
+
side: order.side,
|
|
365
|
+
size: order.size,
|
|
366
|
+
price: order.price,
|
|
367
|
+
timestamp: Date.now(),
|
|
368
|
+
}),
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// Mark order as filled in OrdersDO
|
|
373
|
+
await ordersStub.fetch(
|
|
374
|
+
new Request("http://do/mark-filled", {
|
|
375
|
+
method: "POST",
|
|
376
|
+
headers: { "Content-Type": "application/json" },
|
|
377
|
+
body: JSON.stringify({
|
|
378
|
+
id: order.id,
|
|
379
|
+
fillPrice: order.price,
|
|
380
|
+
}),
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
result.fillsDetected++;
|
|
385
|
+
} catch (e) {
|
|
386
|
+
result.errors.push(
|
|
387
|
+
`Error processing order ${order.id}: ${e instanceof Error ? e.message : String(e)}`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch (e) {
|
|
392
|
+
result.errors.push(
|
|
393
|
+
`Reconciliation error: ${e instanceof Error ? e.message : String(e)}`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
291
399
|
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages two-sided quotes (bid + ask pairs) for market making.
|
|
5
|
+
* Handles placing, updating, and cancelling quote pairs atomically.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Env } from "../types.js";
|
|
9
|
+
import {
|
|
10
|
+
type OrderManager,
|
|
11
|
+
type QuotePair,
|
|
12
|
+
type Order,
|
|
13
|
+
createOrderManagerFromEnv,
|
|
14
|
+
} from "polymarket-trading-sdk";
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Types
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Result of placing a quote.
|
|
22
|
+
*/
|
|
23
|
+
export interface QuotePlacementResult {
|
|
24
|
+
success: boolean;
|
|
25
|
+
tokenId: string;
|
|
26
|
+
bidOrderId?: string;
|
|
27
|
+
askOrderId?: string;
|
|
28
|
+
bidError?: string;
|
|
29
|
+
askError?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An active quote tracked by the manager.
|
|
34
|
+
*/
|
|
35
|
+
export interface ActiveQuote {
|
|
36
|
+
tokenId: string;
|
|
37
|
+
bidOrderId: string;
|
|
38
|
+
askOrderId: string;
|
|
39
|
+
bidPrice: number;
|
|
40
|
+
askPrice: number;
|
|
41
|
+
bidSize: number;
|
|
42
|
+
askSize: number;
|
|
43
|
+
placedAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Options for quote placement.
|
|
48
|
+
*/
|
|
49
|
+
export interface QuotePlacementOptions {
|
|
50
|
+
/** Slug for order tracking */
|
|
51
|
+
slug?: string;
|
|
52
|
+
/** Label for order tracking */
|
|
53
|
+
label?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// Quote Manager
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Manages two-sided quotes for a trading strategy.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* const manager = new QuoteManager(env);
|
|
66
|
+
*
|
|
67
|
+
* // Place a quote
|
|
68
|
+
* const result = await manager.placeQuote({
|
|
69
|
+
* tokenId: "abc123",
|
|
70
|
+
* bidPrice: 0.48,
|
|
71
|
+
* bidSize: 10,
|
|
72
|
+
* askPrice: 0.52,
|
|
73
|
+
* askSize: 10,
|
|
74
|
+
* });
|
|
75
|
+
*
|
|
76
|
+
* // Update quote
|
|
77
|
+
* await manager.updateQuote("abc123", {
|
|
78
|
+
* tokenId: "abc123",
|
|
79
|
+
* bidPrice: 0.47,
|
|
80
|
+
* bidSize: 10,
|
|
81
|
+
* askPrice: 0.53,
|
|
82
|
+
* askSize: 10,
|
|
83
|
+
* });
|
|
84
|
+
*
|
|
85
|
+
* // Cancel quote
|
|
86
|
+
* await manager.cancelQuote("abc123");
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export class QuoteManager {
|
|
90
|
+
private orderManager: OrderManager;
|
|
91
|
+
private ordersStub: DurableObjectStub;
|
|
92
|
+
private activeQuotes: Map<string, ActiveQuote> = new Map();
|
|
93
|
+
|
|
94
|
+
constructor(private env: Env) {
|
|
95
|
+
this.orderManager = createOrderManagerFromEnv(env);
|
|
96
|
+
|
|
97
|
+
const ordersId = env.ORDERS.idFromName("main");
|
|
98
|
+
this.ordersStub = env.ORDERS.get(ordersId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Place a two-sided quote (bid and ask).
|
|
103
|
+
*
|
|
104
|
+
* Both orders are placed in parallel. If one fails, the other may still succeed.
|
|
105
|
+
* Check the result for individual order status.
|
|
106
|
+
*/
|
|
107
|
+
async placeQuote(
|
|
108
|
+
quote: QuotePair,
|
|
109
|
+
options: QuotePlacementOptions = {}
|
|
110
|
+
): Promise<QuotePlacementResult> {
|
|
111
|
+
const { slug = "quote", label = quote.tokenId.slice(-8) } = options;
|
|
112
|
+
|
|
113
|
+
const result: QuotePlacementResult = {
|
|
114
|
+
success: false,
|
|
115
|
+
tokenId: quote.tokenId,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Place bid and ask in parallel
|
|
119
|
+
const [bidResult, askResult] = await Promise.all([
|
|
120
|
+
this.orderManager.placeOrder({
|
|
121
|
+
slug: `${slug}-bid`,
|
|
122
|
+
label,
|
|
123
|
+
tokenId: quote.tokenId,
|
|
124
|
+
side: "buy",
|
|
125
|
+
price: quote.bidPrice,
|
|
126
|
+
size: quote.bidSize,
|
|
127
|
+
}),
|
|
128
|
+
this.orderManager.placeOrder({
|
|
129
|
+
slug: `${slug}-ask`,
|
|
130
|
+
label,
|
|
131
|
+
tokenId: quote.tokenId,
|
|
132
|
+
side: "sell",
|
|
133
|
+
price: quote.askPrice,
|
|
134
|
+
size: quote.askSize,
|
|
135
|
+
}),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
// Record results
|
|
139
|
+
if (bidResult.success) {
|
|
140
|
+
result.bidOrderId = bidResult.clobOrderId;
|
|
141
|
+
|
|
142
|
+
// Record in DO
|
|
143
|
+
await this.recordOrder({
|
|
144
|
+
slug: `${slug}-bid`,
|
|
145
|
+
label,
|
|
146
|
+
tokenId: quote.tokenId,
|
|
147
|
+
side: "buy",
|
|
148
|
+
price: quote.bidPrice,
|
|
149
|
+
size: quote.bidSize,
|
|
150
|
+
clobOrderId: bidResult.clobOrderId,
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
result.bidError = bidResult.error;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (askResult.success) {
|
|
157
|
+
result.askOrderId = askResult.clobOrderId;
|
|
158
|
+
|
|
159
|
+
// Record in DO
|
|
160
|
+
await this.recordOrder({
|
|
161
|
+
slug: `${slug}-ask`,
|
|
162
|
+
label,
|
|
163
|
+
tokenId: quote.tokenId,
|
|
164
|
+
side: "sell",
|
|
165
|
+
price: quote.askPrice,
|
|
166
|
+
size: quote.askSize,
|
|
167
|
+
clobOrderId: askResult.clobOrderId,
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
result.askError = askResult.error;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Track active quote if at least one side succeeded
|
|
174
|
+
if (result.bidOrderId || result.askOrderId) {
|
|
175
|
+
this.activeQuotes.set(quote.tokenId, {
|
|
176
|
+
tokenId: quote.tokenId,
|
|
177
|
+
bidOrderId: result.bidOrderId || "",
|
|
178
|
+
askOrderId: result.askOrderId || "",
|
|
179
|
+
bidPrice: quote.bidPrice,
|
|
180
|
+
askPrice: quote.askPrice,
|
|
181
|
+
bidSize: quote.bidSize,
|
|
182
|
+
askSize: quote.askSize,
|
|
183
|
+
placedAt: Date.now(),
|
|
184
|
+
});
|
|
185
|
+
result.success = true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Cancel all orders for a token.
|
|
193
|
+
*/
|
|
194
|
+
async cancelQuote(tokenId: string): Promise<{ success: boolean; error?: string }> {
|
|
195
|
+
try {
|
|
196
|
+
// Cancel on CLOB
|
|
197
|
+
const cancelResult = await this.orderManager.cancelAllOrders(tokenId);
|
|
198
|
+
|
|
199
|
+
if (!cancelResult.success) {
|
|
200
|
+
return { success: false, error: cancelResult.error };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Remove from tracking
|
|
204
|
+
this.activeQuotes.delete(tokenId);
|
|
205
|
+
|
|
206
|
+
// Note: Orders will be marked as cancelled by reconciliation
|
|
207
|
+
// or we could explicitly mark them here
|
|
208
|
+
|
|
209
|
+
return { success: true };
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return {
|
|
212
|
+
success: false,
|
|
213
|
+
error: e instanceof Error ? e.message : String(e),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Update a quote by cancelling the old one and placing a new one.
|
|
220
|
+
*
|
|
221
|
+
* This is a cancel-and-replace operation. The new quote is placed
|
|
222
|
+
* after the old one is cancelled.
|
|
223
|
+
*/
|
|
224
|
+
async updateQuote(
|
|
225
|
+
tokenId: string,
|
|
226
|
+
newQuote: QuotePair,
|
|
227
|
+
options: QuotePlacementOptions = {}
|
|
228
|
+
): Promise<QuotePlacementResult> {
|
|
229
|
+
// First cancel existing quote
|
|
230
|
+
await this.cancelQuote(tokenId);
|
|
231
|
+
|
|
232
|
+
// Small delay to ensure cancellation propagates
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
234
|
+
|
|
235
|
+
// Place new quote
|
|
236
|
+
return this.placeQuote(newQuote, options);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get all active quotes.
|
|
241
|
+
*/
|
|
242
|
+
getActiveQuotes(): Map<string, ActiveQuote> {
|
|
243
|
+
return new Map(this.activeQuotes);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get active quote for a specific token.
|
|
248
|
+
*/
|
|
249
|
+
getQuote(tokenId: string): ActiveQuote | undefined {
|
|
250
|
+
return this.activeQuotes.get(tokenId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Check if there's an active quote for a token.
|
|
255
|
+
*/
|
|
256
|
+
hasQuote(tokenId: string): boolean {
|
|
257
|
+
return this.activeQuotes.has(tokenId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Cancel all active quotes.
|
|
262
|
+
*/
|
|
263
|
+
async cancelAllQuotes(): Promise<{ cancelled: number; errors: string[] }> {
|
|
264
|
+
const tokenIds = [...this.activeQuotes.keys()];
|
|
265
|
+
let cancelled = 0;
|
|
266
|
+
const errors: string[] = [];
|
|
267
|
+
|
|
268
|
+
for (const tokenId of tokenIds) {
|
|
269
|
+
const result = await this.cancelQuote(tokenId);
|
|
270
|
+
if (result.success) {
|
|
271
|
+
cancelled++;
|
|
272
|
+
} else {
|
|
273
|
+
errors.push(`${tokenId}: ${result.error}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { cancelled, errors };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Refresh active quotes from the orders DO.
|
|
282
|
+
* Call this when starting up to restore state.
|
|
283
|
+
*/
|
|
284
|
+
async refreshFromDO(): Promise<void> {
|
|
285
|
+
try {
|
|
286
|
+
const response = await this.ordersStub.fetch(
|
|
287
|
+
new Request("http://do/list-pending")
|
|
288
|
+
);
|
|
289
|
+
const pendingOrders = (await response.json()) as Order[];
|
|
290
|
+
|
|
291
|
+
// Group by token
|
|
292
|
+
const byToken = new Map<string, { bids: Order[]; asks: Order[] }>();
|
|
293
|
+
|
|
294
|
+
for (const order of pendingOrders) {
|
|
295
|
+
if (!byToken.has(order.tokenId)) {
|
|
296
|
+
byToken.set(order.tokenId, { bids: [], asks: [] });
|
|
297
|
+
}
|
|
298
|
+
const entry = byToken.get(order.tokenId)!;
|
|
299
|
+
if (order.side === "buy") {
|
|
300
|
+
entry.bids.push(order);
|
|
301
|
+
} else {
|
|
302
|
+
entry.asks.push(order);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Reconstruct active quotes
|
|
307
|
+
this.activeQuotes.clear();
|
|
308
|
+
for (const [tokenId, { bids, asks }] of byToken) {
|
|
309
|
+
// Take the most recent bid and ask
|
|
310
|
+
const bid = bids[0];
|
|
311
|
+
const ask = asks[0];
|
|
312
|
+
|
|
313
|
+
if (bid && ask) {
|
|
314
|
+
this.activeQuotes.set(tokenId, {
|
|
315
|
+
tokenId,
|
|
316
|
+
bidOrderId: bid.clobOrderId || bid.id,
|
|
317
|
+
askOrderId: ask.clobOrderId || ask.id,
|
|
318
|
+
bidPrice: bid.price,
|
|
319
|
+
askPrice: ask.price,
|
|
320
|
+
bidSize: bid.size,
|
|
321
|
+
askSize: ask.size,
|
|
322
|
+
placedAt: new Date(bid.createdAt).getTime(),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} catch (e) {
|
|
327
|
+
console.error("Failed to refresh quotes from DO:", e);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Record an order in the orders DO.
|
|
333
|
+
*/
|
|
334
|
+
private async recordOrder(order: {
|
|
335
|
+
slug: string;
|
|
336
|
+
label: string;
|
|
337
|
+
tokenId: string;
|
|
338
|
+
side: "buy" | "sell";
|
|
339
|
+
price: number;
|
|
340
|
+
size: number;
|
|
341
|
+
clobOrderId?: string;
|
|
342
|
+
}): Promise<void> {
|
|
343
|
+
await this.ordersStub.fetch(
|
|
344
|
+
new Request("http://do/create", {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { "Content-Type": "application/json" },
|
|
347
|
+
body: JSON.stringify(order),
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Create a QuoteManager from environment.
|
|
355
|
+
*/
|
|
356
|
+
export function createQuoteManager(env: Env): QuoteManager {
|
|
357
|
+
return new QuoteManager(env);
|
|
358
|
+
}
|