Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pm-filtering-address-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": minor
---

Add prediction market filtering (order_by, volume/liquidity/OI/trader/price/date filters, neg_risk, tags) and address-summary endpoint
243 changes: 239 additions & 4 deletions src/__tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,19 @@ const MOCK_RESPONSES = {
],
pagination: { page: 1, per_page: 100 }
},
pmAddressSummary: {
address: '0x1234567890abcdef1234567890abcdef12345678',
first_seen: '2024-01-15T00:00:00Z',
wallet_age_days: 450,
realized_pnl_usd: 12500,
unrealized_pnl_usd: 3200,
total_pnl_usd: 15700,
markets_won: 8,
markets_traded: 15,
win_rate: 0.533,
p2p_tokens_sent: 5000,
p2p_tokens_received: 3000
},
// Smart Alert endpoints
alertsList: [
{ id: 'alert-1', name: 'ETH SM Flows', type: 'sm-token-flows', isEnabled: true }
Expand Down Expand Up @@ -1695,11 +1708,11 @@ describe('NansenAPI', () => {
expect(result.data[0]).toHaveProperty('volume_usd', 12500);
});

it('should pass sort parameter', async () => {
it('should pass sort as order_by (backward compat)', async () => {
setupMock(MOCK_RESPONSES.pmOhlcv);
await api.pmOhlcv({ marketId: '654412', sort: [{ field: 'period_start', direction: 'DESC' }] });
const body = expectFetchCalledWith('/api/v1/prediction-market/ohlcv');
expect(body.sort).toEqual([{ field: 'period_start', direction: 'DESC' }]);
expect(body.order_by).toEqual([{ field: 'period_start', direction: 'DESC' }]);
});

it('should require marketId', async () => {
Expand Down Expand Up @@ -1820,11 +1833,11 @@ describe('NansenAPI', () => {
});

describe('pmTopHolders (sort)', () => {
it('should pass sort parameter', async () => {
it('should pass sort as order_by (backward compat)', async () => {
setupMock(MOCK_RESPONSES.pmTopHolders);
await api.pmTopHolders({ marketId: '654412', sort: [{ field: 'position_size', direction: 'DESC' }] });
const body = expectFetchCalledWith('/api/v1/prediction-market/top-holders');
expect(body.sort).toEqual([{ field: 'position_size', direction: 'DESC' }]);
expect(body.order_by).toEqual([{ field: 'position_size', direction: 'DESC' }]);
});

it('should pass pagination', async () => {
Expand Down Expand Up @@ -1915,6 +1928,228 @@ describe('NansenAPI', () => {
expect(body.pagination).toEqual({ page: 1, per_page: 10 });
});
});

describe('pmAddressSummary', () => {
it('should fetch address summary with correct endpoint', async () => {
setupMock(MOCK_RESPONSES.pmAddressSummary);
const result = await api.pmAddressSummary({ address: '0x1234567890abcdef1234567890abcdef12345678' });
expectFetchCalledWith('/api/v1/prediction-market/address-summary', { address: '0x1234567890abcdef1234567890abcdef12345678' });
expect(result).toHaveProperty('total_pnl_usd', 15700);
expect(result).toHaveProperty('win_rate', 0.533);
expect(result).toHaveProperty('markets_traded', 15);
});

it('should validate address format', async () => {
try {
await api.pmAddressSummary({ address: 'invalid' });
} catch (e) {
expect(e.code).toBe(ErrorCode.INVALID_ADDRESS);
}
});

it('should require address', async () => {
try {
await api.pmAddressSummary({});
} catch (e) {
expect(e.code).toBe(ErrorCode.MISSING_PARAM);
}
});
});

describe('order_by parameter', () => {
it('should pass order_by to pmOhlcv', async () => {
setupMock(MOCK_RESPONSES.pmOhlcv);
const orderBy = [{ field: 'volume_usd', direction: 'ASC' }];
await api.pmOhlcv({ marketId: '654412', orderBy });
const body = expectFetchCalledWith('/api/v1/prediction-market/ohlcv');
expect(body.order_by).toEqual(orderBy);
});

it('should fall back to sort param as order_by for pmOhlcv', async () => {
setupMock(MOCK_RESPONSES.pmOhlcv);
const sort = [{ field: 'period_start', direction: 'DESC' }];
await api.pmOhlcv({ marketId: '654412', sort });
const body = expectFetchCalledWith('/api/v1/prediction-market/ohlcv');
expect(body.order_by).toEqual(sort);
});

it('should pass order_by to pmTopHolders', async () => {
setupMock(MOCK_RESPONSES.pmTopHolders);
const orderBy = [{ field: 'position_size', direction: 'ASC' }];
await api.pmTopHolders({ marketId: '654412', orderBy });
const body = expectFetchCalledWith('/api/v1/prediction-market/top-holders');
expect(body.order_by).toEqual(orderBy);
});

it('should pass order_by to pmTradesByMarket', async () => {
setupMock(MOCK_RESPONSES.pmTradesByMarket);
const orderBy = [{ field: 'timestamp', direction: 'ASC' }];
await api.pmTradesByMarket({ marketId: '654412', orderBy });
const body = expectFetchCalledWith('/api/v1/prediction-market/trades-by-market');
expect(body.order_by).toEqual(orderBy);
});

it('should pass order_by to pmTradesByAddress', async () => {
setupMock(MOCK_RESPONSES.pmTradesByAddress);
const orderBy = [{ field: 'timestamp', direction: 'DESC' }];
await api.pmTradesByAddress({ address: '0x1234567890abcdef1234567890abcdef12345678', orderBy });
const body = expectFetchCalledWith('/api/v1/prediction-market/trades-by-address');
expect(body.order_by).toEqual(orderBy);
});

it('should pass order_by to pmPnlByMarket', async () => {
setupMock(MOCK_RESPONSES.pmPnlByMarket);
const orderBy = [{ field: 'total_pnl_usd', direction: 'ASC' }];
await api.pmPnlByMarket({ marketId: '654412', orderBy });
const body = expectFetchCalledWith('/api/v1/prediction-market/pnl-by-market');
expect(body.order_by).toEqual(orderBy);
});

it('should pass order_by to pmPnlByAddress', async () => {
setupMock(MOCK_RESPONSES.pmPnlByAddress);
const orderBy = [{ field: 'total_pnl_usd', direction: 'DESC' }];
await api.pmPnlByAddress({ address: '0x1234567890abcdef1234567890abcdef12345678', orderBy });
const body = expectFetchCalledWith('/api/v1/prediction-market/pnl-by-address');
expect(body.order_by).toEqual(orderBy);
});
});

describe('pmMarketScreener (filters)', () => {
it('should pass order_by alongside sort_by', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ orderBy: [{ field: 'liquidity', direction: 'ASC' }] });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.order_by).toEqual([{ field: 'liquidity', direction: 'ASC' }]);
expect(body.sort_by).toBe('volume_24hr');
});

it('should send sort_by without order_by when no orderBy provided', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ sortBy: 'open_interest' });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.sort_by).toBe('open_interest');
expect(body).not.toHaveProperty('order_by');
});

it('should pass volume filters', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ minVolume24hr: 1000, maxVolume24hr: 50000 });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.min_volume_24hr).toBe(1000);
expect(body.max_volume_24hr).toBe(50000);
});

