Compare commits

..

44 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
Kazuma 65907a9b8d merge: resolve conflicts keeping feature/bechmarks changes 2026-06-12 00:30:16 -04:00
Kazuma bf2a85b5c4 phase-10.5: screener enhancements 2026-06-11 19:18:19 -04:00
Kazuma f0c794f0c0 news screen enhancement - 1 2026-06-09 20:12:37 -04:00
Kazuma 5655cde6bf UI enhancemnts 2026-06-09 19:34:31 -04:00
Kazuma fbadd7fb6e phase-10.5: market screener ui enhancements 2026-06-09 01:21:02 -04:00
Kazuma 7bc242911e phase-10: ui code enhancements 2026-06-08 13:15:01 -04:00
Kazuma ad1c3fe3c9 test: mock AnthropicClient in analyze tests to prevent live API calls 2026-06-08 12:10:38 -04:00
Kazuma a289cda704 fix bruno collection 2026-06-06 22:55:43 -04:00
Kazuma 0dac8128bd phase-9: domain-driven architecture complete
- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance
- Migrated 58 TypeScript files to domain-driven structure
- Updated CLAUDE.md with new architecture documentation
- Added .gitignore rules for .md files (except CLAUDE.md)
- Removed unused CatalystAnalyst import from app.ts
- Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions
- Verified no sensitive data in git history
- Server code compiles cleanly with TypeScript strict mode
2026-06-06 22:55:43 -04:00
Kazuma 09f2444157 phase-8g: add sqllite. 2026-06-06 22:55:43 -04:00
Kazuma ca449b4300 phase-8g: rate limiting and update readme doc 2026-06-06 22:55:43 -04:00
Kazuma 8bdcb422aa phase-8f: persistant cache locally 2026-06-06 22:55:43 -04:00
Kazuma 7f2108129a phase-8:server code enhancements. 2026-06-06 22:55:43 -04:00
Kazuma 7cb1b03fd5 phase-7_alpha: legacy code cleanup 2026-06-06 22:55:43 -04:00
Kazuma 5b32bd7a04 phase-7: code restructure 2026-06-06 22:55:43 -04:00
Kazuma 2b785aa861 phase-6: typescript introduction 2026-06-06 22:55:43 -04:00
Kazuma de8427d578 phase-5: code maintenance 2026-06-06 22:55:43 -04:00
Kazuma e4ab9ce45b phase-4: scss upgrade 2026-06-06 22:55:43 -04:00
Kazuma f5a338fc4e phase-2: extract shared utils 2026-06-06 22:55:43 -04:00
Kazuma 96e48aebe5 phase-1: optimize code 2026-06-06 22:55:43 -04:00
Kazuma 640004a921 Segmentational Analysis 2026-06-06 22:55:43 -04:00
Kazuma 6878bd1f2b Stocks Seggregation analysis. 2026-06-06 22:55:43 -04:00
Kazuma 6356415789 code enhancements for hybrid analysis and clean-up dashboards. 2026-06-06 22:55:43 -04:00
Kazuma b9c5dbb7c7 benchmarks 2026-06-06 22:55:43 -04:00
Kazuma 76c2a671f4 fix bruno collection 2026-06-06 21:49:31 -04:00
Kazuma 2e7860637e phase-9: domain-driven architecture complete
- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance
- Migrated 58 TypeScript files to domain-driven structure
- Updated CLAUDE.md with new architecture documentation
- Added .gitignore rules for .md files (except CLAUDE.md)
- Removed unused CatalystAnalyst import from app.ts
- Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions
- Verified no sensitive data in git history
- Server code compiles cleanly with TypeScript strict mode
2026-06-06 18:18:22 -04:00
Kazuma fbd166b1b7 phase-8g: add sqllite. 2026-06-05 23:34:25 -04:00
Kazuma cea0ef4396 phase-8g: rate limiting and update readme doc 2026-06-05 23:02:21 -04:00
Kazuma 9fb3808eb5 phase-8f: persistant cache locally 2026-06-05 22:52:30 -04:00
Kazuma bd373ab69b phase-8:server code enhancements. 2026-06-05 22:44:04 -04:00
Kazuma 617703e91d phase-7_alpha: legacy code cleanup 2026-06-05 22:27:53 -04:00
Kazuma 73db0fe7a8 phase-7: code restructure 2026-06-05 22:11:11 -04:00
Kazuma 69d13c3dbe phase-6: typescript introduction 2026-06-04 22:44:50 -04:00
Kazuma 16bd95aa85 phase-5: code maintenance 2026-06-04 16:28:21 -04:00
Kazuma 104ed81b9f phase-4: scss upgrade 2026-06-04 15:49:49 -04:00
Kazuma 0a0a368b87 phase-2: extract shared utils 2026-06-04 11:24:08 -04:00
Kazuma b75e8bda72 phase-1: optimize code 2026-06-04 01:36:28 -04:00
Kazuma 19fc052d14 Segmentational Analysis 2026-06-02 04:58:07 -04:00
Kazuma 70643a1fd9 Stocks Seggregation analysis. 2026-06-02 03:57:05 -04:00
Kazuma 8ff7e5d235 code enhancements for hybrid analysis and clean-up dashboards. 2026-06-02 03:31:45 -04:00
Kazuma d9879d5c6c benchmarks 2026-06-02 02:50:05 -04:00
Kazuma 74e6797dcc initial commit 2026-06-02 00:34:42 -04:00
25 changed files with 1741 additions and 669 deletions
Executable → Regular
View File
Executable → Regular
View File
-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": {
"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"
},
"variable": [
@@ -15,6 +15,11 @@
"value": "",
"type": "string",
"description": "Set automatically by the Create Market Call test script"
},
{
"key": "jwt",
"value": "",
"type": "string"
}
],
"item": [
@@ -49,11 +54,16 @@
"name": "Screener",
"item": [
{
"name": "Screen Mixed (STOCK + ETF + BOND)",
"name": "Screen \u2014 Mixed (STOCK + ETF + BOND)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\", \"VOO\", \"AGG\"]\n}"
@@ -90,7 +100,7 @@
"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'",
" '\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": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"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": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"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": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"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": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"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}"
@@ -277,11 +307,16 @@
"name": "Portfolio",
"item": [
{
"name": "Add Holding AAPL",
"name": "Add Holding \u2014 AAPL",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"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}"
@@ -307,11 +342,16 @@
]
},
{
"name": "Add Holding VOO (ETF)",
"name": "Add Holding \u2014 VOO (ETF)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"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}"
@@ -329,16 +369,21 @@
]
},
{
"name": "Add Holding BTC-USD (Crypto, no scoring)",
"name": "Add Holding \u2014 BTC-USD (Crypto, no scoring)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"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."
"description": "Crypto is priced via Yahoo but not fundamentally scored. Advice column shows '\u2014' for signal."
},
"event": [
{
@@ -352,11 +397,16 @@
]
},
{
"name": "Add Holding Validation: missing shares (expect 400)",
"name": "Add Holding \u2014 Validation: missing shares (expect 400)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ticker\": \"MSFT\"\n}"
@@ -407,7 +457,7 @@
]
},
{
"name": "Remove Holding AAPL",
"name": "Remove Holding \u2014 AAPL",
"request": {
"method": "DELETE",
"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": {
"method": "DELETE",
"url": "{{baseUrl}}/api/finance/holdings/ZZZZZZ",
@@ -472,7 +522,12 @@
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/calls",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"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}"
@@ -504,7 +559,7 @@
"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."
"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": [
{
@@ -524,7 +579,7 @@
]
},
{
"name": "Get Call Non-existent ID (expect 404)",
"name": "Get Call \u2014 Non-existent ID (expect 404)",
"request": {
"method": "GET",
"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": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/api/calls/calendar?tickers=AAPL,MSFT",
"query": [
{ "key": "tickers", "value": "AAPL,MSFT" }
{
"key": "tickers",
"value": "AAPL,MSFT"
}
]
},
"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": {
"method": "POST",
"url": "{{baseUrl}}/api/calls",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"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": {
"method": "DELETE",
"url": "{{baseUrl}}/api/calls/{{callId}}",
@@ -671,7 +734,12 @@
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/analyze",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"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": {
"method": "POST",
"url": "{{baseUrl}}/api/analyze",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"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 {
logger?: boolean;
db?: DatabaseConnection;
/** Inject a stub in tests to avoid live Yahoo news fetches. */
catalystCache?: CatalystCache;
}
// ── JWT auth helpers ─────────────────────────────────────────────────────────
@@ -78,7 +80,11 @@ function makeRoleGuard(required: 'trader' | 'admin') {
// 3. Create barrel: server/domains/<domain>/index.ts
// 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 });
await app.register(cors, {
@@ -126,7 +132,7 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
const advisor = new PortfolioAdvisor(yahoo);
const calSvc = new CalendarService(yahoo);
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
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 hr = '─'.repeat(innerWidth);
const pad = (s: string) => `${s}${' '.repeat(innerWidth - 1 - s.length)}`;
/* eslint-disable no-console -- boot-time invite code must reach the operator's terminal */
console.log(`\n┌${hr}`);
console.log(pad(''));
console.log(pad(line1));
console.log(pad(line2));
console.log(pad(''));
console.log(`${hr}\n`);
/* eslint-enable no-console */
// Never print the invite code when the logger is disabled (tests) — secrets
// don't belong in test output.
if (logger !== false) {
/* eslint-disable no-console -- boot-time invite code must reach the operator's terminal */
console.log(`\n┌${hr}`);
console.log(pad(''));
console.log(pad(line1));
console.log(pad(line2));
console.log(pad(''));
console.log(`${hr}\n`);
/* eslint-enable no-console */
}
const userStore = new UserStore(db);
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 logger: Logger;
private readonly batchDelayMs: number;
constructor(
private readonly client: YahooFinanceClient,
private readonly benchmarkProvider: BenchmarkProvider,
{ logger }: ScreenerEngineOptions = {},
{ logger, batchDelayMs }: ScreenerEngineOptions = {},
) {
this.batchDelayMs = batchDelayMs ?? ScreenerEngine.BATCH_DELAY_MS;
// eslint-disable-next-line no-console
this.logger = logger ?? {
write: (msg: string) => process.stdout.write(msg),
@@ -65,11 +67,12 @@ export class ScreenerEngine {
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
let processed = 0;
for (const chunk of chunks) {
await this.processBatch(chunk, marketContext, results);
processed += chunk.length;
for (let i = 0; i < chunks.length; i++) {
await this.processBatch(chunks[i], marketContext, results);
processed += chunks[i].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) {
@@ -110,7 +113,8 @@ export class ScreenerEngine {
}
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> {
@@ -90,4 +90,6 @@ export interface RuleSet {
// ── ScreenerEngine ────────────────────────────────────────────────────────
export interface ScreenerEngineOptions {
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;
// 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) => {
// Spy on AnthropicClient.prototype.complete before buildApp wires it up.
// 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 });
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({
method: 'POST',
@@ -47,7 +52,7 @@ test('POST /api/analyze', async (t) => {
// Reset the isAvailable mock to simulate no API key
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({
method: 'POST',
@@ -68,7 +73,7 @@ test('POST /api/analyze', async (t) => {
mock.method(AnthropicClient.prototype, 'isAvailable', () => true, { getter: true });
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({
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
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) => {
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.server);
});
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({
method: 'GET',
url: '/health',
@@ -25,7 +30,7 @@ test('App Bootstrap', async (t) => {
});
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({
method: 'POST',
url: '/api/screen',
@@ -36,7 +41,7 @@ test('App Bootstrap', async (t) => {
});
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({
method: 'POST',
url: '/api/screen',
@@ -46,7 +51,7 @@ test('App Bootstrap', async (t) => {
});
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({
method: 'GET',
url: '/api/screen/catalysts',
@@ -58,7 +63,7 @@ test('App Bootstrap', async (t) => {
});
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({
method: 'GET',
url: '/health',
@@ -70,7 +75,7 @@ test('App Bootstrap', async (t) => {
});
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({
method: 'GET',
url: '/health',
@@ -80,7 +85,7 @@ test('App Bootstrap', async (t) => {
});
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({
method: 'OPTIONS',
url: '/api/screen',
+22
View File
@@ -84,6 +84,28 @@ test('BondScorer', async (t) => {
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', () => {
const metrics = {
ytm: null,
+1
View File
@@ -10,6 +10,7 @@
"devDependencies": {
"@types/node": "^22.0.0",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"sass": "^1.100.0",
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import Spinner from '$lib/Spinner.svelte';
import type { SidebarState } from '$lib/types.js';
let { sidebar, onClose }: { sidebar: SidebarState; onClose: () => void } = $props();
</script>
{#if sidebar.open}
<!-- Backdrop -->
<div
class="sidebar-backdrop"
role="button"
tabindex="-1"
aria-label="Close sidebar"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
></div>
<!-- Panel -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<span>🤖 LLM Analysis</span>
{#if sidebar.type}<span class="sidebar-type">{sidebar.type}S</span>{/if}
</div>
<button class="sidebar-close" onclick={onClose}>✕</button>
</div>
<div class="sidebar-body">
{#if sidebar.loading}
<div class="sidebar-loading">
<Spinner size="lg" label="Analyzing tickers…" />
</div>
{:else if sidebar.error}
<div class="sidebar-error">{sidebar.error}</div>
{:else if sidebar.analysis}
{@const a = sidebar.analysis}
<div class="sb-sentiment-row">
<span class="sentiment-pill" data-sentiment={a.sentiment}>{a.sentiment}</span>
</div>
<p class="sb-summary">{a.summary}</p>
<h3 class="sb-sub">Affected Industries</h3>
<div class="sb-list">
{#each a.affectedIndustries ?? [] as ind}
<div class="sb-item">
<span class="sb-name">{ind.name}</span>
<span class="sb-reason">{ind.reason}</span>
</div>
{/each}
</div>
<h3 class="sb-sub">Related Tickers to Watch</h3>
<div class="sb-list">
{#each a.relatedTickers ?? [] as rt}
<div class="sb-item">
<div class="sb-ticker-row">
<span class="sb-name ticker">{rt.ticker}</span>
<div class="sb-chips">
{#if rt.bias}
<span class="sb-chip sb-bias" data-bias={rt.bias}>{rt.bias}</span>
{/if}
{#if rt.horizon}
<span class="sb-chip sb-horizon">{rt.horizon}</span>
{/if}
{#if rt.sensitivity}
<span class="sb-chip sb-sensitivity" title="Sensitivity {rt.sensitivity}/5">S{rt.sensitivity}</span>
{/if}
</div>
</div>
<span class="sb-reason">{rt.reason}</span>
</div>
{/each}
</div>
{/if}
</div>
</aside>
{/if}
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts">
import { sigOrd, sorted } from '$lib/utils.js';
import VerdictPill from '$lib/VerdictPill.svelte';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
import type { AssetType, AssetResult } from '$lib/types.js';
let {
type,
rows,
analyzeLoading = false,
onAnalyze,
}: {
type: AssetType;
rows: AssetResult[];
analyzeLoading?: boolean;
onAnalyze: () => void;
} = $props();
// Mode state is self-contained — each table independently tracks inflated vs fundamental
let mode = $state('inflated');
// Colour class for signed % values (52W Chg, From High, Upside, DCF Safety)
function signClass(val: string | number | null | undefined): string {
if (val == null) return '';
const n = typeof val === 'number' ? val : parseFloat(String(val));
if (isNaN(n)) return '';
return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
}
</script>
<section class="section">
<div class="section-header">
<h2>{type}S</h2>
<span class="count">{rows.length}</span>
<div class="mode-tabs">
<button class:active={mode === 'inflated'} onclick={() => mode = 'inflated'}>Mkt-Adjusted</button>
<button class:active={mode === 'fundamental'} onclick={() => mode = 'fundamental'}>Graham</button>
</div>
<button
class="btn-analyze"
onclick={onAnalyze}
disabled={analyzeLoading}
title="AI analysis of news for these tickers"
>
{#if analyzeLoading}
<Spinner size="sm" />
{:else}
✦ Analyze
{/if}
</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Verdict</th>
<th>Score</th>
{#if type === 'STOCK'}
<!-- Classification -->
<th title="Market cap tier">Cap</th>
<th title="Growth / style classification">Style</th>
<!-- Valuation -->
<th>P/E</th>
<th>PEG</th>
<!-- Quality -->
<th title="Gross Margin %">GrossM%</th>
<th>ROE%</th>
<th>OpMgn%</th>
<th>FCF%</th>
<!-- Risk -->
<th>D/E</th>
<!-- 52-week movement -->
<th title="Total price return over last 52 weeks">52W Chg</th>
<th title="% below 52-week high">From High</th>
<!-- Expert signals -->
<th title="Wall Street analyst consensus">Analyst</th>
<th title="% upside to analyst target price">Upside</th>
<th title="DCF margin of safety — positive means undervalued">DCF Safety</th>
<!-- Risk flags -->
<th>Flags</th>
{:else if type === 'ETF'}
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th>
{:else}
<th>YTM</th><th>Duration</th><th>Rating</th>
{/if}
</tr>
</thead>
<tbody>
{#each sorted(rows) as r}
{@const m = r.asset.displayMetrics ?? {}}
{@const v = r[mode as 'inflated' | 'fundamental']}
<tr class="data-row" data-signal={sigOrd(r.signal)}>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><VerdictPill label={v.label} /></td>
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td>
{#if type === 'STOCK'}
<!-- Classification -->
<td><span class="tag sm cap-tag">{m['Cap Tier'] ?? '—'}</span></td>
<td><span class="tag sm style-tag">{m['Style'] ?? '—'}</span></td>
<!-- Valuation -->
<td class="num">{m['P/E'] ?? '—'}</td>
<td class="num">{m['PEG'] ?? '—'}</td>
<!-- Quality -->
<td class="num">{m['GrossM%'] ?? '—'}</td>
<td class="num">{m['ROE%'] ?? '—'}</td>
<td class="num">{m['OpMgn%'] ?? '—'}</td>
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
<!-- Risk -->
<td class="num">{m['D/E'] ?? '—'}</td>
<!-- 52-week movement — green if up, red if down -->
<td class="num {signClass(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</td>
<td class="num {signClass(m['From High'])}">{m['From High'] ?? '—'}</td>
<!-- Expert signals -->
<td class="analyst-cell">{m['Analyst'] ?? '—'}</td>
<td class="num {signClass(m['Upside'])}">{m['Upside'] ?? '—'}</td>
<td class="num {signClass(m['DCF Safety'])}">{m['DCF Safety'] ?? '—'}</td>
<!-- Risk flags -->
<td class="flags">
{#each v.audit?.riskFlags ?? [] as flag}
<span class="flag">{flag}</span>
{/each}
</td>
{:else if type === 'ETF'}
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
<td class="num">{m['Yield%'] ?? '—'}</td>
<td class="num">{m['AUM'] ?? '—'}</td>
<td class="num">{m['5Y Return%'] ?? '—'}</td>
{:else}
<td class="num">{m['YTM%'] ?? '—'}</td>
<td class="num">{m['Duration'] ?? '—'}</td>
<td class="num">{m['Rating'] ?? '—'}</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<style>
/* Score cell — truncates long gate summaries, tooltip shows full text */
.score-cell {
color: var(--text-dim);
font-size: var(--fs-sm);
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Classification tags */
.cap-tag { color: var(--blue-light, #93c5fd); border-color: var(--blue-dim, #1e3a5f); }
.style-tag { color: var(--text-muted); }
/* Signed % colouring */
.pos { color: var(--green); }
.neg { color: var(--red); }
/* Analyst label — not a number */
.analyst-cell {
font-size: var(--fs-sm);
color: var(--text-muted);
white-space: nowrap;
}
/* Risk flags column */
.flags { display: flex; flex-direction: column; gap: 2px; min-width: 160px; }
.flag { color: var(--orange); font-size: var(--fs-sm); white-space: nowrap; }
</style>
+177
View File
@@ -0,0 +1,177 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { MarketContext } from '$lib/types.js';
let { ctx, collapsible = false }: { ctx: MarketContext; collapsible?: boolean } = $props();
// Read collapsible once for initial state — untrack avoids a reactive dep on the prop
let expanded = $state(untrack(() => !collapsible));
const cards = $derived.by(() => {
const b = ctx?.benchmarks ?? {};
return [
{
label: '10Y Yield',
value: ctx?.riskFreeRate != null ? ctx.riskFreeRate.toFixed(2) + '%' : '—',
tip: 'US 10-year Treasury yield — the risk-free rate benchmark. Higher = tighter conditions for stocks and bonds.',
},
{
label: 'VIX',
value: ctx?.vixLevel?.toFixed(1) ?? '—',
tip: 'CBOE Volatility Index — measures expected market volatility. Above 20 = elevated fear; above 30 = high stress.',
},
{
label: 'S&P 500',
value: ctx?.sp500Price?.toLocaleString() ?? '—',
tip: 'Live S&P 500 index price — broad US large-cap benchmark.',
},
{
label: 'S&P P/E',
value: b.marketPE != null ? b.marketPE.toFixed(1) + 'x' : '—',
tip: 'Trailing P/E ratio of SPY. Used to set the INFLATED mode P/E gate (S&P P/E × 1.5 in normal rates).',
},
{
label: 'Tech P/E',
value: b.techPE != null ? b.techPE.toFixed(1) + 'x' : '—',
tip: 'Trailing P/E of XLK (tech sector ETF). Sets the tech-sector gate in INFLATED mode (XLK P/E × 1.3).',
},
{
label: 'REIT Yield',
value: b.reitYield != null ? b.reitYield.toFixed(2) + '%' : '—',
tip: 'Dividend yield of XLRE (real estate ETF). Used as the REIT minimum yield gate in INFLATED mode.',
},
{
label: 'IG Spread',
value: b.igSpread != null ? b.igSpread.toFixed(2) + '%' : '—',
tip: 'Investment-grade bond spread (LQD yield 10Y yield). Sets the bond minimum spread gate in INFLATED mode.',
},
{
label: 'Rate Regime',
value: ctx?.rateRegime ?? '—',
tip: 'HIGH (>4.5%) compresses P/E gates and tightens bond/REIT requirements. NORMAL uses looser INFLATED gates.',
},
{
label: 'Volatility',
value: ctx?.volatilityRegime ?? '—',
tip: 'Derived from VIX level — LOW (<15), NORMAL (1525), HIGH (>25). Informational; not currently gating scores.',
},
];
});
</script>
<div class="ctx-wrap">
{#if collapsible}
<button class="ctx-toggle" onclick={() => expanded = !expanded}>
<span class="ctx-toggle-label">Market Context</span>
<span class="ctx-toggle-chevron">{expanded ? '▲' : '▼'}</span>
</button>
{/if}
{#if expanded}
<div class="grid">
{#each cards as c}
<div class="card">
<div class="label-row">
<span class="label">{c.label}</span>
<span class="tip-wrap">
<span class="tip-anchor">?</span>
<span class="tip-box">{c.tip}</span>
</span>
</div>
<div class="value">{c.value}</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.ctx-wrap { margin-bottom: 20px; }
/* ── Collapsible toggle ─────────────────────────────────────────── */
.ctx-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 12px;
cursor: pointer;
margin-bottom: 10px;
}
.ctx-toggle-label {
font-size: var(--fs-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dimmer);
}
.ctx-toggle-chevron { font-size: var(--fs-2xs); color: var(--text-faint); }
/* ── Cards grid ─────────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.card { background: var(--bg-card); border-radius: var(--radius-md); padding: 12px var(--space-lg); }
.label-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.label { font-size: var(--fs-xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
/* ── Tooltip ─────────────────────────────────────────────────────── */
.tip-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
.tip-anchor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--text-faint);
color: var(--text-dimmer);
font-size: var(--fs-2xs);
font-weight: 700;
cursor: help;
}
.tip-box {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 220px;
background: var(--bg-card);
border: 1px solid var(--text-faint);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: var(--fs-sm);
color: var(--text-muted);
line-height: 1.5;
z-index: 50;
pointer-events: none;
white-space: normal;
}
.tip-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--text-faint);
}
.tip-wrap:hover .tip-box { display: block; }
.value { font-size: 17px; font-weight: 700; color: var(--text-primary); margin-top: 4px; }
</style>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import { fmtPE } from '$lib/utils.js';
import type { MarketContext } from '$lib/types.js';
let { ctx }: { ctx: MarketContext } = $props();
// Flat list of chips so the template stays declarative
const chips = $derived([
{ label: '10Y', value: ctx.riskFreeRate?.toFixed(2) + '%' },
{ label: 'VIX', value: ctx.vixLevel?.toFixed(1) },
{ label: 'S&P', value: ctx.sp500Price?.toLocaleString() },
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE) },
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE) },
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield?.toFixed(2) + '%' },
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread?.toFixed(2) + '%' },
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime },
{ label: 'Vol', value: ctx.volatilityRegime, regime: ctx.volatilityRegime },
]);
</script>
<div class="ctx-strip">
{#each chips as chip}
<div class="ctx-chip">
<span class="ctx-label">{chip.label}</span>
<span class="ctx-val" class:ctx-regime={!!chip.regime} data-regime={chip.regime ?? ''}>
{chip.value ?? '—'}
</span>
</div>
{/each}
</div>
<style>
.ctx-strip {
display: flex;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.ctx-chip {
flex: 1;
min-width: 70px;
background: var(--bg-base);
padding: 10px var(--space-lg);
display: flex;
flex-direction: column;
gap: 3px;
}
.ctx-label {
font-size: var(--fs-2xs);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dimmer);
}
.ctx-val {
font-size: var(--fs-lg);
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.ctx-regime[data-regime='HIGH'] { color: var(--red); }
.ctx-regime[data-regime='NORMAL'] { color: var(--text-muted); }
.ctx-regime[data-regime='LOW'] { color: var(--green); }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Signal } from '$lib/types.js';
let { signal }: { signal: Signal | null | undefined } = $props();
const cls = () => {
if (signal?.includes('Strong')) return 'strong';
if (signal?.includes('Momentum')) return 'momentum';
if (signal?.includes('Speculation')) return 'spec';
if (signal?.includes('Neutral')) return 'neutral';
return 'avoid';
};
</script>
<span class="badge {cls()}">{signal ?? '—'}</span>
<style>
.badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
white-space: nowrap;
}
.strong { background: #14532d33; color: #4ade80; }
.momentum { background: #1e3a5f33; color: #60a5fa; }
.spec { background: #7c2d1233; color: #fb923c; }
.neutral { background: #1e293b; color: #94a3b8; }
.avoid { background: #450a0a33; color: #f87171; }
</style>
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
let { size = 'md', label = null }: { size?: 'sm' | 'md' | 'lg'; label?: string | null } = $props();
</script>
{#if size === 'sm'}
<!-- Compact dot-pulse for buttons -->
<span class="dot-pulse">
<span></span><span></span><span></span>
</span>
{:else}
<!-- Market chart line animation for md / lg -->
<div class="chart-wrap" data-size={size}>
<svg
viewBox="0 0 160 60"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="chart-svg"
aria-hidden="true"
>
<!-- Faint grid lines -->
<line x1="0" y1="15" x2="160" y2="15" stroke="#1e293b" stroke-width="1" />
<line x1="0" y1="30" x2="160" y2="30" stroke="#1e293b" stroke-width="1" />
<line x1="0" y1="45" x2="160" y2="45" stroke="#1e293b" stroke-width="1" />
<!-- The market line — rises, dips, spikes, recovers -->
<polyline
class="chart-line"
points="
0,45
12,38
22,42
32,28
42,32
52,18
62,24
72,14
82,20
92,10
104,22
114,16
124,28
134,20
148,8
160,12
"
/>
<!-- Glowing dot at the leading edge -->
<circle class="chart-dot" cx="160" cy="12" r="3" />
</svg>
{#if label}
<span class="chart-label">{label}</span>
{/if}
</div>
{/if}
<style>
/* ── Dot pulse (sm) ─────────────────────────────────────────────── */
.dot-pulse {
display: inline-flex;
align-items: center;
gap: 3px;
}
.dot-pulse span {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: #60a5fa;
animation: dot-bounce 0.9s ease-in-out infinite;
}
.dot-pulse span:nth-child(2) { animation-delay: 0.15s; }
.dot-pulse span:nth-child(3) { animation-delay: 0.30s; }
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* ── Chart wrap (md / lg) ───────────────────────────────────────── */
.chart-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.chart-wrap[data-size="md"] .chart-svg { width: 120px; height: 45px; }
.chart-wrap[data-size="lg"] .chart-svg { width: 200px; height: 75px; }
.chart-svg { overflow: visible; }
/* The animated line */
.chart-line {
stroke: #3b82f6;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
/* total path length ≈ 220 — animate draw-in then loop */
stroke-dasharray: 220;
stroke-dashoffset: 220;
animation: draw-line 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes draw-line {
0% { stroke-dashoffset: 220; opacity: 1; }
70% { stroke-dashoffset: 0; opacity: 1; }
85% { stroke-dashoffset: 0; opacity: 0; }
100% { stroke-dashoffset: 220; opacity: 0; }
}
/* Glowing dot that appears when the line finishes drawing */
.chart-dot {
fill: #3b82f6;
filter: drop-shadow(0 0 4px #3b82f6);
opacity: 0;
animation: dot-appear 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes dot-appear {
0% { opacity: 0; }
60% { opacity: 0; }
70% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}
.chart-label {
font-size: 12px;
color: #475569;
letter-spacing: 0.02em;
}
</style>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import { verdictShort, vClass } from '$lib/utils.js';
let { label }: { label: string | null | undefined } = $props();
</script>
<span class="verdict-pill {vClass(label)}">{verdictShort(label)}</span>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import type { CalendarEvent } from '$lib/types.js';
let { events }: { events: CalendarEvent[] } = $props();
type EventType = 'earnings' | 'exdividend' | 'dividend';
const eventIcon = (t: EventType): string => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[t] ?? '📅';
const eventColor = (t: EventType): string =>
({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[t] ?? '#94a3b8';
const fmtMoney = (n: number | null | undefined): string | null => n == null ? null :
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
const upcoming = $derived(events.filter(e => !e.isPast).slice(0, 20));
const past = $derived(events.filter(e => e.isPast).slice(0, 10));
</script>
{#if events.length > 0}
<section class="section">
<div class="section-header">
<h2>📅 Upcoming Events</h2>
<span class="count">{upcoming.length} upcoming</span>
{#if past.length > 0}
<span class="count" style="margin-left:4px">{past.length} recent</span>
{/if}
</div>
<div class="cal-grid">
{#each upcoming as ev}
<div class="cal-event">
<div class="cal-date">{ev.date}</div>
<div class="cal-content">
<span class="cal-ticker">{ev.ticker}</span>
<span class="cal-type" style="color:{eventColor(ev.type)}">
{eventIcon(ev.type)} {ev.label}
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
</span>
{#if ev.epsEstimate != null}
<span class="cal-est">EPS est. ${ev.epsEstimate.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
{/if}
</div>
</div>
{/each}
{#if past.length > 0}
<div class="cal-divider">— Past —</div>
{#each past as ev}
<div class="cal-event past">
<div class="cal-date">{ev.date}</div>
<div class="cal-content">
<span class="cal-ticker">{ev.ticker}</span>
<span class="cal-type past-type">{eventIcon(ev.type)} {ev.label}</span>
</div>
</div>
{/each}
{/if}
</div>
</section>
{/if}
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
interface TickerSnapshot {
price: number | null;
signal: string | null;
}
interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
let {
call,
onDelete,
}: {
call: MarketCall;
onDelete: (id: string) => void;
} = $props();
const signalColor = (s: string | null | undefined): string => {
if (s?.includes('Strong')) return '#4ade80';
if (s?.includes('Momentum')) return '#60a5fa';
if (s?.includes('Neutral')) return '#94a3b8';
if (s?.includes('Speculation')) return '#fb923c';
return '#f87171';
};
</script>
<section class="section call-card">
<div class="section-header">
<div class="call-card-meta">
<a href="/calls/{call.id}" class="call-card-title">{call.title}</a>
<div class="call-card-badges">
<span class="tag">{call.quarter}</span>
<span class="call-date-badge">{call.date}</span>
<span class="count">{call.tickers.length} tickers</span>
</div>
</div>
<button class="btn-call-delete" onclick={() => onDelete(call.id)}>✕</button>
</div>
<div class="call-card-body">
<p class="call-thesis">{call.thesis}</p>
{#if Object.keys(call.snapshot ?? {}).length}
<div class="snapshot-grid">
{#each call.tickers as ticker}
{@const snap = call.snapshot[ticker]}
{#if snap}
<a href="/calls/{call.id}" class="snap-card">
<div class="snap-ticker">{ticker}</div>
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
</div>
</a>
{/if}
{/each}
</div>
<a href="/calls/{call.id}" class="call-view-link">View performance →</a>
{/if}
</div>
</section>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import Spinner from '$lib/Spinner.svelte';
interface FormData {
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string;
}
let {
saving = false,
error = null,
onSubmit,
onCancel,
}: {
saving?: boolean;
error?: string | null;
onSubmit: (data: FormData) => void;
onCancel: () => void;
} = $props();
function currentQuarter(): string {
const d = new Date();
return `Q${Math.ceil((d.getMonth() + 1) / 3)} ${d.getFullYear()}`;
}
let form = $state<FormData>({
title: '',
quarter: currentQuarter(),
date: new Date().toISOString().slice(0, 10),
thesis: '',
tickers: '',
});
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
onSubmit({ ...form });
}
</script>
<section class="section form-section">
<div class="section-header"><h2>New Market Call</h2></div>
<form class="call-form" onsubmit={handleSubmit}>
<div class="call-form-row">
<label>
<span>Title</span>
<input bind:value={form.title} placeholder="Q3 2025 Rate pivot & tech rotation" required />
</label>
<label class="narrow">
<span>Quarter</span>
<input bind:value={form.quarter} placeholder="Q3 2025" required />
</label>
<label class="narrow">
<span>Date</span>
<input type="date" bind:value={form.date} required />
</label>
</div>
<label>
<span>Thesis</span>
<textarea
bind:value={form.thesis}
rows="4"
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
required
></textarea>
</label>
<label>
<span>Tickers to track</span>
<input bind:value={form.tickers} placeholder="AAPL, MSFT, TLT, GLD …" required />
<span class="call-hint">Comma or space separated. Current prices will be snapshot automatically.</span>
</label>
{#if error}
<div class="form-error-block">{error}</div>
{/if}
<div class="call-form-actions">
<button type="submit" class="btn-primary" disabled={saving}>
{#if saving}<Spinner size="sm" /><span>Snapshotting prices…</span>
{:else}Save Call{/if}
</button>
<button type="button" class="btn-ghost" onclick={onCancel}>Cancel</button>
</div>
</form>
</section>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { fmt, fmtShort } from '$lib/utils.js';
import type { PersonalFinance } from '$lib/types.js';
let { pf }: { pf: PersonalFinance } = $props();
</script>
<div class="pnl-grid">
<div class="pnl-card"><div class="pnl-label">Net Worth</div>
<div class="pnl-value {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div></div>
<div class="pnl-card"><div class="pnl-label">Total Assets</div>
<div class="pnl-value">{fmtShort(pf.totalAssets)}</div></div>
<div class="pnl-card"><div class="pnl-label">Liabilities</div>
<div class="pnl-value red">{fmtShort(pf.totalLiabilities)}</div></div>
<div class="pnl-card"><div class="pnl-label">Cash ({pf.cashPct}%)</div>
<div class="pnl-value">{fmtShort(pf.totalCash)}</div></div>
<div class="pnl-card"><div class="pnl-label">Investments ({pf.investPct}%)</div>
<div class="pnl-value">{fmtShort(pf.totalInvestments)}</div></div>
{#if pf.savingsRate != null}
<div class="pnl-card"><div class="pnl-label">Savings Rate</div>
<div class="pnl-value {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div></div>
{/if}
<div class="pnl-card"><div class="pnl-label">Monthly Income</div>
<div class="pnl-value">{fmtShort(pf.totalIncome)}</div></div>
<div class="pnl-card"><div class="pnl-label">Monthly Spend</div>
<div class="pnl-value">{fmtShort(pf.totalSpend)}</div></div>
</div>
<div class="accounts-two-col">
<section class="accounts-section">
<h2>Accounts</h2>
<table>
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
<tbody>
{#each pf.accounts as a}
<tr>
<td class="ticker">{a.name}</td>
<td><span class="tag">{a.type}</span></td>
<td class="gray">{a.org}</td>
<td class="num right {a.balance >= 0 ? 'green' : 'red'}">{fmt(a.balance)}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section class="accounts-section">
<h2>Spending — Last 30 Days</h2>
<table>
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
<tbody>
{#each pf.categoryBreakdown.slice(0, 10) as c}
<tr>
<td>{c.category}</td>
<td class="num right">{fmt(c.amount)}</td>
<td class="num right gray">{c.pct}%</td>
<td style="width:100px">
<div class="spend-bar-bg"><div class="spend-bar-fill" style="width:{Math.min(c.pct,100)}%"></div></div>
</td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import type { HoldingFormData } from '$lib/types.js';
let {
saving = false,
error = null as string | null,
onSubmit,
onClose,
}: {
saving?: boolean;
error?: string | null;
onSubmit: (data: HoldingFormData) => void;
onClose: () => void;
} = $props();
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
function handleSubmit() {
const ticker = form.ticker.trim().toUpperCase();
const shares = parseFloat(form.shares);
const costBasis = parseFloat(form.costBasis) || 0;
if (!ticker || !shares || shares <= 0) return;
onSubmit({
ticker,
shares,
costBasis,
type: form.type as HoldingFormData['type'],
source: form.source,
});
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
}
</script>
<div class="add-form">
<div class="add-form-title">Add Holding</div>
<div class="add-form-row">
<div class="field">
<label for="form-ticker">Ticker</label>
<input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
</div>
<div class="field">
<label for="form-shares">Shares</label>
<input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
</div>
<div class="field">
<label for="form-cost">Cost Basis / share</label>
<input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
</div>
<div class="field">
<label for="form-type">Type</label>
<select id="form-type" bind:value={form.type}>
<option value="stock">Stock</option>
<option value="etf">ETF</option>
<option value="bond">Bond</option>
<option value="crypto">Crypto</option>
</select>
</div>
<div class="field">
<label for="form-source">Source</label>
<input id="form-source" bind:value={form.source} placeholder="Robinhood" />
</div>
<button class="btn-form-save" onclick={handleSubmit} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
<button class="btn-form-cancel" onclick={onClose}>✕</button>
</div>
{#if error}
<div class="form-error">{error}</div>
{/if}
</div>
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import SignalBadge from '$lib/SignalBadge.svelte';
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
import type { AdviceRow } from '$lib/types.js';
export interface UpdateData {
shares: number;
costBasis: number;
type: string;
source: string;
}
let {
rows,
onUpdate,
onDelete,
}: {
rows: AdviceRow[];
onUpdate: (ticker: string, data: UpdateData) => void;
onDelete: (ticker: string) => void;
} = $props();
// ── Sort ──────────────────────────────────────────────────────────
let sortCol = $state('ticker');
let sortDir = $state(1);
function toggleSort(col: string) {
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
else { sortCol = col; sortDir = 1; }
}
const sortIcon = (col: string) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
const sorted = $derived.by(() => [...rows].sort((a, b) => {
let av: string | number, bv: string | number;
switch (sortCol) {
case 'ticker': av = a.ticker; bv = b.ticker; break;
case 'type': av = a.type ?? ''; bv = b.type ?? ''; break;
case 'shares': av = a.shares ?? 0; bv = b.shares ?? 0; break;
case 'cost': av = a.costBasis ?? 0; bv = b.costBasis ?? 0; break;
case 'current': av = parseFloat(a.currentPrice ?? '0') || 0; bv = parseFloat(b.currentPrice ?? '0') || 0; break;
case 'value': av = parseFloat(a.marketValue ?? '0') || 0; bv = parseFloat(b.marketValue ?? '0') || 0; break;
case 'gl': av = parseFloat(a.gainLossPct ?? '0') || 0; bv = parseFloat(b.gainLossPct ?? '0') || 0; break;
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
default: return 0;
}
return av < bv ? -sortDir : av > bv ? sortDir : 0;
}));
// ── Totals ────────────────────────────────────────────────────────
const totalValue = $derived(rows.reduce((s, a) => s + (parseFloat(a.marketValue ?? '0') || 0), 0));
const totalCost = $derived(rows.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0));
const totalGL = $derived(totalValue - totalCost);
// ── Inline edit ───────────────────────────────────────────────────
interface InlineEdit { ticker: string; shares: string; costBasis: string; type: string; source: string }
let editing: InlineEdit | null = $state(null);
let saving = $state(false);
function startEdit(a: AdviceRow) {
editing = {
ticker: a.ticker,
shares: String(a.shares),
costBasis: String(a.costBasis ?? 0),
type: a.type ?? 'stock',
source: a.source ?? 'Robinhood',
};
}
async function saveEdit() {
if (!editing) return;
saving = true;
onUpdate(editing.ticker, {
shares: parseFloat(editing.shares),
costBasis: parseFloat(editing.costBasis) || 0,
type: editing.type,
source: editing.source,
});
editing = null;
saving = false;
}
</script>
<!-- P&L Summary -->
<div class="pnl-grid">
<div class="pnl-card">
<div class="pnl-label-row">
<span class="pnl-label">Total Value</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Current market value of all holdings. Shares × live price from Yahoo Finance.</span>
</span>
</div>
<div class="pnl-value">{fmtShort(totalValue)}</div>
</div>
<div class="pnl-card">
<div class="pnl-label-row">
<span class="pnl-label">Total Cost</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Total amount invested — sum of cost basis × shares across all positions.</span>
</span>
</div>
<div class="pnl-value">{fmtShort(totalCost)}</div>
</div>
<div class="pnl-card">
<div class="pnl-label-row">
<span class="pnl-label">Total G/L</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Total unrealised gain or loss — Total Value minus Total Cost.</span>
</span>
</div>
<div class="pnl-value {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
</div>
</div>
<!-- Holdings table -->
<section class="advice-section">
<h2>Holdings — Hold / Sell / Add Advice</h2>
<table>
<thead>
<tr>
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
<th class="sortable" onclick={() => toggleSort('type')}>Type {sortIcon('type')}</th>
<th class="sortable" onclick={() => toggleSort('shares')}>Shares {sortIcon('shares')}</th>
<th class="sortable" onclick={() => toggleSort('cost')}>Cost {sortIcon('cost')}</th>
<th class="sortable" onclick={() => toggleSort('current')}>Current {sortIcon('current')}</th>
<th class="sortable" onclick={() => toggleSort('value')}>Value {sortIcon('value')}</th>
<th class="sortable" onclick={() => toggleSort('gl')}>G/L {sortIcon('gl')}</th>
<th class="sortable" onclick={() => toggleSort('signal')}>Signal {sortIcon('signal')}</th>
<th>Advice</th><th>Reason</th><th></th>
</tr>
</thead>
<tbody>
{#each sorted as a}
{@const isEditing = editing?.ticker === a.ticker}
<tr class:editing={isEditing}>
<td class="ticker">{a.ticker}</td>
<td>
{#if isEditing && editing}
<select class="inline-select" bind:value={editing.type}>
<option value="stock">stock</option>
<option value="etf">etf</option>
<option value="bond">bond</option>
<option value="crypto">crypto</option>
</select>
{:else}
<span class="tag">{a.type}</span>
{/if}
</td>
<td class="num">
{#if isEditing && editing}
<input class="inline-input" bind:value={editing.shares} type="number" min="0" step="any" />
{:else}
{a.shares}
{/if}
</td>
<td class="num">
{#if isEditing && editing}
<input class="inline-input" bind:value={editing.costBasis} type="number" min="0" step="any" />
{:else}
{fmt(a.costBasis)}
{/if}
</td>
<td class="num">{fmt(a.currentPrice != null ? parseFloat(a.currentPrice) : null)}</td>
<td class="num">{fmt(a.marketValue != null ? parseFloat(a.marketValue) : null)}</td>
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray"></span>{/if}</td>
<td class={advClass(a.advice)}>{a.advice}</td>
<td class="reason">{a.reason}</td>
<td class="advice-row-actions">
{#if isEditing}
<button class="btn-save-inline" onclick={saveEdit} disabled={saving}>{saving ? '…' : '✓'}</button>
<button class="btn-cancel-inline" onclick={() => editing = null}>✕</button>
{:else}
<button class="btn-row-edit" onclick={() => startEdit(a)} title="Edit"></button>
<button class="btn-row-delete" onclick={() => onDelete(a.ticker)} title="Remove"></button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
+1 -1
View File
@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
export default {
kit: {