polycopy 0.1.9 → 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.
Files changed (2) hide show
  1. package/dist/index.js +152 -233
  2. package/package.json +1 -3
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ const accounts = require("viem/accounts");
11
11
  const chains = require("viem/chains");
12
12
  const builderRelayerClient = require("@polymarket/builder-relayer-client");
13
13
  const setPromiseInterval = require("set-promise-interval");
14
+ const lodash = require("lodash");
14
15
  const child_process = require("child_process");
15
16
  const fs = require("fs");
16
17
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
@@ -578,6 +579,12 @@ class TelegramService {
578
579
  async stop() {
579
580
  await this.ensureListener().stop();
580
581
  }
582
+ /**
583
+ * 发送普通通知
584
+ */
585
+ async info(message, details) {
586
+ await this.send(`${message}`, details);
587
+ }
581
588
  /**
582
589
  * 发送错误通知
583
590
  */
@@ -1071,103 +1078,48 @@ class OrderWatcher {
1071
1078
  }
1072
1079
  }
1073
1080
  const DATA_API_HOST = "https://data-api.polymarket.com";
1074
- const BASE_INTERVAL = 5 * 60 * 1e3;
1075
- const DEFAULT_BATCH_SIZE = 5;
1081
+ const AUTO_REDEEM_INTERVAL = 5 * 60 * 1e3;
1082
+ const MAX_BATCH_SIZE = 5;
1076
1083
  const MAX_MATCH_TIMES = 3;
