pandora-cli-skills 1.1.50 → 1.1.52

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/README.md CHANGED
@@ -73,6 +73,16 @@ pandora --output json lifecycle status --id <lifecycle-id>
73
73
  pandora --output json lifecycle resolve --id <lifecycle-id> --confirm
74
74
  ```
75
75
 
76
+ ### Mega Analytics Page (Dune Panels)
77
+
78
+ ```bash
79
+ # starts local page at http://127.0.0.1:8787
80
+ npm run analytics:mega
81
+ ```
82
+
83
+ - Panel registry source: `analytics/dune/panel_registry.json`
84
+ - Dashboard creation script reuses this registry: `scripts/create_pandora_dashboard.js`
85
+
76
86
  ## Risk Controls
77
87
 
78
88
  - Inspect/engage panic lock:
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: pandora-cli-skills
3
3
  summary: Canonical skill and operator guide for Pandora CLI including mirror, polymarket, resolve, and LP flows.
4
- version: 1.1.50
4
+ version: 1.1.52
5
5
  ---
6
6
 
7
7
  # Pandora CLI & Skills
@@ -91,7 +91,7 @@ function createRunMirrorCommand(deps) {
91
91
  console.log('');
92
92
  console.log('Subcommands:');
93
93
  console.log(
94
- ' browse --min-yes-pct <n> --max-yes-pct <n> --min-volume-24h <n> [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--chain-id <id>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
94
+ ' browse --min-yes-pct <n> --max-yes-pct <n> --min-volume-24h <n> [--closes-after <date>|--end-date-after <date|72h>] [--closes-before <date>|--end-date-before <date|72h>] [--question-contains <text>|--keyword <text>] [--slug <text>] [--category sports|crypto|politics|entertainment] [--exclude-sports] [--sort-by volume24h|liquidity|endDate] [--limit <n>] [--chain-id <id>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
95
95
  );
96
96
  console.log(
97
97
  ' plan --source polymarket --polymarket-market-id <id>|--polymarket-slug <slug> [--chain-id <id>] [--target-slippage-bps <n>] [--turnover-target <n>] [--depth-slippage-bps <n>] [--safety-multiplier <n>] [--min-liquidity-usdc <n>] [--max-liquidity-usdc <n>] [--with-rules] [--include-similarity] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
@@ -22,12 +22,12 @@ module.exports = async function handleMirrorBrowse({ shared, context, deps }) {
22
22
  context.outputMode,
23
23
  'mirror.browse.help',
24
24
  commandHelpPayload(
25
- 'pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>]',
25
+ 'pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>|--end-date-after <date|72h>] [--closes-before <date>|--end-date-before <date|72h>] [--question-contains <text>|--keyword <text>] [--slug <text>] [--category sports|crypto|politics|entertainment] [--exclude-sports] [--sort-by volume24h|liquidity|endDate] [--limit <n>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>]',
26
26
  ),
27
27
  );
28
28
  } else {
29
29
  console.log(
30
- 'Usage: pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>]',
30
+ 'Usage: pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>|--end-date-after <date|72h>] [--closes-before <date>|--end-date-before <date|72h>] [--question-contains <text>|--keyword <text>] [--slug <text>] [--category sports|crypto|politics|entertainment] [--exclude-sports] [--sort-by volume24h|liquidity|endDate] [--limit <n>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>]',
31
31
  );
32
32
  }
33
33
  return;
@@ -413,6 +413,11 @@ async function browseMirrorMarkets(options = {}) {
413
413
  closesAfter: options.closesAfter,
414
414
  closesBefore: options.closesBefore,
415
415
  questionContains: options.questionContains,
416
+ keyword: options.keyword,
417
+ slug: options.slug,
418
+ categories: Array.isArray(options.categories) ? options.categories : [],
419
+ excludeSports: Boolean(options.excludeSports),
420
+ sortBy: options.sortBy,
416
421
  limit: options.limit,
417
422
  });
418
423
 
@@ -102,82 +102,143 @@ async function runMirrorSync(options, deps = {}) {
102
102
  break;
103
103
  }
104
104
 
105
- resetDailyCountersIfNeeded(state, tickAt);
106
-
107
- const verifyPayload =
108
- iteration === 1 && startupVerifyPayload
109
- ? startupVerifyPayload
110
- : await verifyFn(buildVerifyRequest(options));
111
-
112
- const snapshotMetrics = evaluateSnapshot(verifyPayload, options);
113
- const plan = buildTickPlan({
114
- snapshotMetrics,
115
- state,
116
- options,
117
- });
118
- const depth = await fetchDepthSnapshot({
119
- depthFn,
120
- verifyPayload,
121
- options,
122
- });
123
-
124
- const gate = applyGateBypassPolicy(
125
- evaluateStrictGates(
126
- buildTickGateContext({
127
- verifyPayload,
105
+ try {
106
+ resetDailyCountersIfNeeded(state, tickAt);
107
+
108
+ const verifyPayload =
109
+ iteration === 1 && startupVerifyPayload
110
+ ? startupVerifyPayload
111
+ : await verifyFn(buildVerifyRequest(options));
112
+
113
+ const snapshotMetrics = evaluateSnapshot(verifyPayload, options);
114
+ const plan = buildTickPlan({
115
+ snapshotMetrics,
116
+ state,
117
+ options,
118
+ });
119
+ const depth = await fetchDepthSnapshot({
120
+ depthFn,
121
+ verifyPayload,
122
+ options,
123
+ });
124
+
125
+ const gate = applyGateBypassPolicy(
126
+ evaluateStrictGates(
127
+ buildTickGateContext({
128
+ verifyPayload,
129
+ options,
130
+ state,
131
+ plan,
132
+ snapshotMetrics,
133
+ depth,
134
+ minimumTimeToCloseSec,
135
+ }),
136
+ ),
137
+ options,
138
+ );
139
+
140
+ const snapshot = buildTickSnapshot({
141
+ iteration,
142
+ tickAt,
143
+ verifyPayload,
144
+ options,
145
+ snapshotMetrics,
146
+ plan,
147
+ depth,
148
+ gate,
149
+ });
150
+
151
+ if (snapshotMetrics.driftTriggered || plan.hedgeTriggered) {
152
+ await processTriggeredAction({
128
153
  options,
129
154
  state,
155
+ snapshot,
130
156
  plan,
157
+ gate,
158
+ tickAt,
159
+ loadedFilePath: loaded.filePath,
160
+ rebalanceFn,
161
+ hedgeFn,
162
+ sendWebhook,
163
+ strategyHash: hash,
164
+ iteration,
165
+ actions,
166
+ webhookReports,
131
167
  snapshotMetrics,
168
+ verifyPayload,
132
169
  depth,
133
- minimumTimeToCloseSec,
134
- }),
135
- ),
136
- options,
137
- );
138
-
139
- const snapshot = buildTickSnapshot({
140
- iteration,
141
- tickAt,
142
- verifyPayload,
143
- options,
144
- snapshotMetrics,
145
- plan,
146
- depth,
147
- gate,
148
- });
149
-
150
- if (snapshotMetrics.driftTriggered || plan.hedgeTriggered) {
151
- await processTriggeredAction({
152
- options,
170
+ });
171
+ }
172
+
173
+ await persistTickSnapshot({
174
+ loadedFilePath: loaded.filePath,
153
175
  state,
154
- snapshot,
155
- plan,
156
- gate,
157
176
  tickAt,
177
+ snapshot,
178
+ snapshots,
179
+ onTick,
180
+ iteration,
181
+ });
182
+ } catch (err) {
183
+ const errorCode = err && err.code ? String(err.code) : 'MIRROR_SYNC_TICK_FAILED';
184
+ const errorMessage = err && err.message ? err.message : String(err);
185
+ const errorDetails = err && err.details !== undefined ? err.details : null;
186
+ const timestamp = tickAt.toISOString();
187
+
188
+ const diagnostic = {
189
+ level: 'error',
190
+ scope: 'tick',
191
+ iteration,
192
+ timestamp,
193
+ code: errorCode,
194
+ message: errorMessage,
195
+ retryable: options.mode !== 'once',
196
+ };
197
+ if (errorDetails !== null) diagnostic.details = errorDetails;
198
+ diagnostics.push(diagnostic);
199
+
200
+ const snapshot = {
201
+ schemaVersion: MIRROR_SYNC_SCHEMA_VERSION,
202
+ timestamp,
203
+ iteration,
204
+ metrics: {
205
+ driftBps: null,
206
+ plannedRebalanceUsdc: 0,
207
+ plannedHedgeUsdc: 0,
208
+ },
209
+ strictGate: {
210
+ ok: false,
211
+ failedChecks: [],
212
+ checks: [],
213
+ },
214
+ action: {
215
+ status: 'error',
216
+ failedChecks: [],
217
+ forcedGateBypass: false,
218
+ errorCode,
219
+ errorMessage,
220
+ },
221
+ error: {
222
+ code: errorCode,
223
+ message: errorMessage,
224
+ details: errorDetails,
225
+ },
226
+ };
227
+
228
+ await persistTickSnapshot({
158
229
  loadedFilePath: loaded.filePath,
159
- rebalanceFn,
160
- hedgeFn,
161
- sendWebhook,
162
- strategyHash: hash,
230
+ state,
231
+ tickAt,
232
+ snapshot,
233
+ snapshots,
234
+ onTick,
163
235
  iteration,
164
- actions,
165
- webhookReports,
166
- snapshotMetrics,
167
- verifyPayload,
168
- depth,
169
236
  });
170
- }
171
237
 
172
- await persistTickSnapshot({
173
- loadedFilePath: loaded.filePath,
174
- state,
175
- tickAt,
176
- snapshot,
177
- snapshots,
178
- onTick,
179
- iteration,
180
- });
238
+ if (options.mode === 'once') {
239
+ throw err;
240
+ }
241
+ }
181
242
 
182
243
  if (shouldStop) break;
183
244
  if (iteration >= maxIterations) break;
@@ -25,6 +25,71 @@ function createParseMirrorBrowseFlags(deps) {
25
25
  const parseDateLikeFlag = requireDep(deps, 'parseDateLikeFlag');
26
26
  const parsePositiveInteger = requireDep(deps, 'parsePositiveInteger');
27
27
  const parseInteger = requireDep(deps, 'parseInteger');
28
+ const allowedSortBy = new Set(['volume24h', 'liquidity', 'endDate']);
29
+ const allowedCategories = new Set(['sports', 'crypto', 'politics', 'entertainment']);
30
+
31
+ function parseBrowseWindowValue(value, flagName) {
32
+ const text = String(value || '').trim();
33
+ const relativeMatch = /^([1-9]\d*)([smhdw])$/i.exec(text);
34
+ if (relativeMatch) {
35
+ const quantity = Number(relativeMatch[1]);
36
+ const unit = String(relativeMatch[2] || '').toLowerCase();
37
+ const unitMs = {
38
+ s: 1000,
39
+ m: 60 * 1000,
40
+ h: 60 * 60 * 1000,
41
+ d: 24 * 60 * 60 * 1000,
42
+ w: 7 * 24 * 60 * 60 * 1000,
43
+ };
44
+ const multiplier = unitMs[unit];
45
+ if (!multiplier) {
46
+ throw new CliError(
47
+ 'INVALID_FLAG_VALUE',
48
+ `${flagName} relative window must use one of s|m|h|d|w (example: 72h). Received: "${text}"`,
49
+ );
50
+ }
51
+ return new Date(Date.now() + quantity * multiplier).toISOString();
52
+ }
53
+ return parseDateLikeFlag(text, flagName);
54
+ }
55
+
56
+ function parseSortBy(rawValue) {
57
+ const text = String(rawValue || '').trim().toLowerCase();
58
+ if (!text) {
59
+ throw new CliError('INVALID_FLAG_VALUE', '--sort-by requires a value: volume24h|liquidity|endDate.');
60
+ }
61
+ if (text === 'volume' || text === 'volume24h' || text === 'volume24husd') return 'volume24h';
62
+ if (text === 'liquidity' || text === 'liquidityusd') return 'liquidity';
63
+ if (text === 'enddate' || text === 'end-date' || text === 'close' || text === 'close-time' || text === 'closetimestamp') {
64
+ return 'endDate';
65
+ }
66
+ throw new CliError(
67
+ 'INVALID_FLAG_VALUE',
68
+ `--sort-by must be one of volume24h|liquidity|endDate. Received: "${rawValue}"`,
69
+ );
70
+ }
71
+
72
+ function parseCategoryList(rawValue, flagName) {
73
+ const values = String(rawValue || '')
74
+ .split(',')
75
+ .map((value) => value.trim().toLowerCase())
76
+ .filter(Boolean);
77
+ if (!values.length) {
78
+ throw new CliError(
79
+ 'INVALID_FLAG_VALUE',
80
+ `${flagName} must include at least one category: sports|crypto|politics|entertainment.`,
81
+ );
82
+ }
83
+ for (const value of values) {
84
+ if (!allowedCategories.has(value)) {
85
+ throw new CliError(
86
+ 'INVALID_FLAG_VALUE',
87
+ `${flagName} contains unsupported category "${value}". Allowed: sports|crypto|politics|entertainment.`,
88
+ );
89
+ }
90
+ }
91
+ return Array.from(new Set(values));
92
+ }
28
93
 
29
94
  return function parseMirrorBrowseFlags(args) {
30
95
  const options = {
@@ -40,6 +105,11 @@ function createParseMirrorBrowseFlags(deps) {
40
105
  polymarketGammaMockUrl: null,
41
106
  polymarketMockUrl: null,
42
107
  polymarketTagIds: [],
108
+ categories: [],
109
+ excludeSports: false,
110
+ sortBy: 'volume24h',
111
+ keyword: null,
112
+ slug: null,
43
113
  };
44
114
 
45
115
  function pushTagId(rawValue, flagName) {
@@ -63,13 +133,13 @@ function createParseMirrorBrowseFlags(deps) {
63
133
  i += 1;
64
134
  continue;
65
135
  }
66
- if (token === '--closes-after') {
67
- options.closesAfter = parseDateLikeFlag(requireFlagValue(args, i, '--closes-after'), '--closes-after');
136
+ if (token === '--closes-after' || token === '--end-date-after') {
137
+ options.closesAfter = parseBrowseWindowValue(requireFlagValue(args, i, token), token);
68
138
  i += 1;
69
139
  continue;
70
140
  }
71
- if (token === '--closes-before') {
72
- options.closesBefore = parseDateLikeFlag(requireFlagValue(args, i, '--closes-before'), '--closes-before');
141
+ if (token === '--closes-before' || token === '--end-date-before') {
142
+ options.closesBefore = parseBrowseWindowValue(requireFlagValue(args, i, token), token);
73
143
  i += 1;
74
144
  continue;
75
145
  }
@@ -78,11 +148,36 @@ function createParseMirrorBrowseFlags(deps) {
78
148
  i += 1;
79
149
  continue;
80
150
  }
151
+ if (token === '--keyword') {
152
+ options.keyword = requireFlagValue(args, i, '--keyword');
153
+ i += 1;
154
+ continue;
155
+ }
156
+ if (token === '--slug') {
157
+ options.slug = requireFlagValue(args, i, '--slug');
158
+ i += 1;
159
+ continue;
160
+ }
81
161
  if (token === '--limit') {
82
162
  options.limit = parsePositiveInteger(requireFlagValue(args, i, '--limit'), '--limit');
83
163
  i += 1;
84
164
  continue;
85
165
  }
166
+ if (token === '--sort-by') {
167
+ options.sortBy = parseSortBy(requireFlagValue(args, i, '--sort-by'));
168
+ i += 1;
169
+ continue;
170
+ }
171
+ if (token === '--category') {
172
+ const parsed = parseCategoryList(requireFlagValue(args, i, '--category'), '--category');
173
+ options.categories = Array.from(new Set(options.categories.concat(parsed)));
174
+ i += 1;
175
+ continue;
176
+ }
177
+ if (token === '--exclude-sports') {
178
+ options.excludeSports = true;
179
+ continue;
180
+ }
86
181
  if (token === '--chain-id') {
87
182
  options.chainId = parseInteger(requireFlagValue(args, i, '--chain-id'), '--chain-id');
88
183
  i += 1;
@@ -129,10 +224,28 @@ function createParseMirrorBrowseFlags(deps) {
129
224
  if (options.minYesPct !== null && options.maxYesPct !== null && options.minYesPct > options.maxYesPct) {
130
225
  throw new CliError('INVALID_ARGS', '--min-yes-pct cannot be greater than --max-yes-pct.');
131
226
  }
227
+ if (
228
+ options.closesAfter &&
229
+ options.closesBefore &&
230
+ Number.isFinite(Date.parse(options.closesAfter)) &&
231
+ Number.isFinite(Date.parse(options.closesBefore)) &&
232
+ Date.parse(options.closesAfter) > Date.parse(options.closesBefore)
233
+ ) {
234
+ throw new CliError('INVALID_ARGS', '--closes-after/--end-date-after cannot be later than --closes-before/--end-date-before.');
235
+ }
236
+ if (options.excludeSports && options.categories.includes('sports')) {
237
+ throw new CliError('INVALID_ARGS', '--exclude-sports cannot be combined with --category sports.');
238
+ }
132
239
 
133
240
  if (options.polymarketTagIds.length) {
134
241
  options.polymarketTagIds = Array.from(new Set(options.polymarketTagIds));
135
242
  }
243
+ if (options.categories.length) {
244
+ options.categories = Array.from(new Set(options.categories));
245
+ }
246
+ if (allowedSortBy.has(options.sortBy) !== true) {
247
+ throw new CliError('INVALID_FLAG_VALUE', `Unsupported --sort-by value: "${options.sortBy}"`);
248
+ }
136
249
 
137
250
  return options;
138
251
  };
@@ -616,6 +616,184 @@ function normalizeTagIdList(input) {
616
616
  return Array.from(new Set(normalized));
617
617
  }
618
618
 
619
+ const BROWSE_ALLOWED_CATEGORIES = new Set(['sports', 'crypto', 'politics', 'entertainment']);
620
+ const BROWSE_DEFAULT_SPORT_TAG_IDS = Object.freeze([82, 100350]);
621
+ const BROWSE_SPORT_KEYWORDS = [
622
+ 'sport',
623
+ 'soccer',
624
+ 'football',
625
+ 'premier league',
626
+ 'epl',
627
+ 'nba',
628
+ 'nfl',
629
+ 'nhl',
630
+ 'mlb',
631
+ 'tennis',
632
+ 'cricket',
633
+ 'mma',
634
+ 'ufc',
635
+ 'formula 1',
636
+ 'f1',
637
+ ];
638
+ const BROWSE_CRYPTO_KEYWORDS = [
639
+ 'crypto',
640
+ 'bitcoin',
641
+ 'btc',
642
+ 'ethereum',
643
+ 'eth',
644
+ 'solana',
645
+ 'defi',
646
+ 'blockchain',
647
+ 'altcoin',
648
+ 'memecoin',
649
+ ];
650
+ const BROWSE_POLITICS_KEYWORDS = [
651
+ 'politic',
652
+ 'election',
653
+ 'president',
654
+ 'prime minister',
655
+ 'senate',
656
+ 'congress',
657
+ 'government',
658
+ 'parliament',
659
+ 'campaign',
660
+ ];
661
+ const BROWSE_ENTERTAINMENT_KEYWORDS = [
662
+ 'entertain',
663
+ 'movie',
664
+ 'film',
665
+ 'music',
666
+ 'album',
667
+ 'artist',
668
+ 'tv',
669
+ 'celebrity',
670
+ 'oscar',
671
+ 'emmy',
672
+ 'grammy',
673
+ 'box office',
674
+ ];
675
+
676
+ function normalizeBrowseCategoryList(input) {
677
+ const values = Array.isArray(input) ? input : [input];
678
+ const normalized = [];
679
+ for (const value of values) {
680
+ const text = String(value || '').trim().toLowerCase();
681
+ if (!text || !BROWSE_ALLOWED_CATEGORIES.has(text)) continue;
682
+ normalized.push(text);
683
+ }
684
+ return Array.from(new Set(normalized));
685
+ }
686
+
687
+ function normalizeBrowseSortBy(value) {
688
+ const text = String(value || '').trim().toLowerCase();
689
+ if (!text || text === 'volume24h' || text === 'volume24husd' || text === 'volume') return 'volume24h';
690
+ if (text === 'liquidity' || text === 'liquidityusd') return 'liquidity';
691
+ if (text === 'enddate' || text === 'end-date' || text === 'close' || text === 'close-time' || text === 'closetimestamp') {
692
+ return 'endDate';
693
+ }
694
+ return 'volume24h';
695
+ }
696
+
697
+ function collectTagEntries(row) {
698
+ const entries = [];
699
+ const eventTags = row && Array.isArray(row.event_tags) ? row.event_tags : [];
700
+ const directTags = row && Array.isArray(row.tags) ? row.tags : [];
701
+ entries.push(...eventTags);
702
+ entries.push(...directTags);
703
+ if (row && row.tag_id !== undefined) entries.push({ id: row.tag_id });
704
+ if (row && row.tagId !== undefined) entries.push({ id: row.tagId });
705
+ return entries;
706
+ }
707
+
708
+ function readTagId(tag) {
709
+ if (tag === null || tag === undefined) return null;
710
+ if (typeof tag === 'number' && Number.isFinite(tag)) {
711
+ const asInt = Math.trunc(tag);
712
+ return asInt > 0 ? asInt : null;
713
+ }
714
+ if (typeof tag === 'string' && /^\d+$/.test(tag.trim())) {
715
+ const asInt = Math.trunc(Number(tag.trim()));
716
+ return asInt > 0 ? asInt : null;
717
+ }
718
+ if (typeof tag === 'object') {
719
+ const candidate = tag.id !== undefined ? tag.id : tag.tag_id !== undefined ? tag.tag_id : tag.tagId;
720
+ return readTagId(candidate);
721
+ }
722
+ return null;
723
+ }
724
+
725
+ function readTagTextValues(tag) {
726
+ if (tag === null || tag === undefined) return [];
727
+ if (typeof tag === 'string') return [tag];
728
+ if (typeof tag === 'number') return [String(tag)];
729
+ if (typeof tag !== 'object') return [];
730
+ return [
731
+ tag.slug,
732
+ tag.name,
733
+ tag.label,
734
+ tag.title,
735
+ tag.group,
736
+ tag.topic,
737
+ tag.category,
738
+ tag.shortName,
739
+ tag.short_name,
740
+ ]
741
+ .map((value) => String(value || '').trim())
742
+ .filter(Boolean);
743
+ }
744
+
745
+ function includesKeyword(textPool, keywords) {
746
+ return textPool.some((text) => keywords.some((keyword) => text.includes(keyword)));
747
+ }
748
+
749
+ function classifyBrowseCategories(item) {
750
+ const row = item && item.raw && typeof item.raw === 'object' ? item.raw : {};
751
+ const tagEntries = collectTagEntries(row);
752
+ const tagIds = [];
753
+ const tagText = [];
754
+ const seenText = new Set();
755
+ const seenTagIds = new Set();
756
+
757
+ for (const entry of tagEntries) {
758
+ const tagId = readTagId(entry);
759
+ if (tagId !== null && !seenTagIds.has(tagId)) {
760
+ seenTagIds.add(tagId);
761
+ tagIds.push(tagId);
762
+ }
763
+ for (const text of readTagTextValues(entry)) {
764
+ const normalized = normalizeText(text);
765
+ if (!normalized || seenText.has(normalized)) continue;
766
+ seenText.add(normalized);
767
+ tagText.push(normalized);
768
+ }
769
+ }
770
+
771
+ const textPool = tagText.concat(
772
+ [item && item.eventTitle, item && item.eventSlug, item && item.slug, item && item.question]
773
+ .map((value) => normalizeText(value))
774
+ .filter(Boolean),
775
+ );
776
+
777
+ const categories = new Set();
778
+ if (tagIds.some((value) => BROWSE_DEFAULT_SPORT_TAG_IDS.includes(value)) || includesKeyword(textPool, BROWSE_SPORT_KEYWORDS)) {
779
+ categories.add('sports');
780
+ }
781
+ if (includesKeyword(textPool, BROWSE_CRYPTO_KEYWORDS)) {
782
+ categories.add('crypto');
783
+ }
784
+ if (includesKeyword(textPool, BROWSE_POLITICS_KEYWORDS)) {
785
+ categories.add('politics');
786
+ }
787
+ if (includesKeyword(textPool, BROWSE_ENTERTAINMENT_KEYWORDS)) {
788
+ categories.add('entertainment');
789
+ }
790
+
791
+ return {
792
+ categories: Array.from(categories),
793
+ tagIds,
794
+ };
795
+ }
796
+
619
797
  async function fetchGammaRowsByTagIds(params, options = {}, diagnostics = []) {
620
798
  const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 12_000;
621
799
  const gammaUrl = normalizeGammaBaseUrl(options.gammaUrl);
@@ -1132,7 +1310,13 @@ async function browsePolymarketMarkets(options = {}) {
1132
1310
  const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 12_000;
1133
1311
  const requestedLimit = Number.isInteger(Number(options.limit)) && Number(options.limit) > 0 ? Number(options.limit) : 10;
1134
1312
  const scanLimit = Math.max(requestedLimit * 5, 100);
1135
- const polymarketTagIds = normalizeTagIdList(options.polymarketTagIds);
1313
+ const requestedTagIds = normalizeTagIdList(options.polymarketTagIds);
1314
+ const categoryFilters = normalizeBrowseCategoryList(options.categories);
1315
+ const autoSportsTagIds = requestedTagIds.length === 0 && categoryFilters.includes('sports') ? [...BROWSE_DEFAULT_SPORT_TAG_IDS] : [];
1316
+ const polymarketTagIds = requestedTagIds.length ? requestedTagIds : autoSportsTagIds;
1317
+ if (autoSportsTagIds.length) {
1318
+ diagnostics.push(`No explicit sports tag ids provided; using defaults: ${autoSportsTagIds.join(', ')}.`);
1319
+ }
1136
1320
  const useSportsEventsEndpoint = polymarketTagIds.length > 0;
1137
1321
 
1138
1322
  let rows = [];
@@ -1172,20 +1356,86 @@ async function browsePolymarketMarkets(options = {}) {
1172
1356
  const closesAfter = toTimestampSeconds(options.closesAfter);
1173
1357
  const closesBefore = toTimestampSeconds(options.closesBefore);
1174
1358
  const questionContains = normalizeText(options.questionContains);
1359
+ const keyword = normalizeText(options.keyword);
1360
+ const slugContains = normalizeText(options.slug);
1361
+ const excludeSports = Boolean(options.excludeSports);
1362
+ const sortBy = normalizeBrowseSortBy(options.sortBy);
1175
1363
 
1176
1364
  const normalized = rows.map((row) => normalizeMarketRow(row));
1177
- const filtered = normalized.filter((item) => {
1365
+ const enriched = normalized.map((item) => {
1366
+ const classification = classifyBrowseCategories(item);
1367
+ return {
1368
+ ...item,
1369
+ categories: classification.categories,
1370
+ tagIds: classification.tagIds,
1371
+ };
1372
+ });
1373
+
1374
+ const filtered = enriched.filter((item) => {
1178
1375
  if (item.active === false || item.resolved) return false;
1179
- if (minYesPct !== null && toOptionalNumber(item.yesPct) !== null && toOptionalNumber(item.yesPct) < minYesPct) return false;
1180
- if (maxYesPct !== null && toOptionalNumber(item.yesPct) !== null && toOptionalNumber(item.yesPct) > maxYesPct) return false;
1376
+ const yesPct = toOptionalNumber(item.yesPct);
1377
+ if ((minYesPct !== null || maxYesPct !== null) && yesPct === null) return false;
1378
+ if (minYesPct !== null && yesPct < minYesPct) return false;
1379
+ if (maxYesPct !== null && yesPct > maxYesPct) return false;
1181
1380
  if ((toOptionalNumber(item.volume24hUsd) || 0) < minVolume24h) return false;
1182
1381
  if (closesAfter !== null && toIntegerOrNull(item.closeTimestamp) !== null && toIntegerOrNull(item.closeTimestamp) < closesAfter) return false;
1183
1382
  if (closesBefore !== null && toIntegerOrNull(item.closeTimestamp) !== null && toIntegerOrNull(item.closeTimestamp) > closesBefore) return false;
1184
1383
  if (questionContains && !normalizeText(item.question).includes(questionContains)) return false;
1384
+ if (slugContains) {
1385
+ const slugHaystack = [item.slug, item.eventSlug].map((value) => normalizeText(value)).filter(Boolean).join(' ');
1386
+ if (!slugHaystack.includes(slugContains)) return false;
1387
+ }
1388
+ if (keyword) {
1389
+ const keywordHaystack = [item.question, item.slug, item.eventSlug, item.eventTitle]
1390
+ .map((value) => normalizeText(value))
1391
+ .filter(Boolean)
1392
+ .join(' ');
1393
+ if (!keywordHaystack.includes(keyword)) return false;
1394
+ }
1395
+ if (excludeSports && Array.isArray(item.categories) && item.categories.includes('sports')) return false;
1396
+ if (categoryFilters.length) {
1397
+ const hasCategory = Array.isArray(item.categories) && item.categories.some((value) => categoryFilters.includes(value));
1398
+ if (!hasCategory) return false;
1399
+ }
1185
1400
  return true;
1186
1401
  });
1187
1402
 
1188
- filtered.sort((left, right) => (toOptionalNumber(right.volume24hUsd) || 0) - (toOptionalNumber(left.volume24hUsd) || 0));
1403
+ filtered.sort((left, right) => {
1404
+ const leftVolume = toOptionalNumber(left.volume24hUsd) || 0;
1405
+ const rightVolume = toOptionalNumber(right.volume24hUsd) || 0;
1406
+ const leftLiquidity = toOptionalNumber(left.liquidityUsd) || 0;
1407
+ const rightLiquidity = toOptionalNumber(right.liquidityUsd) || 0;
1408
+ const leftClose = toIntegerOrNull(left.closeTimestamp);
1409
+ const rightClose = toIntegerOrNull(right.closeTimestamp);
1410
+
1411
+ if (sortBy === 'liquidity') {
1412
+ if (rightLiquidity !== leftLiquidity) return rightLiquidity - leftLiquidity;
1413
+ if (rightVolume !== leftVolume) return rightVolume - leftVolume;
1414
+ if (leftClose === null && rightClose === null) return 0;
1415
+ if (leftClose === null) return 1;
1416
+ if (rightClose === null) return -1;
1417
+ return leftClose - rightClose;
1418
+ }
1419
+
1420
+ if (sortBy === 'endDate') {
1421
+ if (leftClose === null && rightClose === null) {
1422
+ if (rightVolume !== leftVolume) return rightVolume - leftVolume;
1423
+ return rightLiquidity - leftLiquidity;
1424
+ }
1425
+ if (leftClose === null) return 1;
1426
+ if (rightClose === null) return -1;
1427
+ if (leftClose !== rightClose) return leftClose - rightClose;
1428
+ if (rightVolume !== leftVolume) return rightVolume - leftVolume;
1429
+ return rightLiquidity - leftLiquidity;
1430
+ }
1431
+
1432
+ if (rightVolume !== leftVolume) return rightVolume - leftVolume;
1433
+ if (rightLiquidity !== leftLiquidity) return rightLiquidity - leftLiquidity;
1434
+ if (leftClose === null && rightClose === null) return 0;
1435
+ if (leftClose === null) return 1;
1436
+ if (rightClose === null) return -1;
1437
+ return leftClose - rightClose;
1438
+ });
1189
1439
 
1190
1440
  const items = filtered.slice(0, requestedLimit).map((item) => ({
1191
1441
  marketId: item.marketId,
@@ -1201,6 +1451,7 @@ async function browsePolymarketMarkets(options = {}) {
1201
1451
  liquidityUsd: round(item.liquidityUsd, 6),
1202
1452
  active: item.active,
1203
1453
  resolved: item.resolved,
1454
+ categories: Array.isArray(item.categories) ? item.categories : [],
1204
1455
  url: item.url,
1205
1456
  sourceType: item.source || sourceType,
1206
1457
  }));
@@ -1219,6 +1470,11 @@ async function browsePolymarketMarkets(options = {}) {
1219
1470
  closesAfter,
1220
1471
  closesBefore,
1221
1472
  questionContains: options.questionContains || null,
1473
+ keyword: options.keyword || null,
1474
+ slug: options.slug || null,
1475
+ categories: categoryFilters,
1476
+ excludeSports,
1477
+ sortBy,
1222
1478
  limit: requestedLimit,
1223
1479
  polymarketTagIds,
1224
1480
  },
@@ -267,7 +267,7 @@ function buildCommandDescriptors() {
267
267
  'mirror.browse': commandDescriptor({
268
268
  summary: 'Browse Polymarket mirror candidates with optional sports tag filters.',
269
269
  usage:
270
- 'pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--chain-id <id>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
270
+ 'pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>|--end-date-after <date|72h>] [--closes-before <date>|--end-date-before <date|72h>] [--question-contains <text>|--keyword <text>] [--slug <text>] [--category sports|crypto|politics|entertainment] [--exclude-sports] [--sort-by volume24h|liquidity|endDate] [--limit <n>] [--chain-id <id>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
271
271
  emits: ['mirror.browse', 'mirror.browse.help'],
272
272
  dataSchema: '#/definitions/MirrorBrowsePayload',
273
273
  }),
package/cli/pandora.cjs CHANGED
@@ -516,7 +516,11 @@ function getDoctorServiceInstance() {
516
516
  const ROOT = path.resolve(__dirname, '..');
517
517
  const DEFAULT_ENV_FILE_PRIMARY = path.join(ROOT, 'scripts', '.env');
518
518
  const DEFAULT_ENV_FILE_FALLBACK = path.join(os.homedir(), '.pandora-cli.env');
519
- const DEFAULT_ENV_FILE = fs.existsSync(DEFAULT_ENV_FILE_PRIMARY) ? DEFAULT_ENV_FILE_PRIMARY : DEFAULT_ENV_FILE_FALLBACK;
519
+ const DEFAULT_ENV_FILE = fs.existsSync(DEFAULT_ENV_FILE_FALLBACK)
520
+ ? DEFAULT_ENV_FILE_FALLBACK
521
+ : fs.existsSync(DEFAULT_ENV_FILE_PRIMARY)
522
+ ? DEFAULT_ENV_FILE_PRIMARY
523
+ : DEFAULT_ENV_FILE_FALLBACK;
520
524
  const DEFAULT_ENV_EXAMPLE = path.join(ROOT, 'scripts', '.env.example');
521
525
  const DEFAULT_INDEXER_URL = SHARED_DEFAULT_INDEXER_URL;
522
526
  let PACKAGE_VERSION = '0.0.0';
@@ -855,7 +859,7 @@ Examples:
855
859
 
856
860
  Notes:
857
861
  - launch/clone-bet forward unknown flags directly to underlying scripts.
858
- - Env auto-load default: scripts/.env when present; otherwise ~/.pandora-cli.env. Use --skip-dotenv to disable.
862
+ - Env auto-load default: ~/.pandora-cli.env when present; otherwise scripts/.env. Use --skip-dotenv to disable.
859
863
  - --output json is supported for all commands except launch/clone-bet.
860
864
  - Indexer URL resolution order: --indexer-url, PANDORA_INDEXER_URL, INDEXER_URL, default public indexer.
861
865
  - mirror status --with-live can enrich output with Polymarket position data when POLYMARKET_* credentials are set; missing endpoints/creds return diagnostics instead of hard failures.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pandora-cli-skills",
3
- "version": "1.1.50",
3
+ "version": "1.1.52",
4
4
  "description": "Pandora CLI & Skills",
5
5
  "main": "cli/pandora.cjs",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "init-env": "node cli/pandora.cjs init-env",
30
30
  "doctor": "node cli/pandora.cjs doctor",
31
31
  "setup": "node cli/pandora.cjs setup",
32
+ "analytics:mega": "node analytics/dune/mega/server.cjs",
32
33
  "dry-run": "node cli/pandora.cjs launch --dry-run",
33
34
  "execute": "node cli/pandora.cjs launch --execute",
34
35
  "dry-run:clone": "node cli/pandora.cjs clone-bet --dry-run",
@@ -68,6 +69,7 @@
68
69
  "dependencies": {
69
70
  "@modelcontextprotocol/sdk": "^1.27.1",
70
71
  "@polymarket/clob-client": "^5.2.4",
72
+ "playwright-core": "^1.58.2",
71
73
  "tsx": "^4.21.0",
72
74
  "viem": "^2.46.2",
73
75
  "ws": "^8.19.0"
@@ -5024,6 +5024,126 @@ test('mirror browse supports sports tag filters via gamma events endpoint', asyn
5024
5024
  }
5025
5025
  });
5026
5026
 
5027
+ test('mirror browse supports non-sports short-window filtering in one call', async () => {
5028
+ const indexer = await startIndexerMockServer(buildMirrorIndexerOverrides());
5029
+ const nowMs = Date.now();
5030
+ const toIso = (offsetHours) => new Date(nowMs + offsetHours * 60 * 60 * 1000).toISOString();
5031
+ const gamma = await startJsonHttpServer((request) => {
5032
+ const parsed = new URL(request.url || '/', 'http://127.0.0.1');
5033
+ if (parsed.pathname !== '/markets') {
5034
+ return { status: 404, body: { error: 'not found' } };
5035
+ }
5036
+ return {
5037
+ body: {
5038
+ markets: [
5039
+ {
5040
+ condition_id: 's1',
5041
+ market_slug: 'everton-v-burnley-home',
5042
+ question: 'Will Everton beat Burnley?',
5043
+ end_date_iso: toIso(24),
5044
+ active: true,
5045
+ closed: false,
5046
+ volume24hr: 9000,
5047
+ liquidity: 9000,
5048
+ tags: [{ id: 82, slug: 'soccer' }],
5049
+ tokens: [
5050
+ { outcome: 'Yes', price: '0.61', token_id: 's1-yes' },
5051
+ { outcome: 'No', price: '0.39', token_id: 's1-no' },
5052
+ ],
5053
+ },
5054
+ {
5055
+ condition_id: 'c1',
5056
+ market_slug: 'bitcoin-etf-approval-2026',
5057
+ question: 'Will bitcoin ETF approval happen in 2026?',
5058
+ end_date_iso: toIso(36),
5059
+ active: true,
5060
+ closed: false,
5061
+ volume24hr: 8000,
5062
+ liquidity: 1000,
5063
+ tags: [{ slug: 'crypto' }],
5064
+ tokens: [
5065
+ { outcome: 'Yes', price: '0.45', token_id: 'c1-yes' },
5066
+ { outcome: 'No', price: '0.55', token_id: 'c1-no' },
5067
+ ],
5068
+ },
5069
+ {
5070
+ condition_id: 'c2',
5071
+ market_slug: 'bitcoin-price-120k-2026',
5072
+ question: 'Will bitcoin trade above 120k in 2026?',
5073
+ end_date_iso: toIso(18),
5074
+ active: true,
5075
+ closed: false,
5076
+ volume24hr: 2000,
5077
+ liquidity: 7000,
5078
+ tags: [{ slug: 'crypto' }],
5079
+ tokens: [
5080
+ { outcome: 'Yes', price: '0.55', token_id: 'c2-yes' },
5081
+ { outcome: 'No', price: '0.45', token_id: 'c2-no' },
5082
+ ],
5083
+ },
5084
+ {
5085
+ condition_id: 'x1',
5086
+ market_slug: 'bitcoin-over-300k',
5087
+ question: 'Will bitcoin exceed 300k?',
5088
+ end_date_iso: toIso(12),
5089
+ active: true,
5090
+ closed: false,
5091
+ volume24hr: 10000,
5092
+ liquidity: 1000,
5093
+ tags: [{ slug: 'crypto' }],
5094
+ tokens: [
5095
+ { outcome: 'Yes', price: '0.95', token_id: 'x1-yes' },
5096
+ { outcome: 'No', price: '0.05', token_id: 'x1-no' },
5097
+ ],
5098
+ },
5099
+ ],
5100
+ },
5101
+ };
5102
+ });
5103
+
5104
+ try {
5105
+ const result = await runCliAsync([
5106
+ '--output',
5107
+ 'json',
5108
+ 'mirror',
5109
+ 'browse',
5110
+ '--skip-dotenv',
5111
+ '--indexer-url',
5112
+ indexer.url,
5113
+ '--polymarket-gamma-url',
5114
+ gamma.url,
5115
+ '--exclude-sports',
5116
+ '--end-date-before',
5117
+ '72h',
5118
+ '--min-yes-pct',
5119
+ '15',
5120
+ '--max-yes-pct',
5121
+ '85',
5122
+ '--sort-by',
5123
+ 'volume24h',
5124
+ '--keyword',
5125
+ 'bitcoin',
5126
+ '--limit',
5127
+ '10',
5128
+ ]);
5129
+
5130
+ assert.equal(result.status, 0);
5131
+ const payload = parseJsonOutput(result);
5132
+ assert.equal(payload.ok, true);
5133
+ assert.equal(payload.command, 'mirror.browse');
5134
+ assert.equal(payload.data.filters.excludeSports, true);
5135
+ assert.equal(payload.data.filters.sortBy, 'volume24h');
5136
+ assert.equal(payload.data.count, 2);
5137
+ assert.equal(payload.data.items[0].slug, 'bitcoin-etf-approval-2026');
5138
+ assert.equal(payload.data.items[1].slug, 'bitcoin-price-120k-2026');
5139
+ assert.ok(Array.isArray(payload.data.items[0].categories));
5140
+ assert.ok(payload.data.items[0].categories.includes('crypto'));
5141
+ } finally {
5142
+ await indexer.close();
5143
+ await gamma.close();
5144
+ }
5145
+ });
5146
+
5027
5147
  test('mirror sync accepts --market-address with --dry-run mode alias', async () => {
5028
5148
  const tempDir = createTempDir('pandora-mirror-sync-aliases-');
5029
5149
  const stateFile = path.join(tempDir, 'mirror-state.json');
@@ -53,6 +53,7 @@ const { createParseWatchFlags } = require('../../cli/lib/parsers/watch_flags.cjs
53
53
  const { createParseAutopilotFlags } = require('../../cli/lib/parsers/autopilot_flags.cjs');
54
54
  const { createParseMirrorDeployFlags } = require('../../cli/lib/parsers/mirror_deploy_flags.cjs');
55
55
  const { createParseMirrorGoFlags } = require('../../cli/lib/parsers/mirror_go_flags.cjs');
56
+ const { createParseMirrorBrowseFlags } = require('../../cli/lib/parsers/mirror_remaining_flags.cjs');
56
57
  const { createParseLifecycleFlags } = require('../../cli/lib/parsers/lifecycle_flags.cjs');
57
58
  const { createParseOddsFlags } = require('../../cli/lib/parsers/odds_flags.cjs');
58
59
  const {
@@ -165,6 +166,19 @@ function parserIsSecureHttpUrlOrLocal(value) {
165
166
  return /^https:\/\//.test(value) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/.test(value);
166
167
  }
167
168
 
169
+ function parserParseDateLikeFlag(value, flagName) {
170
+ const text = String(value || '').trim();
171
+ if (/^-?\d+(\.\d+)?$/.test(text)) {
172
+ throw new ParserCliError('INVALID_FLAG_VALUE', `${flagName} must be an ISO date/time string.`);
173
+ }
174
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(text) ? `${text}T00:00:00Z` : text;
175
+ const parsed = Date.parse(normalized);
176
+ if (!Number.isFinite(parsed)) {
177
+ throw new ParserCliError('INVALID_FLAG_VALUE', `${flagName} must be an ISO date/time string.`);
178
+ }
179
+ return text;
180
+ }
181
+
168
182
  function parserParseMirrorSyncGateSkipList(value, flagName) {
169
183
  const checks = String(value)
170
184
  .split(',')
@@ -1152,6 +1166,149 @@ test('browsePolymarketMarkets uses gamma events endpoint for tag-id sports disco
1152
1166
  }
1153
1167
  });
1154
1168
 
1169
+ test('browsePolymarketMarkets supports category/keyword/date filters with explicit sorting', async () => {
1170
+ const nowMs = Date.now();
1171
+ const toIso = (offsetHours) => new Date(nowMs + offsetHours * 60 * 60 * 1000).toISOString();
1172
+
1173
+ const server = http.createServer((req, res) => {
1174
+ const parsed = new URL(req.url || '/', 'http://127.0.0.1');
1175
+ res.statusCode = 200;
1176
+ res.setHeader('content-type', 'application/json');
1177
+ if (parsed.pathname !== '/markets') {
1178
+ res.end(JSON.stringify({ markets: [] }));
1179
+ return;
1180
+ }
1181
+
1182
+ res.end(
1183
+ JSON.stringify({
1184
+ markets: [
1185
+ {
1186
+ condition_id: 'm-sports',
1187
+ market_slug: 'everton-v-burnley-home',
1188
+ question: 'Will Everton beat Burnley?',
1189
+ end_date_iso: toIso(12),
1190
+ active: true,
1191
+ closed: false,
1192
+ volume24hr: 9000,
1193
+ liquidity: 9000,
1194
+ tags: [{ id: 82, slug: 'soccer' }],
1195
+ tokens: [
1196
+ { outcome: 'Yes', price: '0.60', token_id: 'sports-yes' },
1197
+ { outcome: 'No', price: '0.40', token_id: 'sports-no' },
1198
+ ],
1199
+ },
1200
+ {
1201
+ condition_id: 'm-crypto-high-vol',
1202
+ market_slug: 'bitcoin-etf-approval-2026',
1203
+ question: 'Will Bitcoin ETF approval happen in 2026?',
1204
+ end_date_iso: toIso(24),
1205
+ active: true,
1206
+ closed: false,
1207
+ volume24hr: 8000,
1208
+ liquidity: 1000,
1209
+ tags: [{ slug: 'crypto' }],
1210
+ tokens: [
1211
+ { outcome: 'Yes', price: '0.45', token_id: 'c1-yes' },
1212
+ { outcome: 'No', price: '0.55', token_id: 'c1-no' },
1213
+ ],
1214
+ },
1215
+ {
1216
+ condition_id: 'm-crypto-high-liq',
1217
+ market_slug: 'bitcoin-price-120k-2026',
1218
+ question: 'Will bitcoin trade above 120k in 2026?',
1219
+ end_date_iso: toIso(18),
1220
+ active: true,
1221
+ closed: false,
1222
+ volume24hr: 2000,
1223
+ liquidity: 7000,
1224
+ tags: [{ slug: 'crypto' }],
1225
+ tokens: [
1226
+ { outcome: 'Yes', price: '0.55', token_id: 'c2-yes' },
1227
+ { outcome: 'No', price: '0.45', token_id: 'c2-no' },
1228
+ ],
1229
+ },
1230
+ {
1231
+ condition_id: 'm-crypto-extreme',
1232
+ market_slug: 'bitcoin-over-300k',
1233
+ question: 'Will bitcoin exceed 300k?',
1234
+ end_date_iso: toIso(10),
1235
+ active: true,
1236
+ closed: false,
1237
+ volume24hr: 10000,
1238
+ liquidity: 1000,
1239
+ tags: [{ slug: 'crypto' }],
1240
+ tokens: [
1241
+ { outcome: 'Yes', price: '0.95', token_id: 'c3-yes' },
1242
+ { outcome: 'No', price: '0.05', token_id: 'c3-no' },
1243
+ ],
1244
+ },
1245
+ {
1246
+ condition_id: 'm-crypto-far',
1247
+ market_slug: 'bitcoin-long-dated',
1248
+ question: 'Will bitcoin close above 200k by 2028?',
1249
+ end_date_iso: toIso(120),
1250
+ active: true,
1251
+ closed: false,
1252
+ volume24hr: 11000,
1253
+ liquidity: 11000,
1254
+ tags: [{ slug: 'crypto' }],
1255
+ tokens: [
1256
+ { outcome: 'Yes', price: '0.50', token_id: 'c4-yes' },
1257
+ { outcome: 'No', price: '0.50', token_id: 'c4-no' },
1258
+ ],
1259
+ },
1260
+ ],
1261
+ }),
1262
+ );
1263
+ });
1264
+
1265
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
1266
+ const { port } = server.address();
1267
+ const gammaUrl = `http://127.0.0.1:${port}`;
1268
+
1269
+ try {
1270
+ const byVolume = await browsePolymarketMarkets({
1271
+ gammaUrl,
1272
+ minYesPct: 15,
1273
+ maxYesPct: 85,
1274
+ closesBefore: toIso(72),
1275
+ keyword: 'bitcoin',
1276
+ categories: ['crypto'],
1277
+ excludeSports: true,
1278
+ sortBy: 'volume24h',
1279
+ limit: 10,
1280
+ });
1281
+
1282
+ assert.equal(byVolume.count, 2);
1283
+ assert.equal(byVolume.items[0].slug, 'bitcoin-etf-approval-2026');
1284
+ assert.equal(byVolume.items[1].slug, 'bitcoin-price-120k-2026');
1285
+ assert.ok(Array.isArray(byVolume.items[0].categories));
1286
+ assert.ok(byVolume.items[0].categories.includes('crypto'));
1287
+ assert.equal(byVolume.filters.excludeSports, true);
1288
+ assert.deepEqual(byVolume.filters.categories, ['crypto']);
1289
+ assert.equal(byVolume.filters.sortBy, 'volume24h');
1290
+
1291
+ const byLiquidity = await browsePolymarketMarkets({
1292
+ gammaUrl,
1293
+ minYesPct: 15,
1294
+ maxYesPct: 85,
1295
+ closesBefore: toIso(72),
1296
+ keyword: 'bitcoin',
1297
+ categories: ['crypto'],
1298
+ excludeSports: true,
1299
+ sortBy: 'liquidity',
1300
+ limit: 10,
1301
+ });
1302
+
1303
+ assert.equal(byLiquidity.count, 2);
1304
+ assert.equal(byLiquidity.items[0].slug, 'bitcoin-price-120k-2026');
1305
+ assert.equal(byLiquidity.items[1].slug, 'bitcoin-etf-approval-2026');
1306
+ assert.equal(byLiquidity.filters.sortBy, 'liquidity');
1307
+ } finally {
1308
+ await new Promise((resolve) => server.close(resolve));
1309
+ }
1310
+ });
1311
+
1155
1312
  test('computeApprovalDiff deterministically marks missing allowance/operator checks', () => {
1156
1313
  const payload = computeApprovalDiff({
1157
1314
  ownerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
@@ -1493,6 +1650,165 @@ test('runMirrorSync handles thrown hedgeFn errors without consuming idempotency'
1493
1650
  }
1494
1651
  });
1495
1652
 
1653
+ test('runMirrorSync run mode continues after transient tick verification failures', async () => {
1654
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pandora-mirror-sync-tick-retry-'));
1655
+ const stateFile = path.join(tempDir, 'mirror-state.json');
1656
+ const killSwitchFile = path.join(tempDir, 'STOP');
1657
+
1658
+ const verifyPayload = {
1659
+ matchConfidence: 0.99,
1660
+ gateResult: {
1661
+ ok: true,
1662
+ failedChecks: [],
1663
+ checks: [{ code: 'CLOSE_TIME_DELTA', ok: true, meta: { closeDeltaHours: 0 } }],
1664
+ },
1665
+ sourceMarket: {
1666
+ source: 'polymarket',
1667
+ marketId: 'poly-cond-1',
1668
+ yesPct: 60,
1669
+ yesTokenId: 'yes-token',
1670
+ noTokenId: 'no-token',
1671
+ },
1672
+ pandora: {
1673
+ yesPct: 55,
1674
+ reserveYes: 5,
1675
+ reserveNo: 5,
1676
+ },
1677
+ expiry: { minTimeToExpirySec: 7200 },
1678
+ };
1679
+
1680
+ let verifyCallCount = 0;
1681
+
1682
+ try {
1683
+ const payload = await runMirrorSync(
1684
+ {
1685
+ mode: 'run',
1686
+ iterations: 3,
1687
+ indexerUrl: 'https://example.invalid/graphql',
1688
+ timeoutMs: 1000,
1689
+ pandoraMarketAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
1690
+ polymarketMarketId: 'poly-cond-1',
1691
+ executeLive: false,
1692
+ trustDeploy: false,
1693
+ hedgeEnabled: false,
1694
+ hedgeRatio: 1,
1695
+ intervalMs: 1,
1696
+ driftTriggerBps: 10000,
1697
+ hedgeTriggerUsdc: 1000,
1698
+ maxRebalanceUsdc: 25,
1699
+ maxHedgeUsdc: 10,
1700
+ maxOpenExposureUsdc: 100,
1701
+ maxTradesPerDay: 10,
1702
+ cooldownMs: 1000,
1703
+ depthSlippageBps: 100,
1704
+ stateFile,
1705
+ killSwitchFile,
1706
+ polymarketHost: 'https://clob.polymarket.com',
1707
+ },
1708
+ {
1709
+ verifyFn: async () => {
1710
+ verifyCallCount += 1;
1711
+ if (verifyCallCount === 2) {
1712
+ const error = new Error('temporary indexer timeout');
1713
+ error.code = 'INDEXER_TIMEOUT';
1714
+ throw error;
1715
+ }
1716
+ return verifyPayload;
1717
+ },
1718
+ depthFn: async () => ({
1719
+ depthWithinSlippageUsd: 1000,
1720
+ yesDepth: { depthUsd: 1000, midPrice: 0.4, worstPrice: 0.41 },
1721
+ noDepth: { depthUsd: 1000, midPrice: 0.6, worstPrice: 0.61 },
1722
+ }),
1723
+ sleep: async () => {},
1724
+ },
1725
+ );
1726
+
1727
+ assert.equal(payload.iterationsCompleted, 3);
1728
+ assert.equal(payload.snapshots.length, 3);
1729
+ assert.equal(payload.diagnostics.length, 1);
1730
+ assert.equal(payload.diagnostics[0].code, 'INDEXER_TIMEOUT');
1731
+ assert.equal(payload.diagnostics[0].scope, 'tick');
1732
+ assert.equal(payload.snapshots[1].action.status, 'error');
1733
+ assert.equal(payload.snapshots[1].error.code, 'INDEXER_TIMEOUT');
1734
+ } finally {
1735
+ fs.rmSync(tempDir, { recursive: true, force: true });
1736
+ }
1737
+ });
1738
+
1739
+ test('runMirrorSync once mode still fails fast on tick errors', async () => {
1740
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pandora-mirror-sync-once-fail-'));
1741
+ const stateFile = path.join(tempDir, 'mirror-state.json');
1742
+ const killSwitchFile = path.join(tempDir, 'STOP');
1743
+
1744
+ const verifyPayload = {
1745
+ matchConfidence: 0.99,
1746
+ gateResult: {
1747
+ ok: true,
1748
+ failedChecks: [],
1749
+ checks: [{ code: 'CLOSE_TIME_DELTA', ok: true, meta: { closeDeltaHours: 0 } }],
1750
+ },
1751
+ sourceMarket: {
1752
+ source: 'polymarket',
1753
+ marketId: 'poly-cond-1',
1754
+ yesPct: 60,
1755
+ yesTokenId: 'yes-token',
1756
+ noTokenId: 'no-token',
1757
+ },
1758
+ pandora: {
1759
+ yesPct: 55,
1760
+ reserveYes: 5,
1761
+ reserveNo: 5,
1762
+ },
1763
+ expiry: { minTimeToExpirySec: 7200 },
1764
+ };
1765
+
1766
+ try {
1767
+ await assert.rejects(
1768
+ runMirrorSync(
1769
+ {
1770
+ mode: 'once',
1771
+ indexerUrl: 'https://example.invalid/graphql',
1772
+ timeoutMs: 1000,
1773
+ pandoraMarketAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
1774
+ polymarketMarketId: 'poly-cond-1',
1775
+ executeLive: false,
1776
+ trustDeploy: false,
1777
+ hedgeEnabled: false,
1778
+ hedgeRatio: 1,
1779
+ intervalMs: 1,
1780
+ driftTriggerBps: 10000,
1781
+ hedgeTriggerUsdc: 1000,
1782
+ maxRebalanceUsdc: 25,
1783
+ maxHedgeUsdc: 10,
1784
+ maxOpenExposureUsdc: 100,
1785
+ maxTradesPerDay: 10,
1786
+ cooldownMs: 1000,
1787
+ depthSlippageBps: 100,
1788
+ stateFile,
1789
+ killSwitchFile,
1790
+ polymarketHost: 'https://clob.polymarket.com',
1791
+ },
1792
+ {
1793
+ verifyFn: async () => verifyPayload,
1794
+ depthFn: async () => {
1795
+ const error = new Error('depth fetch unavailable');
1796
+ error.code = 'DEPTH_FETCH_FAILED';
1797
+ throw error;
1798
+ },
1799
+ sleep: async () => {},
1800
+ },
1801
+ ),
1802
+ (error) => {
1803
+ assert.equal(error.code, 'DEPTH_FETCH_FAILED');
1804
+ return true;
1805
+ },
1806
+ );
1807
+ } finally {
1808
+ fs.rmSync(tempDir, { recursive: true, force: true });
1809
+ }
1810
+ });
1811
+
1496
1812
  test('runAutopilot does not consume budget/idempotency when executeFn throws', async () => {
1497
1813
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pandora-autopilot-failure-'));
1498
1814
  const stateFile = path.join(tempDir, 'autopilot-state.json');
@@ -1623,6 +1939,59 @@ test('createParseTradeFlags enforces secure --rpc-url and drops dead funder fiel
1623
1939
  );
1624
1940
  });
1625
1941
 
1942
+ test('createParseMirrorBrowseFlags parses relative end-date windows and new browse selectors', () => {
1943
+ const parseMirrorBrowseFlags = createParseMirrorBrowseFlags(
1944
+ buildParserDeps({
1945
+ parseDateLikeFlag: parserParseDateLikeFlag,
1946
+ }),
1947
+ );
1948
+
1949
+ const startedAt = Date.now();
1950
+ const options = parseMirrorBrowseFlags([
1951
+ '--end-date-after',
1952
+ '1h',
1953
+ '--end-date-before',
1954
+ '72h',
1955
+ '--keyword',
1956
+ 'bitcoin',
1957
+ '--slug',
1958
+ 'etf',
1959
+ '--category',
1960
+ 'crypto,politics',
1961
+ '--sort-by',
1962
+ 'liquidity',
1963
+ '--limit',
1964
+ '7',
1965
+ ]);
1966
+
1967
+ assert.equal(options.keyword, 'bitcoin');
1968
+ assert.equal(options.slug, 'etf');
1969
+ assert.deepEqual(options.categories, ['crypto', 'politics']);
1970
+ assert.equal(options.sortBy, 'liquidity');
1971
+ assert.equal(options.limit, 7);
1972
+ assert.ok(Number.isFinite(Date.parse(options.closesAfter)));
1973
+ assert.ok(Number.isFinite(Date.parse(options.closesBefore)));
1974
+ assert.ok(Date.parse(options.closesAfter) >= startedAt + 45 * 60 * 1000);
1975
+ assert.ok(Date.parse(options.closesBefore) >= startedAt + 70 * 60 * 60 * 1000);
1976
+ });
1977
+
1978
+ test('createParseMirrorBrowseFlags rejects contradictory sports filters', () => {
1979
+ const parseMirrorBrowseFlags = createParseMirrorBrowseFlags(
1980
+ buildParserDeps({
1981
+ parseDateLikeFlag: parserParseDateLikeFlag,
1982
+ }),
1983
+ );
1984
+
1985
+ assert.throws(
1986
+ () => parseMirrorBrowseFlags(['--category', 'sports', '--exclude-sports']),
1987
+ (error) => {
1988
+ assert.equal(error.code, 'INVALID_ARGS');
1989
+ assert.match(error.message, /cannot be combined/i);
1990
+ return true;
1991
+ },
1992
+ );
1993
+ });
1994
+
1626
1995
  test('resolveForkRuntime supports attach-only fork mode with strict env/flag precedence', () => {
1627
1996
  const live = resolveForkRuntime(
1628
1997
  { chainId: 146 },