it('should pass liquidity filters', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ minLiquidity: 5000, maxLiquidity: 100000 });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.min_liquidity).toBe(5000);
expect(body.max_liquidity).toBe(100000);
});

it('should pass open interest filters', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ minOpenInterest: 10000, maxOpenInterest: 500000 });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.min_open_interest).toBe(10000);
expect(body.max_open_interest).toBe(500000);
});

it('should pass unique traders filters', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ minUniqueTraders24h: 10, maxUniqueTraders24h: 500 });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.min_unique_traders_24h).toBe(10);
expect(body.max_unique_traders_24h).toBe(500);
});

it('should pass neg_risk boolean', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ negRisk: true });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.neg_risk).toBe(true);
});

it('should pass tags as array', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ tags: ['crypto', 'sports'] });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.tags).toEqual(['crypto', 'sports']);
});

it('should pass price filters', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ minPrice: 0.1, maxPrice: 0.9 });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.min_price).toBe(0.1);
expect(body.max_price).toBe(0.9);
});

it('should pass date filters', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ endDateBefore: '2025-12-31', endDateAfter: '2025-01-01' });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.end_date_before).toBe('2025-12-31');
expect(body.end_date_after).toBe('2025-01-01');
});

it('should not include filters when not provided', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({});
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body).not.toHaveProperty('min_volume_24hr');
expect(body).not.toHaveProperty('max_volume_24hr');
expect(body).not.toHaveProperty('tags');
expect(body).not.toHaveProperty('neg_risk');
});