1077
1084
  class Redeemer {
1078
1085
  // conditionId -> RedeemRecord
1079
1086
  records = /* @__PURE__ */ new Map();
1080
- intervalId;
1081
- delayTimeoutId;
1082
- // stopped: 未启动; running: 正常轮询; paused: 限流等待恢复
1083
- state = "stopped";
1084
- scheduler;
1085
- redeemFn = null;
1086
- batchSize = DEFAULT_BATCH_SIZE;
1087
- funderAddress = "";
1088
- onRedeemSuccess = null;
1089
- constructor(scheduler = {
1090
- setInterval: setPromiseInterval__default.default,
1091
- clearInterval: setPromiseInterval.clearPromiseInterval,
1092
- setTimeout,
1093
- clearTimeout
1094
- }) {
1095
- this.scheduler = scheduler;
1096
- }
1097
- /**
1098
- * 启动 Redeemer
1099
- * @param redeemFn 执行 Redeem 的函数(返回交易哈希)
1100
- * @param funderAddress Polymarket 账户地址(用于查询仓位)
1101
- * @param onRedeemSuccess Redeem 成功后的回调(用于更新余额缓存)
1102
- */
1103
- start(redeemFn, funderAddress, onRedeemSuccess) {
1104
- if (this.state !== "stopped") {
1105
- return;
1106
- }
1107
- this.redeemFn = redeemFn;
1108
- const envBatchSize = Number(process.env.POLYCOPY_REDEEM_BATCH_SIZE || "");
1109
- if (!Number.isNaN(envBatchSize) && envBatchSize > 0) {
1110
- this.batchSize = Math.floor(envBatchSize);
1111
- }
1112
- this.funderAddress = funderAddress;
1113
- this.onRedeemSuccess = onRedeemSuccess || null;
1114
- this.state = "running";
1115
- logger.success("自动 redeem 已启动");
1116
- this.startInterval();
1117
- }
1118
- /**
1119
- * 启动定时任务(仅在 running 状态生效)
1120
- */
1121
- startInterval() {
1122
- if (this.state !== "running" || this.intervalId !== void 0) {
1123
- return;
1124
- }
1125
- this.intervalId = this.scheduler.setInterval(async () => {
1126
- await this.runOnce();
1127
- }, BASE_INTERVAL);
1128
- }
1129
- /**
1130
- * 暂停定时任务,延迟后重启(用于 429 限流)
1131
- */
1132
- pauseAndRestart(delayMs) {
1133
- if (this.intervalId !== void 0) {
1134
- this.scheduler.clearInterval(this.intervalId);
1135
- this.intervalId = void 0;
1136
- }
1137
- if (this.delayTimeoutId) {
1138
- this.scheduler.clearTimeout(this.delayTimeoutId);
1139
- this.delayTimeoutId = void 0;
1140
- }
1141
- this.state = "paused";
1142
- this.delayTimeoutId = this.scheduler.setTimeout(() => {
1143
- this.delayTimeoutId = void 0;
1144
- if (this.state === "paused") {
1145
- this.state = "running";
1146
- this.startInterval();
1147
- }
1148
- }, delayMs);
1087
+ running = false;
1088
+ mainTimer;
1089
+ restartTimer;
1090
+ options;
1091
+ constructor(options) {
1092
+ this.options = options;
1093
+ }
1094
+ start(restart) {
1095
+ if (this.running) return;
1096
+ this.running = true;
1097
+ this.mainTimer = setPromiseInterval__default.default(
1098
+ this.autoRedeem.bind(this),
1099
+ AUTO_REDEEM_INTERVAL
1100
+ );
1101
+ logger.success(restart ? "自动 redeem 已重启" : "自动 redeem 已启动");
1149
1102
  }
1150
- /**
1151
- * 停止 Redeemer
1152
- */
1153
1103
  stop() {
1154
- if (this.intervalId !== void 0) {
1155
- this.scheduler.clearInterval(this.intervalId);
1156
- this.intervalId = void 0;
1157
- }
1158
- if (this.delayTimeoutId) {
1159
- this.scheduler.clearTimeout(this.delayTimeoutId);
1160
- this.delayTimeoutId = void 0;
1161
- }
1162
- this.state = "stopped";
1104
+ setPromiseInterval.clearPromiseInterval(this.mainTimer);
1105
+ clearTimeout(this.restartTimer);
1106
+ this.running = false;
1163
1107
  logger.info("自动 redeem 已停止");
1164
1108
  }
1109
+ restart(delayMs) {
1110
+ if (!this.running) return;
1111
+ this.stop();
1112
+ this.restartTimer = setTimeout(() => {
1113
+ this.start(true);
1114
+ telegramService.info("自动 redeem 已重启");
1115
+ }, delayMs);
1116
+ }
1165
1117
  /**
1166
1118
  * 获取可 Redeem 的仓位
1167
1119
  */
1168
1120
  async fetchRedeemablePositions() {
1169
1121
  const url = new URL(`${DATA_API_HOST}/positions`);
1170
- url.searchParams.set("user", this.funderAddress);
1122
+ url.searchParams.set("user", this.options.funderAddress);
1171
1123
  url.searchParams.set("redeemable", "true");
1172
1124
  url.searchParams.set("sizeThreshold", ".1");
1173
1125
  url.searchParams.set("sortBy", "CURRENT");
@@ -1197,63 +1149,35 @@ class Redeemer {
1197
1149
  }
1198
1150
  return positions;
1199
1151
  }
1152
+ testRequestLimit(errorMsg) {
1153
+ if (errorMsg.includes("429") || errorMsg.includes("Too Many Requests")) {
1154
+ const match = errorMsg.match(/resets in (\d+) seconds/);
1155
+ return match ? parseInt(match[1]) : 0;
1156
+ }
1157
+ return 0;
1158
+ }
1159
+ toMessages(records) {
1160
+ return records.map(
1161
+ ({ position, matchedCount, failedCount }) => ` - ${position.slug} ${position.outcome}: ${position.currentValue} USDC (matched: ${matchedCount} failed: ${failedCount})`
1162
+ );
1163
+ }
1200
1164
  /**
1201
1165
  * 执行一轮 Redeem
1202
1166
  */
1203
- async runOnce() {
1204
- if (this.state !== "running" || !this.redeemFn) {
1205
- return;
1206
- }
1207
- let positions;
1167
+ async autoRedeem() {
1168
+ let redeemablePositions;
1208
1169
  try {
1209
- positions = await this.fetchRedeemablePositions();
1170
+ redeemablePositions = await this.fetchRedeemablePositions();
1210
1171
  } catch (error) {
1211
1172
  const errorMsg = error instanceof Error ? error.message : String(error);
1212
- if (errorMsg.includes("429") || errorMsg.includes("Too Many Requests")) {
1213
- const resetSeconds = this.parseResetSeconds(errorMsg);
1214
- logger.warning(`获取仓位限流,${resetSeconds} 秒后重试`);
1215
- telegramService.warning(
1216
- "API 限流",
1217
- `获取仓位限流,${resetSeconds} 秒后重试`
1218
- );
1219
- this.pauseAndRestart((resetSeconds + 60) * 1e3);
1220
- return;
1221
- }
1222
- logger.error(`获取仓位失败: ${errorMsg}`);
1173
+ logger.error(`自动 redeem: 获取仓位失败: ${errorMsg}`);
1223
1174
  return;
1224
1175
  }
1225
- const currentIds = new Set(positions.map((position) => position.conditionId));
1226
- this.logPendingPositions(positions);
1227
- const pendingRecords = this.buildRecords(positions);
1228
- this.updateMatchedRecords(currentIds);
1229
- const shouldContinue = await this.executeBatches(pendingRecords);
1230
- if (!shouldContinue) {
1231
- return;
1232
- }
1233
- const successItems = this.collectSuccessRecords(currentIds);
1234
- if (successItems.length > 0) {
1235
- const successTitle = `自动 redeem: 成功 ${successItems.length} 个`;
1236
- const successLines = this.logPositions(successTitle, successItems);
1237
- telegramService.success(successTitle, successLines.join("\n"));
1238
- this.onRedeemSuccess?.();
1239
- }
1240
- logger.info(`redeem 状态: ${this.records.size} 个待确认`);
1241
- }
1242
- logPendingPositions(positions) {
1243
- const pendingRedeemablePositions = positions.filter((position) => {
1244
- const record = this.records.get(position.conditionId);
1245
- return !record?.success;
1246
- });
1247
- if (pendingRedeemablePositions.length > 0) {
1248
- this.logPositions(
1249
- `自动 redeem: 发现 ${pendingRedeemablePositions.length} 个可 Redeem 仓位`,
1250
- pendingRedeemablePositions.map((position) => ({ position }))
1251
- );
1252
- logger.info(`Redeem 批量大小: ${this.batchSize}`);
1253
- }
1254
- }
1255
- buildRecords(positions) {
1256
- return positions.map((position) => {
1176
+ let requestLimit = 0;
1177
+ const pendingRedeems = [];
1178
+ const failedRedeems = [];
1179
+ const successRedeem = [];
1180
+ for (const position of redeemablePositions) {
1257
1181
  let record = this.records.get(position.conditionId);
1258
1182
  if (!record) {
1259
1183
  record = {
@@ -1265,113 +1189,104 @@ class Redeemer {
1265
1189
  this.records.set(position.conditionId, record);
1266
1190
  }
1267
1191
  record.position = position;
1268
- return record;
1269
- }).filter((record) => !record.success && record.failedCount < MAX_MATCH_TIMES);
1270
- }
1271
- updateMatchedRecords(currentIds) {
1272
- for (const record of this.records.values()) {
1273
- if (!record.success) {
1274
- continue;
1275
- }
1276
- if (!currentIds.has(record.position.conditionId)) {
1277
- continue;
1278
- }
1279
- record.matchedCount++;
1280
- if (record.matchedCount >= MAX_MATCH_TIMES) {
1281
- logger.error(
1282
- `Redeem 失败: 执行成功但仍存在 ${record.matchedCount} 次 (conditionId: ${record.position.conditionId.slice(0, 10)}...)`
1283
- );
1284
- telegramService.error(
1285
- "Redeem 失败",
1286
- `执行成功但仓位仍存在 ${record.matchedCount} 次
1287
- conditionId: ${record.position.conditionId}
1288
- 价值: ${record.position.currentValue} USDC`
1192
+ if (record.success) {
1193
+ record.matchedCount++;
1194
+ if (record.matchedCount === MAX_MATCH_TIMES) {
1195
+ failedRedeems.push(record);
1196
+ }
1197
+ } else if (record.failedCount < MAX_MATCH_TIMES) {
1198
+ const insertIndex = lodash.sortedIndexBy(
1199
+ pendingRedeems,
1200
+ record,
1201
+ "failedCount"
1289
1202
  );
1203
+ pendingRedeems.splice(insertIndex, 0, record);
1290
1204
  }
1291
1205
  }
1292
- }
1293
- async executeBatches(pendingRecords) {
1294
- for (let i = 0; i < pendingRecords.length; i += this.batchSize) {
1295
- const batch = pendingRecords.slice(i, i + this.batchSize);
1296
- const conditionIds = batch.map((record) => record.position.conditionId);
1297
- if (conditionIds.length === 0) {
1298
- continue;
1299
- }
1206
+ const execRedeems = pendingRedeems.slice(0, MAX_BATCH_SIZE);
1207
+ if (execRedeems.length > 0) {
1300
1208
  try {
1301
- const txHash = await this.redeemFn?.(conditionIds);
1302
- batch.forEach((record) => {
1303
- record.success = true;
1304
- record.matchedCount = 0;
1305
- });
1306
- logger.info(`Redeem 已执行 (批量 ${batch.length}): ${txHash}`);
1209
+ const txHash = await this.options.redeemFn(
1210
+ execRedeems.map(({ position }) => position.conditionId)
1211
+ );
1212
+ logger.info(`Redeem 已执行 (批量 ${execRedeems.length}): ${txHash}`);
1213
+ for (const record of execRedeems) record.success = true;
1307
1214
  } catch (error) {
1308
1215
  const errorMsg = error instanceof Error ? error.message : String(error);
1309
- if (errorMsg.includes("429") || errorMsg.includes("Too Many Requests")) {
1310
- const resetSeconds = this.parseResetSeconds(errorMsg);
1311
- logger.warning(`Redeem 限流,${resetSeconds} 秒后重试`);
1312
- telegramService.warning("Redeem 限流", `${resetSeconds} 秒后重试`);
1313
- this.pauseAndRestart((resetSeconds + 60) * 1e3);
1314
- return false;
1315
- }
1316
- batch.forEach((record) => {
1317
- record.failedCount++;
1318
- });
1319
- logger.error(`Redeem 批量异常 (批量 ${batch.length}): ${errorMsg}`);
1320
- batch.forEach((record) => {
1321
- if (record.failedCount >= MAX_MATCH_TIMES) {
1322
- telegramService.error(
1323
- "Redeem 异常",
1324
- `重试 ${record.failedCount} 次仍失败
1325
- conditionId: ${record.position.conditionId}
1326
- 错误: ${errorMsg}`
1327
- );
1216
+ logger.error(
1217
+ `Redeem 批量异常 (批量 ${execRedeems.length}): ${errorMsg}`
1218
+ );
1219
+ requestLimit = this.testRequestLimit(errorMsg);
1220
+ if (requestLimit <= 0) {
1221
+ for (const record of execRedeems) {
1222
+ record.failedCount++;
1223
+ if (record.failedCount >= MAX_MATCH_TIMES) {
1224
+ failedRedeems.push(record);
1225
+ }
1328
1226
  }
1329
- });
1227
+ }
1330
1228
  }
1331
1229
  }
1332
- return true;
1333
- }
1334
- collectSuccessRecords(currentIds) {
1335
- const successItems = [];
1336
1230
  for (const [conditionId, record] of this.records.entries()) {
1337
- if (!currentIds.has(conditionId)) {
1338
- successItems.push({
1339
- position: record.position,
1340
- matchedCount: record.matchedCount
1341
- });
1231
+ if (!redeemablePositions.some((p) => p.conditionId === conditionId)) {
1232
+ successRedeem.push(record);
1342
1233
  this.records.delete(conditionId);
1343
1234
  }
1344
1235
  }
1345
- return successItems;
1346
- }
1347
- /**
1348
- * 统一格式化仓位信息
1349
- */
1350
- formatPositionLine(position, matchedCount) {
1351
- const label = position.slug || `${position.assetId.slice(0, 10)}...`;
1352
- const outcomeLabel = position.outcome ? ` ${position.outcome}` : "";
1353
- const matchedLabel = matchedCount !== void 0 ? ` (matched: ${matchedCount})` : "";
1354
- return `${label}${outcomeLabel}: ${position.currentValue} USDC${matchedLabel}`;
1355
- }
1356
- /**
1357
- * 统一输出 Redeem 仓位列表
1358
- */
1359
- logPositions(title, items) {
1360
- logger.info(title);
1361
- const lines = [];
1362
- for (const item of items) {
1363
- const line = this.formatPositionLine(item.position, item.matchedCount);
1364
- lines.push(line);
1365
- logger.line("", `- ${line}`);
1236
+ const messages = [];
1237
+ if (successRedeem.length > 0) {
1238
+ messages.push("🟢 redeem 成功:", ...this.toMessages(successRedeem));
1239
+ }
1240
+ if (failedRedeems.length > 0) {
1241
+ messages.push("🔴 redeem 失败:", ...this.toMessages(failedRedeems));
1242
+ }
1243
+ if (messages.length > 0) {
1244
+ telegramService.info(messages.join("\n"));
1245
+ }
1246
+ const logLines = [];
1247
+ if (execRedeems.length > 0) {
1248
+ logLines.push(
1249
+ `redeem 本轮执行(待确认): ${execRedeems.length} 个`,
1250
+ ...this.toMessages(execRedeems),
1251
+ ""
1252
+ );
1253
+ }
1254
+ if (pendingRedeems.length > 0) {
1255
+ logLines.push(
1256
+ `redeem 本轮未执行(等待下轮): ${pendingRedeems.length} 个`,
1257
+ ...this.toMessages(pendingRedeems),
1258
+ ""
1259
+ );
1260
+ }
1261
+ logLines.push(`redeem 待确认总数: ${this.records.size} 个`, "");
1262
+ if (successRedeem.length > 0) {
1263
+ logLines.push(
1264
+ `🟢 redeem 成功确认: ${successRedeem.length} 个`,
1265
+ ...this.toMessages(successRedeem),
1266
+ ""
1267
+ );
1268
+ }
1269
+ if (failedRedeems.length > 0) {
1270
+ logLines.push(
1271
+ `🔴 redeem 失败已放弃(重试超限): ${failedRedeems.length} 个`,
1272
+ ...this.toMessages(failedRedeems),
1273
+ ""
1274
+ );
1275
+ }
1276
+ if (logLines.length > 0) {
1277
+ while (logLines.length > 0 && logLines[logLines.length - 1] === "") {
1278
+ logLines.pop();
1279
+ }
1280
+ logger.info(logLines.join("\n"));
1281
+ }
1282
+ if (successRedeem.length > 0) {
1283
+ this.options.onRedeemSuccess?.();
1284
+ }
1285
+ if (requestLimit > 0) {
1286
+ logger.warning(`Redeem 限流,${requestLimit} 秒后重试`);
1287
+ telegramService.warning("Redeem 限流", `${requestLimit} 秒后重试`);
1288
+ this.restart(requestLimit * 1e3);
1366
1289
  }
1367
- return lines;
1368
- }
1369
- /**
1370
- * 解析 429 错误中的 resets in 时间
1371
- */
1372
- parseResetSeconds(errorMsg) {
1373
- const match = errorMsg.match(/resets in (\d+) seconds/);
1374
- return match ? parseInt(match[1]) : 3600;
1375
1290
  }
1376
1291
  }
1377
1292
  const POSITION_POLL_INTERVAL = 5e3;
@@ -1391,7 +1306,6 @@ class Trader {
1391
1306
  this.assetFilter = new AssetFilter();
1392
1307
  this.balanceCache = new BalanceCache();
1393
1308
  this.orderWatcher = new OrderWatcher();
1394
- this.redeemer = new Redeemer();
1395
1309
  }
1396
1310
  /**
1397
1311
  * 初始化交易模块
@@ -1428,11 +1342,16 @@ class Trader {
1428
1342
  if (!this.config?.polymarket?.builderCreds) {
1429
1343
  return;
1430
1344
  }
1431
- this.redeemer.start(
1432
- this.executeRedeemBatch.bind(this),
1433
- this.config.polymarket.funderAddress || "",
1434
- () => this.balanceCache.invalidate()
1435
- );
1345
+ if (!this.redeemer) {
1346
+ this.redeemer = new Redeemer({
1347
+ funderAddress: this.config.polymarket.funderAddress,
1348
+ redeemFn: this.executeRedeemBatch.bind(this),
1349
+ onRedeemSuccess: () => {
1350
+ this.balanceCache.invalidate();
1351
+ }
1352
+ });
1353
+ this.redeemer.start();
1354
+ }
1436
1355
  }
1437
1356
  /**
1438
1357
  * 检查是否已初始化
@@ -1647,7 +1566,7 @@ class Trader {
1647
1566
  * 停止交易模块
1648
1567
  */
1649
1568
  async shutdown() {
1650
- this.redeemer.stop();
1569
+ this.redeemer?.stop();
1651
1570
  logger.info("交易模块已停止");
1652
1571
  }
1653
1572
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polycopy",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "polycopy test",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -13,8 +13,6 @@
13
13
  "dev": "tsc --noEmit && vite build && node dist/index.js",
14
14
  "cli": "node dist/cli.js",
15
15
  "prepublishOnly": "npm run build",
16
- "test": "HOME=$PWD node --import tsx tests/redeemer.test.ts",
17
- "test:redeemer": "HOME=$PWD node --import tsx tests/redeemer.test.ts",
18
16
  "test:log-follow": "node scripts/test-log-follow.cjs"
19
17
  },
20
18
  "dependencies": {