Compare commits

..

2 Commits

Author SHA1 Message Date
saikiranvella 2aadbef308 fix: switch to adapter-node for Docker compatibility 2026-06-12 05:52:52 +00:00
saikiranvella 6cb4c93a0e phase-10.5: test case fixes and updated postman collection 2026-06-12 00:47:41 -04:00
10 changed files with 526 additions and 669 deletions
-606
View File
@@ -1,606 +0,0 @@
openapi: 3.0.0
info:
title: market-screener.bruno
version: 1.0.0
paths:
/api/analyze:
post:
summary: 'Analyze — Validation: empty tickers (expect 400)'
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_llm_analysis_analyze_validation-_empty_tickers_expect_400_bru
description: 'Schema validation: minItems: 1. Expect 400.'
tags:
- LLM Analysis
responses:
'200':
description: ''
parameters:
- name: Content-Type
in: header
description: ''
required: true
schema:
type: string
example: application/json
requestBody:
$ref: '#/components/requestBodies/analyze_validation_empty_tickers_expect_400'
/health:
get:
summary: Health Check
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_health_health_check_bru
description: 'Confirms the server is running. Expects { status: ''ok'' }.'
tags:
- Health
responses:
'200':
description: ''
/api/finance/market-context:
get:
summary: Get Market Context
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_market_context_get_market_context_bru
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.
tags:
- Market Context
responses:
'200':
description: ''
/api/calls:
post:
summary: Create Market Call
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_market_calls_create_market_call_bru
description: >-
Creates a market thesis call. Snapshots current prices + screener
signals at creation time for future comparison.
The test script saves the returned ID to the {{callId}} collection
variable for use in subsequent requests.
tags:
- Market Calls
responses:
'200':
description: ''
parameters:
- name: Content-Type
in: header
description: ''
required: true
schema:
type: string
example: application/json
requestBody:
$ref: '#/components/requestBodies/create_market_call'
get:
summary: List Calls (empty or existing)
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_market_calls_list_calls_empty_or_existing_bru
description: >-
Returns all market calls sorted newest first. Returns { calls: [] } if
none exist yet.
tags:
- Market Calls
responses:
'200':
description: ''
/api/calls/{{callId}}:
delete:
summary: Delete Call
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_market_calls_delete_call_bru
description: >-
Deletes the call created earlier. Returns { ok: true }. Requires
{{callId}} to be set.
tags:
- Market Calls
responses:
'200':
description: ''
get:
summary: Get Call by ID (with current re-screen)
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_market_calls_get_call_by_id_with_current_re-screen_bru
description: >-
Fetches the call and re-screens all tickers to show how signal/price has
changed since creation.
Returns: original call fields + `current` map of ticker → { price,
signal, inflatedVerdict, fundamentalVerdict, pe, roe, fcf }.
Depends on {{callId}} being set by the Create Market Call request.
tags:
- Market Calls
responses:
'200':
description: ''
/api/calls/00000000-0000-0000-0000-000000000000:
get:
summary: Get Call — Non-existent ID (expect 404)
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_market_calls_get_call_non-existent_id_expect_404_bru
description: A UUID that doesn't exist. Expect 404.
tags:
- Market Calls
responses:
'200':
description: ''
/api/calls/calendar:
get:
summary: Get Earnings Calendar (call tickers)
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_market_calls_get_earnings_calendar_call_tickers_bru
description: >-
Returns upcoming earnings dates and dividend events for all tickers
across all saved calls.
Optional query param ?tickers=AAPL,MSFT to restrict to specific tickers.
tags:
- Market Calls
responses:
'200':
description: ''
/api/calls/calendar?tickers=AAPL,MSFT:
get:
summary: Get Earnings Calendar — Specific Tickers
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_market_calls_get_earnings_calendar_specific_tickers_bru
description: Calendar for specific tickers regardless of saved calls.
tags:
- Market Calls
responses:
'200':
description: ''
parameters:
- name: tickers
in: query
description: ''
required: true
schema:
type: string
example: AAPL,MSFT
/api/finance/holdings:
post:
summary: 'Add Holding — Validation: missing shares (expect 400)'
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_portfolio_add_holding_validation-_missing_shares_expect_400_bru
description: 'Schema validation: shares is required. Expect 400.'
tags:
- Portfolio
responses:
'200':
description: ''
parameters:
- name: Content-Type
in: header
description: ''
required: true
schema:
type: string
example: application/json
requestBody:
$ref: >-
#/components/requestBodies/add_holding_validation_missing_shares_expect_400
/api/finance/portfolio:
get:
summary: Get Portfolio
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_portfolio_get_portfolio_bru
description: >-
Screens all non-crypto holdings via Yahoo Finance, then cross-references
with signals to produce buy/hold/sell advice.
Each row has: ticker, signal, advice, reason, currentPrice, marketValue,
gainLossPct.
Also returns marketContext.
Note: first call after server start may be slow (benchmark cache cold).
tags:
- Portfolio
responses:
'200':
description: ''
/api/finance/holdings/AAPL:
delete:
summary: Remove Holding — AAPL
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_portfolio_remove_holding_aapl_bru
description: 'Removes the AAPL holding from portfolio.json. Expect { ok: true }.'
tags:
- Portfolio
responses:
'200':
description: ''
/api/finance/holdings/ZZZZZZ:
delete:
summary: Remove Holding — Non-existent (expect 404)
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_portfolio_remove_holding_non-existent_expect_404_bru
description: Ticker does not exist in portfolio. Expect 404.
tags:
- Portfolio
responses:
'200':
description: ''
/api/screen/catalysts:
get:
summary: Get Catalysts
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_screener_get_catalysts_bru
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.
tags:
- Screener
responses:
'200':
description: ''
/api/screen:
post:
summary: 'Screen — Validation: over 50 tickers (expect 400)'
operationId: >-
users_kanna_documents_bruno_market_screener_api_-_1_screener_screen_validation-_over_50_tickers_expect_400_bru
description: 'Schema validation: maxItems: 50. 51 tickers should return 400.'
tags:
- Screener
responses:
'200':
description: ''
parameters:
- name: Content-Type
in: header
description: ''
required: true
schema:
type: string
example: application/json
requestBody:
$ref: >-
#/components/requestBodies/screen_validation_over_50_tickers_expect_400
servers:
- url: http://localhost:3000
description: Base Server
components:
schemas:
analyze_tickers:
type: object
properties:
tickers:
type: array
items:
type: string
example:
tickers:
- NVDA
- AMD
- INTC
analyze_validation_empty_tickers_expect_400:
type: object
properties:
tickers:
type: array
items:
type: string
example:
tickers: []
create_call_validation_short_thesis_expect_400:
type: object
properties:
title:
type: string
quarter:
type: string
thesis:
type: string
tickers:
type: array
items:
type: string
example:
title: Test
quarter: Q1
thesis: short
tickers:
- AAPL
create_market_call:
type: object
properties:
title:
type: string
quarter:
type: string
thesis:
type: string
tickers:
type: array
items:
type: string
example:
title: AI Infrastructure Supercycle
quarter: Q3 2025
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.
tickers:
- NVDA
- MSFT
- AMD
add_holding_aapl:
type: object
properties:
ticker:
type: string
shares:
type: integer
costBasis:
type: integer
type:
type: string
source:
type: string
example:
ticker: AAPL
shares: 10
costBasis: 150
type: stock
source: Robinhood
add_holding_btc-usd_crypto_no_scoring:
type: object
properties:
ticker:
type: string
shares:
type: number
costBasis:
type: integer
type:
type: string
source:
type: string
example:
ticker: BTC-USD
shares: 0.1
costBasis: 50000
type: crypto
source: Coinbase
add_holding_voo_etf:
type: object
properties:
ticker:
type: string
shares:
type: integer
costBasis:
type: integer
type:
type: string
source:
type: string
example:
ticker: VOO
shares: 5
costBasis: 420
type: etf
source: Vanguard
add_holding_validation_missing_shares_expect_400:
type: object
properties:
ticker:
type: string
example:
ticker: MSFT
screen_mixed_stock_etf_bond:
type: object
properties:
tickers:
type: array
items:
type: string
example:
tickers:
- AAPL
- MSFT
- GOOGL
- VOO
- AGG
screen_reit_tests_p_ffo_scoring_path:
type: object
properties:
tickers:
type: array
items:
type: string
example:
tickers:
- O
- VICI
- PLD
screen_tech_stocks_tests_technology_sector_override:
type: object
properties:
tickers:
type: array
items:
type: string
example:
tickers:
- NVDA
- META
- AMZN
- TSLA
screen_validation_empty_tickers_expect_400:
type: object
properties:
tickers:
type: array
items:
type: string
example:
tickers: []
screen_validation_over_50_tickers_expect_400:
type: object
properties:
tickers:
type: array
items:
type: string
example:
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
requestBodies:
analyze_tickers:
content:
application/json:
schema:
$ref: '#/components/schemas/analyze_tickers'
description: ''
required: true
analyze_validation_empty_tickers_expect_400:
content:
application/json:
schema:
$ref: '#/components/schemas/analyze_validation_empty_tickers_expect_400'
description: ''
required: true
create_call_validation_short_thesis_expect_400:
content:
application/json:
schema:
$ref: >-
#/components/schemas/create_call_validation_short_thesis_expect_400
description: ''
required: true
create_market_call:
content:
application/json:
schema:
$ref: '#/components/schemas/create_market_call'
description: ''
required: true
add_holding_aapl:
content:
application/json:
schema:
$ref: '#/components/schemas/add_holding_aapl'
description: ''
required: true
add_holding_btc-usd_crypto_no_scoring:
content:
application/json:
schema:
$ref: '#/components/schemas/add_holding_btc-usd_crypto_no_scoring'
description: ''
required: true
add_holding_voo_etf:
content:
application/json:
schema:
$ref: '#/components/schemas/add_holding_voo_etf'
description: ''
required: true
add_holding_validation_missing_shares_expect_400:
content:
application/json:
schema:
$ref: >-
#/components/schemas/add_holding_validation_missing_shares_expect_400
description: ''
required: true
screen_mixed_stock_etf_bond:
content:
application/json:
schema:
$ref: '#/components/schemas/screen_mixed_stock_etf_bond'
description: ''
required: true
screen_reit_tests_p_ffo_scoring_path:
content:
application/json:
schema:
$ref: '#/components/schemas/screen_reit_tests_p_ffo_scoring_path'
description: ''
required: true
screen_tech_stocks_tests_technology_sector_override:
content:
application/json:
schema:
$ref: >-
#/components/schemas/screen_tech_stocks_tests_technology_sector_override
description: ''
required: true
screen_validation_empty_tickers_expect_400:
content:
application/json:
schema:
$ref: '#/components/schemas/screen_validation_empty_tickers_expect_400'
description: ''
required: true
screen_validation_over_50_tickers_expect_400:
content:
application/json:
schema:
$ref: '#/components/schemas/screen_validation_over_50_tickers_expect_400'
description: ''
required: true
securitySchemes: {}
@@ -1,7 +1,7 @@
{ {
"info": { "info": {
"name": "Market Screener API", "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)", "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 \u2192 Get Portfolio\n6. Create Market Call \u2192 Get Call \u2192 Calendar\n7. Analyze\n8. Cleanup (delete holding, delete call)",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
}, },
"variable": [ "variable": [
@@ -15,6 +15,11 @@
"value": "", "value": "",
"type": "string", "type": "string",
"description": "Set automatically by the Create Market Call test script" "description": "Set automatically by the Create Market Call test script"
},
{
"key": "jwt",
"value": "",
"type": "string"
} }
], ],
"item": [ "item": [
@@ -49,11 +54,16 @@
"name": "Screener", "name": "Screener",
"item": [ "item": [
{ {
"name": "Screen Mixed (STOCK + ETF + BOND)", "name": "Screen \u2014 Mixed (STOCK + ETF + BOND)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/screen", "url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\", \"VOO\", \"AGG\"]\n}" "raw": "{\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\", \"VOO\", \"AGG\"]\n}"
@@ -90,7 +100,7 @@
"pm.test('Each stock has a signal', () => {", "pm.test('Each stock has a signal', () => {",
" pm.response.json().STOCK.forEach(r => {", " pm.response.json().STOCK.forEach(r => {",
" pm.expect(r.signal).to.be.oneOf([", " pm.expect(r.signal).to.be.oneOf([",
" ' Strong Buy', ' Momentum', '⚠️ Speculation', '🔄 Neutral', ' Avoid'", " '\u2705 Strong Buy', '\u26a1 Momentum', '\u26a0\ufe0f Speculation', '\ud83d\udd04 Neutral', '\u274c Avoid'",
" ]);", " ]);",
" });", " });",
"});" "});"
@@ -100,11 +110,16 @@
] ]
}, },
{ {
"name": "Screen Tech Stocks (tests TECHNOLOGY sector override)", "name": "Screen \u2014 Tech Stocks (tests TECHNOLOGY sector override)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/screen", "url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"tickers\": [\"NVDA\", \"META\", \"AMZN\", \"TSLA\"]\n}" "raw": "{\n \"tickers\": [\"NVDA\", \"META\", \"AMZN\", \"TSLA\"]\n}"
@@ -140,11 +155,16 @@
] ]
}, },
{ {
"name": "Screen REIT (tests P/FFO scoring path)", "name": "Screen \u2014 REIT (tests P/FFO scoring path)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/screen", "url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"tickers\": [\"O\", \"VICI\", \"PLD\"]\n}" "raw": "{\n \"tickers\": [\"O\", \"VICI\", \"PLD\"]\n}"
@@ -169,11 +189,16 @@
] ]
}, },
{ {
"name": "Screen Validation: empty tickers (expect 400)", "name": "Screen \u2014 Validation: empty tickers (expect 400)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/screen", "url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"tickers\": []\n}" "raw": "{\n \"tickers\": []\n}"
@@ -192,11 +217,16 @@
] ]
}, },
{ {
"name": "Screen Validation: over 50 tickers (expect 400)", "name": "Screen \u2014 Validation: over 50 tickers (expect 400)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/screen", "url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "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}" "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}"
@@ -277,11 +307,16 @@
"name": "Portfolio", "name": "Portfolio",
"item": [ "item": [
{ {
"name": "Add Holding AAPL", "name": "Add Holding \u2014 AAPL",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/finance/holdings", "url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"ticker\": \"AAPL\",\n \"shares\": 10,\n \"costBasis\": 150.00,\n \"type\": \"stock\",\n \"source\": \"Robinhood\"\n}" "raw": "{\n \"ticker\": \"AAPL\",\n \"shares\": 10,\n \"costBasis\": 150.00,\n \"type\": \"stock\",\n \"source\": \"Robinhood\"\n}"
@@ -307,11 +342,16 @@
] ]
}, },
{ {
"name": "Add Holding VOO (ETF)", "name": "Add Holding \u2014 VOO (ETF)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/finance/holdings", "url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"ticker\": \"VOO\",\n \"shares\": 5,\n \"costBasis\": 420.00,\n \"type\": \"etf\",\n \"source\": \"Vanguard\"\n}" "raw": "{\n \"ticker\": \"VOO\",\n \"shares\": 5,\n \"costBasis\": 420.00,\n \"type\": \"etf\",\n \"source\": \"Vanguard\"\n}"
@@ -329,16 +369,21 @@
] ]
}, },
{ {
"name": "Add Holding BTC-USD (Crypto, no scoring)", "name": "Add Holding \u2014 BTC-USD (Crypto, no scoring)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/finance/holdings", "url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"ticker\": \"BTC-USD\",\n \"shares\": 0.1,\n \"costBasis\": 50000,\n \"type\": \"crypto\",\n \"source\": \"Coinbase\"\n}" "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." "description": "Crypto is priced via Yahoo but not fundamentally scored. Advice column shows '\u2014' for signal."
}, },
"event": [ "event": [
{ {
@@ -352,11 +397,16 @@
] ]
}, },
{ {
"name": "Add Holding Validation: missing shares (expect 400)", "name": "Add Holding \u2014 Validation: missing shares (expect 400)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/finance/holdings", "url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"ticker\": \"MSFT\"\n}" "raw": "{\n \"ticker\": \"MSFT\"\n}"
@@ -407,7 +457,7 @@
] ]
}, },
{ {
"name": "Remove Holding AAPL", "name": "Remove Holding \u2014 AAPL",
"request": { "request": {
"method": "DELETE", "method": "DELETE",
"url": "{{baseUrl}}/api/finance/holdings/AAPL", "url": "{{baseUrl}}/api/finance/holdings/AAPL",
@@ -426,7 +476,7 @@
] ]
}, },
{ {
"name": "Remove Holding Non-existent (expect 404)", "name": "Remove Holding \u2014 Non-existent (expect 404)",
"request": { "request": {
"method": "DELETE", "method": "DELETE",
"url": "{{baseUrl}}/api/finance/holdings/ZZZZZZ", "url": "{{baseUrl}}/api/finance/holdings/ZZZZZZ",
@@ -472,7 +522,12 @@
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/calls", "url": "{{baseUrl}}/api/calls",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "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}" "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}"
@@ -504,7 +559,7 @@
"request": { "request": {
"method": "GET", "method": "GET",
"url": "{{baseUrl}}/api/calls/{{callId}}", "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." "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 \u2192 { price, signal, inflatedVerdict, fundamentalVerdict, pe, roe, fcf }.\n\nDepends on {{callId}} being set by the Create Market Call request."
}, },
"event": [ "event": [
{ {
@@ -524,7 +579,7 @@
] ]
}, },
{ {
"name": "Get Call Non-existent ID (expect 404)", "name": "Get Call \u2014 Non-existent ID (expect 404)",
"request": { "request": {
"method": "GET", "method": "GET",
"url": "{{baseUrl}}/api/calls/00000000-0000-0000-0000-000000000000", "url": "{{baseUrl}}/api/calls/00000000-0000-0000-0000-000000000000",
@@ -574,13 +629,16 @@
] ]
}, },
{ {
"name": "Get Earnings Calendar Specific Tickers", "name": "Get Earnings Calendar \u2014 Specific Tickers",
"request": { "request": {
"method": "GET", "method": "GET",
"url": { "url": {
"raw": "{{baseUrl}}/api/calls/calendar?tickers=AAPL,MSFT", "raw": "{{baseUrl}}/api/calls/calendar?tickers=AAPL,MSFT",
"query": [ "query": [
{ "key": "tickers", "value": "AAPL,MSFT" } {
"key": "tickers",
"value": "AAPL,MSFT"
}
] ]
}, },
"description": "Calendar for specific tickers regardless of saved calls." "description": "Calendar for specific tickers regardless of saved calls."
@@ -602,11 +660,16 @@
] ]
}, },
{ {
"name": "Create Call Validation: short thesis (expect 400)", "name": "Create Call \u2014 Validation: short thesis (expect 400)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/calls", "url": "{{baseUrl}}/api/calls",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"title\": \"Test\",\n \"quarter\": \"Q1\",\n \"thesis\": \"short\",\n \"tickers\": [\"AAPL\"]\n}" "raw": "{\n \"title\": \"Test\",\n \"quarter\": \"Q1\",\n \"thesis\": \"short\",\n \"tickers\": [\"AAPL\"]\n}"
@@ -644,7 +707,7 @@
] ]
}, },
{ {
"name": "Delete Call Already Deleted (expect 404)", "name": "Delete Call \u2014 Already Deleted (expect 404)",
"request": { "request": {
"method": "DELETE", "method": "DELETE",
"url": "{{baseUrl}}/api/calls/{{callId}}", "url": "{{baseUrl}}/api/calls/{{callId}}",
@@ -671,7 +734,12 @@
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/analyze", "url": "{{baseUrl}}/api/analyze",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"tickers\": [\"NVDA\", \"AMD\", \"INTC\"]\n}" "raw": "{\n \"tickers\": [\"NVDA\", \"AMD\", \"INTC\"]\n}"
@@ -709,11 +777,16 @@
] ]
}, },
{ {
"name": "Analyze Validation: empty tickers (expect 400)", "name": "Analyze \u2014 Validation: empty tickers (expect 400)",
"request": { "request": {
"method": "POST", "method": "POST",
"url": "{{baseUrl}}/api/analyze", "url": "{{baseUrl}}/api/analyze",
"header": [{ "key": "Content-Type", "value": "application/json" }], "header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"tickers\": []\n}" "raw": "{\n \"tickers\": []\n}"
@@ -732,6 +805,347 @@
] ]
} }
] ]
},
{
"name": "Screener \u2014 Ticker Detail & Sectors (June 2026)",
"item": [
{
"name": "Signal history (snapshot ledger)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/screen/history/AAPL",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"screen",
"history",
"AAPL"
]
},
"description": "Per-day signal/tier/score history from the signal_snapshots ledger (P0.1)."
}
},
{
"name": "Company profile",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/screen/profile/AAPL",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"screen",
"profile",
"AAPL"
]
},
"description": "Name, description, sector, market cap + analyst targets (mean/high/low, upside). Cached 1h."
}
},
{
"name": "Price chart",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/screen/chart/AAPL?range=6mo",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"screen",
"chart",
"AAPL"
],
"query": [
{
"key": "range",
"value": "6mo"
}
]
},
"description": "Closes for the ticker modal chart. range: 1d|5d|1mo|3mo|6mo|ytd|1y|5y (intraday bars for 1d/5d)."
}
},
{
"name": "Sector pulse",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/screen/sectors",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"screen",
"sectors"
]
},
"description": "Today's % change per sector via SPDR ETFs, sorted best\u2192worst, with leader. Cached 15m."
}
},
{
"name": "Sector drill-down",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/screen/sector/TECHNOLOGY",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"screen",
"sector",
"TECHNOLOGY"
]
},
"description": "Top-10 ETF holdings screened + 3-day sector news. Sectors: TECHNOLOGY, FINANCIAL, ENERGY, HEALTHCARE, COMMUNICATION, CONSUMER_STAPLES, CONSUMER_DISCRETIONARY, REIT. Cached 30m."
}
}
]
},
{
"name": "News & Digest (June 2026)",
"item": [
{
"name": "News for ticker (stored + live merge)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/news/AAPL?days=7&live=1",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"news",
"AAPL"
],
"query": [
{
"key": "days",
"value": "7"
},
{
"key": "live",
"value": "1"
}
]
},
"description": "Stored pipeline stories (EDGAR + PR wires, catalyst-tagged) merged with a live Yahoo search. live=0 for stored only."
}
},
{
"name": "Recent news (all tickers)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/news/recent?limit=50",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"news",
"recent"
],
"query": [
{
"key": "limit",
"value": "50"
}
]
},
"description": ""
}
},
{
"name": "Daily digest (today)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/digest",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"digest"
]
},
"description": "Signal flips vs previous snapshots + news catalysts + M&A stories."
}
},
{
"name": "Daily digest (specific day)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/digest?date=2026-06-12",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"digest"
],
"query": [
{
"key": "date",
"value": "2026-06-12"
}
]
},
"description": ""
}
}
]
},
{
"name": "Auth & Watchlist",
"item": [
{
"name": "Register",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/auth/register",
"host": [
"{{baseUrl}}"
],
"path": [
"auth",
"register"
]
},
"description": "Invite code is printed in the server terminal on boot (hidden during tests).",
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"you@example.com\",\n \"password\": \"min-8-chars\",\n \"inviteCode\": \"<printed on server boot>\"\n}"
}
}
},
{
"name": "Login",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/auth/login",
"host": [
"{{baseUrl}}"
],
"path": [
"auth",
"login"
]
},
"description": "Returns { token } \u2014 save as {{jwt}} collection variable.",
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"you@example.com\",\n \"password\": \"min-8-chars\"\n}"
}
}
},
{
"name": "Get watchlist",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{jwt}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/watchlist",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"watchlist"
]
},
"description": ""
}
},
{
"name": "Pin ticker",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{jwt}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/watchlist/AAPL",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"watchlist",
"AAPL"
]
},
"description": ""
}
},
{
"name": "Unpin ticker",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{jwt}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/watchlist/AAPL",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"watchlist",
"AAPL"
]
},
"description": ""
}
}
]
} }
] ]
} }
+20 -10
View File
@@ -40,6 +40,8 @@ import {
interface BuildAppOptions { interface BuildAppOptions {
logger?: boolean; logger?: boolean;
db?: DatabaseConnection; db?: DatabaseConnection;
/** Inject a stub in tests to avoid live Yahoo news fetches. */
catalystCache?: CatalystCache;
} }
// ── JWT auth helpers ───────────────────────────────────────────────────────── // ── JWT auth helpers ─────────────────────────────────────────────────────────
@@ -78,7 +80,11 @@ function makeRoleGuard(required: 'trader' | 'admin') {
// 3. Create barrel: server/domains/<domain>/index.ts // 3. Create barrel: server/domains/<domain>/index.ts
// 4. Import from domain and register controller below // 4. Import from domain and register controller below
// ─────────────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────────
export async function buildApp({ logger = true, db: injectedDb }: BuildAppOptions = {}) { export async function buildApp({
logger = true,
db: injectedDb,
catalystCache: injectedCache,
}: BuildAppOptions = {}) {
const app = Fastify({ logger }); const app = Fastify({ logger });
await app.register(cors, { await app.register(cors, {
@@ -126,7 +132,7 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
const advisor = new PortfolioAdvisor(yahoo); const advisor = new PortfolioAdvisor(yahoo);
const calSvc = new CalendarService(yahoo); const calSvc = new CalendarService(yahoo);
const llm = new LLMAnalyst({ logger: noopLogger }); const llm = new LLMAnalyst({ logger: noopLogger });
const catalystCache = new CatalystCache({ logger: noopLogger }); // Singleton, cached for 15m const catalystCache = injectedCache ?? new CatalystCache({ logger: noopLogger }); // Singleton, 15m cache
// Auth domain — generate a fresh invite code on every boot and print it // Auth domain — generate a fresh invite code on every boot and print it
const INVITE_CODE = randomBytes(12).toString('hex'); // 24-char hex string const INVITE_CODE = randomBytes(12).toString('hex'); // 24-char hex string
@@ -136,14 +142,18 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
const innerWidth = Math.max(line1.length, line2.length) + 2; const innerWidth = Math.max(line1.length, line2.length) + 2;
const hr = '─'.repeat(innerWidth); const hr = '─'.repeat(innerWidth);
const pad = (s: string) => `${s}${' '.repeat(innerWidth - 1 - s.length)}`; const pad = (s: string) => `${s}${' '.repeat(innerWidth - 1 - s.length)}`;
/* eslint-disable no-console -- boot-time invite code must reach the operator's terminal */ // Never print the invite code when the logger is disabled (tests) — secrets
console.log(`\n┌${hr}`); // don't belong in test output.
console.log(pad('')); if (logger !== false) {
console.log(pad(line1)); /* eslint-disable no-console -- boot-time invite code must reach the operator's terminal */
console.log(pad(line2)); console.log(`\n┌${hr}`);
console.log(pad('')); console.log(pad(''));
console.log(`${hr}\n`); console.log(pad(line1));
/* eslint-enable no-console */ console.log(pad(line2));
console.log(pad(''));
console.log(`${hr}\n`);
/* eslint-enable no-console */
}
const userStore = new UserStore(db); const userStore = new UserStore(db);
const authService = new AuthService(userStore, JWT_SECRET); const authService = new AuthService(userStore, JWT_SECRET);
+10 -6
View File
@@ -35,12 +35,14 @@ export class ScreenerEngine {
private static readonly BATCH_DELAY_MS = 1000; private static readonly BATCH_DELAY_MS = 1000;
private logger: Logger; private logger: Logger;
private readonly batchDelayMs: number;
constructor( constructor(
private readonly client: YahooFinanceClient, private readonly client: YahooFinanceClient,
private readonly benchmarkProvider: BenchmarkProvider, private readonly benchmarkProvider: BenchmarkProvider,
{ logger }: ScreenerEngineOptions = {}, { logger, batchDelayMs }: ScreenerEngineOptions = {},
) { ) {
this.batchDelayMs = batchDelayMs ?? ScreenerEngine.BATCH_DELAY_MS;
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
this.logger = logger ?? { this.logger = logger ?? {
write: (msg: string) => process.stdout.write(msg), write: (msg: string) => process.stdout.write(msg),
@@ -65,11 +67,12 @@ export class ScreenerEngine {
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE); const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
let processed = 0; let processed = 0;
for (const chunk of chunks) { for (let i = 0; i < chunks.length; i++) {
await this.processBatch(chunk, marketContext, results); await this.processBatch(chunks[i], marketContext, results);
processed += chunk.length; processed += chunks[i].length;
this.logProgress(showProgress, processed, tickers.length); this.logProgress(showProgress, processed, tickers.length);
await this.rateLimitDelay(); // Rate-limit pause between batches — never after the last one
if (i < chunks.length - 1) await this.rateLimitDelay();
} }
if (showProgress) { if (showProgress) {
@@ -110,7 +113,8 @@ export class ScreenerEngine {
} }
private async rateLimitDelay(): Promise<void> { private async rateLimitDelay(): Promise<void> {
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS)); if (this.batchDelayMs <= 0) return;
await new Promise<void>((r) => setTimeout(r, this.batchDelayMs));
} }
private async fetch(ticker: string): Promise<MappedData | ErrorResult> { private async fetch(ticker: string): Promise<MappedData | ErrorResult> {
@@ -90,4 +90,6 @@ export interface RuleSet {
// ── ScreenerEngine ──────────────────────────────────────────────────────── // ── ScreenerEngine ────────────────────────────────────────────────────────
export interface ScreenerEngineOptions { export interface ScreenerEngineOptions {
logger?: Logger; logger?: Logger;
/** Delay between Yahoo batches (ms). Default 1000; set 0 in tests. */
batchDelayMs?: number;
} }
+8 -3
View File
@@ -13,6 +13,11 @@ const MOCK_LLM_RESPONSE = JSON.stringify({
const mockDb = new MockDatabaseConnection() as never; const mockDb = new MockDatabaseConnection() as never;
// Stub catalyst cache — no live Yahoo news fetches in tests (fast + offline)
const stubCatalystCache = {
get: async () => ({ tickers: [] as string[], stories: [] }),
} as never;
test('POST /api/analyze', async (t) => { test('POST /api/analyze', async (t) => {
// Spy on AnthropicClient.prototype.complete before buildApp wires it up. // Spy on AnthropicClient.prototype.complete before buildApp wires it up.
// This prevents any real API calls during tests. // This prevents any real API calls during tests.
@@ -26,7 +31,7 @@ test('POST /api/analyze', async (t) => {
mock.method(AnthropicClient.prototype, 'isAvailable', () => true, { getter: true }); mock.method(AnthropicClient.prototype, 'isAvailable', () => true, { getter: true });
await t.test('returns analysis when stories match tickers', async () => { await t.test('returns analysis when stories match tickers', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
@@ -47,7 +52,7 @@ test('POST /api/analyze', async (t) => {
// Reset the isAvailable mock to simulate no API key // Reset the isAvailable mock to simulate no API key
mock.method(AnthropicClient.prototype, 'isAvailable', () => false, { getter: true }); mock.method(AnthropicClient.prototype, 'isAvailable', () => false, { getter: true });
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
@@ -68,7 +73,7 @@ test('POST /api/analyze', async (t) => {
mock.method(AnthropicClient.prototype, 'isAvailable', () => true, { getter: true }); mock.method(AnthropicClient.prototype, 'isAvailable', () => true, { getter: true });
const callsBefore = completeSpy.mock.calls.length; const callsBefore = completeSpy.mock.calls.length;
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
+13 -8
View File
@@ -6,15 +6,20 @@ import { MockDatabaseConnection } from './helpers/mockDb.js';
// Inject mock DB so tests don't require the native better-sqlite3 binary // Inject mock DB so tests don't require the native better-sqlite3 binary
const mockDb = new MockDatabaseConnection() as never; const mockDb = new MockDatabaseConnection() as never;
// Stub catalyst cache — no live Yahoo news fetches in tests (fast + offline)
const stubCatalystCache = {
get: async () => ({ tickers: [] as string[], stories: [] }),
} as never;
test('App Bootstrap', async (t) => { test('App Bootstrap', async (t) => {
await t.test('builds successfully without logger', async () => { await t.test('builds successfully without logger', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
assert.ok(app); assert.ok(app);
assert.ok(app.server); assert.ok(app.server);
}); });
await t.test('health check endpoint returns 200', async () => { await t.test('health check endpoint returns 200', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: 'GET',
url: '/health', url: '/health',
@@ -25,7 +30,7 @@ test('App Bootstrap', async (t) => {
}); });
await t.test('POST /api/screen requires valid schema', async () => { await t.test('POST /api/screen requires valid schema', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/api/screen', url: '/api/screen',
@@ -36,7 +41,7 @@ test('App Bootstrap', async (t) => {
}); });
await t.test('POST /api/screen rejects invalid payload', async () => { await t.test('POST /api/screen rejects invalid payload', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/api/screen', url: '/api/screen',
@@ -46,7 +51,7 @@ test('App Bootstrap', async (t) => {
}); });
await t.test('GET /api/screen/catalysts returns results', async () => { await t.test('GET /api/screen/catalysts returns results', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: 'GET',
url: '/api/screen/catalysts', url: '/api/screen/catalysts',
@@ -58,7 +63,7 @@ test('App Bootstrap', async (t) => {
}); });
await t.test('CORS is enabled for configured origin', async () => { await t.test('CORS is enabled for configured origin', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: 'GET',
url: '/health', url: '/health',
@@ -70,7 +75,7 @@ test('App Bootstrap', async (t) => {
}); });
await t.test('API key auth is optional (disabled by default)', async () => { await t.test('API key auth is optional (disabled by default)', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: 'GET',
url: '/health', url: '/health',
@@ -80,7 +85,7 @@ test('App Bootstrap', async (t) => {
}); });
await t.test('OPTIONS requests bypass auth check', async () => { await t.test('OPTIONS requests bypass auth check', async () => {
const app = await buildApp({ logger: false, db: mockDb }); const app = await buildApp({ logger: false, db: mockDb, catalystCache: stubCatalystCache });
const response = await app.inject({ const response = await app.inject({
method: 'OPTIONS', method: 'OPTIONS',
url: '/api/screen', url: '/api/screen',
+22
View File
@@ -84,6 +84,28 @@ test('BondScorer', async (t) => {
assert.ok(resultLong.audit?.passedGates); assert.ok(resultLong.audit?.passedGates);
}); });
await t.test('returns structured tier (P0.3)', () => {
const good: BondMetrics = {
ytm: 7.5, // 7.5% vs ~4% risk-free → wide spread
duration: 5,
creditRating: 'A',
creditRatingNumeric: 8,
};
const pass = BondScorer.score(good, DEFAULT_RULES);
assert.equal(pass.tier, 'PASS');
assert.equal(typeof pass.score, 'number');
const junk: BondMetrics = {
ytm: 8,
duration: 5,
creditRating: 'CCC',
creditRatingNumeric: 4, // below investment-grade gate
};
const reject = BondScorer.score(junk, DEFAULT_RULES);
assert.equal(reject.tier, 'REJECT');
assert.equal(reject.score, null);
});
await t.test('handles null/undefined metrics gracefully', () => { await t.test('handles null/undefined metrics gracefully', () => {
const metrics = { const metrics = {
ytm: null, ytm: null,
+1
View File
@@ -10,6 +10,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"sass": "^1.100.0", "sass": "^1.100.0",
+1 -1
View File
@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
export default { export default {
kit: { kit: {