it('should pass 0 as a valid filter value (not sentinel)', async () => {
setupMock(MOCK_RESPONSES.pmMarketScreener);
await api.pmMarketScreener({ minLiquidity: 0, maxLiquidity: 0, minVolume24hr: 0 });
const body = expectFetchCalledWith('/api/v1/prediction-market/market-screener');
expect(body.min_liquidity).toBe(0);
expect(body.max_liquidity).toBe(0);
expect(body.min_volume_24hr).toBe(0);
});
});

describe('pmEventScreener (filters)', () => {
it('should pass order_by alongside sort_by to event screener', async () => {
setupMock(MOCK_RESPONSES.pmEventScreener);
await api.pmEventScreener({ orderBy: [{ field: 'volume_24hr', direction: 'ASC' }] });
const body = expectFetchCalledWith('/api/v1/prediction-market/event-screener');
expect(body.order_by).toEqual([{ field: 'volume_24hr', direction: 'ASC' }]);
expect(body.sort_by).toBe('volume_24hr');
});

it('should pass volume and liquidity filters', async () => {
setupMock(MOCK_RESPONSES.pmEventScreener);
await api.pmEventScreener({ minVolume24hr: 1000, maxVolume24hr: 50000, minLiquidity: 5000, maxLiquidity: 100000 });
const body = expectFetchCalledWith('/api/v1/prediction-market/event-screener');
expect(body.min_volume_24hr).toBe(1000);
expect(body.max_volume_24hr).toBe(50000);
expect(body.min_liquidity).toBe(5000);
expect(body.max_liquidity).toBe(100000);
});

it('should pass unique traders and open interest filters', async () => {
setupMock(MOCK_RESPONSES.pmEventScreener);
await api.pmEventScreener({ minUniqueTraders24h: 10, maxUniqueTraders24h: 500, minOpenInterest: 1000, maxOpenInterest: 100000 });
const body = expectFetchCalledWith('/api/v1/prediction-market/event-screener');
expect(body.min_unique_traders_24h).toBe(10);
expect(body.max_unique_traders_24h).toBe(500);
expect(body.min_open_interest).toBe(1000);
expect(body.max_open_interest).toBe(100000);
});

it('should pass neg_risk and tags', async () => {
setupMock(MOCK_RESPONSES.pmEventScreener);
await api.pmEventScreener({ negRisk: false, tags: ['politics'] });
const body = expectFetchCalledWith('/api/v1/prediction-market/event-screener');
expect(body.neg_risk).toBe(false);
expect(body.tags).toEqual(['politics']);
});
});
});

// =================== Error Handling ===================
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,15 @@ describe('CLI Smoke Tests', () => {
expect(stdout).toContain('ohlcv');
expect(stdout).toContain('market-screener');
expect(stdout).toContain('categories');
expect(stdout).toContain('address-summary');
});

it('should route top-level pm alias to prediction-market', () => {
const { stdout } = runCLI('pm help');
expect(stdout).toContain('ohlcv');
expect(stdout).toContain('market-screener');
expect(stdout).toContain('categories');
expect(stdout).toContain('address-summary');
});

it('should error on pm ohlcv without --market-id', () => {
Expand All @@ -156,6 +158,22 @@ describe('CLI Smoke Tests', () => {
expect(json.code).toBe('INVALID_ADDRESS');
});

it('should error on pm address-summary without --address', () => {
const { stdout, stderr, exitCode } = runCLI('research pm address-summary');
expect(exitCode).not.toBe(0);
const output = stdout || stderr;
const json = JSON.parse(output.split('\n').find(l => l.startsWith('{')));
expect(json.code).toBe('MISSING_PARAM');
});

it('should error on pm address-summary with invalid address', () => {
const { stdout, stderr, exitCode } = runCLI('research pm address-summary --address notanaddress');
expect(exitCode).not.toBe(0);
const output = stdout || stderr;
const json = JSON.parse(output.split('\n').find(l => l.startsWith('{')));
expect(json.code).toBe('INVALID_ADDRESS');
});

it('should list available subcommands for unknown pm subcommand', () => {
const { stdout, stderr, exitCode } = runCLI('research pm nonexistent');
expect(exitCode).not.toBe(0);
Expand Down
Loading
Loading