{ "info": { "name": "Market Screener API", "description": "Full test suite for the market-screener Fastify server.\n\nBase URL is stored in the `baseUrl` collection variable (default: http://localhost:3000).\n\nWorkflow order for a clean session:\n1. Health Check\n2. Screen Tickers (creates results to inspect)\n3. Get Market Context\n4. Get Catalysts\n5. Add Holdings → Get Portfolio\n6. Create Market Call → Get Call → Calendar\n7. Analyze\n8. Cleanup (delete holding, delete call)", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "variable": [ { "key": "baseUrl", "value": "http://localhost:3000", "type": "string" }, { "key": "callId", "value": "", "type": "string", "description": "Set automatically by the Create Market Call test script" } ], "item": [ { "name": "Health", "item": [ { "name": "Health Check", "request": { "method": "GET", "url": "{{baseUrl}}/health", "description": "Confirms the server is running. Expects { status: 'ok' }." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "pm.test('Body has status ok', () => {", " const json = pm.response.json();", " pm.expect(json.status).to.eql('ok');", "});" ] } } ] } ] }, { "name": "Screener", "item": [ { "name": "Screen — Mixed (STOCK + ETF + BOND)", "request": { "method": "POST", "url": "{{baseUrl}}/api/screen", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\", \"VOO\", \"AGG\"]\n}" }, "description": "Screens a mixed set of stocks, an ETF (VOO), and a bond ETF (AGG).\n\nExpect each result to have:\n- asset.ticker, asset.type, asset.currentPrice\n- asset.displayMetrics (Cap Tier, Style, Analyst, DCF Safety, 52W fields)\n- fundamental + inflated score labels\n- signal (Strong Buy / Momentum / Speculation / Neutral / Avoid)\n- marketContext (riskFreeRate, rateRegime, benchmarks)" }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "", "pm.test('Response shape is valid', () => {", " const json = pm.response.json();", " pm.expect(json).to.have.all.keys('STOCK', 'ETF', 'BOND', 'ERROR', 'marketContext');", "});", "", "pm.test('marketContext has required fields', () => {", " const ctx = pm.response.json().marketContext;", " pm.expect(ctx).to.have.property('riskFreeRate');", " pm.expect(ctx).to.have.property('rateRegime');", " pm.expect(ctx.benchmarks).to.have.all.keys('marketPE', 'techPE', 'reitYield', 'igSpread');", "});", "", "pm.test('Each stock has expert fields in displayMetrics', () => {", " const stocks = pm.response.json().STOCK;", " if (stocks.length === 0) return;", " const dm = stocks[0].asset.displayMetrics;", " pm.expect(dm).to.have.property('Cap Tier');", " pm.expect(dm).to.have.property('Style');", "});", "", "pm.test('Each stock has a signal', () => {", " pm.response.json().STOCK.forEach(r => {", " pm.expect(r.signal).to.be.oneOf([", " '✅ Strong Buy', '⚡ Momentum', '⚠️ Speculation', '🔄 Neutral', '❌ Avoid'", " ]);", " });", "});" ] } } ] }, { "name": "Screen — Tech Stocks (tests TECHNOLOGY sector override)", "request": { "method": "POST", "url": "{{baseUrl}}/api/screen", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"tickers\": [\"NVDA\", \"META\", \"AMZN\", \"TSLA\"]\n}" }, "description": "High-growth tech tickers to validate DCF margin-of-safety scoring, analyst consensus, and 52W movement fields. Expect 'High Growth' in Style field for most." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "", "pm.test('52W fields present when data available', () => {", " pm.response.json().STOCK.forEach(r => {", " const dm = r.asset.displayMetrics;", " // at least one 52W field should be populated", " const has52W = dm['52W Chg'] || dm['From High'] || dm['52W Pos'];", " pm.expect(has52W).to.be.ok;", " });", "});", "", "pm.test('DCF or analyst field present for stocks with FCF', () => {", " // Not all stocks will have positive FCF, but at least one should have DCF", " const stocks = pm.response.json().STOCK;", " const hasDcf = stocks.some(r => r.asset.displayMetrics['DCF Safety'] != null);", " const hasAnalyst = stocks.some(r => r.asset.displayMetrics['Analyst'] != null);", " pm.expect(hasDcf || hasAnalyst).to.be.true;", "});" ] } } ] }, { "name": "Screen — REIT (tests P/FFO scoring path)", "request": { "method": "POST", "url": "{{baseUrl}}/api/screen", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"tickers\": [\"O\", \"VICI\", \"PLD\"]\n}" }, "description": "REITs should show sector REIT, P/FFO in displayMetrics (not P/E), and be scored on yield rather than the standard Graham gates." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "", "pm.test('REITs land in STOCK bucket with REIT sector', () => {", " pm.response.json().STOCK.forEach(r => {", " pm.expect(r.asset.displayMetrics['Sector']).to.eql('REIT');", " });", "});" ] } } ] }, { "name": "Screen — Validation: empty tickers (expect 400)", "request": { "method": "POST", "url": "{{baseUrl}}/api/screen", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"tickers\": []\n}" }, "description": "Schema validation: minItems: 1. Expect 400 Bad Request." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 400 for empty array', () => pm.response.to.have.status(400));" ] } } ] }, { "name": "Screen — Validation: over 50 tickers (expect 400)", "request": { "method": "POST", "url": "{{baseUrl}}/api/screen", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"tickers\": [\"A\",\"B\",\"C\",\"D\",\"E\",\"F\",\"G\",\"H\",\"I\",\"J\",\"K\",\"L\",\"M\",\"N\",\"O\",\"P\",\"Q\",\"R\",\"S\",\"T\",\"U\",\"V\",\"W\",\"X\",\"Y\",\"Z\",\"AA\",\"BB\",\"CC\",\"DD\",\"EE\",\"FF\",\"GG\",\"HH\",\"II\",\"JJ\",\"KK\",\"LL\",\"MM\",\"NN\",\"OO\",\"PP\",\"QQ\",\"RR\",\"SS\",\"TT\",\"UU\",\"VV\",\"WW\",\"XX\",\"YY\"]\n}" }, "description": "Schema validation: maxItems: 50. 51 tickers should return 400." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 400 for 51 tickers', () => pm.response.to.have.status(400));" ] } } ] }, { "name": "Get Catalysts", "request": { "method": "GET", "url": "{{baseUrl}}/api/screen/catalysts", "description": "Fetches today's Yahoo Finance news, extracts ticker symbols mentioned, and returns { tickers, stories }. May take 3-5s as it queries multiple news endpoints." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "", "pm.test('Response has tickers and stories arrays', () => {", " const json = pm.response.json();", " pm.expect(json.tickers).to.be.an('array');", " pm.expect(json.stories).to.be.an('array');", "});" ] } } ] } ] }, { "name": "Market Context", "item": [ { "name": "Get Market Context", "request": { "method": "GET", "url": "{{baseUrl}}/api/finance/market-context", "description": "Returns live benchmark data: S&P500 price, 10Y rate, VIX, SPY P/E, XLK P/E, XLRE yield, LQD spread. Served from 1-hour in-memory cache." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "", "pm.test('All benchmark fields present', () => {", " const json = pm.response.json();", " pm.expect(json).to.have.property('sp500Price');", " pm.expect(json).to.have.property('riskFreeRate');", " pm.expect(json).to.have.property('vixLevel');", " pm.expect(json).to.have.property('rateRegime');", " pm.expect(json.rateRegime).to.be.oneOf(['LOW', 'NORMAL', 'HIGH']);", " pm.expect(json.benchmarks).to.have.all.keys('marketPE', 'techPE', 'reitYield', 'igSpread');", "});" ] } } ] } ] }, { "name": "Portfolio", "item": [ { "name": "Add Holding — AAPL", "request": { "method": "POST", "url": "{{baseUrl}}/api/finance/holdings", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"ticker\": \"AAPL\",\n \"shares\": 10,\n \"costBasis\": 150.00,\n \"type\": \"stock\",\n \"source\": \"Robinhood\"\n}" }, "description": "Adds or updates an AAPL holding in portfolio.json. Returns the saved holding with status 201." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 201', () => pm.response.to.have.status(201));", "", "pm.test('Saved holding matches input', () => {", " const json = pm.response.json();", " pm.expect(json.ticker).to.eql('AAPL');", " pm.expect(json.shares).to.eql(10);", " pm.expect(json.costBasis).to.eql(150);", "});" ] } } ] }, { "name": "Add Holding — VOO (ETF)", "request": { "method": "POST", "url": "{{baseUrl}}/api/finance/holdings", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"ticker\": \"VOO\",\n \"shares\": 5,\n \"costBasis\": 420.00,\n \"type\": \"etf\",\n \"source\": \"Vanguard\"\n}" } }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 201', () => pm.response.to.have.status(201));" ] } } ] }, { "name": "Add Holding — BTC-USD (Crypto, no scoring)", "request": { "method": "POST", "url": "{{baseUrl}}/api/finance/holdings", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"ticker\": \"BTC-USD\",\n \"shares\": 0.1,\n \"costBasis\": 50000,\n \"type\": \"crypto\",\n \"source\": \"Coinbase\"\n}" }, "description": "Crypto is priced via Yahoo but not fundamentally scored. Advice column shows '—' for signal." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 201', () => pm.response.to.have.status(201));" ] } } ] }, { "name": "Add Holding — Validation: missing shares (expect 400)", "request": { "method": "POST", "url": "{{baseUrl}}/api/finance/holdings", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"ticker\": \"MSFT\"\n}" }, "description": "Schema validation: shares is required. Expect 400." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 400 when shares missing', () => pm.response.to.have.status(400));" ] } } ] }, { "name": "Get Portfolio", "request": { "method": "GET", "url": "{{baseUrl}}/api/finance/portfolio", "description": "Screens all non-crypto holdings via Yahoo Finance, then cross-references with signals to produce buy/hold/sell advice.\n\nEach row has: ticker, signal, advice, reason, currentPrice, marketValue, gainLossPct.\nAlso returns marketContext.\n\nNote: first call after server start may be slow (benchmark cache cold)." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "", "pm.test('Advice array present', () => {", " const json = pm.response.json();", " pm.expect(json.advice).to.be.an('array');", " pm.expect(json.advice.length).to.be.greaterThan(0);", "});", "", "pm.test('Each advice row has required fields', () => {", " pm.response.json().advice.forEach(row => {", " pm.expect(row).to.have.property('ticker');", " pm.expect(row).to.have.property('advice');", " pm.expect(row).to.have.property('reason');", " });", "});" ] } } ] }, { "name": "Remove Holding — AAPL", "request": { "method": "DELETE", "url": "{{baseUrl}}/api/finance/holdings/AAPL", "description": "Removes the AAPL holding from portfolio.json. Expect { ok: true }." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "pm.test('ok true', () => pm.expect(pm.response.json().ok).to.be.true);" ] } } ] }, { "name": "Remove Holding — Non-existent (expect 404)", "request": { "method": "DELETE", "url": "{{baseUrl}}/api/finance/holdings/ZZZZZZ", "description": "Ticker does not exist in portfolio. Expect 404." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 404', () => pm.response.to.have.status(404));" ] } } ] } ] }, { "name": "Market Calls", "item": [ { "name": "List Calls (empty or existing)", "request": { "method": "GET", "url": "{{baseUrl}}/api/calls", "description": "Returns all market calls sorted newest first. Returns { calls: [] } if none exist yet." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "pm.test('calls is array', () => pm.expect(pm.response.json().calls).to.be.an('array'));" ] } } ] }, { "name": "Create Market Call", "request": { "method": "POST", "url": "{{baseUrl}}/api/calls", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"title\": \"AI Infrastructure Supercycle\",\n \"quarter\": \"Q3 2025\",\n \"thesis\": \"Hyperscaler capex remains elevated through 2026 driven by LLM training demand. NVDA, MSFT and AMD are the primary beneficiaries. Entry here as NVDA pulled back 15% from high.\",\n \"tickers\": [\"NVDA\", \"MSFT\", \"AMD\"]\n}" }, "description": "Creates a market thesis call. Snapshots current prices + screener signals at creation time for future comparison.\n\nThe test script saves the returned ID to the {{callId}} collection variable for use in subsequent requests." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 201', () => pm.response.to.have.status(201));", "", "pm.test('Call has id, snapshot, and createdAt', () => {", " const json = pm.response.json();", " pm.expect(json).to.have.property('id');", " pm.expect(json).to.have.property('snapshot');", " pm.expect(json).to.have.property('createdAt');", " // Save for downstream tests", " pm.collectionVariables.set('callId', json.id);", "});" ] } } ] }, { "name": "Get Call by ID (with current re-screen)", "request": { "method": "GET", "url": "{{baseUrl}}/api/calls/{{callId}}", "description": "Fetches the call and re-screens all tickers to show how signal/price has changed since creation.\n\nReturns: original call fields + `current` map of ticker → { price, signal, inflatedVerdict, fundamentalVerdict, pe, roe, fcf }.\n\nDepends on {{callId}} being set by the Create Market Call request." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "", "pm.test('Response has snapshot and current', () => {", " const json = pm.response.json();", " pm.expect(json).to.have.property('snapshot');", " pm.expect(json).to.have.property('current');", "});" ] } } ] }, { "name": "Get Call — Non-existent ID (expect 404)", "request": { "method": "GET", "url": "{{baseUrl}}/api/calls/00000000-0000-0000-0000-000000000000", "description": "A UUID that doesn't exist. Expect 404." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 404', () => pm.response.to.have.status(404));" ] } } ] }, { "name": "Get Earnings Calendar (call tickers)", "request": { "method": "GET", "url": "{{baseUrl}}/api/calls/calendar", "description": "Returns upcoming earnings dates and dividend events for all tickers across all saved calls.\n\nOptional query param ?tickers=AAPL,MSFT to restrict to specific tickers." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "", "pm.test('events is array', () => {", " pm.expect(pm.response.json().events).to.be.an('array');", "});", "", "pm.test('Events have expected shape', () => {", " pm.response.json().events.forEach(e => {", " pm.expect(e).to.have.property('ticker');", " pm.expect(e).to.have.property('type');", " pm.expect(e.type).to.be.oneOf(['earnings', 'dividend', 'exdividend']);", " pm.expect(e).to.have.property('date');", " pm.expect(e).to.have.property('isPast');", " });", "});" ] } } ] }, { "name": "Get Earnings Calendar — Specific Tickers", "request": { "method": "GET", "url": { "raw": "{{baseUrl}}/api/calls/calendar?tickers=AAPL,MSFT", "query": [ { "key": "tickers", "value": "AAPL,MSFT" } ] }, "description": "Calendar for specific tickers regardless of saved calls." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "pm.test('Only AAPL and MSFT events', () => {", " pm.response.json().events.forEach(e => {", " pm.expect(e.ticker).to.be.oneOf(['AAPL', 'MSFT']);", " });", "});" ] } } ] }, { "name": "Create Call — Validation: short thesis (expect 400)", "request": { "method": "POST", "url": "{{baseUrl}}/api/calls", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"title\": \"Test\",\n \"quarter\": \"Q1\",\n \"thesis\": \"short\",\n \"tickers\": [\"AAPL\"]\n}" }, "description": "Schema: thesis minLength: 10. Expect 400." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 400 for short thesis', () => pm.response.to.have.status(400));" ] } } ] }, { "name": "Delete Call", "request": { "method": "DELETE", "url": "{{baseUrl}}/api/calls/{{callId}}", "description": "Deletes the call created earlier. Returns { ok: true }. Requires {{callId}} to be set." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200', () => pm.response.to.have.status(200));", "pm.test('ok true', () => pm.expect(pm.response.json().ok).to.be.true);" ] } } ] }, { "name": "Delete Call — Already Deleted (expect 404)", "request": { "method": "DELETE", "url": "{{baseUrl}}/api/calls/{{callId}}", "description": "Second delete of the same ID. Expect 404." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 404 on double delete', () => pm.response.to.have.status(404));" ] } } ] } ] }, { "name": "LLM Analysis", "item": [ { "name": "Analyze Tickers", "request": { "method": "POST", "url": "{{baseUrl}}/api/analyze", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"tickers\": [\"NVDA\", \"AMD\", \"INTC\"]\n}" }, "description": "Fetches Yahoo Finance news for the given tickers, then sends headlines to Claude (Haiku) for analysis.\n\nReturns: { analysis: { summary, sentiment, affectedIndustries, relatedTickers } }\n\nReturns 400 if ANTHROPIC_API_KEY is not set in .env.\nReturns { analysis: null, reason: 'no_stories' } if no news found." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 200 or 400', () => {", " pm.expect(pm.response.code).to.be.oneOf([200, 400]);", "});", "", "if (pm.response.code === 200) {", " pm.test('Analysis shape valid (if present)', () => {", " const json = pm.response.json();", " if (json.analysis) {", " pm.expect(json.analysis).to.have.property('summary');", " pm.expect(json.analysis).to.have.property('sentiment');", " pm.expect(json.analysis.sentiment).to.be.oneOf(['BULLISH', 'NEUTRAL', 'BEARISH']);", " pm.expect(json.analysis.affectedIndustries).to.be.an('array');", " pm.expect(json.analysis.relatedTickers).to.be.an('array');", " }", " });", "} else {", " pm.test('400 means API key not set', () => {", " pm.expect(pm.response.json().error).to.include('ANTHROPIC_API_KEY');", " });", "}" ] } } ] }, { "name": "Analyze — Validation: empty tickers (expect 400)", "request": { "method": "POST", "url": "{{baseUrl}}/api/analyze", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"tickers\": []\n}" }, "description": "Schema validation: minItems: 1. Expect 400." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('Status 400', () => pm.response.to.have.status(400));" ] } } ] } ] } ] }