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.
@@ -2,10 +2,14 @@
2
2
  * Positions Durable Object
3
3
  *
4
4
  * SQL-backed persistent storage for position tracking.
5
- * Tracks open positions and calculates P&L.
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
+ }