Skip to content

Commit 1cf43c3

Browse files
committed
test: add auto-discovery param-forwarding test framework
Parses TypeScript source to discover all fetchRaw* methods and their param types, then verifies each param reaches the outgoing API call. 47 known failures document params silently dropped by venue fetchers.
1 parent 5efe02c commit 1cf43c3

2 files changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* Auto-discovers fetchRaw* methods and their param types from exchange
3+
* fetcher source files and BaseExchange.ts interface definitions.
4+
*/
5+
6+
import * as fs from 'fs';
7+
import * as path from 'path';
8+
9+
export interface ParamField {
10+
name: string;
11+
type: string;
12+
}
13+
14+
export interface DiscoveredMethod {
15+
exchange: string;
16+
method: string;
17+
paramType: string;
18+
fields: ParamField[];
19+
argPattern: 'params-only' | 'id-params' | 'params-wallet' | 'id-params-extra';
20+
}
21+
22+
function parseInterfaces(source: string): Map<string, ParamField[]> {
23+
const result = new Map<string, ParamField[]>();
24+
const re = /export\s+interface\s+(\w+)(?:\s+extends\s+([\w,\s]+))?\s*\{/g;
25+
26+
let match: RegExpExecArray | null;
27+
while ((match = re.exec(source)) !== null) {
28+
const name = match[1];
29+
const startBrace = match.index + match[0].length - 1;
30+
let depth = 1;
31+
let pos = startBrace + 1;
32+
while (pos < source.length && depth > 0) {
33+
if (source[pos] === '{') depth++;
34+
if (source[pos] === '}') depth--;
35+
pos++;
36+
}
37+
const body = source.slice(startBrace + 1, pos - 1);
38+
result.set(name, parseFieldsFromBody(body));
39+
}
40+
return result;
41+
}
42+
43+
function parseExtends(source: string): Map<string, string[]> {
44+
const result = new Map<string, string[]>();
45+
const re = /export\s+interface\s+(\w+)\s+extends\s+([\w,\s]+)\s*\{/g;
46+
let match: RegExpExecArray | null;
47+
while ((match = re.exec(source)) !== null) {
48+
result.set(match[1], match[2].split(',').map((s) => s.trim()).filter(Boolean));
49+
}
50+
return result;
51+
}
52+
53+
function parseFieldsFromBody(body: string): ParamField[] {
54+
const fields: ParamField[] = [];
55+
const stripped = body.replace(/\/\*\*[\s\S]*?\*\//g, '');
56+
const re = /^\s*(\w+)\??\s*:\s*([^;/\n]+)/gm;
57+
let m: RegExpExecArray | null;
58+
while ((m = re.exec(stripped)) !== null) {
59+
fields.push({ name: m[1], type: normalizeType(m[2].trim()) });
60+
}
61+
return fields;
62+
}
63+
64+
function normalizeType(raw: string): string {
65+
if (/^'[^']*'(\s*\|\s*'[^']*')*$/.test(raw)) return 'string';
66+
if (raw === 'CandleInterval') return 'string';
67+
return raw;
68+
}
69+
70+
function resolveFields(
71+
name: string,
72+
ownFields: Map<string, ParamField[]>,
73+
extendsMap: Map<string, string[]>,
74+
visited: Set<string> = new Set(),
75+
): ParamField[] {
76+
if (visited.has(name)) return [];
77+
visited.add(name);
78+
79+
const own = ownFields.get(name) ?? [];
80+
const parents = extendsMap.get(name) ?? [];
81+
const inherited: ParamField[] = [];
82+
for (const parent of parents) {
83+
inherited.push(...resolveFields(parent, ownFields, extendsMap, visited));
84+
}
85+
86+
const byName = new Map<string, ParamField>();
87+
for (const f of inherited) byName.set(f.name, f);
88+
for (const f of own) byName.set(f.name, f);
89+
return Array.from(byName.values());
90+
}
91+
92+
function parseMethodSignatures(source: string): { method: string; paramType: string; argPattern: DiscoveredMethod['argPattern'] }[] {
93+
const results: { method: string; paramType: string; argPattern: DiscoveredMethod['argPattern'] }[] = [];
94+
const methodRe = /(?:^|\n)\s+async\s+(fetchRaw\w+)\s*\(/g;
95+
96+
let match: RegExpExecArray | null;
97+
while ((match = methodRe.exec(source)) !== null) {
98+
const lineStart = source.lastIndexOf('\n', match.index) + 1;
99+
const prefix = source.slice(lineStart, match.index + match[0].length);
100+
if (/private\s+async\s+fetchRaw/.test(prefix)) continue;
101+
102+
const argsStart = match.index + match[0].length;
103+
let depth = 1;
104+
let pos = argsStart;
105+
while (pos < source.length && depth > 0) {
106+
if (source[pos] === '(') depth++;
107+
if (source[pos] === ')') depth--;
108+
pos++;
109+
}
110+
111+
const argsBody = source.slice(argsStart, pos - 1);
112+
const args = splitArgs(argsBody);
113+
114+
let paramIdx = -1;
115+
let paramType = '';
116+
for (let i = 0; i < args.length; i++) {
117+
const argMatch = args[i].trim().match(/^_?(\w+)\??\s*:\s*(.+)$/s);
118+
if (!argMatch) continue;
119+
const typeName = argMatch[2].trim();
120+
if (/^Record\s*</.test(typeName) || /^\{/.test(typeName)) continue;
121+
if (/^\w+Params$/.test(typeName)) {
122+
paramIdx = i;
123+
paramType = typeName;
124+
break;
125+
}
126+
}
127+
128+
if (paramIdx === -1) continue;
129+
130+
let argPattern: DiscoveredMethod['argPattern'];
131+
if (args.length === 1 && paramIdx === 0) argPattern = 'params-only';
132+
else if (args.length === 2 && paramIdx === 1) argPattern = 'id-params';
133+
else if (args.length === 2 && paramIdx === 0) argPattern = 'params-wallet';
134+
else argPattern = 'id-params-extra';
135+
136+
results.push({ method: match[1], paramType, argPattern });
137+
}
138+
return results;
139+
}
140+
141+
function splitArgs(argsBody: string): string[] {
142+
const result: string[] = [];
143+
let current = '';
144+
let angle = 0;
145+
let brace = 0;
146+
for (const ch of argsBody) {
147+
if (ch === '<') angle++;
148+
if (ch === '>') angle--;
149+
if (ch === '{') brace++;
150+
if (ch === '}') brace--;
151+
if (ch === ',' && angle === 0 && brace === 0) {
152+
result.push(current);
153+
current = '';
154+
} else {
155+
current += ch;
156+
}
157+
}
158+
if (current.trim()) result.push(current);
159+
return result;
160+
}
161+
162+
export function discoverTestMatrix(coreDir: string): DiscoveredMethod[] {
163+
const baseSource = fs.readFileSync(path.join(coreDir, 'BaseExchange.ts'), 'utf-8');
164+
const ownFields = parseInterfaces(baseSource);
165+
const extendsMap = parseExtends(baseSource);
166+
167+
const resolvedFields = new Map<string, ParamField[]>();
168+
for (const name of ownFields.keys()) {
169+
resolvedFields.set(name, resolveFields(name, ownFields, extendsMap));
170+
}
171+
172+
const exchangesDir = path.join(coreDir, 'exchanges');
173+
const exchangeDirs = fs.readdirSync(exchangesDir, { withFileTypes: true })
174+
.filter((e) => e.isDirectory())
175+
.map((e) => e.name)
176+
.sort();
177+
178+
const discovered: DiscoveredMethod[] = [];
179+
180+
for (const exchangeName of exchangeDirs) {
181+
const fetcherPath = path.join(exchangesDir, exchangeName, 'fetcher.ts');
182+
if (!fs.existsSync(fetcherPath)) continue;
183+
184+
const fetcherSource = fs.readFileSync(fetcherPath, 'utf-8');
185+
for (const sig of parseMethodSignatures(fetcherSource)) {
186+
const fields = resolvedFields.get(sig.paramType);
187+
if (!fields) continue;
188+
189+
discovered.push({
190+
exchange: exchangeName,
191+
method: sig.method,
192+
paramType: sig.paramType,
193+
fields,
194+
argPattern: sig.argPattern,
195+
});
196+
}
197+
}
198+
199+
return discovered;
200+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Param-forwarding contract test: auto-discovers all fetchRaw* methods
3+
* from TypeScript source, then verifies each forwards user params to
4+
* the outgoing API call.
5+
*/
6+
7+
import path from 'path';
8+
import { discoverTestMatrix, DiscoveredMethod } from './param-discovery';
9+
10+
import { PolymarketFetcher } from '../../src/exchanges/polymarket/fetcher';
11+
import { KalshiFetcher } from '../../src/exchanges/kalshi/fetcher';
12+
import { MyriadFetcher } from '../../src/exchanges/myriad/fetcher';
13+
import { ProbableFetcher } from '../../src/exchanges/probable/fetcher';
14+
import { SmarketsFetcher } from '../../src/exchanges/smarkets/fetcher';
15+
import { HyperliquidFetcher } from '../../src/exchanges/hyperliquid/fetcher';
16+
import { OpinionFetcher } from '../../src/exchanges/opinion/fetcher';
17+
import { LimitlessFetcher } from '../../src/exchanges/limitless/fetcher';
18+
import { GeminiFetcher } from '../../src/exchanges/gemini-titan/fetcher';
19+
20+
const FETCHER_FACTORIES: Record<string, (ctx: any, http: any) => any> = {
21+
polymarket: (ctx, http) => new PolymarketFetcher(ctx, http),
22+
kalshi: (ctx) => new KalshiFetcher(ctx),
23+
myriad: (ctx) => new MyriadFetcher(ctx),
24+
probable: (ctx) => new ProbableFetcher(ctx),
25+
smarkets: (ctx) => new SmarketsFetcher(ctx),
26+
hyperliquid: (ctx) => new HyperliquidFetcher(ctx, 'https://fake'),
27+
opinion: (ctx) => new OpinionFetcher(ctx),
28+
limitless: (ctx, http) => new LimitlessFetcher(ctx, http),
29+
'gemini-titan': (ctx) => new GeminiFetcher(ctx, 'https://fake'),
30+
};
31+
32+
// Exchange-specific IDs that pass each venue's format validation.
33+
const EXCHANGE_IDS: Record<string, string> = {
34+
hyperliquid: 'hl-outcome-1',
35+
myriad: '1:2:3',
36+
};
37+
38+
const SENTINELS: Record<string, { value: unknown; needles: string[] }> = {
39+
number: { value: 770001, needles: ['770001'] },
40+
Date: { value: new Date('2099-01-01T00:00:00Z'), needles: ['2099', '4070908800'] },
41+
string: { value: 'SENTINEL_770003', needles: ['SENTINEL_770003'] },
42+
'string[]': { value: ['SENTINEL_770004'], needles: ['SENTINEL_770004'] },
43+
};
44+
45+
// resolution is mapped through lookup tables (e.g., '1h' -> 60), so a
46+
// string sentinel can never survive. Skip it — resolution forwarding is
47+
// a mapping test, not a forwarding test.
48+
const SKIP_FIELDS = new Set(['resolution']);
49+
50+
function createMockCtx() {
51+
const captured: unknown[] = [];
52+
const noopResponse = {
53+
data: { data: [], result: { list: [], total: 0 }, trades: [], fills: [], account_activity: [], cursor: '' },
54+
};
55+
const mockHttp: any = {
56+
get: async (_url: string, opts?: any) => { captured.push(opts?.params ?? {}); return noopResponse; },
57+
post: async (_url: string, data?: any) => { captured.push(data ?? {}); return noopResponse; },
58+
request: async (config: any) => { captured.push(config.params ?? config.data ?? {}); return noopResponse; },
59+
};
60+
const ctx: any = {
61+
http: mockHttp,
62+
callApi: async (_op: string, params?: any) => { captured.push(params ?? {}); return []; },
63+
getHeaders: () => ({}),
64+
};
65+
return { ctx, mockHttp, captured };
66+
}
67+
68+
function buildArgs(pattern: string, params: Record<string, unknown>, exchange: string): unknown[] {
69+
const id = EXCHANGE_IDS[exchange] || 'FAKE_ID';
70+
switch (pattern) {
71+
case 'params-only': return [params];
72+
case 'id-params': return [id, params];
73+
case 'params-wallet': return [params, '0xFAKE_WALLET'];
74+
default: return [id, params];
75+
}
76+
}
77+
78+
function hasSentinel(captured: unknown[], needles: string[]): boolean {
79+
const serialized = JSON.stringify(captured);
80+
return needles.some((n) => serialized.includes(n));
81+
}
82+
83+
describe('param forwarding (fetcher level)', () => {
84+
const matrix = discoverTestMatrix(path.resolve(__dirname, '../../src'));
85+
86+
// fetchRawMarkets and fetchRawEvents have their params (limit, offset, sort, etc.)
87+
// handled client-side by BaseExchange.fetchMarketsImpl/fetchEventsImpl — these are
88+
// intentionally NOT forwarded to the fetcher's upstream API call.
89+
const SKIP_METHODS = new Set(['fetchRawMarkets', 'fetchRawEvents']);
90+
91+
const grouped = new Map<string, DiscoveredMethod[]>();
92+
for (const entry of matrix) {
93+
if (!FETCHER_FACTORIES[entry.exchange]) continue;
94+
if (SKIP_METHODS.has(entry.method)) continue;
95+
const list = grouped.get(entry.exchange) ?? [];
96+
list.push(entry);
97+
grouped.set(entry.exchange, list);
98+
}
99+
100+
for (const [exchange, methods] of grouped) {
101+
describe(exchange, () => {
102+
for (const method of methods) {
103+
for (const field of method.fields) {
104+
if (SKIP_FIELDS.has(field.name)) continue;
105+
const sentinel = SENTINELS[field.type];
106+
if (!sentinel) continue;
107+
108+
it(`${method.method} forwards ${field.name}`, async () => {
109+
const { ctx, mockHttp, captured } = createMockCtx();
110+
const fetcher = FETCHER_FACTORIES[exchange](ctx, mockHttp);
111+
112+
if (typeof fetcher[method.method] !== 'function') return;
113+
114+
const params = { [field.name]: sentinel.value };
115+
const args = buildArgs(method.argPattern, params, exchange);
116+
117+
try {
118+
await fetcher[method.method](...args);
119+
} catch {
120+
// errors expected — we only care about what was sent
121+
}
122+
123+
expect(hasSentinel(captured, sentinel.needles)).toBe(true);
124+
});
125+
}
126+
}
127+
});
128+
}
129+
});

0 commit comments

Comments
 (0)