mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: premium finance stock analysis suite (#1268)
* Add premium finance stock analysis suite * docs: link premium finance from README Add Premium Stock Analysis entry to the Finance & Markets section with a link to docs/PREMIUM_FINANCE.md. * fix: address review feedback on premium finance suite - Chunk Redis pipelines into batches of 200 (Upstash limit) - Add try-catch around cachedFetchJson in backtest handler - Log warnings on Redis pipeline HTTP failures - Include name in analyze-stock cache key to avoid collisions - Change analyze-stock and backtest-stock gateway cache to 'slow' - Add dedup guard for concurrent ledger generation - Add SerpAPI date pre-filter (tbs=qdr:d/w) - Extract sanitizeSymbol to shared module - Extract buildEmptyAnalysisResponse helper - Fix RSI to use Wilder's smoothing (matches TradingView) - Add console.warn for daily brief summarization errors - Fall back to stale data in loadStockBacktest on error - Make daily-market-brief premium on all platforms - Use word boundaries for short token headline matching - Add stock-analysis 15-min refresh interval - Stagger stock-analysis and backtest requests (200ms) - Rename signalTone to stockSignalTone
This commit is contained in:
@@ -144,6 +144,7 @@ All five variants run from a single codebase — switch between them with one cl
|
||||
- **Stablecoin & BTC ETF** — peg health monitoring and spot ETF flow tracking. [Details →](./docs/FINANCE_DATA.md)
|
||||
- **Oil & Energy** — WTI/Brent prices, production, inventory via EIA. [Details →](./docs/FINANCE_DATA.md#oil--energy-analytics)
|
||||
- **BIS & WTO** — central bank rates, trade policy intelligence. [Details →](./docs/FINANCE_DATA.md)
|
||||
- **Premium Stock Analysis** — analysis engine with stored history, backtesting, and daily market brief. [Details →](./docs/PREMIUM_FINANCE.md)
|
||||
|
||||
### Desktop & Mobile
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ function extractOriginFromReferer(referer) {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateApiKey(req) {
|
||||
export function validateApiKey(req, options = {}) {
|
||||
const forceKey = options.forceKey === true;
|
||||
const key = req.headers.get('X-WorldMonitor-Key');
|
||||
// Same-origin browser requests don't send Origin (per CORS spec).
|
||||
// Fall back to Referer to identify trusted same-origin callers.
|
||||
@@ -47,11 +48,14 @@ export function validateApiKey(req) {
|
||||
|
||||
// Trusted browser origin (worldmonitor.app, Vercel previews, localhost dev) — no key needed
|
||||
if (isTrustedBrowserOrigin(origin)) {
|
||||
if (forceKey && !key) {
|
||||
return { valid: false, required: true, error: 'API key required' };
|
||||
}
|
||||
if (key) {
|
||||
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
|
||||
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
|
||||
}
|
||||
return { valid: true, required: false };
|
||||
return { valid: true, required: forceKey };
|
||||
}
|
||||
|
||||
// Explicit key provided from unknown origin — validate it
|
||||
|
||||
@@ -16,6 +16,8 @@ AI-powered real-time global intelligence dashboard aggregating news, markets, ge
|
||||
| [AI Intelligence](./AI_INTELLIGENCE.md) | LLM chains, RAG, threat classification, deduction |
|
||||
| [Desktop App](./DESKTOP_APP.md) | Tauri architecture, sidecar, secret management |
|
||||
| [Finance Data](./FINANCE_DATA.md) | Market radar, Gulf FDI, stablecoins, BIS, WTO |
|
||||
| [Premium Finance](./PREMIUM_FINANCE.md) | Premium stock analysis, stored history, backtests, daily brief |
|
||||
| [Premium Finance Search Layer](./PREMIUM_FINANCE_SEARCH.md) | Targeted stock-news provider chain layered on top of premium finance |
|
||||
| [Orbital Surveillance](./ORBITAL_SURVEILLANCE.md) | Satellite tracking, SGP4 propagation, tier availability, roadmap |
|
||||
| [API Reference](./api/) | OpenAPI specs for all 22 services |
|
||||
| [Adding Endpoints](./ADDING_ENDPOINTS.md) | Guide for adding new RPC endpoints |
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Market radar, Gulf FDI tracking, stablecoin monitoring, energy analytics, and trade policy intelligence in World Monitor.
|
||||
|
||||
For the premium stock-analysis product layer, see:
|
||||
|
||||
- [Premium Finance](./PREMIUM_FINANCE.md)
|
||||
- [Premium Finance Search Layer](./PREMIUM_FINANCE_SEARCH.md)
|
||||
|
||||
---
|
||||
|
||||
## Market Monitoring
|
||||
|
||||
242
docs/PREMIUM_FINANCE.md
Normal file
242
docs/PREMIUM_FINANCE.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Premium Finance
|
||||
|
||||
Premium finance is the finance-variant layer that ports the reusable stock-analysis product surface from `../daily_stock_analysis` into World Monitor without importing that repo's full standalone app architecture.
|
||||
|
||||
This layer is intentionally split into:
|
||||
|
||||
- `core premium finance`
|
||||
- `extra enrichment layers`
|
||||
|
||||
The core layer is the part required for the premium feature to work. Extra layers improve output quality or efficiency but are not the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Core Scope
|
||||
|
||||
The current premium finance scope includes:
|
||||
|
||||
- premium stock analysis
|
||||
- shared analysis history
|
||||
- stored backtest summaries
|
||||
- scheduled daily market brief generation
|
||||
- finance-variant premium panels
|
||||
- Redis-backed shared backend persistence for analysis history and backtests
|
||||
|
||||
It does **not** attempt full parity with the source repo's:
|
||||
|
||||
- standalone web app
|
||||
- relational database model
|
||||
- notification system
|
||||
- agent/chat workflows
|
||||
- bot integrations
|
||||
- image ticker extraction
|
||||
- China-specific provider mesh
|
||||
|
||||
---
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. Finance premium panels load through the normal app shell and panel loader.
|
||||
2. Premium stock RPCs are called through `MarketService`.
|
||||
3. Premium endpoints require `WORLDMONITOR_API_KEY` server-side, not just a locked UI.
|
||||
4. Results are persisted into Redis-backed shared storage.
|
||||
5. Panels prefer stored shared results before recomputing fresh analyses or backtests.
|
||||
|
||||
### Core Backend Surfaces
|
||||
|
||||
Primary handlers:
|
||||
|
||||
- [analyze-stock.ts](../server/worldmonitor/market/v1/analyze-stock.ts)
|
||||
- [get-stock-analysis-history.ts](../server/worldmonitor/market/v1/get-stock-analysis-history.ts)
|
||||
- [backtest-stock.ts](../server/worldmonitor/market/v1/backtest-stock.ts)
|
||||
- [list-stored-stock-backtests.ts](../server/worldmonitor/market/v1/list-stored-stock-backtests.ts)
|
||||
- [premium-stock-store.ts](../server/worldmonitor/market/v1/premium-stock-store.ts)
|
||||
|
||||
Primary contracts:
|
||||
|
||||
- [analyze_stock.proto](../proto/worldmonitor/market/v1/analyze_stock.proto)
|
||||
- [get_stock_analysis_history.proto](../proto/worldmonitor/market/v1/get_stock_analysis_history.proto)
|
||||
- [backtest_stock.proto](../proto/worldmonitor/market/v1/backtest_stock.proto)
|
||||
- [list_stored_stock_backtests.proto](../proto/worldmonitor/market/v1/list_stored_stock_backtests.proto)
|
||||
- [service.proto](../proto/worldmonitor/market/v1/service.proto)
|
||||
|
||||
### Frontend Surfaces
|
||||
|
||||
Panels:
|
||||
|
||||
- [StockAnalysisPanel.ts](../src/components/StockAnalysisPanel.ts)
|
||||
- [StockBacktestPanel.ts](../src/components/StockBacktestPanel.ts)
|
||||
- [DailyMarketBriefPanel.ts](../src/components/DailyMarketBriefPanel.ts)
|
||||
|
||||
Services and loading:
|
||||
|
||||
- [stock-analysis.ts](../src/services/stock-analysis.ts)
|
||||
- [stock-analysis-history.ts](../src/services/stock-analysis-history.ts)
|
||||
- [stock-backtest.ts](../src/services/stock-backtest.ts)
|
||||
- [daily-market-brief.ts](../src/services/daily-market-brief.ts)
|
||||
- [data-loader.ts](../src/app/data-loader.ts)
|
||||
|
||||
---
|
||||
|
||||
## Stock Analysis
|
||||
|
||||
The premium stock-analysis engine is a TypeScript port of the reusable core logic from the source repo, adapted to World Monitor conventions.
|
||||
|
||||
It computes:
|
||||
|
||||
- moving-average stack and trend state
|
||||
- bias versus short and medium moving averages
|
||||
- volume pattern scoring
|
||||
- MACD state
|
||||
- RSI state
|
||||
- bullish and risk factors
|
||||
- composite signal and signal score
|
||||
- AI overlay using the shared LLM chain when configured
|
||||
|
||||
Each stored analysis record includes stable replay fields so the record can be reused later:
|
||||
|
||||
- `analysisId`
|
||||
- `analysisAt`
|
||||
- `signal`
|
||||
- `currentPrice`
|
||||
- `stopLoss`
|
||||
- `takeProfit`
|
||||
- `engineVersion`
|
||||
|
||||
Those fields matter because backtesting now validates stored analysis records rather than re-deriving a different strategy view later.
|
||||
|
||||
---
|
||||
|
||||
## Shared Store
|
||||
|
||||
World Monitor still lacks a general-purpose relational backend, so premium finance currently uses Redis as the backend-owned source of truth.
|
||||
|
||||
### What Redis Stores
|
||||
|
||||
- latest shared stock-analysis snapshots
|
||||
- per-symbol analysis history index
|
||||
- historical analysis ledger used by backtesting
|
||||
- stored backtest summary snapshots
|
||||
|
||||
### Why This Is Different From The Earlier App-Layer Version
|
||||
|
||||
Earlier iterations stored history locally per device and recomputed backtests on demand. The hardened version promotes those artifacts into the backend layer so:
|
||||
|
||||
- multiple users can share the same analysis results
|
||||
- multiple users can share the same backtest summaries
|
||||
- browser or desktop cache is no longer the canonical history
|
||||
|
||||
### Current Storage Model
|
||||
|
||||
Redis is used as:
|
||||
|
||||
- shared product memory
|
||||
- cache-backed persistence
|
||||
- the current source of truth for premium finance artifacts
|
||||
|
||||
It is **not** a relational finance ledger yet. Long-lived querying, rich pagination, and full auditability would still be better served by a future database layer.
|
||||
|
||||
---
|
||||
|
||||
## Backtesting
|
||||
|
||||
Backtesting in World Monitor is intentionally tied to stored analysis records, not just a raw signal replay.
|
||||
|
||||
Current flow:
|
||||
|
||||
1. Build or refresh a historical stored analysis ledger from Yahoo daily bars.
|
||||
2. Persist those records with stable IDs and timestamps.
|
||||
3. Evaluate forward performance from each stored record's saved signal and target levels.
|
||||
4. Store the resulting backtest summary in Redis for shared reuse.
|
||||
|
||||
This makes the feature closer to "validate prior premium analyses" than "rerun whatever the latest strategy code happens to do."
|
||||
|
||||
---
|
||||
|
||||
## Daily Market Brief
|
||||
|
||||
The daily market brief is a premium finance panel layered on top of the project's existing market and news infrastructure.
|
||||
|
||||
It:
|
||||
|
||||
- builds once per local day
|
||||
- uses the tracked watchlist and available market/news context
|
||||
- caches the brief
|
||||
- avoids unnecessary regeneration before the next local morning schedule
|
||||
|
||||
This is a World Monitor adaptation, not a port of the source repo's full scheduler/automation system.
|
||||
|
||||
---
|
||||
|
||||
## Premium Access Control
|
||||
|
||||
Premium finance endpoints are enforced server-side.
|
||||
|
||||
Premium RPC paths are gated in:
|
||||
|
||||
- [gateway.ts](../server/gateway.ts)
|
||||
- [api/_api-key.js](../api/_api-key.js)
|
||||
|
||||
This matters because a UI-only lock would still allow direct API usage from trusted browser origins.
|
||||
|
||||
---
|
||||
|
||||
## Data Sources
|
||||
|
||||
Core premium finance currently depends on:
|
||||
|
||||
- Yahoo Finance chart/history endpoints
|
||||
- Finnhub for broader market data already used elsewhere in World Monitor
|
||||
- Google News RSS as the baseline stock-news fallback
|
||||
- the shared LLM provider chain in [llm.ts](../server/_shared/llm.ts)
|
||||
|
||||
The provider-backed targeted stock-news layer is documented separately in [PREMIUM_FINANCE_SEARCH.md](./PREMIUM_FINANCE_SEARCH.md).
|
||||
|
||||
---
|
||||
|
||||
## Caching And Freshness
|
||||
|
||||
There are three distinct cache or persistence behaviors in play:
|
||||
|
||||
- Redis shared storage for premium analysis history and backtests
|
||||
- Redis response caching for expensive server recomputation
|
||||
- client-side cache only as a rendering/performance layer
|
||||
|
||||
The data loader refreshes stale symbols individually rather than recomputing the whole watchlist when only one symbol is missing or stale.
|
||||
|
||||
---
|
||||
|
||||
## Separation Of Layers
|
||||
|
||||
### Core Premium Finance
|
||||
|
||||
The core layer is:
|
||||
|
||||
- analysis engine
|
||||
- stored history
|
||||
- stored backtests
|
||||
- premium auth
|
||||
- premium UI panels
|
||||
- daily brief
|
||||
|
||||
### Extra Layer: Targeted Search Enrichment
|
||||
|
||||
The search-backed stock-news layer is intentionally separate because it improves analysis quality but is not required for the feature to function. If all search providers are unavailable, premium stock analysis still works using Google News RSS fallback.
|
||||
|
||||
See [PREMIUM_FINANCE_SEARCH.md](./PREMIUM_FINANCE_SEARCH.md).
|
||||
|
||||
---
|
||||
|
||||
## Current Boundaries
|
||||
|
||||
This feature is valid and production-usable within World Monitor's current architecture, but some boundaries remain explicit:
|
||||
|
||||
- Redis is the canonical store for now
|
||||
- there is no standalone finance database
|
||||
- there is no agent/chat or notifications integration yet
|
||||
- the source repo's broader provider stack was not fully ported
|
||||
- China-focused market data/search layers were intentionally excluded
|
||||
|
||||
That tradeoff keeps the feature aligned with World Monitor rather than contaminating the repo with a second backend architecture.
|
||||
171
docs/PREMIUM_FINANCE_SEARCH.md
Normal file
171
docs/PREMIUM_FINANCE_SEARCH.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Premium Finance Search Layer
|
||||
|
||||
This document covers the **extra** targeted stock-news search layer used by premium finance.
|
||||
|
||||
It is separate from the core premium-finance architecture on purpose.
|
||||
|
||||
Core premium finance can still function without this layer. The search layer exists to improve stock-news discovery quality for targeted ticker analysis, especially where feed-only coverage is weak.
|
||||
|
||||
See the core system document in [PREMIUM_FINANCE.md](./PREMIUM_FINANCE.md).
|
||||
|
||||
---
|
||||
|
||||
## Why This Exists
|
||||
|
||||
World Monitor is mostly feed-first:
|
||||
|
||||
- curated RSS feeds
|
||||
- digest aggregation
|
||||
- Google News RSS fallbacks
|
||||
|
||||
The source repo being migrated from uses a broader search-provider layer for stock-specific news lookup. That produces better targeted coverage for:
|
||||
|
||||
- single-symbol premium analysis
|
||||
- less prominent tickers
|
||||
- recent company-specific developments not well represented in the feed inventory
|
||||
|
||||
This layer closes that gap without replacing the project's broader feed architecture.
|
||||
|
||||
---
|
||||
|
||||
## Provider Order
|
||||
|
||||
The current provider chain is:
|
||||
|
||||
1. `Tavily`
|
||||
2. `Brave`
|
||||
3. `SerpAPI`
|
||||
4. Google News RSS fallback
|
||||
|
||||
`Bocha` was intentionally not added because the current premium-finance direction is not China-focused.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Primary implementation:
|
||||
|
||||
- [stock-news-search.ts](../server/worldmonitor/market/v1/stock-news-search.ts)
|
||||
|
||||
Integration point:
|
||||
|
||||
- [analyze-stock.ts](../server/worldmonitor/market/v1/analyze-stock.ts)
|
||||
|
||||
The helper:
|
||||
|
||||
- builds a normalized stock-news query
|
||||
- tries providers in priority order
|
||||
- rotates across configured keys
|
||||
- tracks temporary provider/key failures in memory
|
||||
- normalizes provider responses into `StockAnalysisHeadline`
|
||||
- caches search results in Redis
|
||||
- falls back to Google News RSS when provider-backed search is unavailable
|
||||
|
||||
---
|
||||
|
||||
## Query Strategy
|
||||
|
||||
The current query shape intentionally mirrors the stock-news style from the source repo for foreign equities:
|
||||
|
||||
`<Company Name> <SYMBOL> stock latest news`
|
||||
|
||||
Examples:
|
||||
|
||||
- `Apple AAPL stock latest news`
|
||||
- `Microsoft MSFT stock latest news`
|
||||
|
||||
Search freshness is dynamic:
|
||||
|
||||
- Monday: 3 days
|
||||
- Saturday/Sunday: 2 days
|
||||
- Tuesday-Friday: 1 day
|
||||
|
||||
That mirrors the idea that weekend gaps need a wider lookback than midweek trading days.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Secrets
|
||||
|
||||
The search layer uses runtime-managed secret keys so it fits the same desktop/web secret model as the rest of the project.
|
||||
|
||||
Configured keys:
|
||||
|
||||
- `TAVILY_API_KEYS`
|
||||
- `BRAVE_API_KEYS`
|
||||
- `SERPAPI_API_KEYS`
|
||||
|
||||
These are wired through:
|
||||
|
||||
- [runtime-config.ts](../src/services/runtime-config.ts)
|
||||
- [settings-constants.ts](../src/services/settings-constants.ts)
|
||||
- [main.rs](../src-tauri/src/main.rs)
|
||||
- [local-api-server.mjs](../src-tauri/sidecar/local-api-server.mjs)
|
||||
|
||||
The values are multi-key strings, split on commas or newlines.
|
||||
|
||||
---
|
||||
|
||||
## Caching
|
||||
|
||||
Search results are cached in Redis under a query-derived key. The cache key includes:
|
||||
|
||||
- symbol
|
||||
- dynamic day window
|
||||
- result limit
|
||||
- hashed query
|
||||
|
||||
This avoids repeated provider calls when multiple users request the same premium stock analysis.
|
||||
|
||||
The cache is intentionally short-lived because search-backed finance news gets stale quickly.
|
||||
|
||||
---
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
If `Tavily` fails, the system tries `Brave`.
|
||||
|
||||
If `Brave` fails, the system tries `SerpAPI`.
|
||||
|
||||
If provider-backed search is unavailable, empty, or unconfigured, the system falls back to Google News RSS.
|
||||
|
||||
That means:
|
||||
|
||||
- premium stock analysis does not hard-depend on paid search providers
|
||||
- provider keys improve coverage, not feature availability
|
||||
|
||||
---
|
||||
|
||||
## Why This Is A Separate Layer
|
||||
|
||||
This layer is not the stock-analysis engine itself.
|
||||
|
||||
It should be treated as:
|
||||
|
||||
- targeted news enrichment
|
||||
- a coverage-quality upgrade
|
||||
- a provider-backed precision lookup layer
|
||||
|
||||
It should **not** be treated as:
|
||||
|
||||
- the canonical market/news ingestion architecture
|
||||
- a replacement for feed digest aggregation
|
||||
- the source of truth for premium finance persistence
|
||||
|
||||
That separation matters because it keeps the premium finance feature understandable:
|
||||
|
||||
- core finance product logic stays stable
|
||||
- search-backed enrichment can evolve independently
|
||||
|
||||
---
|
||||
|
||||
## Known Boundaries
|
||||
|
||||
The current implementation does not yet expose a standalone public stock-news search RPC.
|
||||
|
||||
Right now it is an internal backend helper used by premium stock analysis. That is deliberate:
|
||||
|
||||
- it keeps the surface area small
|
||||
- it avoids adding a premature UI/API product surface
|
||||
- it allows provider behavior to evolve before being frozen into a dedicated external contract
|
||||
|
||||
If needed later, this helper can be promoted into a first-class market RPC.
|
||||
File diff suppressed because one or more lines are too long
@@ -253,6 +253,172 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/market/v1/analyze-stock:
|
||||
get:
|
||||
tags:
|
||||
- MarketService
|
||||
summary: AnalyzeStock
|
||||
description: AnalyzeStock retrieves a premium stock analysis report with technicals, news, and AI synthesis.
|
||||
operationId: AnalyzeStock
|
||||
parameters:
|
||||
- name: symbol
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: name
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: include_news
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AnalyzeStockResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/market/v1/get-stock-analysis-history:
|
||||
get:
|
||||
tags:
|
||||
- MarketService
|
||||
summary: GetStockAnalysisHistory
|
||||
description: GetStockAnalysisHistory retrieves shared premium stock analysis history from the backend store.
|
||||
operationId: GetStockAnalysisHistory
|
||||
parameters:
|
||||
- name: symbols
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: limit_per_symbol
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
- name: include_news
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetStockAnalysisHistoryResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/market/v1/backtest-stock:
|
||||
get:
|
||||
tags:
|
||||
- MarketService
|
||||
summary: BacktestStock
|
||||
description: BacktestStock replays premium stock-analysis signals over recent price history.
|
||||
operationId: BacktestStock
|
||||
parameters:
|
||||
- name: symbol
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: name
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: eval_window_days
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BacktestStockResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/market/v1/list-stored-stock-backtests:
|
||||
get:
|
||||
tags:
|
||||
- MarketService
|
||||
summary: ListStoredStockBacktests
|
||||
description: ListStoredStockBacktests retrieves stored premium backtest snapshots from the backend store.
|
||||
operationId: ListStoredStockBacktests
|
||||
parameters:
|
||||
- name: symbols
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: eval_window_days
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ListStoredStockBacktestsResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
components:
|
||||
schemas:
|
||||
Error:
|
||||
@@ -721,3 +887,304 @@ components:
|
||||
type: number
|
||||
format: double
|
||||
description: GulfQuote represents a Gulf region market quote (index, currency, or oil).
|
||||
AnalyzeStockRequest:
|
||||
type: object
|
||||
properties:
|
||||
symbol:
|
||||
type: string
|
||||
maxLength: 32
|
||||
minLength: 1
|
||||
name:
|
||||
type: string
|
||||
maxLength: 120
|
||||
includeNews:
|
||||
type: boolean
|
||||
required:
|
||||
- symbol
|
||||
AnalyzeStockResponse:
|
||||
type: object
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
symbol:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
display:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
currentPrice:
|
||||
type: number
|
||||
format: double
|
||||
changePercent:
|
||||
type: number
|
||||
format: double
|
||||
signalScore:
|
||||
type: number
|
||||
format: double
|
||||
signal:
|
||||
type: string
|
||||
trendStatus:
|
||||
type: string
|
||||
volumeStatus:
|
||||
type: string
|
||||
macdStatus:
|
||||
type: string
|
||||
rsiStatus:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
action:
|
||||
type: string
|
||||
confidence:
|
||||
type: string
|
||||
technicalSummary:
|
||||
type: string
|
||||
newsSummary:
|
||||
type: string
|
||||
whyNow:
|
||||
type: string
|
||||
bullishFactors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
riskFactors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
supportLevels:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
format: double
|
||||
resistanceLevels:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
format: double
|
||||
headlines:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StockAnalysisHeadline'
|
||||
ma5:
|
||||
type: number
|
||||
format: double
|
||||
ma10:
|
||||
type: number
|
||||
format: double
|
||||
ma20:
|
||||
type: number
|
||||
format: double
|
||||
ma60:
|
||||
type: number
|
||||
format: double
|
||||
biasMa5:
|
||||
type: number
|
||||
format: double
|
||||
biasMa10:
|
||||
type: number
|
||||
format: double
|
||||
biasMa20:
|
||||
type: number
|
||||
format: double
|
||||
volumeRatio5d:
|
||||
type: number
|
||||
format: double
|
||||
rsi12:
|
||||
type: number
|
||||
format: double
|
||||
macdDif:
|
||||
type: number
|
||||
format: double
|
||||
macdDea:
|
||||
type: number
|
||||
format: double
|
||||
macdBar:
|
||||
type: number
|
||||
format: double
|
||||
provider:
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
fallback:
|
||||
type: boolean
|
||||
newsSearched:
|
||||
type: boolean
|
||||
generatedAt:
|
||||
type: string
|
||||
analysisId:
|
||||
type: string
|
||||
analysisAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
stopLoss:
|
||||
type: number
|
||||
format: double
|
||||
takeProfit:
|
||||
type: number
|
||||
format: double
|
||||
engineVersion:
|
||||
type: string
|
||||
StockAnalysisHeadline:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
link:
|
||||
type: string
|
||||
publishedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
GetStockAnalysisHistoryRequest:
|
||||
type: object
|
||||
properties:
|
||||
symbols:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
limitPerSymbol:
|
||||
type: integer
|
||||
maximum: 32
|
||||
minimum: 1
|
||||
format: int32
|
||||
includeNews:
|
||||
type: boolean
|
||||
GetStockAnalysisHistoryResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StockAnalysisHistoryItem'
|
||||
StockAnalysisHistoryItem:
|
||||
type: object
|
||||
properties:
|
||||
symbol:
|
||||
type: string
|
||||
snapshots:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AnalyzeStockResponse'
|
||||
BacktestStockRequest:
|
||||
type: object
|
||||
properties:
|
||||
symbol:
|
||||
type: string
|
||||
maxLength: 32
|
||||
minLength: 1
|
||||
name:
|
||||
type: string
|
||||
maxLength: 120
|
||||
evalWindowDays:
|
||||
type: integer
|
||||
maximum: 30
|
||||
minimum: 3
|
||||
format: int32
|
||||
required:
|
||||
- symbol
|
||||
BacktestStockResponse:
|
||||
type: object
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
symbol:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
display:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
evalWindowDays:
|
||||
type: integer
|
||||
format: int32
|
||||
evaluationsRun:
|
||||
type: integer
|
||||
format: int32
|
||||
actionableEvaluations:
|
||||
type: integer
|
||||
format: int32
|
||||
winRate:
|
||||
type: number
|
||||
format: double
|
||||
directionAccuracy:
|
||||
type: number
|
||||
format: double
|
||||
avgSimulatedReturnPct:
|
||||
type: number
|
||||
format: double
|
||||
cumulativeSimulatedReturnPct:
|
||||
type: number
|
||||
format: double
|
||||
latestSignal:
|
||||
type: string
|
||||
latestSignalScore:
|
||||
type: number
|
||||
format: double
|
||||
summary:
|
||||
type: string
|
||||
generatedAt:
|
||||
type: string
|
||||
evaluations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BacktestStockEvaluation'
|
||||
engineVersion:
|
||||
type: string
|
||||
BacktestStockEvaluation:
|
||||
type: object
|
||||
properties:
|
||||
analysisAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
signal:
|
||||
type: string
|
||||
signalScore:
|
||||
type: number
|
||||
format: double
|
||||
entryPrice:
|
||||
type: number
|
||||
format: double
|
||||
exitPrice:
|
||||
type: number
|
||||
format: double
|
||||
simulatedReturnPct:
|
||||
type: number
|
||||
format: double
|
||||
directionCorrect:
|
||||
type: boolean
|
||||
outcome:
|
||||
type: string
|
||||
stopLoss:
|
||||
type: number
|
||||
format: double
|
||||
takeProfit:
|
||||
type: number
|
||||
format: double
|
||||
analysisId:
|
||||
type: string
|
||||
ListStoredStockBacktestsRequest:
|
||||
type: object
|
||||
properties:
|
||||
symbols:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
evalWindowDays:
|
||||
type: integer
|
||||
maximum: 30
|
||||
minimum: 3
|
||||
format: int32
|
||||
ListStoredStockBacktestsResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BacktestStockResponse'
|
||||
|
||||
81
proto/worldmonitor/market/v1/analyze_stock.proto
Normal file
81
proto/worldmonitor/market/v1/analyze_stock.proto
Normal file
@@ -0,0 +1,81 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.market.v1;
|
||||
|
||||
import "buf/validate/validate.proto";
|
||||
import "sebuf/http/annotations.proto";
|
||||
|
||||
message StockAnalysisHeadline {
|
||||
string title = 1;
|
||||
string source = 2;
|
||||
string link = 3;
|
||||
int64 published_at = 4 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
}
|
||||
|
||||
message AnalyzeStockRequest {
|
||||
string symbol = 1 [(sebuf.http.query) = { name: "symbol" },
|
||||
(buf.validate.field).required = true,
|
||||
(buf.validate.field).string.min_len = 1,
|
||||
(buf.validate.field).string.max_len = 32
|
||||
];
|
||||
|
||||
string name = 2 [
|
||||
(sebuf.http.query) = { name: "name" },
|
||||
(buf.validate.field).string.max_len = 120
|
||||
];
|
||||
|
||||
bool include_news = 3 [(sebuf.http.query) = { name: "include_news" }];
|
||||
}
|
||||
|
||||
message AnalyzeStockResponse {
|
||||
bool available = 1;
|
||||
string symbol = 2;
|
||||
string name = 3;
|
||||
string display = 4;
|
||||
string currency = 5;
|
||||
|
||||
double current_price = 6;
|
||||
double change_percent = 7;
|
||||
double signal_score = 8;
|
||||
string signal = 9;
|
||||
string trend_status = 10;
|
||||
string volume_status = 11;
|
||||
string macd_status = 12;
|
||||
string rsi_status = 13;
|
||||
string summary = 14;
|
||||
string action = 15;
|
||||
string confidence = 16;
|
||||
string technical_summary = 17;
|
||||
string news_summary = 18;
|
||||
string why_now = 19;
|
||||
|
||||
repeated string bullish_factors = 20;
|
||||
repeated string risk_factors = 21;
|
||||
repeated double support_levels = 22;
|
||||
repeated double resistance_levels = 23;
|
||||
repeated StockAnalysisHeadline headlines = 24;
|
||||
|
||||
double ma5 = 25;
|
||||
double ma10 = 26;
|
||||
double ma20 = 27;
|
||||
double ma60 = 28;
|
||||
double bias_ma5 = 29;
|
||||
double bias_ma10 = 30;
|
||||
double bias_ma20 = 31;
|
||||
double volume_ratio_5d = 32;
|
||||
double rsi_12 = 33;
|
||||
double macd_dif = 34;
|
||||
double macd_dea = 35;
|
||||
double macd_bar = 36;
|
||||
|
||||
string provider = 37;
|
||||
string model = 38;
|
||||
bool fallback = 39;
|
||||
bool news_searched = 40;
|
||||
string generated_at = 41;
|
||||
string analysis_id = 42;
|
||||
int64 analysis_at = 43 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
double stop_loss = 44;
|
||||
double take_profit = 45;
|
||||
string engine_version = 46;
|
||||
}
|
||||
60
proto/worldmonitor/market/v1/backtest_stock.proto
Normal file
60
proto/worldmonitor/market/v1/backtest_stock.proto
Normal file
@@ -0,0 +1,60 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.market.v1;
|
||||
|
||||
import "buf/validate/validate.proto";
|
||||
import "sebuf/http/annotations.proto";
|
||||
|
||||
message BacktestStockEvaluation {
|
||||
int64 analysis_at = 1 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
string signal = 2;
|
||||
double signal_score = 3;
|
||||
double entry_price = 4;
|
||||
double exit_price = 5;
|
||||
double simulated_return_pct = 6;
|
||||
bool direction_correct = 7;
|
||||
string outcome = 8;
|
||||
double stop_loss = 9;
|
||||
double take_profit = 10;
|
||||
string analysis_id = 11;
|
||||
}
|
||||
|
||||
message BacktestStockRequest {
|
||||
string symbol = 1 [(sebuf.http.query) = { name: "symbol" },
|
||||
(buf.validate.field).required = true,
|
||||
(buf.validate.field).string.min_len = 1,
|
||||
(buf.validate.field).string.max_len = 32
|
||||
];
|
||||
|
||||
string name = 2 [
|
||||
(sebuf.http.query) = { name: "name" },
|
||||
(buf.validate.field).string.max_len = 120
|
||||
];
|
||||
|
||||
int32 eval_window_days = 3 [
|
||||
(sebuf.http.query) = { name: "eval_window_days" },
|
||||
(buf.validate.field).int32.gte = 3,
|
||||
(buf.validate.field).int32.lte = 30
|
||||
];
|
||||
}
|
||||
|
||||
message BacktestStockResponse {
|
||||
bool available = 1;
|
||||
string symbol = 2;
|
||||
string name = 3;
|
||||
string display = 4;
|
||||
string currency = 5;
|
||||
int32 eval_window_days = 6;
|
||||
int32 evaluations_run = 7;
|
||||
int32 actionable_evaluations = 8;
|
||||
double win_rate = 9;
|
||||
double direction_accuracy = 10;
|
||||
double avg_simulated_return_pct = 11;
|
||||
double cumulative_simulated_return_pct = 12;
|
||||
string latest_signal = 13;
|
||||
double latest_signal_score = 14;
|
||||
string summary = 15;
|
||||
string generated_at = 16;
|
||||
repeated BacktestStockEvaluation evaluations = 17;
|
||||
string engine_version = 18;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.market.v1;
|
||||
|
||||
import "buf/validate/validate.proto";
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/market/v1/analyze_stock.proto";
|
||||
|
||||
message GetStockAnalysisHistoryRequest {
|
||||
repeated string symbols = 1 [(sebuf.http.query) = { name: "symbols" }];
|
||||
int32 limit_per_symbol = 2 [
|
||||
(sebuf.http.query) = { name: "limit_per_symbol" },
|
||||
(buf.validate.field).int32.gte = 1,
|
||||
(buf.validate.field).int32.lte = 32
|
||||
];
|
||||
bool include_news = 3 [(sebuf.http.query) = { name: "include_news" }];
|
||||
}
|
||||
|
||||
message StockAnalysisHistoryItem {
|
||||
string symbol = 1;
|
||||
repeated AnalyzeStockResponse snapshots = 2;
|
||||
}
|
||||
|
||||
message GetStockAnalysisHistoryResponse {
|
||||
repeated StockAnalysisHistoryItem items = 1;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.market.v1;
|
||||
|
||||
import "buf/validate/validate.proto";
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/market/v1/backtest_stock.proto";
|
||||
|
||||
message ListStoredStockBacktestsRequest {
|
||||
repeated string symbols = 1 [(sebuf.http.query) = { name: "symbols" }];
|
||||
int32 eval_window_days = 2 [
|
||||
(sebuf.http.query) = { name: "eval_window_days" },
|
||||
(buf.validate.field).int32.gte = 3,
|
||||
(buf.validate.field).int32.lte = 30
|
||||
];
|
||||
}
|
||||
|
||||
message ListStoredStockBacktestsResponse {
|
||||
repeated BacktestStockResponse items = 1;
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import "worldmonitor/market/v1/list_stablecoin_markets.proto";
|
||||
import "worldmonitor/market/v1/list_etf_flows.proto";
|
||||
import "worldmonitor/market/v1/get_country_stock_index.proto";
|
||||
import "worldmonitor/market/v1/list_gulf_quotes.proto";
|
||||
import "worldmonitor/market/v1/analyze_stock.proto";
|
||||
import "worldmonitor/market/v1/backtest_stock.proto";
|
||||
import "worldmonitor/market/v1/get_stock_analysis_history.proto";
|
||||
import "worldmonitor/market/v1/list_stored_stock_backtests.proto";
|
||||
|
||||
// MarketService provides APIs for financial market data from Finnhub, Yahoo Finance, and CoinGecko.
|
||||
service MarketService {
|
||||
@@ -55,4 +59,24 @@ service MarketService {
|
||||
rpc ListGulfQuotes(ListGulfQuotesRequest) returns (ListGulfQuotesResponse) {
|
||||
option (sebuf.http.config) = {path: "/list-gulf-quotes", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// AnalyzeStock retrieves a premium stock analysis report with technicals, news, and AI synthesis.
|
||||
rpc AnalyzeStock(AnalyzeStockRequest) returns (AnalyzeStockResponse) {
|
||||
option (sebuf.http.config) = {path: "/analyze-stock", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// GetStockAnalysisHistory retrieves shared premium stock analysis history from the backend store.
|
||||
rpc GetStockAnalysisHistory(GetStockAnalysisHistoryRequest) returns (GetStockAnalysisHistoryResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-stock-analysis-history", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// BacktestStock replays premium stock-analysis signals over recent price history.
|
||||
rpc BacktestStock(BacktestStockRequest) returns (BacktestStockResponse) {
|
||||
option (sebuf.http.config) = {path: "/backtest-stock", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// ListStoredStockBacktests retrieves stored premium backtest snapshots from the backend store.
|
||||
rpc ListStoredStockBacktests(ListStoredStockBacktestsRequest) returns (ListStoredStockBacktestsResponse) {
|
||||
option (sebuf.http.config) = {path: "/list-stored-stock-backtests", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
|
||||
194
server/_shared/llm.ts
Normal file
194
server/_shared/llm.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { CHROME_UA } from './constants';
|
||||
|
||||
export interface ProviderCredentials {
|
||||
apiUrl: string;
|
||||
model: string;
|
||||
headers: Record<string, string>;
|
||||
extraBody?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const OLLAMA_HOST_ALLOWLIST = new Set([
|
||||
'localhost', '127.0.0.1', '::1', '[::1]', 'host.docker.internal',
|
||||
]);
|
||||
|
||||
function isSidecar(): boolean {
|
||||
return typeof process !== 'undefined' &&
|
||||
(process.env?.LOCAL_API_MODE || '').includes('sidecar');
|
||||
}
|
||||
|
||||
export function getProviderCredentials(provider: string): ProviderCredentials | null {
|
||||
if (provider === 'ollama') {
|
||||
const baseUrl = process.env.OLLAMA_API_URL;
|
||||
if (!baseUrl) return null;
|
||||
|
||||
if (!isSidecar()) {
|
||||
try {
|
||||
const hostname = new URL(baseUrl).hostname;
|
||||
if (!OLLAMA_HOST_ALLOWLIST.has(hostname)) {
|
||||
console.warn(`[llm] Ollama blocked: hostname "${hostname}" not in allowlist`);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const apiKey = process.env.OLLAMA_API_KEY;
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
|
||||
return {
|
||||
apiUrl: new URL('/v1/chat/completions', baseUrl).toString(),
|
||||
model: process.env.OLLAMA_MODEL || 'llama3.1:8b',
|
||||
headers,
|
||||
extraBody: { think: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'groq') {
|
||||
const apiKey = process.env.GROQ_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return {
|
||||
apiUrl: 'https://api.groq.com/openai/v1/chat/completions',
|
||||
model: 'llama-3.1-8b-instant',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'openrouter') {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return {
|
||||
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
|
||||
model: 'openrouter/free',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://worldmonitor.app',
|
||||
'X-Title': 'WorldMonitor',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function stripThinkingTags(text: string): string {
|
||||
let s = text
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||
.replace(/<\|thinking\|>[\s\S]*?<\|\/thinking\|>/gi, '')
|
||||
.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
|
||||
.replace(/<reflection>[\s\S]*?<\/reflection>/gi, '')
|
||||
.replace(/<\|begin_of_thought\|>[\s\S]*?<\|end_of_thought\|>/gi, '')
|
||||
.trim();
|
||||
|
||||
s = s
|
||||
.replace(/<think>[\s\S]*/gi, '')
|
||||
.replace(/<\|thinking\|>[\s\S]*/gi, '')
|
||||
.replace(/<reasoning>[\s\S]*/gi, '')
|
||||
.replace(/<reflection>[\s\S]*/gi, '')
|
||||
.replace(/<\|begin_of_thought\|>[\s\S]*/gi, '')
|
||||
.trim();
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
const PROVIDER_CHAIN = ['ollama', 'groq', 'openrouter'] as const;
|
||||
|
||||
export interface LlmCallOptions {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
timeoutMs?: number;
|
||||
provider?: string;
|
||||
stripThinkingTags?: boolean;
|
||||
validate?: (content: string) => boolean;
|
||||
}
|
||||
|
||||
export interface LlmCallResult {
|
||||
content: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export async function callLlm(opts: LlmCallOptions): Promise<LlmCallResult | null> {
|
||||
const {
|
||||
messages,
|
||||
temperature = 0.3,
|
||||
maxTokens = 1500,
|
||||
timeoutMs = 25_000,
|
||||
provider: forcedProvider,
|
||||
stripThinkingTags: shouldStrip = true,
|
||||
validate,
|
||||
} = opts;
|
||||
|
||||
const providers = forcedProvider ? [forcedProvider] : [...PROVIDER_CHAIN];
|
||||
|
||||
for (const providerName of providers) {
|
||||
const creds = getProviderCredentials(providerName);
|
||||
if (!creds) {
|
||||
if (forcedProvider) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(creds.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { ...creds.headers, 'User-Agent': CHROME_UA },
|
||||
body: JSON.stringify({
|
||||
...creds.extraBody,
|
||||
model: creds.model,
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.warn(`[llm:${providerName}] HTTP ${resp.status}`);
|
||||
if (forcedProvider) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: { total_tokens?: number };
|
||||
};
|
||||
|
||||
let content = data.choices?.[0]?.message?.content?.trim() || '';
|
||||
if (!content) {
|
||||
if (forcedProvider) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokens = data.usage?.total_tokens ?? 0;
|
||||
|
||||
if (shouldStrip) {
|
||||
content = stripThinkingTags(content);
|
||||
if (!content) {
|
||||
if (forcedProvider) return null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (validate && !validate(content)) {
|
||||
console.warn(`[llm:${providerName}] validate() rejected response, trying next`);
|
||||
if (forcedProvider) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
return { content, model: creds.model, provider: providerName, tokens };
|
||||
} catch (err) {
|
||||
console.warn(`[llm:${providerName}] ${(err as Error).message}`);
|
||||
if (forcedProvider) return null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -285,3 +285,39 @@ export async function getHashFieldsBatch(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runRedisPipeline(
|
||||
commands: Array<Array<string | number>>,
|
||||
raw = false,
|
||||
): Promise<Array<{ result?: unknown }>> {
|
||||
if (commands.length === 0) return [];
|
||||
|
||||
const url = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
if (!url || !token) return [];
|
||||
|
||||
const pipeline = commands.map((command) => {
|
||||
const [verb, ...rest] = command;
|
||||
if (raw || rest.length === 0 || typeof rest[0] !== 'string') {
|
||||
return command.map((part) => String(part));
|
||||
}
|
||||
return [String(verb), prefixKey(rest[0]), ...rest.slice(1).map((part) => String(part))];
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${url}/pipeline`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pipeline),
|
||||
signal: AbortSignal.timeout(REDIS_PIPELINE_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`[redis] runRedisPipeline HTTP ${resp.status}`);
|
||||
return [];
|
||||
}
|
||||
return await resp.json() as Array<{ result?: unknown }>;
|
||||
} catch (err) {
|
||||
console.warn('[redis] runRedisPipeline failed:', errMsg(err));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/market/v1/list-stablecoin-markets': 'medium',
|
||||
'/api/market/v1/get-sector-summary': 'medium',
|
||||
'/api/market/v1/list-gulf-quotes': 'medium',
|
||||
'/api/market/v1/analyze-stock': 'slow',
|
||||
'/api/market/v1/get-stock-analysis-history': 'medium',
|
||||
'/api/market/v1/backtest-stock': 'slow',
|
||||
'/api/market/v1/list-stored-stock-backtests': 'medium',
|
||||
'/api/infrastructure/v1/list-service-statuses': 'slow',
|
||||
'/api/seismology/v1/list-earthquakes': 'slow',
|
||||
'/api/infrastructure/v1/list-internet-outages': 'slow',
|
||||
@@ -123,6 +127,13 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/news/v1/summarize-article-cache': 'slow',
|
||||
};
|
||||
|
||||
const PREMIUM_RPC_PATHS = new Set([
|
||||
'/api/market/v1/analyze-stock',
|
||||
'/api/market/v1/get-stock-analysis-history',
|
||||
'/api/market/v1/backtest-stock',
|
||||
'/api/market/v1/list-stored-stock-backtests',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Creates a Vercel Edge handler for a single domain's routes.
|
||||
*
|
||||
@@ -136,6 +147,8 @@ export function createDomainGateway(
|
||||
|
||||
return async function handler(originalRequest: Request): Promise<Response> {
|
||||
let request = originalRequest;
|
||||
const rawPathname = new URL(request.url).pathname;
|
||||
const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') : rawPathname;
|
||||
|
||||
// Origin check — skip CORS headers for disallowed origins
|
||||
if (isDisallowedOrigin(request)) {
|
||||
@@ -158,7 +171,9 @@ export function createDomainGateway(
|
||||
}
|
||||
|
||||
// API key validation (origin-aware)
|
||||
const keyCheck = validateApiKey(request);
|
||||
const keyCheck = validateApiKey(request, {
|
||||
forceKey: PREMIUM_RPC_PATHS.has(pathname),
|
||||
});
|
||||
if (keyCheck.required && !keyCheck.valid) {
|
||||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||||
status: 401,
|
||||
@@ -167,9 +182,6 @@ export function createDomainGateway(
|
||||
}
|
||||
|
||||
// IP-based rate limiting — two-phase: endpoint-specific first, then global fallback
|
||||
const rawPathname = new URL(request.url).pathname;
|
||||
const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') : rawPathname;
|
||||
|
||||
const endpointRlResponse = await checkEndpointRateLimit(request, pathname, corsHeaders);
|
||||
if (endpointRlResponse) return endpointRlResponse;
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ function getRelayHeaders(): Record<string, string> {
|
||||
|
||||
export const UPSTREAM_TIMEOUT_MS = 10_000;
|
||||
|
||||
export function sanitizeSymbol(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, '').slice(0, 32).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive parser for repeated-string query params.
|
||||
* The sebuf codegen assigns `params.get("symbols")` (a string) to a field
|
||||
|
||||
861
server/worldmonitor/market/v1/analyze-stock.ts
Normal file
861
server/worldmonitor/market/v1/analyze-stock.ts
Normal file
@@ -0,0 +1,861 @@
|
||||
import type {
|
||||
AnalyzeStockRequest,
|
||||
AnalyzeStockResponse,
|
||||
ServerContext,
|
||||
StockAnalysisHeadline,
|
||||
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
|
||||
import { callLlm } from '../../../_shared/llm';
|
||||
import { cachedFetchJson } from '../../../_shared/redis';
|
||||
import { CHROME_UA, yahooGate } from '../../../_shared/constants';
|
||||
import { UPSTREAM_TIMEOUT_MS, sanitizeSymbol } from './_shared';
|
||||
import { storeStockAnalysisSnapshot } from './premium-stock-store';
|
||||
import { searchRecentStockHeadlines } from './stock-news-search';
|
||||
|
||||
export type Candle = {
|
||||
timestamp: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
type TrendStatus = 'Strong bull' | 'Bull' | 'Weak bull' | 'Consolidation' | 'Weak bear' | 'Bear' | 'Strong bear';
|
||||
type VolumeStatus = 'Heavy volume up' | 'Heavy volume down' | 'Shrink volume up' | 'Shrink volume down' | 'Normal';
|
||||
type Signal = 'Strong buy' | 'Buy' | 'Hold' | 'Watch' | 'Sell' | 'Strong sell';
|
||||
type MacdStatus = 'Golden cross above zero' | 'Golden cross' | 'Bullish' | 'Crossing up' | 'Crossing down' | 'Bearish' | 'Death cross';
|
||||
type RsiStatus = 'Overbought' | 'Strong buy' | 'Neutral' | 'Weak' | 'Oversold';
|
||||
|
||||
export type TechnicalSnapshot = {
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
currency: string;
|
||||
ma5: number;
|
||||
ma10: number;
|
||||
ma20: number;
|
||||
ma60: number;
|
||||
biasMa5: number;
|
||||
biasMa10: number;
|
||||
biasMa20: number;
|
||||
trendStatus: TrendStatus;
|
||||
trendStrength: number;
|
||||
maAlignment: string;
|
||||
volumeStatus: VolumeStatus;
|
||||
volumeRatio5d: number;
|
||||
volumeTrend: string;
|
||||
supportLevels: number[];
|
||||
resistanceLevels: number[];
|
||||
supportMa5: boolean;
|
||||
supportMa10: boolean;
|
||||
macdDif: number;
|
||||
macdDea: number;
|
||||
macdBar: number;
|
||||
macdStatus: MacdStatus;
|
||||
macdSignal: string;
|
||||
rsi6: number;
|
||||
rsi12: number;
|
||||
rsi24: number;
|
||||
rsiStatus: RsiStatus;
|
||||
rsiSignal: string;
|
||||
signal: Signal;
|
||||
signalScore: number;
|
||||
bullishFactors: string[];
|
||||
riskFactors: string[];
|
||||
};
|
||||
|
||||
export type AiOverlay = {
|
||||
summary: string;
|
||||
action: string;
|
||||
confidence: string;
|
||||
whyNow: string;
|
||||
technicalSummary: string;
|
||||
newsSummary: string;
|
||||
bullishFactors: string[];
|
||||
riskFactors: string[];
|
||||
provider: string;
|
||||
model: string;
|
||||
fallback: boolean;
|
||||
};
|
||||
|
||||
type YahooChartResponse = {
|
||||
chart?: {
|
||||
result?: Array<{
|
||||
timestamp?: number[];
|
||||
meta?: {
|
||||
currency?: string;
|
||||
regularMarketPrice?: number;
|
||||
previousClose?: number;
|
||||
chartPreviousClose?: number;
|
||||
};
|
||||
indicators?: {
|
||||
quote?: Array<{
|
||||
open?: Array<number | null>;
|
||||
high?: Array<number | null>;
|
||||
low?: Array<number | null>;
|
||||
close?: Array<number | null>;
|
||||
volume?: Array<number | null>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const CACHE_TTL_SECONDS = 900;
|
||||
const NEWS_LIMIT = 5;
|
||||
const BIAS_THRESHOLD = 5;
|
||||
const VOLUME_SHRINK_RATIO = 0.7;
|
||||
const VOLUME_HEAVY_RATIO = 1.5;
|
||||
const MA_SUPPORT_TOLERANCE = 0.02;
|
||||
export const STOCK_ANALYSIS_ENGINE_VERSION = 'v2';
|
||||
|
||||
function round(value: number, digits = 2): number {
|
||||
return Number.isFinite(value) ? Number(value.toFixed(digits)) : 0;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function signalDirection(signal: string): 'long' | 'short' | null {
|
||||
const normalized = signal.toLowerCase();
|
||||
if (normalized.includes('buy')) return 'long';
|
||||
if (normalized.includes('sell')) return 'short';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deriveTradeLevels(
|
||||
signal: string,
|
||||
entryPrice: number,
|
||||
supports: number[],
|
||||
resistances: number[],
|
||||
): { stopLoss: number; takeProfit: number } {
|
||||
const direction = signalDirection(signal);
|
||||
if (direction === 'short') {
|
||||
const stopLoss = resistances.find((level) => level > entryPrice) || entryPrice * 1.05;
|
||||
const takeProfit = supports.find((level) => level > 0 && level < entryPrice) || entryPrice * 0.92;
|
||||
return { stopLoss: round(stopLoss), takeProfit: round(takeProfit) };
|
||||
}
|
||||
|
||||
const stopLoss = supports.find((level) => level > 0 && level < entryPrice) || entryPrice * 0.95;
|
||||
const takeProfit = resistances.find((level) => level > entryPrice) || entryPrice * 1.08;
|
||||
return { stopLoss: round(stopLoss), takeProfit: round(takeProfit) };
|
||||
}
|
||||
|
||||
function mean(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function smaSeries(values: number[], period: number): number[] {
|
||||
const out: number[] = new Array(values.length).fill(Number.NaN);
|
||||
let rolling = 0;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
rolling += values[i] ?? 0;
|
||||
if (i >= period) rolling -= values[i - period] ?? 0;
|
||||
if (i >= period - 1) out[i] = rolling / period;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function emaSeries(values: number[], period: number): number[] {
|
||||
const out: number[] = [];
|
||||
const multiplier = 2 / (period + 1);
|
||||
let prev = values[0] ?? 0;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const value = values[i] ?? prev;
|
||||
prev = i === 0 ? value : ((value - prev) * multiplier) + prev;
|
||||
out.push(prev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wilderSmoothing(values: number[], period: number): number[] {
|
||||
const out: number[] = new Array(values.length).fill(Number.NaN);
|
||||
let sum = 0;
|
||||
for (let i = 1; i <= period && i < values.length; i++) sum += values[i] ?? 0;
|
||||
if (period < values.length) out[period] = sum / period;
|
||||
for (let i = period + 1; i < values.length; i++) {
|
||||
const prev = out[i - 1] ?? 0;
|
||||
out[i] = (prev * (period - 1) + (values[i] ?? 0)) / period;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rsiSeries(values: number[], period: number): number[] {
|
||||
const deltas = values.map((value, index) => index === 0 ? 0 : value - (values[index - 1] ?? value));
|
||||
const gains = deltas.map((delta) => delta > 0 ? delta : 0);
|
||||
const losses = deltas.map((delta) => delta < 0 ? -delta : 0);
|
||||
const avgGains = wilderSmoothing(gains, period);
|
||||
const avgLosses = wilderSmoothing(losses, period);
|
||||
return values.map((_, index) => {
|
||||
const avgGain = avgGains[index] ?? Number.NaN;
|
||||
const avgLoss = avgLosses[index] ?? Number.NaN;
|
||||
if (!Number.isFinite(avgGain) || !Number.isFinite(avgLoss)) return 50;
|
||||
if (avgLoss === 0) return avgGain === 0 ? 50 : 100;
|
||||
const rs = avgGain / avgLoss;
|
||||
return 100 - (100 / (1 + rs));
|
||||
});
|
||||
}
|
||||
|
||||
function latestFinite(values: number[]): number {
|
||||
for (let i = values.length - 1; i >= 0; i--) {
|
||||
if (Number.isFinite(values[i])) return values[i] as number;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function uniqueRounded(values: number[]): number[] {
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (const value of values) {
|
||||
const rounded = round(value);
|
||||
if (!rounded || seen.has(rounded)) continue;
|
||||
seen.add(rounded);
|
||||
out.push(rounded);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchYahooHistory(symbol: string): Promise<{ candles: Candle[]; currency: string } | null> {
|
||||
await yahooGate();
|
||||
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=6mo&interval=1d&includePrePost=false&events=div,splits`;
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json() as YahooChartResponse;
|
||||
const result = data.chart?.result?.[0];
|
||||
const quote = result?.indicators?.quote?.[0];
|
||||
const timestamps = result?.timestamp ?? [];
|
||||
const closes = quote?.close ?? [];
|
||||
const opens = quote?.open ?? [];
|
||||
const highs = quote?.high ?? [];
|
||||
const lows = quote?.low ?? [];
|
||||
const volumes = quote?.volume ?? [];
|
||||
|
||||
const candles: Candle[] = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
const close = closes[i];
|
||||
const open = opens[i];
|
||||
const high = highs[i];
|
||||
const low = lows[i];
|
||||
if (![close, open, high, low].every((value) => typeof value === 'number' && Number.isFinite(value))) continue;
|
||||
candles.push({
|
||||
timestamp: (timestamps[i] ?? 0) * 1000,
|
||||
open: open as number,
|
||||
high: high as number,
|
||||
low: low as number,
|
||||
close: close as number,
|
||||
volume: typeof volumes[i] === 'number' && Number.isFinite(volumes[i]) ? (volumes[i] as number) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (candles.length < 30) return null;
|
||||
return { candles, currency: result?.meta?.currency || 'USD' };
|
||||
}
|
||||
|
||||
export function buildTechnicalSnapshot(candles: Candle[]): TechnicalSnapshot {
|
||||
const closes = candles.map((candle) => candle.close);
|
||||
const highs = candles.map((candle) => candle.high);
|
||||
const volumes = candles.map((candle) => candle.volume);
|
||||
|
||||
const ma5Series = smaSeries(closes, 5);
|
||||
const ma10Series = smaSeries(closes, 10);
|
||||
const ma20Series = smaSeries(closes, 20);
|
||||
const ma60Series = candles.length >= 60 ? smaSeries(closes, 60) : ma20Series.slice();
|
||||
const ema12 = emaSeries(closes, 12);
|
||||
const ema26 = emaSeries(closes, 26);
|
||||
const macdDifSeries = closes.map((_, index) => (ema12[index] ?? 0) - (ema26[index] ?? 0));
|
||||
const macdDeaSeries = emaSeries(macdDifSeries, 9);
|
||||
const macdBarSeries = macdDifSeries.map((value, index) => (value - (macdDeaSeries[index] ?? 0)) * 2);
|
||||
const rsi6Series = rsiSeries(closes, 6);
|
||||
const rsi12Series = rsiSeries(closes, 12);
|
||||
const rsi24Series = rsiSeries(closes, 24);
|
||||
|
||||
const latestIndex = closes.length - 1;
|
||||
const prevIndex = Math.max(0, latestIndex - 1);
|
||||
const spreadIndex = Math.max(0, latestIndex - 4);
|
||||
|
||||
const currentPrice = closes[latestIndex] ?? 0;
|
||||
const previousClose = closes[prevIndex] ?? currentPrice;
|
||||
const ma5 = latestFinite(ma5Series);
|
||||
const ma10 = latestFinite(ma10Series);
|
||||
const ma20 = latestFinite(ma20Series);
|
||||
const ma60 = latestFinite(ma60Series);
|
||||
const macdDif = macdDifSeries[latestIndex] ?? 0;
|
||||
const macdDea = macdDeaSeries[latestIndex] ?? 0;
|
||||
const macdBar = macdBarSeries[latestIndex] ?? 0;
|
||||
const rsi6 = rsi6Series[latestIndex] ?? 50;
|
||||
const rsi12 = rsi12Series[latestIndex] ?? 50;
|
||||
const rsi24 = rsi24Series[latestIndex] ?? 50;
|
||||
|
||||
let trendStatus: TrendStatus = 'Consolidation';
|
||||
let trendStrength = 50;
|
||||
let maAlignment = 'Moving averages are compressed and direction is unclear.';
|
||||
|
||||
if (ma5 > ma10 && ma10 > ma20) {
|
||||
const prevSpread = ((ma5Series[spreadIndex] ?? ma5) - (ma20Series[spreadIndex] ?? ma20)) / Math.max(ma20Series[spreadIndex] ?? ma20, 0.0001) * 100;
|
||||
const currSpread = (ma5 - ma20) / Math.max(ma20, 0.0001) * 100;
|
||||
if (currSpread > prevSpread && currSpread > 5) {
|
||||
trendStatus = 'Strong bull';
|
||||
trendStrength = 90;
|
||||
maAlignment = 'MA5 > MA10 > MA20 with expanding separation.';
|
||||
} else {
|
||||
trendStatus = 'Bull';
|
||||
trendStrength = 75;
|
||||
maAlignment = 'MA5 > MA10 > MA20 confirms a bullish stack.';
|
||||
}
|
||||
} else if (ma5 > ma10 && ma10 <= ma20) {
|
||||
trendStatus = 'Weak bull';
|
||||
trendStrength = 55;
|
||||
maAlignment = 'Short-term trend is positive but MA20 still lags.';
|
||||
} else if (ma5 < ma10 && ma10 < ma20) {
|
||||
const prevSpread = ((ma20Series[spreadIndex] ?? ma20) - (ma5Series[spreadIndex] ?? ma5)) / Math.max(ma5Series[spreadIndex] ?? ma5, 0.0001) * 100;
|
||||
const currSpread = (ma20 - ma5) / Math.max(ma5, 0.0001) * 100;
|
||||
if (currSpread > prevSpread && currSpread > 5) {
|
||||
trendStatus = 'Strong bear';
|
||||
trendStrength = 10;
|
||||
maAlignment = 'MA5 < MA10 < MA20 with widening downside separation.';
|
||||
} else {
|
||||
trendStatus = 'Bear';
|
||||
trendStrength = 25;
|
||||
maAlignment = 'MA5 < MA10 < MA20 confirms a bearish stack.';
|
||||
}
|
||||
} else if (ma5 < ma10 && ma10 >= ma20) {
|
||||
trendStatus = 'Weak bear';
|
||||
trendStrength = 40;
|
||||
maAlignment = 'Short-term momentum is weak while MA20 still props the trend.';
|
||||
}
|
||||
|
||||
const biasMa5 = ((currentPrice - ma5) / Math.max(ma5, 0.0001)) * 100;
|
||||
const biasMa10 = ((currentPrice - ma10) / Math.max(ma10, 0.0001)) * 100;
|
||||
const biasMa20 = ((currentPrice - ma20) / Math.max(ma20, 0.0001)) * 100;
|
||||
|
||||
const prevFiveVolume = volumes.slice(Math.max(0, volumes.length - 6), volumes.length - 1).filter((value) => value > 0);
|
||||
const volumeRatio5d = prevFiveVolume.length > 0 ? (volumes[latestIndex] ?? 0) / mean(prevFiveVolume) : 0;
|
||||
const dayChange = ((currentPrice - previousClose) / Math.max(previousClose, 0.0001)) * 100;
|
||||
|
||||
let volumeStatus: VolumeStatus = 'Normal';
|
||||
let volumeTrend = 'Volume is close to the recent baseline.';
|
||||
if (volumeRatio5d >= VOLUME_HEAVY_RATIO) {
|
||||
if (dayChange > 0) {
|
||||
volumeStatus = 'Heavy volume up';
|
||||
volumeTrend = 'Price rose on strong participation.';
|
||||
} else {
|
||||
volumeStatus = 'Heavy volume down';
|
||||
volumeTrend = 'Selling pressure expanded sharply.';
|
||||
}
|
||||
} else if (volumeRatio5d <= VOLUME_SHRINK_RATIO) {
|
||||
if (dayChange > 0) {
|
||||
volumeStatus = 'Shrink volume up';
|
||||
volumeTrend = 'Price pushed higher but participation stayed light.';
|
||||
} else {
|
||||
volumeStatus = 'Shrink volume down';
|
||||
volumeTrend = 'Pullback happened on lighter volume, which often signals digestion instead of panic.';
|
||||
}
|
||||
}
|
||||
|
||||
const supportLevels: number[] = [];
|
||||
let supportMa5 = false;
|
||||
let supportMa10 = false;
|
||||
const ma5Distance = Math.abs(currentPrice - ma5) / Math.max(ma5, 0.0001);
|
||||
if (ma5Distance <= MA_SUPPORT_TOLERANCE && currentPrice >= ma5) {
|
||||
supportMa5 = true;
|
||||
supportLevels.push(ma5);
|
||||
}
|
||||
const ma10Distance = Math.abs(currentPrice - ma10) / Math.max(ma10, 0.0001);
|
||||
if (ma10Distance <= MA_SUPPORT_TOLERANCE && currentPrice >= ma10) {
|
||||
supportMa10 = true;
|
||||
supportLevels.push(ma10);
|
||||
}
|
||||
if (currentPrice >= ma20) supportLevels.push(ma20);
|
||||
const recentHigh = Math.max(...highs.slice(-20));
|
||||
const resistanceLevels = recentHigh > currentPrice ? [recentHigh] : [];
|
||||
|
||||
const prevMacdGap = (macdDifSeries[prevIndex] ?? 0) - (macdDeaSeries[prevIndex] ?? 0);
|
||||
const currMacdGap = macdDif - macdDea;
|
||||
const isGoldenCross = prevMacdGap <= 0 && currMacdGap > 0;
|
||||
const isDeathCross = prevMacdGap >= 0 && currMacdGap < 0;
|
||||
const prevZero = macdDifSeries[prevIndex] ?? 0;
|
||||
const isCrossingUp = prevZero <= 0 && macdDif > 0;
|
||||
const isCrossingDown = prevZero >= 0 && macdDif < 0;
|
||||
|
||||
let macdStatus: MacdStatus = 'Bullish';
|
||||
let macdSignal = 'MACD is neutral.';
|
||||
if (isGoldenCross && macdDif > 0) {
|
||||
macdStatus = 'Golden cross above zero';
|
||||
macdSignal = 'MACD flashed a golden cross above the zero line.';
|
||||
} else if (isCrossingUp) {
|
||||
macdStatus = 'Crossing up';
|
||||
macdSignal = 'MACD moved back above the zero line.';
|
||||
} else if (isGoldenCross) {
|
||||
macdStatus = 'Golden cross';
|
||||
macdSignal = 'MACD turned up with a fresh golden cross.';
|
||||
} else if (isDeathCross) {
|
||||
macdStatus = 'Death cross';
|
||||
macdSignal = 'MACD rolled over into a death cross.';
|
||||
} else if (isCrossingDown) {
|
||||
macdStatus = 'Crossing down';
|
||||
macdSignal = 'MACD slipped below the zero line.';
|
||||
} else if (macdDif > 0 && macdDea > 0) {
|
||||
macdStatus = 'Bullish';
|
||||
macdSignal = 'MACD remains above zero and constructive.';
|
||||
} else if (macdDif < 0 && macdDea < 0) {
|
||||
macdStatus = 'Bearish';
|
||||
macdSignal = 'MACD remains below zero and defensive.';
|
||||
}
|
||||
|
||||
let rsiStatus: RsiStatus = 'Neutral';
|
||||
let rsiSignal = `RSI(12) is ${round(rsi12, 1)}.`;
|
||||
if (rsi12 > 70) {
|
||||
rsiStatus = 'Overbought';
|
||||
rsiSignal = `RSI(12) at ${round(rsi12, 1)} suggests stretched momentum.`;
|
||||
} else if (rsi12 > 60) {
|
||||
rsiStatus = 'Strong buy';
|
||||
rsiSignal = `RSI(12) at ${round(rsi12, 1)} confirms strong upside momentum.`;
|
||||
} else if (rsi12 >= 40) {
|
||||
rsiStatus = 'Neutral';
|
||||
rsiSignal = `RSI(12) at ${round(rsi12, 1)} sits in the neutral zone.`;
|
||||
} else if (rsi12 >= 30) {
|
||||
rsiStatus = 'Weak';
|
||||
rsiSignal = `RSI(12) at ${round(rsi12, 1)} shows weak momentum but not washout.`;
|
||||
} else {
|
||||
rsiStatus = 'Oversold';
|
||||
rsiSignal = `RSI(12) at ${round(rsi12, 1)} is deeply oversold.`;
|
||||
}
|
||||
|
||||
let signalScore = 0;
|
||||
const bullishFactors: string[] = [];
|
||||
const riskFactors: string[] = [];
|
||||
|
||||
const trendScores: Record<TrendStatus, number> = {
|
||||
'Strong bull': 30,
|
||||
'Bull': 26,
|
||||
'Weak bull': 18,
|
||||
'Consolidation': 12,
|
||||
'Weak bear': 8,
|
||||
'Bear': 4,
|
||||
'Strong bear': 0,
|
||||
};
|
||||
signalScore += trendScores[trendStatus];
|
||||
if (trendStatus === 'Strong bull' || trendStatus === 'Bull') bullishFactors.push(`${trendStatus}: trend structure stays in buyers' favor.`);
|
||||
if (trendStatus === 'Bear' || trendStatus === 'Strong bear') riskFactors.push(`${trendStatus}: moving-average structure is still working against longs.`);
|
||||
|
||||
const effectiveThreshold = trendStatus === 'Strong bull' && trendStrength >= 70 ? BIAS_THRESHOLD * 1.5 : BIAS_THRESHOLD;
|
||||
if (biasMa5 < 0) {
|
||||
if (biasMa5 > -3) {
|
||||
signalScore += 20;
|
||||
bullishFactors.push(`Price is only ${round(biasMa5, 1)}% below MA5, a controlled pullback.`);
|
||||
} else if (biasMa5 > -5) {
|
||||
signalScore += 16;
|
||||
bullishFactors.push(`Price is testing MA5 support at ${round(biasMa5, 1)}% below the line.`);
|
||||
} else {
|
||||
signalScore += 8;
|
||||
riskFactors.push(`Price is ${round(biasMa5, 1)}% below MA5, which raises breakdown risk.`);
|
||||
}
|
||||
} else if (biasMa5 < 2) {
|
||||
signalScore += 18;
|
||||
bullishFactors.push(`Price is hugging MA5 with only ${round(biasMa5, 1)}% extension.`);
|
||||
} else if (biasMa5 < BIAS_THRESHOLD) {
|
||||
signalScore += 14;
|
||||
bullishFactors.push(`Price is modestly extended at ${round(biasMa5, 1)}% above MA5.`);
|
||||
} else if (biasMa5 > effectiveThreshold) {
|
||||
signalScore += 4;
|
||||
riskFactors.push(`Price is ${round(biasMa5, 1)}% above MA5, which is a chasing setup.`);
|
||||
} else {
|
||||
signalScore += 10;
|
||||
bullishFactors.push(`Strong trend gives some room for the current ${round(biasMa5, 1)}% extension.`);
|
||||
}
|
||||
|
||||
const volumeScores: Record<VolumeStatus, number> = {
|
||||
'Shrink volume down': 15,
|
||||
'Heavy volume up': 12,
|
||||
'Normal': 10,
|
||||
'Shrink volume up': 6,
|
||||
'Heavy volume down': 0,
|
||||
};
|
||||
signalScore += volumeScores[volumeStatus];
|
||||
if (volumeStatus === 'Shrink volume down') bullishFactors.push('Pullback volume is light, which supports the consolidation thesis.');
|
||||
if (volumeStatus === 'Heavy volume down') riskFactors.push('Downside move arrived with heavy volume.');
|
||||
|
||||
if (supportMa5) {
|
||||
signalScore += 5;
|
||||
bullishFactors.push('Price is holding the MA5 support area.');
|
||||
}
|
||||
if (supportMa10) {
|
||||
signalScore += 5;
|
||||
bullishFactors.push('Price is holding the MA10 support area.');
|
||||
}
|
||||
|
||||
const macdScores: Record<MacdStatus, number> = {
|
||||
'Golden cross above zero': 15,
|
||||
'Golden cross': 12,
|
||||
'Crossing up': 10,
|
||||
'Bullish': 8,
|
||||
'Bearish': 2,
|
||||
'Crossing down': 0,
|
||||
'Death cross': 0,
|
||||
};
|
||||
signalScore += macdScores[macdStatus];
|
||||
if (macdStatus === 'Golden cross above zero' || macdStatus === 'Golden cross') bullishFactors.push(macdSignal);
|
||||
else if (macdStatus === 'Death cross' || macdStatus === 'Crossing down') riskFactors.push(macdSignal);
|
||||
else bullishFactors.push(macdSignal);
|
||||
|
||||
const rsiScores: Record<RsiStatus, number> = {
|
||||
'Oversold': 10,
|
||||
'Strong buy': 8,
|
||||
'Neutral': 5,
|
||||
'Weak': 3,
|
||||
'Overbought': 0,
|
||||
};
|
||||
signalScore += rsiScores[rsiStatus];
|
||||
if (rsiStatus === 'Oversold' || rsiStatus === 'Strong buy') bullishFactors.push(rsiSignal);
|
||||
else if (rsiStatus === 'Overbought') riskFactors.push(rsiSignal);
|
||||
else bullishFactors.push(rsiSignal);
|
||||
|
||||
signalScore = clamp(Math.round(signalScore), 0, 100);
|
||||
|
||||
let signal: Signal = 'Sell';
|
||||
if (signalScore >= 75 && (trendStatus === 'Strong bull' || trendStatus === 'Bull')) signal = 'Strong buy';
|
||||
else if (signalScore >= 60 && (trendStatus === 'Strong bull' || trendStatus === 'Bull' || trendStatus === 'Weak bull')) signal = 'Buy';
|
||||
else if (signalScore >= 45) signal = 'Hold';
|
||||
else if (signalScore >= 30) signal = 'Watch';
|
||||
else if (trendStatus === 'Bear' || trendStatus === 'Strong bear') signal = 'Strong sell';
|
||||
|
||||
return {
|
||||
currentPrice: round(currentPrice),
|
||||
changePercent: round(((currentPrice - previousClose) / Math.max(previousClose, 0.0001)) * 100),
|
||||
currency: 'USD',
|
||||
ma5: round(ma5),
|
||||
ma10: round(ma10),
|
||||
ma20: round(ma20),
|
||||
ma60: round(ma60),
|
||||
biasMa5: round(biasMa5),
|
||||
biasMa10: round(biasMa10),
|
||||
biasMa20: round(biasMa20),
|
||||
trendStatus,
|
||||
trendStrength,
|
||||
maAlignment,
|
||||
volumeStatus,
|
||||
volumeRatio5d: round(volumeRatio5d),
|
||||
volumeTrend,
|
||||
supportLevels: uniqueRounded(supportLevels),
|
||||
resistanceLevels: uniqueRounded(resistanceLevels),
|
||||
supportMa5,
|
||||
supportMa10,
|
||||
macdDif: round(macdDif, 4),
|
||||
macdDea: round(macdDea, 4),
|
||||
macdBar: round(macdBar, 4),
|
||||
macdStatus,
|
||||
macdSignal,
|
||||
rsi6: round(rsi6, 1),
|
||||
rsi12: round(rsi12, 1),
|
||||
rsi24: round(rsi24, 1),
|
||||
rsiStatus,
|
||||
rsiSignal,
|
||||
signal,
|
||||
signalScore,
|
||||
bullishFactors: bullishFactors.slice(0, 6),
|
||||
riskFactors: riskFactors.slice(0, 6),
|
||||
};
|
||||
}
|
||||
|
||||
export function getFallbackOverlay(name: string, technical: TechnicalSnapshot, headlines: StockAnalysisHeadline[]): AiOverlay {
|
||||
const technicalSummary = `${technical.maAlignment} ${technical.volumeTrend} ${technical.macdSignal} ${technical.rsiSignal}`;
|
||||
const newsSummary = headlines.length > 0
|
||||
? `Recent coverage is led by ${headlines[0]?.source || 'market press'}: ${headlines[0]?.title || 'no headline available'}`
|
||||
: 'No material recent headlines were pulled into the report.';
|
||||
const actionMap: Record<Signal, string> = {
|
||||
'Strong buy': 'Build or add on controlled pullbacks.',
|
||||
'Buy': 'Accumulate selectively while the trend holds.',
|
||||
'Hold': 'Keep exposure but wait for a cleaner entry or confirmation.',
|
||||
'Watch': 'Stay patient until the setup improves.',
|
||||
'Sell': 'Reduce exposure into strength.',
|
||||
'Strong sell': 'Exit or avoid new long exposure.',
|
||||
};
|
||||
const confidence = technical.signalScore >= 75 ? 'High' : technical.signalScore >= 55 ? 'Medium' : 'Low';
|
||||
return {
|
||||
summary: `${name} screens as ${technical.signal.toLowerCase()} with a ${technical.trendStatus.toLowerCase()} setup and a ${technical.signalScore}/100 score.`,
|
||||
action: actionMap[technical.signal],
|
||||
confidence,
|
||||
whyNow: `Price sits ${technical.biasMa5}% versus MA5, MACD is ${technical.macdStatus.toLowerCase()}, and RSI(12) is ${technical.rsi12}.`,
|
||||
technicalSummary,
|
||||
newsSummary,
|
||||
bullishFactors: technical.bullishFactors.slice(0, 4),
|
||||
riskFactors: technical.riskFactors.slice(0, 4),
|
||||
provider: 'rules',
|
||||
model: '',
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildAiOverlay(
|
||||
symbol: string,
|
||||
name: string,
|
||||
technical: TechnicalSnapshot,
|
||||
headlines: StockAnalysisHeadline[],
|
||||
): Promise<AiOverlay> {
|
||||
const fallback = getFallbackOverlay(name, technical, headlines);
|
||||
const llm = await callLlm({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a disciplined stock analyst. Return strict JSON only with keys: summary, action, confidence, whyNow, technicalSummary, newsSummary, bullishFactors, riskFactors. Keep it concise, factual, and free of disclaimers.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify({
|
||||
symbol,
|
||||
name,
|
||||
technical: {
|
||||
signal: technical.signal,
|
||||
signalScore: technical.signalScore,
|
||||
trendStatus: technical.trendStatus,
|
||||
maAlignment: technical.maAlignment,
|
||||
currentPrice: technical.currentPrice,
|
||||
changePercent: technical.changePercent,
|
||||
ma5: technical.ma5,
|
||||
ma10: technical.ma10,
|
||||
ma20: technical.ma20,
|
||||
ma60: technical.ma60,
|
||||
biasMa5: technical.biasMa5,
|
||||
volumeStatus: technical.volumeStatus,
|
||||
volumeRatio5d: technical.volumeRatio5d,
|
||||
macdStatus: technical.macdStatus,
|
||||
macdSignal: technical.macdSignal,
|
||||
rsi12: technical.rsi12,
|
||||
rsiStatus: technical.rsiStatus,
|
||||
bullishFactors: technical.bullishFactors,
|
||||
riskFactors: technical.riskFactors,
|
||||
supportLevels: technical.supportLevels,
|
||||
resistanceLevels: technical.resistanceLevels,
|
||||
},
|
||||
headlines: headlines.map((headline) => ({
|
||||
title: headline.title,
|
||||
source: headline.source,
|
||||
publishedAt: headline.publishedAt,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
maxTokens: 500,
|
||||
timeoutMs: 20_000,
|
||||
validate: (content) => {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
return typeof parsed.summary === 'string' && typeof parsed.action === 'string';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!llm) return fallback;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(llm.content) as {
|
||||
summary?: string;
|
||||
action?: string;
|
||||
confidence?: string;
|
||||
whyNow?: string;
|
||||
technicalSummary?: string;
|
||||
newsSummary?: string;
|
||||
bullishFactors?: string[];
|
||||
riskFactors?: string[];
|
||||
};
|
||||
|
||||
return {
|
||||
summary: parsed.summary?.trim() || fallback.summary,
|
||||
action: parsed.action?.trim() || fallback.action,
|
||||
confidence: parsed.confidence?.trim() || fallback.confidence,
|
||||
whyNow: parsed.whyNow?.trim() || fallback.whyNow,
|
||||
technicalSummary: parsed.technicalSummary?.trim() || fallback.technicalSummary,
|
||||
newsSummary: parsed.newsSummary?.trim() || fallback.newsSummary,
|
||||
bullishFactors: Array.isArray(parsed.bullishFactors) && parsed.bullishFactors.length > 0 ? parsed.bullishFactors.slice(0, 4) : fallback.bullishFactors,
|
||||
riskFactors: Array.isArray(parsed.riskFactors) && parsed.riskFactors.length > 0 ? parsed.riskFactors.slice(0, 4) : fallback.riskFactors,
|
||||
provider: llm.provider,
|
||||
model: llm.model,
|
||||
fallback: false,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAnalysisResponse(params: {
|
||||
symbol: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
technical: TechnicalSnapshot;
|
||||
headlines: StockAnalysisHeadline[];
|
||||
overlay: AiOverlay;
|
||||
includeNews: boolean;
|
||||
analysisAt: number;
|
||||
generatedAt: string;
|
||||
analysisId?: string;
|
||||
}): AnalyzeStockResponse {
|
||||
const {
|
||||
symbol,
|
||||
name,
|
||||
currency,
|
||||
technical,
|
||||
headlines,
|
||||
overlay,
|
||||
includeNews,
|
||||
analysisAt,
|
||||
generatedAt,
|
||||
} = params;
|
||||
const analysisId = params.analysisId || `stock:${STOCK_ANALYSIS_ENGINE_VERSION}:${symbol}:${analysisAt}:${includeNews ? 'news' : 'core'}`;
|
||||
const { stopLoss, takeProfit } = deriveTradeLevels(
|
||||
technical.signal,
|
||||
technical.currentPrice,
|
||||
technical.supportLevels,
|
||||
technical.resistanceLevels,
|
||||
);
|
||||
|
||||
return {
|
||||
available: true,
|
||||
symbol,
|
||||
name,
|
||||
display: symbol,
|
||||
currency,
|
||||
currentPrice: technical.currentPrice,
|
||||
changePercent: technical.changePercent,
|
||||
signalScore: technical.signalScore,
|
||||
signal: technical.signal,
|
||||
trendStatus: technical.trendStatus,
|
||||
volumeStatus: technical.volumeStatus,
|
||||
macdStatus: technical.macdStatus,
|
||||
rsiStatus: technical.rsiStatus,
|
||||
summary: overlay.summary,
|
||||
action: overlay.action,
|
||||
confidence: overlay.confidence,
|
||||
technicalSummary: overlay.technicalSummary,
|
||||
newsSummary: overlay.newsSummary,
|
||||
whyNow: overlay.whyNow,
|
||||
bullishFactors: overlay.bullishFactors,
|
||||
riskFactors: overlay.riskFactors,
|
||||
supportLevels: technical.supportLevels,
|
||||
resistanceLevels: technical.resistanceLevels,
|
||||
headlines,
|
||||
ma5: technical.ma5,
|
||||
ma10: technical.ma10,
|
||||
ma20: technical.ma20,
|
||||
ma60: technical.ma60,
|
||||
biasMa5: technical.biasMa5,
|
||||
biasMa10: technical.biasMa10,
|
||||
biasMa20: technical.biasMa20,
|
||||
volumeRatio5d: technical.volumeRatio5d,
|
||||
rsi12: technical.rsi12,
|
||||
macdDif: technical.macdDif,
|
||||
macdDea: technical.macdDea,
|
||||
macdBar: technical.macdBar,
|
||||
provider: overlay.provider,
|
||||
model: overlay.model,
|
||||
fallback: overlay.fallback,
|
||||
newsSearched: includeNews,
|
||||
generatedAt,
|
||||
analysisId,
|
||||
analysisAt,
|
||||
stopLoss,
|
||||
takeProfit,
|
||||
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyAnalysisResponse(symbol: string, name: string, includeNews: boolean): AnalyzeStockResponse {
|
||||
return {
|
||||
available: false,
|
||||
symbol,
|
||||
name,
|
||||
display: symbol,
|
||||
currency: '',
|
||||
currentPrice: 0,
|
||||
changePercent: 0,
|
||||
signalScore: 0,
|
||||
signal: '',
|
||||
trendStatus: '',
|
||||
volumeStatus: '',
|
||||
macdStatus: '',
|
||||
rsiStatus: '',
|
||||
summary: '',
|
||||
action: '',
|
||||
confidence: '',
|
||||
technicalSummary: '',
|
||||
newsSummary: '',
|
||||
whyNow: '',
|
||||
bullishFactors: [],
|
||||
riskFactors: [],
|
||||
supportLevels: [],
|
||||
resistanceLevels: [],
|
||||
headlines: [],
|
||||
ma5: 0,
|
||||
ma10: 0,
|
||||
ma20: 0,
|
||||
ma60: 0,
|
||||
biasMa5: 0,
|
||||
biasMa10: 0,
|
||||
biasMa20: 0,
|
||||
volumeRatio5d: 0,
|
||||
rsi12: 0,
|
||||
macdDif: 0,
|
||||
macdDea: 0,
|
||||
macdBar: 0,
|
||||
provider: '',
|
||||
model: '',
|
||||
fallback: true,
|
||||
newsSearched: includeNews,
|
||||
generatedAt: '',
|
||||
analysisId: '',
|
||||
analysisAt: 0,
|
||||
stopLoss: 0,
|
||||
takeProfit: 0,
|
||||
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export async function analyzeStock(
|
||||
_ctx: ServerContext,
|
||||
req: AnalyzeStockRequest,
|
||||
): Promise<AnalyzeStockResponse> {
|
||||
const symbol = sanitizeSymbol(req.symbol || '');
|
||||
if (!symbol) {
|
||||
return buildEmptyAnalysisResponse('', '', false);
|
||||
}
|
||||
|
||||
const name = (req.name || symbol).trim().slice(0, 120) || symbol;
|
||||
const includeNews = req.includeNews === true;
|
||||
const nameSuffix = name !== symbol ? `:${name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 30).toLowerCase()}` : '';
|
||||
const cacheKey = `market:analyze-stock:v1:${symbol}:${includeNews ? 'news' : 'no-news'}${nameSuffix}`;
|
||||
|
||||
const cached = await cachedFetchJson<AnalyzeStockResponse>(cacheKey, CACHE_TTL_SECONDS, async () => {
|
||||
const history = await fetchYahooHistory(symbol);
|
||||
if (!history) return null;
|
||||
|
||||
const technical = buildTechnicalSnapshot(history.candles);
|
||||
technical.currency = history.currency || 'USD';
|
||||
const headlines = includeNews ? (await searchRecentStockHeadlines(symbol, name, NEWS_LIMIT)).headlines : [];
|
||||
const overlay = await buildAiOverlay(symbol, name, technical, headlines);
|
||||
const analysisAt = history.candles[history.candles.length - 1]?.timestamp || Date.now();
|
||||
const response = buildAnalysisResponse({
|
||||
symbol,
|
||||
name,
|
||||
currency: history.currency || 'USD',
|
||||
technical,
|
||||
headlines,
|
||||
overlay,
|
||||
includeNews,
|
||||
analysisAt,
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
await storeStockAnalysisSnapshot(response, includeNews);
|
||||
return response;
|
||||
});
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
return buildEmptyAnalysisResponse(symbol, name, includeNews);
|
||||
}
|
||||
274
server/worldmonitor/market/v1/backtest-stock.ts
Normal file
274
server/worldmonitor/market/v1/backtest-stock.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
AnalyzeStockResponse,
|
||||
BacktestStockResponse,
|
||||
BacktestStockEvaluation,
|
||||
MarketServiceHandler,
|
||||
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
|
||||
import { cachedFetchJson } from '../../../_shared/redis';
|
||||
import {
|
||||
buildAnalysisResponse,
|
||||
buildTechnicalSnapshot,
|
||||
fetchYahooHistory,
|
||||
getFallbackOverlay,
|
||||
signalDirection,
|
||||
type Candle,
|
||||
STOCK_ANALYSIS_ENGINE_VERSION,
|
||||
} from './analyze-stock';
|
||||
import {
|
||||
getStoredHistoricalBacktestAnalyses,
|
||||
storeHistoricalBacktestAnalysisRecords,
|
||||
storeStockBacktestSnapshot,
|
||||
} from './premium-stock-store';
|
||||
import { sanitizeSymbol } from './_shared';
|
||||
|
||||
const CACHE_TTL_SECONDS = 900;
|
||||
const DEFAULT_WINDOW_DAYS = 10;
|
||||
const MIN_REQUIRED_BARS = 80;
|
||||
const MAX_EVALUATIONS = 8;
|
||||
const MIN_ANALYSIS_BARS = 60;
|
||||
|
||||
function round(value: number, digits = 2): number {
|
||||
return Number.isFinite(value) ? Number(value.toFixed(digits)) : 0;
|
||||
}
|
||||
|
||||
function compareByAnalysisAtDesc<T extends { analysisAt: number }>(a: T, b: T): number {
|
||||
return (b.analysisAt || 0) - (a.analysisAt || 0);
|
||||
}
|
||||
|
||||
function simulateEvaluation(
|
||||
analysis: AnalyzeStockResponse,
|
||||
forwardBars: Candle[],
|
||||
): BacktestStockEvaluation | null {
|
||||
const direction = signalDirection(analysis.signal);
|
||||
if (!direction) return null;
|
||||
|
||||
const entryPrice = analysis.currentPrice;
|
||||
const stopLoss = analysis.stopLoss;
|
||||
const takeProfit = analysis.takeProfit;
|
||||
if (!entryPrice || !stopLoss || !takeProfit) return null;
|
||||
|
||||
let exitPrice = forwardBars[forwardBars.length - 1]?.close ?? entryPrice;
|
||||
let outcome = 'window_close';
|
||||
|
||||
for (const bar of forwardBars) {
|
||||
if (direction === 'long') {
|
||||
if (bar.low <= stopLoss) {
|
||||
exitPrice = stopLoss;
|
||||
outcome = 'stop_loss';
|
||||
break;
|
||||
}
|
||||
if (bar.high >= takeProfit) {
|
||||
exitPrice = takeProfit;
|
||||
outcome = 'take_profit';
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bar.high >= stopLoss) {
|
||||
exitPrice = stopLoss;
|
||||
outcome = 'stop_loss';
|
||||
break;
|
||||
}
|
||||
if (bar.low <= takeProfit) {
|
||||
exitPrice = takeProfit;
|
||||
outcome = 'take_profit';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const simulatedReturnPct = direction === 'long'
|
||||
? ((exitPrice - entryPrice) / entryPrice) * 100
|
||||
: ((entryPrice - exitPrice) / entryPrice) * 100;
|
||||
|
||||
return {
|
||||
analysisId: analysis.analysisId,
|
||||
analysisAt: analysis.analysisAt,
|
||||
signal: analysis.signal,
|
||||
signalScore: round(analysis.signalScore),
|
||||
entryPrice: round(entryPrice),
|
||||
exitPrice: round(exitPrice),
|
||||
simulatedReturnPct: round(simulatedReturnPct),
|
||||
directionCorrect: simulatedReturnPct > 0,
|
||||
outcome,
|
||||
stopLoss: round(stopLoss),
|
||||
takeProfit: round(takeProfit),
|
||||
};
|
||||
}
|
||||
|
||||
const ledgerInFlight = new Map<string, Promise<AnalyzeStockResponse[]>>();
|
||||
|
||||
async function ensureHistoricalAnalysisLedger(
|
||||
symbol: string,
|
||||
name: string,
|
||||
currency: string,
|
||||
candles: Candle[],
|
||||
): Promise<AnalyzeStockResponse[]> {
|
||||
const existing = ledgerInFlight.get(symbol);
|
||||
if (existing) return existing;
|
||||
const promise = _ensureHistoricalAnalysisLedger(symbol, name, currency, candles);
|
||||
ledgerInFlight.set(symbol, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
ledgerInFlight.delete(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
async function _ensureHistoricalAnalysisLedger(
|
||||
symbol: string,
|
||||
name: string,
|
||||
currency: string,
|
||||
candles: Candle[],
|
||||
): Promise<AnalyzeStockResponse[]> {
|
||||
const existing = await getStoredHistoricalBacktestAnalyses(symbol);
|
||||
const latestStoredAt = existing[0]?.analysisAt || 0;
|
||||
const latestCandleAt = candles[candles.length - 1]?.timestamp || 0;
|
||||
if (existing.length > 0 && latestStoredAt >= latestCandleAt) {
|
||||
return existing.sort(compareByAnalysisAtDesc);
|
||||
}
|
||||
|
||||
const generated: AnalyzeStockResponse[] = [];
|
||||
for (let index = MIN_ANALYSIS_BARS - 1; index < candles.length; index++) {
|
||||
const analysisWindow = candles.slice(0, index + 1);
|
||||
const technical = buildTechnicalSnapshot(analysisWindow);
|
||||
technical.currency = currency;
|
||||
const analysisAt = candles[index]?.timestamp || 0;
|
||||
if (!analysisAt) continue;
|
||||
|
||||
generated.push(buildAnalysisResponse({
|
||||
symbol,
|
||||
name,
|
||||
currency,
|
||||
technical,
|
||||
headlines: [],
|
||||
overlay: getFallbackOverlay(name, technical, []),
|
||||
includeNews: false,
|
||||
analysisAt,
|
||||
generatedAt: new Date(analysisAt).toISOString(),
|
||||
analysisId: `ledger:${STOCK_ANALYSIS_ENGINE_VERSION}:${symbol}:${analysisAt}`,
|
||||
}));
|
||||
}
|
||||
|
||||
await storeHistoricalBacktestAnalysisRecords(generated);
|
||||
return generated.sort(compareByAnalysisAtDesc);
|
||||
}
|
||||
|
||||
export const backtestStock: MarketServiceHandler['backtestStock'] = async (
|
||||
_ctx,
|
||||
req,
|
||||
): Promise<BacktestStockResponse> => {
|
||||
const symbol = sanitizeSymbol(req.symbol || '');
|
||||
if (!symbol) {
|
||||
return {
|
||||
available: false,
|
||||
symbol: '',
|
||||
name: req.name || '',
|
||||
display: '',
|
||||
currency: 'USD',
|
||||
evalWindowDays: req.evalWindowDays || DEFAULT_WINDOW_DAYS,
|
||||
evaluationsRun: 0,
|
||||
actionableEvaluations: 0,
|
||||
winRate: 0,
|
||||
directionAccuracy: 0,
|
||||
avgSimulatedReturnPct: 0,
|
||||
cumulativeSimulatedReturnPct: 0,
|
||||
latestSignal: '',
|
||||
latestSignalScore: 0,
|
||||
summary: 'No symbol provided.',
|
||||
generatedAt: new Date().toISOString(),
|
||||
evaluations: [],
|
||||
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
const evalWindowDays = Math.max(3, Math.min(30, req.evalWindowDays || DEFAULT_WINDOW_DAYS));
|
||||
const cacheKey = `market:backtest:v2:${symbol}:${evalWindowDays}`;
|
||||
|
||||
try {
|
||||
const cached = await cachedFetchJson<BacktestStockResponse>(cacheKey, CACHE_TTL_SECONDS, async () => {
|
||||
const history = await fetchYahooHistory(symbol);
|
||||
if (!history || history.candles.length < MIN_REQUIRED_BARS) return null;
|
||||
|
||||
const analyses = await ensureHistoricalAnalysisLedger(
|
||||
symbol,
|
||||
req.name || symbol,
|
||||
history.currency || 'USD',
|
||||
history.candles,
|
||||
);
|
||||
if (analyses.length === 0) return null;
|
||||
|
||||
const candleIndexByTimestamp = new Map<number, number>();
|
||||
history.candles.forEach((candle, index) => {
|
||||
candleIndexByTimestamp.set(candle.timestamp, index);
|
||||
});
|
||||
|
||||
const evaluations = analyses
|
||||
.map((analysis) => {
|
||||
const candleIndex = candleIndexByTimestamp.get(analysis.analysisAt);
|
||||
if (candleIndex == null) return null;
|
||||
const forwardBars = history.candles.slice(candleIndex + 1, candleIndex + 1 + evalWindowDays);
|
||||
if (forwardBars.length < evalWindowDays) return null;
|
||||
return simulateEvaluation(analysis, forwardBars);
|
||||
})
|
||||
.filter((evaluation): evaluation is BacktestStockEvaluation => !!evaluation)
|
||||
.sort(compareByAnalysisAtDesc);
|
||||
|
||||
if (evaluations.length === 0) return null;
|
||||
|
||||
const actionableEvaluations = evaluations.length;
|
||||
const profitable = evaluations.filter((evaluation) => evaluation.simulatedReturnPct > 0);
|
||||
const winRate = (profitable.length / actionableEvaluations) * 100;
|
||||
const directionAccuracy = (evaluations.filter((evaluation) => evaluation.directionCorrect).length / actionableEvaluations) * 100;
|
||||
const avgSimulatedReturnPct = evaluations.reduce((sum, evaluation) => sum + evaluation.simulatedReturnPct, 0) / actionableEvaluations;
|
||||
const cumulativeSimulatedReturnPct = evaluations.reduce((sum, evaluation) => sum + evaluation.simulatedReturnPct, 0);
|
||||
const latest = evaluations[0]!;
|
||||
const response: BacktestStockResponse = {
|
||||
available: true,
|
||||
symbol,
|
||||
name: req.name || symbol,
|
||||
display: symbol,
|
||||
currency: history.currency || 'USD',
|
||||
evalWindowDays,
|
||||
evaluationsRun: analyses.length,
|
||||
actionableEvaluations,
|
||||
winRate: round(winRate),
|
||||
directionAccuracy: round(directionAccuracy),
|
||||
avgSimulatedReturnPct: round(avgSimulatedReturnPct),
|
||||
cumulativeSimulatedReturnPct: round(cumulativeSimulatedReturnPct),
|
||||
latestSignal: latest.signal,
|
||||
latestSignalScore: round(latest.signalScore),
|
||||
summary: `Validated ${actionableEvaluations} stored analysis records over ${evalWindowDays} trading days with ${round(winRate)}% win rate and ${round(avgSimulatedReturnPct)}% average simulated return.`,
|
||||
generatedAt: new Date().toISOString(),
|
||||
evaluations: evaluations.slice(0, MAX_EVALUATIONS),
|
||||
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
|
||||
};
|
||||
await storeStockBacktestSnapshot(response);
|
||||
return response;
|
||||
});
|
||||
if (cached) return cached;
|
||||
} catch (err) {
|
||||
console.warn(`[backtestStock] ${symbol} failed:`, (err as Error).message);
|
||||
}
|
||||
|
||||
return {
|
||||
available: false,
|
||||
symbol,
|
||||
name: req.name || symbol,
|
||||
display: symbol,
|
||||
currency: 'USD',
|
||||
evalWindowDays,
|
||||
evaluationsRun: 0,
|
||||
actionableEvaluations: 0,
|
||||
winRate: 0,
|
||||
directionAccuracy: 0,
|
||||
avgSimulatedReturnPct: 0,
|
||||
cumulativeSimulatedReturnPct: 0,
|
||||
latestSignal: '',
|
||||
latestSignalScore: 0,
|
||||
summary: 'Backtest unavailable for this symbol.',
|
||||
generatedAt: new Date().toISOString(),
|
||||
evaluations: [],
|
||||
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
|
||||
};
|
||||
};
|
||||
28
server/worldmonitor/market/v1/get-stock-analysis-history.ts
Normal file
28
server/worldmonitor/market/v1/get-stock-analysis-history.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
GetStockAnalysisHistoryRequest,
|
||||
GetStockAnalysisHistoryResponse,
|
||||
MarketServiceHandler,
|
||||
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
|
||||
import { parseStringArray } from './_shared';
|
||||
import { getStoredStockAnalysisHistory } from './premium-stock-store';
|
||||
|
||||
const DEFAULT_LIMIT_PER_SYMBOL = 4;
|
||||
const MAX_LIMIT_PER_SYMBOL = 32;
|
||||
|
||||
export const getStockAnalysisHistory: MarketServiceHandler['getStockAnalysisHistory'] = async (
|
||||
_ctx,
|
||||
req: GetStockAnalysisHistoryRequest,
|
||||
): Promise<GetStockAnalysisHistoryResponse> => {
|
||||
const symbols = parseStringArray(req.symbols).slice(0, 8);
|
||||
const limitPerSymbol = Math.max(1, Math.min(MAX_LIMIT_PER_SYMBOL, req.limitPerSymbol || DEFAULT_LIMIT_PER_SYMBOL));
|
||||
const history = await getStoredStockAnalysisHistory(symbols, !!req.includeNews, limitPerSymbol);
|
||||
|
||||
return {
|
||||
items: Object.entries(history)
|
||||
.filter(([, snapshots]) => snapshots.length > 0)
|
||||
.map(([symbol, snapshots]) => ({
|
||||
symbol,
|
||||
snapshots,
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -21,6 +21,10 @@ import { listStablecoinMarkets } from './list-stablecoin-markets';
|
||||
import { listEtfFlows } from './list-etf-flows';
|
||||
import { getCountryStockIndex } from './get-country-stock-index';
|
||||
import { listGulfQuotes } from './list-gulf-quotes';
|
||||
import { analyzeStock } from './analyze-stock';
|
||||
import { getStockAnalysisHistory } from './get-stock-analysis-history';
|
||||
import { backtestStock } from './backtest-stock';
|
||||
import { listStoredStockBacktests } from './list-stored-stock-backtests';
|
||||
|
||||
export const marketHandler: MarketServiceHandler = {
|
||||
listMarketQuotes,
|
||||
@@ -31,4 +35,8 @@ export const marketHandler: MarketServiceHandler = {
|
||||
listEtfFlows,
|
||||
getCountryStockIndex,
|
||||
listGulfQuotes,
|
||||
analyzeStock,
|
||||
getStockAnalysisHistory,
|
||||
backtestStock,
|
||||
listStoredStockBacktests,
|
||||
};
|
||||
|
||||
19
server/worldmonitor/market/v1/list-stored-stock-backtests.ts
Normal file
19
server/worldmonitor/market/v1/list-stored-stock-backtests.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
ListStoredStockBacktestsRequest,
|
||||
ListStoredStockBacktestsResponse,
|
||||
MarketServiceHandler,
|
||||
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
|
||||
import { parseStringArray } from './_shared';
|
||||
import { getStoredStockBacktestSnapshots } from './premium-stock-store';
|
||||
|
||||
const DEFAULT_EVAL_WINDOW_DAYS = 10;
|
||||
|
||||
export const listStoredStockBacktests: MarketServiceHandler['listStoredStockBacktests'] = async (
|
||||
_ctx,
|
||||
req: ListStoredStockBacktestsRequest,
|
||||
): Promise<ListStoredStockBacktestsResponse> => {
|
||||
const symbols = parseStringArray(req.symbols).slice(0, 8);
|
||||
const evalWindowDays = Math.max(3, Math.min(30, req.evalWindowDays || DEFAULT_EVAL_WINDOW_DAYS));
|
||||
const items = await getStoredStockBacktestSnapshots(symbols, evalWindowDays);
|
||||
return { items };
|
||||
};
|
||||
213
server/worldmonitor/market/v1/premium-stock-store.ts
Normal file
213
server/worldmonitor/market/v1/premium-stock-store.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type {
|
||||
AnalyzeStockResponse,
|
||||
BacktestStockResponse,
|
||||
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
|
||||
import { getCachedJsonBatch, runRedisPipeline, setCachedJson } from '../../../_shared/redis';
|
||||
import { sanitizeSymbol } from './_shared';
|
||||
|
||||
const ANALYSIS_HISTORY_LIMIT = 32;
|
||||
const ANALYSIS_HISTORY_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
const BACKTEST_LEDGER_LIMIT = 192;
|
||||
const BACKTEST_LEDGER_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
const BACKTEST_STORE_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
type AnalysisHistoryRecord = Record<string, AnalyzeStockResponse[]>;
|
||||
|
||||
function compareAnalysisDesc<T extends { analysisAt: number; generatedAt: string }>(a: T, b: T): number {
|
||||
const aTime = a.analysisAt || Date.parse(a.generatedAt || '') || 0;
|
||||
const bTime = b.analysisAt || Date.parse(b.generatedAt || '') || 0;
|
||||
return bTime - aTime;
|
||||
}
|
||||
|
||||
function analysisHistoryIndexKey(symbol: string, includeNews: boolean): string {
|
||||
return `market:stock-analysis-history:index:v2:${sanitizeSymbol(symbol)}:${includeNews ? 'news' : 'core'}`;
|
||||
}
|
||||
|
||||
function analysisItemKey(analysisId: string): string {
|
||||
return `market:stock-analysis-history:item:v2:${analysisId}`;
|
||||
}
|
||||
|
||||
function backtestSnapshotKey(symbol: string, evalWindowDays: number): string {
|
||||
return `market:stock-backtest-store:v2:${sanitizeSymbol(symbol)}:${evalWindowDays}`;
|
||||
}
|
||||
|
||||
function backtestLedgerIndexKey(symbol: string): string {
|
||||
return `market:stock-analysis-ledger:index:v1:${sanitizeSymbol(symbol)}`;
|
||||
}
|
||||
|
||||
function backtestLedgerItemKey(analysisId: string): string {
|
||||
return `market:stock-analysis-ledger:item:v1:${analysisId}`;
|
||||
}
|
||||
|
||||
function normalizeAnalysisRecord(
|
||||
snapshot: AnalyzeStockResponse,
|
||||
includeNews: boolean,
|
||||
): AnalyzeStockResponse | null {
|
||||
if (!snapshot.available || !snapshot.symbol) return null;
|
||||
|
||||
const symbol = sanitizeSymbol(snapshot.symbol);
|
||||
const analysisAt = snapshot.analysisAt || Date.parse(snapshot.generatedAt || '') || 0;
|
||||
if (!analysisAt) return null;
|
||||
|
||||
const engineVersion = snapshot.engineVersion || 'v1';
|
||||
const analysisId = snapshot.analysisId || `stock:${engineVersion}:${symbol}:${analysisAt}:${includeNews ? 'news' : 'core'}`;
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
symbol,
|
||||
analysisId,
|
||||
analysisAt,
|
||||
engineVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLedgerRecord(snapshot: AnalyzeStockResponse): AnalyzeStockResponse | null {
|
||||
if (!snapshot.available || !snapshot.symbol) return null;
|
||||
|
||||
const symbol = sanitizeSymbol(snapshot.symbol);
|
||||
const analysisAt = snapshot.analysisAt || Date.parse(snapshot.generatedAt || '') || 0;
|
||||
if (!analysisAt) return null;
|
||||
|
||||
const engineVersion = snapshot.engineVersion || 'v1';
|
||||
const analysisId = snapshot.analysisId || `ledger:${engineVersion}:${symbol}:${analysisAt}`;
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
symbol,
|
||||
analysisId,
|
||||
analysisAt,
|
||||
engineVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function zrevrange(key: string, limit: number): Promise<string[]> {
|
||||
if (limit <= 0) return [];
|
||||
const data = await runRedisPipeline([
|
||||
['ZREVRANGE', key, 0, Math.max(0, limit - 1)],
|
||||
]);
|
||||
return Array.isArray(data[0]?.result)
|
||||
? data[0]!.result!.map((item) => String(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
async function loadAnalysisRecords(ids: string[], itemKeyFor: (analysisId: string) => string): Promise<AnalyzeStockResponse[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const itemKeys = ids.map(itemKeyFor);
|
||||
const cached = await getCachedJsonBatch(itemKeys);
|
||||
|
||||
return ids
|
||||
.map((_, index) => cached.get(itemKeys[index]!) as AnalyzeStockResponse | undefined)
|
||||
.filter((item): item is AnalyzeStockResponse => !!item?.available)
|
||||
.sort(compareAnalysisDesc);
|
||||
}
|
||||
|
||||
async function trimIndexTail(indexKey: string, ids: string[], keepLimit: number): Promise<void> {
|
||||
if (ids.length <= keepLimit) return;
|
||||
const overflow = ids.slice(keepLimit);
|
||||
await runRedisPipeline([
|
||||
['ZREM', indexKey, ...overflow],
|
||||
]);
|
||||
}
|
||||
|
||||
export async function storeStockAnalysisSnapshot(
|
||||
snapshot: AnalyzeStockResponse,
|
||||
includeNews: boolean,
|
||||
): Promise<void> {
|
||||
const record = normalizeAnalysisRecord(snapshot, includeNews);
|
||||
if (!record) return;
|
||||
|
||||
const indexKey = analysisHistoryIndexKey(record.symbol, includeNews);
|
||||
const itemKey = analysisItemKey(record.analysisId);
|
||||
|
||||
await runRedisPipeline([
|
||||
['SET', itemKey, JSON.stringify(record), 'EX', ANALYSIS_HISTORY_TTL_SECONDS],
|
||||
['ZADD', indexKey, record.analysisAt, record.analysisId],
|
||||
['EXPIRE', indexKey, ANALYSIS_HISTORY_TTL_SECONDS],
|
||||
]);
|
||||
|
||||
const ids = await zrevrange(indexKey, ANALYSIS_HISTORY_LIMIT + 4);
|
||||
await trimIndexTail(indexKey, ids, ANALYSIS_HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
export async function getStoredStockAnalysisHistory(
|
||||
symbols: string[],
|
||||
includeNews: boolean,
|
||||
limitPerSymbol = ANALYSIS_HISTORY_LIMIT,
|
||||
): Promise<AnalysisHistoryRecord> {
|
||||
const normalized = [...new Set(symbols.map(sanitizeSymbol).filter(Boolean))];
|
||||
const clampedLimit = Math.max(1, Math.min(ANALYSIS_HISTORY_LIMIT, limitPerSymbol));
|
||||
const out: AnalysisHistoryRecord = {};
|
||||
|
||||
await Promise.all(normalized.map(async (symbol) => {
|
||||
const ids = await zrevrange(analysisHistoryIndexKey(symbol, includeNews), clampedLimit);
|
||||
out[symbol] = await loadAnalysisRecords(ids, analysisItemKey);
|
||||
}));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function storeHistoricalBacktestAnalysisRecords(
|
||||
snapshots: AnalyzeStockResponse[],
|
||||
): Promise<void> {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const touchedSymbols = new Set<string>();
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
const record = normalizeLedgerRecord(snapshot);
|
||||
if (!record) continue;
|
||||
|
||||
const indexKey = backtestLedgerIndexKey(record.symbol);
|
||||
commands.push(
|
||||
['SET', backtestLedgerItemKey(record.analysisId), JSON.stringify(record), 'EX', BACKTEST_LEDGER_TTL_SECONDS],
|
||||
['ZADD', indexKey, record.analysisAt, record.analysisId],
|
||||
['EXPIRE', indexKey, BACKTEST_LEDGER_TTL_SECONDS],
|
||||
);
|
||||
touchedSymbols.add(record.symbol);
|
||||
}
|
||||
|
||||
if (commands.length === 0) return;
|
||||
const PIPELINE_CHUNK = 200;
|
||||
for (let i = 0; i < commands.length; i += PIPELINE_CHUNK) {
|
||||
await runRedisPipeline(commands.slice(i, i + PIPELINE_CHUNK));
|
||||
}
|
||||
|
||||
await Promise.all([...touchedSymbols].map(async (symbol) => {
|
||||
const ids = await zrevrange(backtestLedgerIndexKey(symbol), BACKTEST_LEDGER_LIMIT + 8);
|
||||
await trimIndexTail(backtestLedgerIndexKey(symbol), ids, BACKTEST_LEDGER_LIMIT);
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getStoredHistoricalBacktestAnalyses(
|
||||
symbol: string,
|
||||
limit = BACKTEST_LEDGER_LIMIT,
|
||||
): Promise<AnalyzeStockResponse[]> {
|
||||
const normalized = sanitizeSymbol(symbol);
|
||||
if (!normalized) return [];
|
||||
const ids = await zrevrange(backtestLedgerIndexKey(normalized), Math.max(1, limit));
|
||||
return loadAnalysisRecords(ids, backtestLedgerItemKey);
|
||||
}
|
||||
|
||||
export async function storeStockBacktestSnapshot(
|
||||
snapshot: BacktestStockResponse,
|
||||
): Promise<void> {
|
||||
if (!snapshot.available || !snapshot.symbol) return;
|
||||
const key = backtestSnapshotKey(snapshot.symbol, snapshot.evalWindowDays || 10);
|
||||
await setCachedJson(key, {
|
||||
...snapshot,
|
||||
symbol: sanitizeSymbol(snapshot.symbol),
|
||||
}, BACKTEST_STORE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
export async function getStoredStockBacktestSnapshots(
|
||||
symbols: string[],
|
||||
evalWindowDays: number,
|
||||
): Promise<BacktestStockResponse[]> {
|
||||
const normalized = [...new Set(symbols.map(sanitizeSymbol).filter(Boolean))];
|
||||
const keys = normalized.map((symbol) => backtestSnapshotKey(symbol, evalWindowDays));
|
||||
const cached = await getCachedJsonBatch(keys);
|
||||
|
||||
return normalized
|
||||
.map((_, index) => cached.get(keys[index]!) as BacktestStockResponse | undefined)
|
||||
.filter((item): item is BacktestStockResponse => !!item?.available)
|
||||
.sort((a, b) => (Date.parse(b.generatedAt || '') || 0) - (Date.parse(a.generatedAt || '') || 0));
|
||||
}
|
||||
366
server/worldmonitor/market/v1/stock-news-search.ts
Normal file
366
server/worldmonitor/market/v1/stock-news-search.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
import type { StockAnalysisHeadline } from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
|
||||
import { CHROME_UA } from '../../../_shared/constants';
|
||||
import { cachedFetchJson } from '../../../_shared/redis';
|
||||
import { UPSTREAM_TIMEOUT_MS } from './_shared';
|
||||
|
||||
export type StockNewsSearchProviderId = 'tavily' | 'brave' | 'serpapi' | 'google-news-rss';
|
||||
|
||||
type StockNewsSearchResult = {
|
||||
provider: StockNewsSearchProviderId;
|
||||
headlines: StockAnalysisHeadline[];
|
||||
};
|
||||
|
||||
type SearchProviderDefinition = {
|
||||
id: Exclude<StockNewsSearchProviderId, 'google-news-rss'>;
|
||||
envKey: 'TAVILY_API_KEYS' | 'BRAVE_API_KEYS' | 'SERPAPI_API_KEYS';
|
||||
search: (query: string, maxResults: number, days: number, apiKey: string) => Promise<StockAnalysisHeadline[]>;
|
||||
};
|
||||
|
||||
type ProviderRotationState = {
|
||||
cursor: number;
|
||||
errors: Map<string, number>;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const SEARCH_CACHE_TTL_SECONDS = 1_200;
|
||||
const PROVIDER_ERROR_THRESHOLD = 3;
|
||||
const SEARCH_XML = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
trimValues: true,
|
||||
});
|
||||
const providerState = new Map<string, ProviderRotationState>();
|
||||
|
||||
export function resetStockNewsSearchStateForTests(): void {
|
||||
providerState.clear();
|
||||
}
|
||||
|
||||
function splitApiKeys(raw: string | undefined): string[] {
|
||||
return String(raw || '')
|
||||
.split(/[\n,]+/)
|
||||
.map(key => key.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeSymbol(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, '').slice(0, 32).toUpperCase();
|
||||
}
|
||||
|
||||
function stableHash(input: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return Math.abs(hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname.replace(/^www\./, '') || 'Unknown source';
|
||||
} catch {
|
||||
return 'Unknown source';
|
||||
}
|
||||
}
|
||||
|
||||
function parsePublishedAt(value: unknown): number {
|
||||
if (typeof value !== 'string' || !value.trim()) return 0;
|
||||
const parsed = Date.parse(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function relativeDateToTimestamp(value: unknown): number {
|
||||
if (typeof value !== 'string' || !value.trim()) return 0;
|
||||
const raw = value.trim().toLowerCase();
|
||||
const absolute = Date.parse(raw);
|
||||
if (Number.isFinite(absolute)) return absolute;
|
||||
|
||||
const match = raw.match(/^(\d+)\s+(minute|minutes|hour|hours|day|days|week|weeks|month|months)\s+ago$/);
|
||||
if (!match) return 0;
|
||||
|
||||
const amount = Number(match[1] || 0);
|
||||
const unit = match[2] || '';
|
||||
const now = Date.now();
|
||||
const unitMs =
|
||||
unit.startsWith('minute') ? 60_000 :
|
||||
unit.startsWith('hour') ? 3_600_000 :
|
||||
unit.startsWith('day') ? 86_400_000 :
|
||||
unit.startsWith('week') ? 7 * 86_400_000 :
|
||||
30 * 86_400_000;
|
||||
return now - (amount * unitMs);
|
||||
}
|
||||
|
||||
function dedupeHeadlines(headlines: StockAnalysisHeadline[], maxResults: number): StockAnalysisHeadline[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized = headlines
|
||||
.filter(item => item.title.trim() && item.link.trim())
|
||||
.filter((item) => {
|
||||
const key = `${item.link.trim().toLowerCase()}|${item.title.trim().toLowerCase()}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => (b.publishedAt || 0) - (a.publishedAt || 0));
|
||||
return normalized.slice(0, maxResults);
|
||||
}
|
||||
|
||||
function getSearchDays(now = new Date()): number {
|
||||
const weekday = now.getDay();
|
||||
if (weekday === 1) return 3;
|
||||
if (weekday === 0 || weekday === 6) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function buildStockNewsSearchQuery(symbol: string, name: string): string {
|
||||
const normalizedSymbol = normalizeSymbol(symbol);
|
||||
const normalizedName = name.trim();
|
||||
return normalizedName
|
||||
? `${normalizedName} ${normalizedSymbol} stock latest news`
|
||||
: `${normalizedSymbol} stock latest news`;
|
||||
}
|
||||
|
||||
function getProviderCandidates(provider: SearchProviderDefinition): string[] {
|
||||
const keys = splitApiKeys(process.env[provider.envKey]);
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const signature = keys.join('|');
|
||||
let state = providerState.get(provider.id);
|
||||
if (!state || state.signature !== signature) {
|
||||
state = { cursor: 0, errors: new Map<string, number>(), signature };
|
||||
providerState.set(provider.id, state);
|
||||
}
|
||||
|
||||
const ordered: string[] = [];
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const candidate = keys[(state.cursor + i) % keys.length]!;
|
||||
if ((state.errors.get(candidate) || 0) < PROVIDER_ERROR_THRESHOLD) {
|
||||
ordered.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (ordered.length > 0) {
|
||||
state.cursor = (state.cursor + 1) % keys.length;
|
||||
return ordered;
|
||||
}
|
||||
|
||||
state.errors = new Map<string, number>();
|
||||
state.cursor = (state.cursor + 1) % keys.length;
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
function recordProviderSuccess(providerId: string, apiKey: string): void {
|
||||
const state = providerState.get(providerId);
|
||||
if (!state) return;
|
||||
const errors = state.errors.get(apiKey) || 0;
|
||||
if (errors > 0) state.errors.set(apiKey, errors - 1);
|
||||
}
|
||||
|
||||
function recordProviderError(providerId: string, apiKey: string): void {
|
||||
const state = providerState.get(providerId);
|
||||
if (!state) return;
|
||||
state.errors.set(apiKey, (state.errors.get(apiKey) || 0) + 1);
|
||||
}
|
||||
|
||||
async function searchWithTavily(query: string, maxResults: number, days: number, apiKey: string): Promise<StockAnalysisHeadline[]> {
|
||||
const response = await fetch('https://api.tavily.com/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': CHROME_UA,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: apiKey,
|
||||
query,
|
||||
topic: 'news',
|
||||
search_depth: 'advanced',
|
||||
max_results: Math.min(maxResults, 10),
|
||||
include_answer: false,
|
||||
include_raw_content: false,
|
||||
days,
|
||||
}),
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tavily HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json() as {
|
||||
results?: Array<{ title?: string; url?: string; content?: string; published_date?: string; source?: string }>;
|
||||
};
|
||||
return dedupeHeadlines(
|
||||
(payload.results || []).map(item => ({
|
||||
title: String(item.title || '').trim(),
|
||||
source: String(item.source || '').trim() || extractDomain(String(item.url || '')),
|
||||
link: String(item.url || '').trim(),
|
||||
publishedAt: parsePublishedAt(item.published_date),
|
||||
})),
|
||||
maxResults,
|
||||
);
|
||||
}
|
||||
|
||||
async function searchWithBrave(query: string, maxResults: number, days: number, apiKey: string): Promise<StockAnalysisHeadline[]> {
|
||||
const freshness = days <= 1 ? 'pd' : days <= 7 ? 'pw' : days <= 30 ? 'pm' : 'py';
|
||||
const url = new URL('https://api.search.brave.com/res/v1/web/search');
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('count', String(Math.min(maxResults, 10)));
|
||||
url.searchParams.set('freshness', freshness);
|
||||
url.searchParams.set('search_lang', 'en');
|
||||
url.searchParams.set('country', 'US');
|
||||
url.searchParams.set('safesearch', 'moderate');
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': CHROME_UA,
|
||||
'X-Subscription-Token': apiKey,
|
||||
},
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json() as {
|
||||
web?: {
|
||||
results?: Array<{ title?: string; url?: string; description?: string; age?: string; page_age?: string; meta_url?: { hostname?: string } }>;
|
||||
};
|
||||
};
|
||||
return dedupeHeadlines(
|
||||
(payload.web?.results || []).map(item => ({
|
||||
title: String(item.title || '').trim(),
|
||||
source: String(item.meta_url?.hostname || '').replace(/^www\./, '') || extractDomain(String(item.url || '')),
|
||||
link: String(item.url || '').trim(),
|
||||
publishedAt: relativeDateToTimestamp(item.age || item.page_age),
|
||||
})),
|
||||
maxResults,
|
||||
);
|
||||
}
|
||||
|
||||
async function searchWithSerpApi(query: string, maxResults: number, days: number, apiKey: string): Promise<StockAnalysisHeadline[]> {
|
||||
const response = await fetch(`https://serpapi.com/search.json?${new URLSearchParams({
|
||||
engine: 'google_news',
|
||||
q: query,
|
||||
api_key: apiKey,
|
||||
gl: 'us',
|
||||
hl: 'en',
|
||||
tbs: days <= 1 ? 'qdr:d' : days <= 7 ? 'qdr:w' : '',
|
||||
no_cache: 'false',
|
||||
}).toString()}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': CHROME_UA,
|
||||
},
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SerpAPI HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json() as {
|
||||
news_results?: Array<{ title?: string; link?: string; source?: string; date?: string }>;
|
||||
organic_results?: Array<{ title?: string; link?: string; source?: string; date?: string }>;
|
||||
};
|
||||
const rawResults = (payload.news_results?.length ? payload.news_results : payload.organic_results) || [];
|
||||
const maxAgeMs = days * 86_400_000;
|
||||
return dedupeHeadlines(
|
||||
rawResults
|
||||
.map(item => ({
|
||||
title: String(item.title || '').trim(),
|
||||
source: String(item.source || '').trim() || extractDomain(String(item.link || '')),
|
||||
link: String(item.link || '').trim(),
|
||||
publishedAt: relativeDateToTimestamp(item.date),
|
||||
}))
|
||||
.filter(item => !item.publishedAt || (Date.now() - item.publishedAt) <= maxAgeMs),
|
||||
maxResults,
|
||||
);
|
||||
}
|
||||
|
||||
async function searchViaProviders(query: string, maxResults: number, days: number): Promise<StockNewsSearchResult | null> {
|
||||
const providers: SearchProviderDefinition[] = [
|
||||
{ id: 'tavily', envKey: 'TAVILY_API_KEYS', search: searchWithTavily },
|
||||
{ id: 'brave', envKey: 'BRAVE_API_KEYS', search: searchWithBrave },
|
||||
{ id: 'serpapi', envKey: 'SERPAPI_API_KEYS', search: searchWithSerpApi },
|
||||
];
|
||||
|
||||
for (const provider of providers) {
|
||||
const candidates = getProviderCandidates(provider);
|
||||
for (const apiKey of candidates) {
|
||||
try {
|
||||
const headlines = await provider.search(query, maxResults, days, apiKey);
|
||||
recordProviderSuccess(provider.id, apiKey);
|
||||
if (headlines.length > 0) {
|
||||
return { provider: provider.id, headlines };
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
recordProviderError(provider.id, apiKey);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[stock-news-search] ${provider.id} failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchGoogleNewsRss(query: string, maxResults: number): Promise<StockAnalysisHeadline[]> {
|
||||
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const xml = await response.text();
|
||||
const parsed = SEARCH_XML.parse(xml) as {
|
||||
rss?: { channel?: { item?: Array<Record<string, unknown>> | Record<string, unknown> } };
|
||||
};
|
||||
const items = Array.isArray(parsed.rss?.channel?.item)
|
||||
? parsed.rss?.channel?.item
|
||||
: parsed.rss?.channel?.item ? [parsed.rss.channel.item] : [];
|
||||
|
||||
return dedupeHeadlines(
|
||||
items.map((item) => {
|
||||
const source = typeof item.source === 'string'
|
||||
? item.source
|
||||
: typeof (item.source as Record<string, unknown> | undefined)?.['#text'] === 'string'
|
||||
? String((item.source as Record<string, unknown>)['#text'])
|
||||
: '';
|
||||
return {
|
||||
title: String(item.title || '').trim(),
|
||||
source: source || 'Google News',
|
||||
link: String(item.link || '').trim(),
|
||||
publishedAt: parsePublishedAt(item.pubDate),
|
||||
};
|
||||
}),
|
||||
maxResults,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchRecentStockHeadlines(symbol: string, name: string, maxResults = 5): Promise<StockNewsSearchResult> {
|
||||
const query = buildStockNewsSearchQuery(symbol, name);
|
||||
const days = getSearchDays();
|
||||
const symbolKey = normalizeSymbol(symbol) || 'UNKNOWN';
|
||||
const queryHash = stableHash(query).slice(0, 12);
|
||||
const cacheKey = `market:stock-news-search:v1:${symbolKey}:${days}:${maxResults}:${queryHash}`;
|
||||
|
||||
const cached = await cachedFetchJson<StockNewsSearchResult>(cacheKey, SEARCH_CACHE_TTL_SECONDS, async () => {
|
||||
const providerResult = await searchViaProviders(query, maxResults, days);
|
||||
if (providerResult?.headlines.length) return providerResult;
|
||||
return {
|
||||
provider: 'google-news-rss',
|
||||
headlines: await fetchGoogleNewsRss(query, maxResults),
|
||||
};
|
||||
}, 180);
|
||||
|
||||
return cached || { provider: 'google-news-rss', headlines: [] };
|
||||
}
|
||||
@@ -136,7 +136,7 @@ globalThis.fetch = async function ipv4Fetch(input, init) {
|
||||
};
|
||||
|
||||
const ALLOWED_ENV_KEYS = new Set([
|
||||
'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'FRED_API_KEY', 'EIA_API_KEY',
|
||||
'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'TAVILY_API_KEYS', 'BRAVE_API_KEYS', 'SERPAPI_API_KEYS', 'FRED_API_KEY', 'EIA_API_KEY',
|
||||
'CLOUDFLARE_API_TOKEN', 'ACLED_ACCESS_TOKEN', 'URLHAUS_AUTH_KEY',
|
||||
'OTX_API_KEY', 'ABUSEIPDB_API_KEY', 'WINGBITS_API_KEY', 'WS_RELAY_URL',
|
||||
'VITE_OPENSKY_RELAY_URL', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET',
|
||||
|
||||
@@ -27,9 +27,12 @@ const MENU_HELP_GITHUB_ID: &str = "help.github";
|
||||
#[cfg(feature = "devtools")]
|
||||
const MENU_HELP_DEVTOOLS_ID: &str = "help.devtools";
|
||||
const TRUSTED_WINDOWS: [&str; 3] = ["main", "settings", "live-channels"];
|
||||
const SUPPORTED_SECRET_KEYS: [&str; 25] = [
|
||||
const SUPPORTED_SECRET_KEYS: [&str; 28] = [
|
||||
"GROQ_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"TAVILY_API_KEYS",
|
||||
"BRAVE_API_KEYS",
|
||||
"SERPAPI_API_KEYS",
|
||||
"FRED_API_KEY",
|
||||
"EIA_API_KEY",
|
||||
"CLOUDFLARE_API_TOKEN",
|
||||
|
||||
22
src/App.ts
22
src/App.ts
@@ -25,6 +25,7 @@ import type { MacroSignalsPanel } from '@/components/MacroSignalsPanel';
|
||||
import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel';
|
||||
import type { StrategicRiskPanel } from '@/components/StrategicRiskPanel';
|
||||
import { isDesktopRuntime, waitForSidecarReady } from '@/services/runtime';
|
||||
import { getSecretState } from '@/services/runtime-config';
|
||||
import { BETA_MODE } from '@/config/beta';
|
||||
import { trackEvent, trackDeeplinkOpened } from '@/services/analytics';
|
||||
import { preloadCountryGeometry, getCountryNameByCode } from '@/services/country-geometry';
|
||||
@@ -576,6 +577,27 @@ export class App {
|
||||
]);
|
||||
}
|
||||
|
||||
if (SITE_VARIANT === 'finance') {
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'stock-analysis',
|
||||
() => this.dataLoader.loadStockAnalysis(),
|
||||
15 * 60 * 1000,
|
||||
() => getSecretState('WORLDMONITOR_API_KEY').present,
|
||||
);
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'daily-market-brief',
|
||||
() => this.dataLoader.loadDailyMarketBrief(),
|
||||
60 * 60 * 1000,
|
||||
() => getSecretState('WORLDMONITOR_API_KEY').present,
|
||||
);
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'stock-backtest',
|
||||
() => this.dataLoader.loadStockBacktest(),
|
||||
4 * 60 * 60 * 1000,
|
||||
() => getSecretState('WORLDMONITOR_API_KEY').present,
|
||||
);
|
||||
}
|
||||
|
||||
// Panel-level refreshes (moved from panel constructors into scheduler for hidden-tab awareness + jitter)
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'service-status',
|
||||
|
||||
@@ -60,6 +60,20 @@ import {
|
||||
fetchCriticalMinerals,
|
||||
} from '@/services';
|
||||
import { getMarketWatchlistEntries } from '@/services/market-watchlist';
|
||||
import { fetchStockAnalysesForTargets, getStockAnalysisTargets } from '@/services/stock-analysis';
|
||||
import {
|
||||
fetchStockBacktestsForTargets,
|
||||
fetchStoredStockBacktests,
|
||||
getMissingOrStaleStoredStockBacktests,
|
||||
hasFreshStoredStockBacktests,
|
||||
} from '@/services/stock-backtest';
|
||||
import {
|
||||
fetchStockAnalysisHistory,
|
||||
getMissingOrStaleStockAnalysisSymbols,
|
||||
hasFreshStockAnalysisHistory,
|
||||
getLatestStockAnalysisSnapshots,
|
||||
mergeStockAnalysisHistory,
|
||||
} from '@/services/stock-analysis-history';
|
||||
import { checkBatchForBreakingAlerts, dispatchOrefBreakingAlert } from '@/services/breaking-news-alerts';
|
||||
import { mlWorker } from '@/services/ml-worker';
|
||||
import { clusterNewsHybrid } from '@/services/clustering';
|
||||
@@ -82,7 +96,7 @@ import { fetchTelegramFeed } from '@/services/telegram-intel';
|
||||
import { fetchOrefAlerts, startOrefPolling, stopOrefPolling, onOrefAlertsUpdate } from '@/services/oref-alerts';
|
||||
import { enrichEventsWithExposure } from '@/services/population-exposure';
|
||||
import { debounce, getCircuitBreakerCooldownInfo } from '@/utils';
|
||||
import { isFeatureAvailable, isFeatureEnabled } from '@/services/runtime-config';
|
||||
import { getSecretState, isFeatureAvailable, isFeatureEnabled } from '@/services/runtime-config';
|
||||
import { isDesktopRuntime } from '@/services/runtime';
|
||||
import { getAiFlowSettings } from '@/services/ai-flow-settings';
|
||||
import { t, getCurrentLanguage } from '@/services/i18n';
|
||||
@@ -96,6 +110,8 @@ import { mountCommunityWidget } from '@/components/CommunityWidget';
|
||||
import { ResearchServiceClient } from '@/generated/client/worldmonitor/research/v1/service_client';
|
||||
import {
|
||||
MarketPanel,
|
||||
StockAnalysisPanel,
|
||||
StockBacktestPanel,
|
||||
HeatmapPanel,
|
||||
CommoditiesPanel,
|
||||
CryptoPanel,
|
||||
@@ -125,6 +141,12 @@ import { fetchPositiveGeoEvents, geocodePositiveNewsItems, type PositiveGeoEvent
|
||||
import type { HappyContentCategory } from '@/services/positive-classifier';
|
||||
import { fetchKindnessData } from '@/services/kindness-data';
|
||||
import { getPersistentCache, setPersistentCache } from '@/services/persistent-cache';
|
||||
import {
|
||||
buildDailyMarketBrief,
|
||||
cacheDailyMarketBrief,
|
||||
getCachedDailyMarketBrief,
|
||||
shouldRefreshDailyBrief,
|
||||
} from '@/services/daily-market-brief';
|
||||
import { fetchCachedRiskScores } from '@/services/cached-risk-scores';
|
||||
import type { ThreatLevel as ClientThreatLevel } from '@/services/threat-classifier';
|
||||
import type { NewsItem as ProtoNewsItem, ThreatLevel as ProtoThreatLevel } from '@/generated/client/worldmonitor/news/v1/service_client';
|
||||
@@ -206,7 +228,13 @@ export class DataLoaderManager implements AppModule {
|
||||
|
||||
init(): void {
|
||||
this.boundMarketWatchlistHandler = () => {
|
||||
void this.loadMarkets();
|
||||
void this.loadMarkets().then(async () => {
|
||||
if (SITE_VARIANT === 'finance' && getSecretState('WORLDMONITOR_API_KEY').present) {
|
||||
await this.loadStockAnalysis();
|
||||
await this.loadStockBacktest();
|
||||
await this.loadDailyMarketBrief(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('wm-market-watchlist-changed', this.boundMarketWatchlistHandler as EventListener);
|
||||
}
|
||||
@@ -316,6 +344,10 @@ export class DataLoaderManager implements AppModule {
|
||||
// Happy variant only loads news data -- skip all geopolitical/financial/military data
|
||||
if (SITE_VARIANT !== 'happy') {
|
||||
tasks.push({ name: 'markets', task: runGuarded('markets', () => this.loadMarkets()) });
|
||||
if (SITE_VARIANT === 'finance' && getSecretState('WORLDMONITOR_API_KEY').present) {
|
||||
tasks.push({ name: 'stockAnalysis', task: runGuarded('stockAnalysis', () => this.loadStockAnalysis()) });
|
||||
tasks.push({ name: 'stockBacktest', task: runGuarded('stockBacktest', () => this.loadStockBacktest()) });
|
||||
}
|
||||
tasks.push({ name: 'predictions', task: runGuarded('predictions', () => this.loadPredictions()) });
|
||||
tasks.push({ name: 'pizzint', task: runGuarded('pizzint', () => this.loadPizzInt()) });
|
||||
tasks.push({ name: 'fred', task: runGuarded('fred', () => this.loadFredData()) });
|
||||
@@ -422,6 +454,10 @@ export class DataLoaderManager implements AppModule {
|
||||
|
||||
this.updateSearchIndex();
|
||||
|
||||
if (SITE_VARIANT === 'finance' && getSecretState('WORLDMONITOR_API_KEY').present) {
|
||||
await this.loadDailyMarketBrief();
|
||||
}
|
||||
|
||||
const bootstrapTemporal = consumeServerAnomalies();
|
||||
if (bootstrapTemporal.anomalies.length > 0 || bootstrapTemporal.trackedTypes.length > 0) {
|
||||
signalAggregator.ingestTemporalAnomalies(bootstrapTemporal.anomalies, bootstrapTemporal.trackedTypes);
|
||||
@@ -973,6 +1009,82 @@ export class DataLoaderManager implements AppModule {
|
||||
}
|
||||
}
|
||||
|
||||
async loadStockAnalysis(): Promise<void> {
|
||||
const panel = this.ctx.panels['stock-analysis'] as StockAnalysisPanel | undefined;
|
||||
if (!panel) return;
|
||||
|
||||
try {
|
||||
const targets = getStockAnalysisTargets();
|
||||
const targetSymbols = targets.map((target) => target.symbol);
|
||||
const storedHistory = await fetchStockAnalysisHistory(targets.length);
|
||||
const cachedSnapshots = getLatestStockAnalysisSnapshots(storedHistory, targets.length);
|
||||
if (cachedSnapshots.length > 0) {
|
||||
panel.renderAnalyses(cachedSnapshots, storedHistory, 'cached');
|
||||
}
|
||||
|
||||
if (hasFreshStockAnalysisHistory(storedHistory, targetSymbols)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const staleSymbols = getMissingOrStaleStockAnalysisSymbols(storedHistory, targetSymbols);
|
||||
const staleTargets = targets.filter((target) => staleSymbols.includes(target.symbol));
|
||||
const results = await fetchStockAnalysesForTargets(staleTargets);
|
||||
if (results.length === 0) {
|
||||
if (cachedSnapshots.length === 0) {
|
||||
panel.showRetrying('Stock analysis is waiting for eligible watchlist symbols.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextHistory = mergeStockAnalysisHistory(storedHistory, results);
|
||||
panel.renderAnalyses(results, nextHistory, 'live');
|
||||
} catch (error) {
|
||||
console.error('[StockAnalysis] failed:', error);
|
||||
const cachedHistory = await fetchStockAnalysisHistory().catch(() => ({}));
|
||||
const cachedSnapshots = getLatestStockAnalysisSnapshots(cachedHistory);
|
||||
if (cachedSnapshots.length > 0) {
|
||||
panel.renderAnalyses(cachedSnapshots, cachedHistory, 'cached');
|
||||
return;
|
||||
}
|
||||
panel.showError('Premium stock analysis is temporarily unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
async loadStockBacktest(): Promise<void> {
|
||||
const panel = this.ctx.panels['stock-backtest'] as StockBacktestPanel | undefined;
|
||||
if (!panel) return;
|
||||
|
||||
try {
|
||||
const targets = getStockAnalysisTargets();
|
||||
const targetSymbols = targets.map((target) => target.symbol);
|
||||
const stored = await fetchStoredStockBacktests(targets.length);
|
||||
if (stored.length > 0) {
|
||||
panel.renderBacktests(stored, 'cached');
|
||||
}
|
||||
if (hasFreshStoredStockBacktests(stored, targetSymbols)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const staleSymbols = getMissingOrStaleStoredStockBacktests(stored, targetSymbols);
|
||||
const staleTargets = targets.filter((target) => staleSymbols.includes(target.symbol));
|
||||
const results = await fetchStockBacktestsForTargets(staleTargets);
|
||||
if (results.length === 0) {
|
||||
if (stored.length === 0) {
|
||||
panel.showRetrying('Backtesting is waiting for eligible watchlist symbols.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
panel.renderBacktests(results);
|
||||
} catch (error) {
|
||||
console.error('[StockBacktest] failed:', error);
|
||||
const stored = await fetchStoredStockBacktests().catch(() => []);
|
||||
if (stored.length > 0) {
|
||||
panel.renderBacktests(stored, 'cached');
|
||||
return;
|
||||
}
|
||||
panel.showError('Premium stock backtesting is temporarily unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
async loadMarkets(): Promise<void> {
|
||||
try {
|
||||
const customEntries = getMarketWatchlistEntries();
|
||||
@@ -1121,6 +1233,56 @@ export class DataLoaderManager implements AppModule {
|
||||
}
|
||||
}
|
||||
|
||||
async loadDailyMarketBrief(force = false): Promise<void> {
|
||||
if (SITE_VARIANT !== 'finance' || !getSecretState('WORLDMONITOR_API_KEY').present) return;
|
||||
if (this.ctx.isDestroyed || this.ctx.inFlight.has('dailyMarketBrief')) return;
|
||||
|
||||
this.ctx.inFlight.add('dailyMarketBrief');
|
||||
try {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const cached = await getCachedDailyMarketBrief(timezone);
|
||||
|
||||
if (cached?.available) {
|
||||
this.callPanel('daily-market-brief', 'renderBrief', cached, 'cached');
|
||||
}
|
||||
|
||||
if (!force && cached && !shouldRefreshDailyBrief(cached, timezone)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
this.callPanel('daily-market-brief', 'showLoading', 'Building daily market brief...');
|
||||
}
|
||||
|
||||
const brief = await buildDailyMarketBrief({
|
||||
markets: this.ctx.latestMarkets,
|
||||
newsByCategory: this.ctx.newsByCategory,
|
||||
timezone,
|
||||
});
|
||||
|
||||
if (!brief.available) {
|
||||
if (!cached?.available) {
|
||||
this.callPanel('daily-market-brief', 'showUnavailable');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await cacheDailyMarketBrief(brief);
|
||||
this.callPanel('daily-market-brief', 'renderBrief', brief, 'live');
|
||||
} catch (error) {
|
||||
console.warn('[DailyBrief] Failed to build daily market brief:', error);
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const cached = await getCachedDailyMarketBrief(timezone).catch(() => null);
|
||||
if (cached?.available) {
|
||||
this.callPanel('daily-market-brief', 'renderBrief', cached, 'cached');
|
||||
return;
|
||||
}
|
||||
this.callPanel('daily-market-brief', 'showError', 'Failed to build daily market brief. Retrying later.');
|
||||
} finally {
|
||||
this.ctx.inFlight.delete('dailyMarketBrief');
|
||||
}
|
||||
}
|
||||
|
||||
async loadPredictions(): Promise<void> {
|
||||
try {
|
||||
const predictions = await fetchPredictions();
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
MapContainer,
|
||||
NewsPanel,
|
||||
MarketPanel,
|
||||
StockAnalysisPanel,
|
||||
StockBacktestPanel,
|
||||
HeatmapPanel,
|
||||
CommoditiesPanel,
|
||||
CryptoPanel,
|
||||
@@ -480,6 +482,22 @@ export class PanelLayoutManager implements AppModule {
|
||||
|
||||
this.createPanel('heatmap', () => new HeatmapPanel());
|
||||
this.createPanel('markets', () => new MarketPanel());
|
||||
const stockAnalysisPanel = this.createPanel('stock-analysis', () => new StockAnalysisPanel());
|
||||
if (stockAnalysisPanel && !getSecretState('WORLDMONITOR_API_KEY').present) {
|
||||
stockAnalysisPanel.showLocked([
|
||||
'AI stock briefs with technical + news synthesis',
|
||||
'Trend scoring from MA, MACD, RSI, and volume structure',
|
||||
'Actionable watchlist monitoring for your premium workspace',
|
||||
]);
|
||||
}
|
||||
const stockBacktestPanel = this.createPanel('stock-backtest', () => new StockBacktestPanel());
|
||||
if (stockBacktestPanel && !getSecretState('WORLDMONITOR_API_KEY').present) {
|
||||
stockBacktestPanel.showLocked([
|
||||
'Historical replay of premium stock-analysis signals',
|
||||
'Win-rate, accuracy, and simulated-return metrics',
|
||||
'Recent evaluation samples for your tracked symbols',
|
||||
]);
|
||||
}
|
||||
|
||||
const monitorPanel = this.createPanel('monitors', () => new MonitorPanel(this.ctx.monitors));
|
||||
monitorPanel?.onChanged((monitors) => {
|
||||
@@ -627,6 +645,12 @@ export class PanelLayoutManager implements AppModule {
|
||||
const _wmKeyPresent = getSecretState('WORLDMONITOR_API_KEY').present;
|
||||
const _lockPanels = this.ctx.isDesktopApp && !_wmKeyPresent;
|
||||
|
||||
this.lazyPanel('daily-market-brief', () =>
|
||||
import('@/components/DailyMarketBriefPanel').then(m => new m.DailyMarketBriefPanel()),
|
||||
undefined,
|
||||
!_wmKeyPresent ? ['Pre-market watchlist priorities', 'Action plan for the session', 'Risk watch tied to current finance headlines'] : undefined,
|
||||
);
|
||||
|
||||
this.lazyPanel('oref-sirens', () =>
|
||||
import('@/components/OrefSirensPanel').then(m => new m.OrefSirensPanel()),
|
||||
undefined,
|
||||
|
||||
104
src/components/DailyMarketBriefPanel.ts
Normal file
104
src/components/DailyMarketBriefPanel.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Panel } from './Panel';
|
||||
import type { DailyMarketBrief } from '@/services/daily-market-brief';
|
||||
import { describeFreshness } from '@/services/persistent-cache';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
|
||||
type BriefSource = 'live' | 'cached';
|
||||
|
||||
function formatGeneratedTime(isoTimestamp: string, timezone: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(isoTimestamp));
|
||||
} catch {
|
||||
return isoTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
function stanceLabel(stance: DailyMarketBrief['items'][number]['stance']): string {
|
||||
if (stance === 'bullish') return 'Bullish';
|
||||
if (stance === 'defensive') return 'Defensive';
|
||||
return 'Neutral';
|
||||
}
|
||||
|
||||
function formatPrice(price: number | null): string {
|
||||
if (typeof price !== 'number' || !Number.isFinite(price)) return 'N/A';
|
||||
return price.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatChange(change: number | null): string {
|
||||
if (typeof change !== 'number' || !Number.isFinite(change)) return 'Flat';
|
||||
const sign = change > 0 ? '+' : '';
|
||||
return `${sign}${change.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export class DailyMarketBriefPanel extends Panel {
|
||||
constructor() {
|
||||
super({ id: 'daily-market-brief', title: 'Daily Market Brief' });
|
||||
}
|
||||
|
||||
public renderBrief(brief: DailyMarketBrief, source: BriefSource = 'live'): void {
|
||||
const freshness = describeFreshness(new Date(brief.generatedAt).getTime());
|
||||
this.setDataBadge(source, freshness);
|
||||
this.resetRetryBackoff();
|
||||
|
||||
const html = `
|
||||
<div class="daily-brief-shell" style="display:grid;gap:12px">
|
||||
<div class="daily-brief-card" style="display:grid;gap:6px;padding:12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,0.03)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div style="font-size:13px;font-weight:600">${escapeHtml(brief.title)}</div>
|
||||
<div style="font-size:11px;color:var(--text-dim)">${escapeHtml(formatGeneratedTime(brief.generatedAt, brief.timezone))}</div>
|
||||
</div>
|
||||
<div style="font-size:13px;line-height:1.5;color:var(--text)">${escapeHtml(brief.summary)}</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:10px">
|
||||
<div style="padding:10px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,0.02)">
|
||||
<div style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);margin-bottom:4px">Action Plan</div>
|
||||
<div style="font-size:12px;line-height:1.5">${escapeHtml(brief.actionPlan)}</div>
|
||||
</div>
|
||||
<div style="padding:10px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,0.02)">
|
||||
<div style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);margin-bottom:4px">Risk Watch</div>
|
||||
<div style="font-size:12px;line-height:1.5">${escapeHtml(brief.riskWatch)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:8px">
|
||||
${brief.items.map((item) => `
|
||||
<div style="display:grid;gap:6px;padding:10px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,0.02)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<div style="font-size:12px;font-weight:600">${escapeHtml(item.name)}</div>
|
||||
<div style="font-size:11px;color:var(--text-dim)">${escapeHtml(item.display)}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-size:12px;font-weight:600">${escapeHtml(formatPrice(item.price))}</div>
|
||||
<div style="font-size:11px;color:var(--text-dim)">${escapeHtml(formatChange(item.change))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim)">${escapeHtml(stanceLabel(item.stance))}</div>
|
||||
${item.relatedHeadline ? `<div style="font-size:11px;color:var(--text-dim);text-align:right;max-width:55%">Linked headline</div>` : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;line-height:1.45">${escapeHtml(item.note)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div style="font-size:11px;color:var(--text-dim)">
|
||||
${escapeHtml(brief.fallback ? 'Rules-based brief' : `AI-assisted brief via ${brief.provider}${brief.model ? ` (${brief.model})` : ''}`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setContent(html);
|
||||
}
|
||||
|
||||
public showUnavailable(message = 'The daily brief needs live market data before it can be generated.'): void {
|
||||
this.showError(message);
|
||||
}
|
||||
}
|
||||
128
src/components/StockAnalysisPanel.ts
Normal file
128
src/components/StockAnalysisPanel.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Panel } from './Panel';
|
||||
import type { StockAnalysisResult } from '@/services/stock-analysis';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import type { StockAnalysisHistory } from '@/services/stock-analysis-history';
|
||||
|
||||
function formatChange(change: number): string {
|
||||
const rounded = Number.isFinite(change) ? change.toFixed(2) : '0.00';
|
||||
return `${change >= 0 ? '+' : ''}${rounded}%`;
|
||||
}
|
||||
|
||||
function formatPrice(price: number, currency: string): string {
|
||||
if (!Number.isFinite(price)) return 'N/A';
|
||||
return `${currency === 'USD' ? '$' : ''}${price.toFixed(2)}${currency && currency !== 'USD' ? ` ${currency}` : ''}`;
|
||||
}
|
||||
|
||||
function stockSignalTone(signal: string): string {
|
||||
const normalized = signal.toLowerCase();
|
||||
if (normalized.includes('buy')) return '#8df0b2';
|
||||
if (normalized.includes('hold') || normalized.includes('watch')) return '#f4d06f';
|
||||
return '#ff8c8c';
|
||||
}
|
||||
|
||||
function list(items: string[], tone: string): string {
|
||||
if (items.length === 0) return '';
|
||||
return `<ul style="margin:8px 0 0;padding-left:18px;color:${tone};font-size:12px;line-height:1.5">${items.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`;
|
||||
}
|
||||
|
||||
export class StockAnalysisPanel extends Panel {
|
||||
constructor() {
|
||||
super({ id: 'stock-analysis', title: 'Premium Stock Analysis' });
|
||||
}
|
||||
|
||||
public renderAnalyses(items: StockAnalysisResult[], historyBySymbol: StockAnalysisHistory = {}, source: 'live' | 'cached' = 'live'): void {
|
||||
if (items.length === 0) {
|
||||
this.setDataBadge('unavailable');
|
||||
this.showRetrying('No premium stock analyses available yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setDataBadge(source, `${items.length} symbols`);
|
||||
|
||||
const html = `
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font-size:12px;color:var(--text-dim);line-height:1.5">
|
||||
Analyst-grade equity reports powered by the shared market watchlist. The panel tracks the first ${items.length} eligible tickers.
|
||||
</div>
|
||||
${items.map((item) => this.renderCard(item, historyBySymbol[item.symbol] || [])).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setContent(html);
|
||||
}
|
||||
|
||||
private renderCard(item: StockAnalysisResult, history: StockAnalysisResult[]): string {
|
||||
const tone = stockSignalTone(item.signal);
|
||||
const priorRuns = history.filter((entry) => entry.generatedAt !== item.generatedAt).slice(0, 3);
|
||||
const previous = priorRuns[0];
|
||||
const signalDelta = previous ? item.signalScore - previous.signalScore : null;
|
||||
const headlines = item.headlines.slice(0, 2).map((headline) => {
|
||||
const href = sanitizeUrl(headline.link);
|
||||
const title = escapeHtml(headline.title);
|
||||
const source = escapeHtml(headline.source || 'Source');
|
||||
return `<a href="${href}" target="_blank" rel="noreferrer" style="display:block;color:var(--text);text-decoration:none;padding:8px 10px;border:1px solid var(--border);background:rgba(255,255,255,0.02)"><div style="font-size:12px;line-height:1.45">${title}</div><div style="margin-top:4px;font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">${source}</div></a>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<section style="border:1px solid var(--border);background:rgba(255,255,255,0.03);padding:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<strong style="font-size:16px;letter-spacing:-0.02em">${escapeHtml(item.name || item.symbol)}</strong>
|
||||
<span style="font-size:11px;color:var(--text-dim);font-family:monospace;text-transform:uppercase">${escapeHtml(item.display || item.symbol)}</span>
|
||||
<span style="font-size:11px;padding:3px 6px;border:1px solid ${tone};color:${tone};font-family:monospace;text-transform:uppercase;letter-spacing:0.08em">${escapeHtml(item.signal)}</span>
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--text-dim);line-height:1.5">${escapeHtml(item.summary)}</div>
|
||||
</div>
|
||||
<div style="text-align:right;min-width:110px">
|
||||
<div style="font-size:18px;font-weight:700">${escapeHtml(formatPrice(item.currentPrice, item.currency))}</div>
|
||||
<div style="font-size:12px;color:${item.changePercent >= 0 ? '#8df0b2' : '#ff8c8c'}">${escapeHtml(formatChange(item.changePercent))}</div>
|
||||
<div style="margin-top:6px;font-size:11px;color:var(--text-dim)">Score ${escapeHtml(String(item.signalScore))} · ${escapeHtml(item.confidence)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;font-size:11px">
|
||||
<div style="border:1px solid var(--border);padding:8px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Trend</div><div style="margin-top:4px">${escapeHtml(item.trendStatus)}</div></div>
|
||||
<div style="border:1px solid var(--border);padding:8px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">MA5 Bias</div><div style="margin-top:4px">${escapeHtml(formatChange(item.biasMa5))}</div></div>
|
||||
<div style="border:1px solid var(--border);padding:8px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">RSI 12</div><div style="margin-top:4px">${escapeHtml(item.rsi12.toFixed(1))}</div></div>
|
||||
<div style="border:1px solid var(--border);padding:8px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Volume</div><div style="margin-top:4px">${escapeHtml(item.volumeStatus)}</div></div>
|
||||
</div>
|
||||
<div style="font-size:12px;line-height:1.55;color:var(--text)"><strong style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Action</strong><div style="margin-top:4px">${escapeHtml(item.action)}</div></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
|
||||
<div>
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Bullish Factors</div>
|
||||
${list(item.bullishFactors.slice(0, 3), '#8df0b2')}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Risk Factors</div>
|
||||
${list(item.riskFactors.slice(0, 3), '#ffb0b0')}
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;line-height:1.55;color:var(--text-dim)">
|
||||
<strong style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Why Now</strong>
|
||||
<div style="margin-top:4px">${escapeHtml(item.whyNow)}</div>
|
||||
</div>
|
||||
${previous ? `
|
||||
<div style="font-size:12px;line-height:1.55;color:var(--text-dim)">
|
||||
<strong style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Signal Drift</strong>
|
||||
<div style="margin-top:4px">
|
||||
Previous run was ${escapeHtml(previous.signal)} at score ${escapeHtml(String(previous.signalScore))}.
|
||||
Current drift is ${escapeHtml(`${signalDelta && signalDelta > 0 ? '+' : ''}${(signalDelta || 0).toFixed(1)}`)}.
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${priorRuns.length > 0 ? `
|
||||
<div style="display:grid;gap:6px">
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Recent History</div>
|
||||
${priorRuns.map((entry) => `
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border:1px solid var(--border);background:rgba(255,255,255,0.02);font-size:11px">
|
||||
<span>${escapeHtml(entry.signal)} · score ${escapeHtml(String(entry.signalScore))}</span>
|
||||
<span style="color:var(--text-dim)">${escapeHtml(new Date(entry.generatedAt).toLocaleString())}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
${headlines ? `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">${headlines}</div>` : ''}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
72
src/components/StockBacktestPanel.ts
Normal file
72
src/components/StockBacktestPanel.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Panel } from './Panel';
|
||||
import type { StockBacktestResult } from '@/services/stock-backtest';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
|
||||
function tone(value: number): string {
|
||||
if (value > 0) return '#8df0b2';
|
||||
if (value < 0) return '#ff8c8c';
|
||||
return 'var(--text-dim)';
|
||||
}
|
||||
|
||||
function fmtPct(value: number): string {
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export class StockBacktestPanel extends Panel {
|
||||
constructor() {
|
||||
super({ id: 'stock-backtest', title: 'Premium Backtesting' });
|
||||
}
|
||||
|
||||
public renderBacktests(items: StockBacktestResult[], source: 'live' | 'cached' = 'live'): void {
|
||||
if (items.length === 0) {
|
||||
this.setDataBadge('unavailable');
|
||||
this.showRetrying('No stock backtests available yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setDataBadge(source, `${items.length} symbols`);
|
||||
|
||||
const html = `
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font-size:12px;color:var(--text-dim);line-height:1.5">
|
||||
Historical replay of the premium stock-analysis signal engine over recent daily bars.
|
||||
</div>
|
||||
${items.map((item) => `
|
||||
<section style="border:1px solid var(--border);background:rgba(255,255,255,0.03);padding:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<strong style="font-size:16px;letter-spacing:-0.02em">${escapeHtml(item.name || item.symbol)}</strong>
|
||||
<span style="font-size:11px;color:var(--text-dim);font-family:monospace;text-transform:uppercase">${escapeHtml(item.display || item.symbol)}</span>
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--text-dim);line-height:1.5">${escapeHtml(item.summary)}</div>
|
||||
</div>
|
||||
<div style="text-align:right;min-width:110px">
|
||||
<div style="font-size:18px;font-weight:700;color:${tone(item.avgSimulatedReturnPct)}">${escapeHtml(fmtPct(item.avgSimulatedReturnPct))}</div>
|
||||
<div style="font-size:11px;color:var(--text-dim)">Avg simulated return</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;font-size:11px">
|
||||
<div style="border:1px solid var(--border);padding:8px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Win Rate</div><div style="margin-top:4px">${escapeHtml(fmtPct(item.winRate))}</div></div>
|
||||
<div style="border:1px solid var(--border);padding:8px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Direction Accuracy</div><div style="margin-top:4px">${escapeHtml(fmtPct(item.directionAccuracy))}</div></div>
|
||||
<div style="border:1px solid var(--border);padding:8px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Cumulative</div><div style="margin-top:4px;color:${tone(item.cumulativeSimulatedReturnPct)}">${escapeHtml(fmtPct(item.cumulativeSimulatedReturnPct))}</div></div>
|
||||
<div style="border:1px solid var(--border);padding:8px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Signals</div><div style="margin-top:4px">${escapeHtml(String(item.actionableEvaluations))}</div></div>
|
||||
</div>
|
||||
<div style="display:grid;gap:6px">
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Recent Evaluations</div>
|
||||
${item.evaluations.map((evaluation) => `
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border:1px solid var(--border);background:rgba(255,255,255,0.02);font-size:11px">
|
||||
<span>${escapeHtml(evaluation.signal)} · ${escapeHtml(evaluation.outcome)} · ${escapeHtml(fmtPct(evaluation.simulatedReturnPct))}</span>
|
||||
<span style="color:var(--text-dim)">${escapeHtml(new Date(Number(evaluation.analysisAt)).toLocaleDateString())}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setContent(html);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export { DeckGLMap } from './DeckGLMap';
|
||||
export { MapContainer, type MapView, type TimeRange, type MapContainerState } from './MapContainer';
|
||||
export * from './NewsPanel';
|
||||
export * from './MarketPanel';
|
||||
export * from './StockAnalysisPanel';
|
||||
export * from './StockBacktestPanel';
|
||||
export * from './PredictionPanel';
|
||||
export * from './MonitorPanel';
|
||||
export * from './SignalModal';
|
||||
@@ -33,6 +35,7 @@ export * from './MacroSignalsPanel';
|
||||
export * from './ETFFlowsPanel';
|
||||
export * from './StablecoinPanel';
|
||||
export * from './UcdpEventsPanel';
|
||||
export * from './DailyMarketBriefPanel';
|
||||
export * from './GivingPanel';
|
||||
export * from './DisplacementPanel';
|
||||
export * from './ClimateAnomalyPanel';
|
||||
|
||||
@@ -345,6 +345,9 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
|
||||
'live-webcams': { name: 'Live Webcams', enabled: true, priority: 2 },
|
||||
insights: { name: 'AI Market Insights', enabled: true, priority: 1 },
|
||||
markets: { name: 'Live Markets', enabled: true, priority: 1 },
|
||||
'stock-analysis': { name: 'Premium Stock Analysis', enabled: true, priority: 1, premium: 'locked' },
|
||||
'stock-backtest': { name: 'Premium Backtesting', enabled: true, priority: 1, premium: 'locked' },
|
||||
'daily-market-brief': { name: 'Daily Market Brief', enabled: true, priority: 1, premium: 'locked' },
|
||||
'markets-news': { name: 'Markets News', enabled: true, priority: 2 },
|
||||
forex: { name: 'Forex & Currencies', enabled: true, priority: 1 },
|
||||
bonds: { name: 'Fixed Income', enabled: true, priority: 1 },
|
||||
@@ -884,7 +887,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
// Finance variant
|
||||
finMarkets: {
|
||||
labelKey: 'header.panelCatMarkets',
|
||||
panelKeys: ['markets', 'markets-news', 'heatmap', 'macro-signals', 'analysis', 'polymarket'],
|
||||
panelKeys: ['markets', 'stock-analysis', 'stock-backtest', 'daily-market-brief', 'markets-news', 'heatmap', 'macro-signals', 'analysis', 'polymarket'],
|
||||
variants: ['finance'],
|
||||
},
|
||||
fixedIncomeFx: {
|
||||
|
||||
@@ -166,6 +166,133 @@ export interface GulfQuote {
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
export interface AnalyzeStockRequest {
|
||||
symbol: string;
|
||||
name: string;
|
||||
includeNews: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyzeStockResponse {
|
||||
available: boolean;
|
||||
symbol: string;
|
||||
name: string;
|
||||
display: string;
|
||||
currency: string;
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
signalScore: number;
|
||||
signal: string;
|
||||
trendStatus: string;
|
||||
volumeStatus: string;
|
||||
macdStatus: string;
|
||||
rsiStatus: string;
|
||||
summary: string;
|
||||
action: string;
|
||||
confidence: string;
|
||||
technicalSummary: string;
|
||||
newsSummary: string;
|
||||
whyNow: string;
|
||||
bullishFactors: string[];
|
||||
riskFactors: string[];
|
||||
supportLevels: number[];
|
||||
resistanceLevels: number[];
|
||||
headlines: StockAnalysisHeadline[];
|
||||
ma5: number;
|
||||
ma10: number;
|
||||
ma20: number;
|
||||
ma60: number;
|
||||
biasMa5: number;
|
||||
biasMa10: number;
|
||||
biasMa20: number;
|
||||
volumeRatio5d: number;
|
||||
rsi12: number;
|
||||
macdDif: number;
|
||||
macdDea: number;
|
||||
macdBar: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
fallback: boolean;
|
||||
newsSearched: boolean;
|
||||
generatedAt: string;
|
||||
analysisId: string;
|
||||
analysisAt: number;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
engineVersion: string;
|
||||
}
|
||||
|
||||
export interface StockAnalysisHeadline {
|
||||
title: string;
|
||||
source: string;
|
||||
link: string;
|
||||
publishedAt: number;
|
||||
}
|
||||
|
||||
export interface GetStockAnalysisHistoryRequest {
|
||||
symbols: string[];
|
||||
limitPerSymbol: number;
|
||||
includeNews: boolean;
|
||||
}
|
||||
|
||||
export interface GetStockAnalysisHistoryResponse {
|
||||
items: StockAnalysisHistoryItem[];
|
||||
}
|
||||
|
||||
export interface StockAnalysisHistoryItem {
|
||||
symbol: string;
|
||||
snapshots: AnalyzeStockResponse[];
|
||||
}
|
||||
|
||||
export interface BacktestStockRequest {
|
||||
symbol: string;
|
||||
name: string;
|
||||
evalWindowDays: number;
|
||||
}
|
||||
|
||||
export interface BacktestStockResponse {
|
||||
available: boolean;
|
||||
symbol: string;
|
||||
name: string;
|
||||
display: string;
|
||||
currency: string;
|
||||
evalWindowDays: number;
|
||||
evaluationsRun: number;
|
||||
actionableEvaluations: number;
|
||||
winRate: number;
|
||||
directionAccuracy: number;
|
||||
avgSimulatedReturnPct: number;
|
||||
cumulativeSimulatedReturnPct: number;
|
||||
latestSignal: string;
|
||||
latestSignalScore: number;
|
||||
summary: string;
|
||||
generatedAt: string;
|
||||
evaluations: BacktestStockEvaluation[];
|
||||
engineVersion: string;
|
||||
}
|
||||
|
||||
export interface BacktestStockEvaluation {
|
||||
analysisAt: number;
|
||||
signal: string;
|
||||
signalScore: number;
|
||||
entryPrice: number;
|
||||
exitPrice: number;
|
||||
simulatedReturnPct: number;
|
||||
directionCorrect: boolean;
|
||||
outcome: string;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
analysisId: string;
|
||||
}
|
||||
|
||||
export interface ListStoredStockBacktestsRequest {
|
||||
symbols: string[];
|
||||
evalWindowDays: number;
|
||||
}
|
||||
|
||||
export interface ListStoredStockBacktestsResponse {
|
||||
items: BacktestStockResponse[];
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
@@ -410,6 +537,113 @@ export class MarketServiceClient {
|
||||
return await resp.json() as ListGulfQuotesResponse;
|
||||
}
|
||||
|
||||
async analyzeStock(req: AnalyzeStockRequest, options?: MarketServiceCallOptions): Promise<AnalyzeStockResponse> {
|
||||
let path = "/api/market/v1/analyze-stock";
|
||||
const params = new URLSearchParams();
|
||||
if (req.symbol != null && req.symbol !== "") params.set("symbol", String(req.symbol));
|
||||
if (req.name != null && req.name !== "") params.set("name", String(req.name));
|
||||
if (req.includeNews) params.set("include_news", String(req.includeNews));
|
||||
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as AnalyzeStockResponse;
|
||||
}
|
||||
|
||||
async getStockAnalysisHistory(req: GetStockAnalysisHistoryRequest, options?: MarketServiceCallOptions): Promise<GetStockAnalysisHistoryResponse> {
|
||||
let path = "/api/market/v1/get-stock-analysis-history";
|
||||
const params = new URLSearchParams();
|
||||
if (req.symbols != null && req.symbols !== "") params.set("symbols", String(req.symbols));
|
||||
if (req.limitPerSymbol != null && req.limitPerSymbol !== 0) params.set("limit_per_symbol", String(req.limitPerSymbol));
|
||||
if (req.includeNews) params.set("include_news", String(req.includeNews));
|
||||
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetStockAnalysisHistoryResponse;
|
||||
}
|
||||
|
||||
async backtestStock(req: BacktestStockRequest, options?: MarketServiceCallOptions): Promise<BacktestStockResponse> {
|
||||
let path = "/api/market/v1/backtest-stock";
|
||||
const params = new URLSearchParams();
|
||||
if (req.symbol != null && req.symbol !== "") params.set("symbol", String(req.symbol));
|
||||
if (req.name != null && req.name !== "") params.set("name", String(req.name));
|
||||
if (req.evalWindowDays != null && req.evalWindowDays !== 0) params.set("eval_window_days", String(req.evalWindowDays));
|
||||
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as BacktestStockResponse;
|
||||
}
|
||||
|
||||
async listStoredStockBacktests(req: ListStoredStockBacktestsRequest, options?: MarketServiceCallOptions): Promise<ListStoredStockBacktestsResponse> {
|
||||
let path = "/api/market/v1/list-stored-stock-backtests";
|
||||
const params = new URLSearchParams();
|
||||
if (req.symbols != null && req.symbols !== "") params.set("symbols", String(req.symbols));
|
||||
if (req.evalWindowDays != null && req.evalWindowDays !== 0) params.set("eval_window_days", String(req.evalWindowDays));
|
||||
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as ListStoredStockBacktestsResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
@@ -425,4 +659,3 @@ export class MarketServiceClient {
|
||||
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,133 @@ export interface GulfQuote {
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
export interface AnalyzeStockRequest {
|
||||
symbol: string;
|
||||
name: string;
|
||||
includeNews: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyzeStockResponse {
|
||||
available: boolean;
|
||||
symbol: string;
|
||||
name: string;
|
||||
display: string;
|
||||
currency: string;
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
signalScore: number;
|
||||
signal: string;
|
||||
trendStatus: string;
|
||||
volumeStatus: string;
|
||||
macdStatus: string;
|
||||
rsiStatus: string;
|
||||
summary: string;
|
||||
action: string;
|
||||
confidence: string;
|
||||
technicalSummary: string;
|
||||
newsSummary: string;
|
||||
whyNow: string;
|
||||
bullishFactors: string[];
|
||||
riskFactors: string[];
|
||||
supportLevels: number[];
|
||||
resistanceLevels: number[];
|
||||
headlines: StockAnalysisHeadline[];
|
||||
ma5: number;
|
||||
ma10: number;
|
||||
ma20: number;
|
||||
ma60: number;
|
||||
biasMa5: number;
|
||||
biasMa10: number;
|
||||
biasMa20: number;
|
||||
volumeRatio5d: number;
|
||||
rsi12: number;
|
||||
macdDif: number;
|
||||
macdDea: number;
|
||||
macdBar: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
fallback: boolean;
|
||||
newsSearched: boolean;
|
||||
generatedAt: string;
|
||||
analysisId: string;
|
||||
analysisAt: number;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
engineVersion: string;
|
||||
}
|
||||
|
||||
export interface StockAnalysisHeadline {
|
||||
title: string;
|
||||
source: string;
|
||||
link: string;
|
||||
publishedAt: number;
|
||||
}
|
||||
|
||||
export interface GetStockAnalysisHistoryRequest {
|
||||
symbols: string[];
|
||||
limitPerSymbol: number;
|
||||
includeNews: boolean;
|
||||
}
|
||||
|
||||
export interface GetStockAnalysisHistoryResponse {
|
||||
items: StockAnalysisHistoryItem[];
|
||||
}
|
||||
|
||||
export interface StockAnalysisHistoryItem {
|
||||
symbol: string;
|
||||
snapshots: AnalyzeStockResponse[];
|
||||
}
|
||||
|
||||
export interface BacktestStockRequest {
|
||||
symbol: string;
|
||||
name: string;
|
||||
evalWindowDays: number;
|
||||
}
|
||||
|
||||
export interface BacktestStockResponse {
|
||||
available: boolean;
|
||||
symbol: string;
|
||||
name: string;
|
||||
display: string;
|
||||
currency: string;
|
||||
evalWindowDays: number;
|
||||
evaluationsRun: number;
|
||||
actionableEvaluations: number;
|
||||
winRate: number;
|
||||
directionAccuracy: number;
|
||||
avgSimulatedReturnPct: number;
|
||||
cumulativeSimulatedReturnPct: number;
|
||||
latestSignal: string;
|
||||
latestSignalScore: number;
|
||||
summary: string;
|
||||
generatedAt: string;
|
||||
evaluations: BacktestStockEvaluation[];
|
||||
engineVersion: string;
|
||||
}
|
||||
|
||||
export interface BacktestStockEvaluation {
|
||||
analysisAt: number;
|
||||
signal: string;
|
||||
signalScore: number;
|
||||
entryPrice: number;
|
||||
exitPrice: number;
|
||||
simulatedReturnPct: number;
|
||||
directionCorrect: boolean;
|
||||
outcome: string;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
analysisId: string;
|
||||
}
|
||||
|
||||
export interface ListStoredStockBacktestsRequest {
|
||||
symbols: string[];
|
||||
evalWindowDays: number;
|
||||
}
|
||||
|
||||
export interface ListStoredStockBacktestsResponse {
|
||||
items: BacktestStockResponse[];
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
@@ -219,6 +346,10 @@ export interface MarketServiceHandler {
|
||||
listEtfFlows(ctx: ServerContext, req: ListEtfFlowsRequest): Promise<ListEtfFlowsResponse>;
|
||||
getCountryStockIndex(ctx: ServerContext, req: GetCountryStockIndexRequest): Promise<GetCountryStockIndexResponse>;
|
||||
listGulfQuotes(ctx: ServerContext, req: ListGulfQuotesRequest): Promise<ListGulfQuotesResponse>;
|
||||
analyzeStock(ctx: ServerContext, req: AnalyzeStockRequest): Promise<AnalyzeStockResponse>;
|
||||
getStockAnalysisHistory(ctx: ServerContext, req: GetStockAnalysisHistoryRequest): Promise<GetStockAnalysisHistoryResponse>;
|
||||
backtestStock(ctx: ServerContext, req: BacktestStockRequest): Promise<BacktestStockResponse>;
|
||||
listStoredStockBacktests(ctx: ServerContext, req: ListStoredStockBacktestsRequest): Promise<ListStoredStockBacktestsResponse>;
|
||||
}
|
||||
|
||||
export function createMarketServiceRoutes(
|
||||
@@ -582,6 +713,200 @@ export function createMarketServiceRoutes(
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/market/v1/analyze-stock",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const params = url.searchParams;
|
||||
const body: AnalyzeStockRequest = {
|
||||
symbol: params.get("symbol") ?? "",
|
||||
name: params.get("name") ?? "",
|
||||
includeNews: params.get("include_news") === "true",
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("analyzeStock", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.analyzeStock(ctx, body);
|
||||
return new Response(JSON.stringify(result as AnalyzeStockResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/market/v1/get-stock-analysis-history",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const params = url.searchParams;
|
||||
const body: GetStockAnalysisHistoryRequest = {
|
||||
symbols: params.get("symbols") ?? "",
|
||||
limitPerSymbol: Number(params.get("limit_per_symbol") ?? "0"),
|
||||
includeNews: params.get("include_news") === "true",
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("getStockAnalysisHistory", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getStockAnalysisHistory(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetStockAnalysisHistoryResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/market/v1/backtest-stock",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const params = url.searchParams;
|
||||
const body: BacktestStockRequest = {
|
||||
symbol: params.get("symbol") ?? "",
|
||||
name: params.get("name") ?? "",
|
||||
evalWindowDays: Number(params.get("eval_window_days") ?? "0"),
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("backtestStock", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.backtestStock(ctx, body);
|
||||
return new Response(JSON.stringify(result as BacktestStockResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/market/v1/list-stored-stock-backtests",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const params = url.searchParams;
|
||||
const body: ListStoredStockBacktestsRequest = {
|
||||
symbols: params.get("symbols") ?? "",
|
||||
evalWindowDays: Number(params.get("eval_window_days") ?? "0"),
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("listStoredStockBacktests", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.listStoredStockBacktests(ctx, body);
|
||||
return new Response(JSON.stringify(result as ListStoredStockBacktestsResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
381
src/services/daily-market-brief.ts
Normal file
381
src/services/daily-market-brief.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import type { MarketData, NewsItem } from '@/types';
|
||||
import type { MarketWatchlistEntry } from './market-watchlist';
|
||||
import { getMarketWatchlistEntries } from './market-watchlist';
|
||||
import type { SummarizationResult } from './summarization';
|
||||
|
||||
export interface DailyMarketBriefItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
display: string;
|
||||
price: number | null;
|
||||
change: number | null;
|
||||
stance: 'bullish' | 'neutral' | 'defensive';
|
||||
note: string;
|
||||
relatedHeadline?: string;
|
||||
}
|
||||
|
||||
export interface DailyMarketBrief {
|
||||
available: boolean;
|
||||
title: string;
|
||||
dateKey: string;
|
||||
timezone: string;
|
||||
summary: string;
|
||||
actionPlan: string;
|
||||
riskWatch: string;
|
||||
items: DailyMarketBriefItem[];
|
||||
provider: string;
|
||||
model: string;
|
||||
fallback: boolean;
|
||||
generatedAt: string;
|
||||
headlineCount: number;
|
||||
}
|
||||
|
||||
export interface BuildDailyMarketBriefOptions {
|
||||
markets: MarketData[];
|
||||
newsByCategory: Record<string, NewsItem[]>;
|
||||
timezone?: string;
|
||||
now?: Date;
|
||||
targets?: MarketWatchlistEntry[];
|
||||
summarize?: (
|
||||
headlines: string[],
|
||||
onProgress?: undefined,
|
||||
geoContext?: string,
|
||||
lang?: string,
|
||||
) => Promise<SummarizationResult | null>;
|
||||
}
|
||||
|
||||
async function getDefaultSummarizer(): Promise<NonNullable<BuildDailyMarketBriefOptions['summarize']>> {
|
||||
const { generateSummary } = await import('./summarization');
|
||||
return generateSummary;
|
||||
}
|
||||
|
||||
async function getPersistentCacheApi(): Promise<{
|
||||
getPersistentCache: <T>(key: string) => Promise<{ data: T } | null>;
|
||||
setPersistentCache: <T>(key: string, data: T) => Promise<void>;
|
||||
}> {
|
||||
const { getPersistentCache, setPersistentCache } = await import('./persistent-cache');
|
||||
return { getPersistentCache, setPersistentCache };
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'premium:daily-market-brief:v1';
|
||||
const DEFAULT_SCHEDULE_HOUR = 8;
|
||||
const DEFAULT_TARGET_COUNT = 4;
|
||||
const BRIEF_NEWS_CATEGORIES = ['markets', 'economic', 'crypto', 'finance'];
|
||||
const COMMON_NAME_TOKENS = new Set(['inc', 'corp', 'group', 'holdings', 'company', 'companies', 'class', 'common', 'plc', 'limited', 'ltd', 'adr']);
|
||||
|
||||
function resolveTimeZone(timezone?: string): string {
|
||||
const candidate = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
try {
|
||||
Intl.DateTimeFormat('en-US', { timeZone: candidate }).format(new Date());
|
||||
return candidate;
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalDateParts(date: Date, timezone: string): { year: string; month: string; day: string; hour: string } {
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: resolveTimeZone(timezone),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const read = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||
return {
|
||||
year: read('year'),
|
||||
month: read('month'),
|
||||
day: read('day'),
|
||||
hour: read('hour'),
|
||||
};
|
||||
}
|
||||
|
||||
function getDateKey(date: Date, timezone: string): string {
|
||||
const parts = getLocalDateParts(date, timezone);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
function getLocalHour(date: Date, timezone: string): number {
|
||||
return Number.parseInt(getLocalDateParts(date, timezone).hour || '0', 10) || 0;
|
||||
}
|
||||
|
||||
function formatTitleDate(date: Date, timezone: string): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: resolveTimeZone(timezone),
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function sanitizeCacheKeyPart(value: string): string {
|
||||
return value.replace(/[^a-z0-9/_-]+/gi, '-').toLowerCase();
|
||||
}
|
||||
|
||||
function getCacheKey(timezone: string): string {
|
||||
return `${CACHE_PREFIX}:${sanitizeCacheKeyPart(resolveTimeZone(timezone))}`;
|
||||
}
|
||||
|
||||
function isMeaningfulToken(token: string): boolean {
|
||||
return token.length >= 3 && !COMMON_NAME_TOKENS.has(token);
|
||||
}
|
||||
|
||||
function getSymbolTokens(item: Pick<MarketData, 'symbol' | 'display' | 'name'>): string[] {
|
||||
const raw = [
|
||||
item.symbol,
|
||||
item.display,
|
||||
...item.name.toLowerCase().split(/[^a-z0-9]+/gi),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const token of raw) {
|
||||
const normalized = token.trim().toLowerCase();
|
||||
if (!isMeaningfulToken(normalized) || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function matchesMarketHeadline(market: Pick<MarketData, 'symbol' | 'display' | 'name'>, title: string): boolean {
|
||||
const normalizedTitle = title.toLowerCase();
|
||||
return getSymbolTokens(market).some((token) => {
|
||||
if (token.length <= 4) {
|
||||
return new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(normalizedTitle);
|
||||
}
|
||||
return normalizedTitle.includes(token);
|
||||
});
|
||||
}
|
||||
|
||||
function collectHeadlinePool(newsByCategory: Record<string, NewsItem[]>): NewsItem[] {
|
||||
return BRIEF_NEWS_CATEGORIES
|
||||
.flatMap((category) => newsByCategory[category] || [])
|
||||
.filter((item) => !!item?.title)
|
||||
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
|
||||
}
|
||||
|
||||
function resolveTargets(markets: MarketData[], explicitTargets?: MarketWatchlistEntry[]): MarketData[] {
|
||||
const explicitEntries = explicitTargets?.length ? explicitTargets : null;
|
||||
const watchlistEntries = explicitEntries ? null : getMarketWatchlistEntries();
|
||||
const targetEntries = explicitEntries || (watchlistEntries && watchlistEntries.length > 0 ? watchlistEntries : []);
|
||||
|
||||
const bySymbol = new Map(markets.map((market) => [market.symbol, market]));
|
||||
const resolved: MarketData[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of targetEntries) {
|
||||
const match = bySymbol.get(entry.symbol);
|
||||
if (!match || seen.has(match.symbol)) continue;
|
||||
seen.add(match.symbol);
|
||||
resolved.push(match);
|
||||
if (resolved.length >= DEFAULT_TARGET_COUNT) return resolved;
|
||||
}
|
||||
|
||||
if (!explicitEntries && !(watchlistEntries && watchlistEntries.length > 0)) {
|
||||
for (const market of markets) {
|
||||
if (seen.has(market.symbol)) continue;
|
||||
seen.add(market.symbol);
|
||||
resolved.push(market);
|
||||
if (resolved.length >= DEFAULT_TARGET_COUNT) break;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function getStance(change: number | null): DailyMarketBriefItem['stance'] {
|
||||
if (typeof change !== 'number') return 'neutral';
|
||||
if (change >= 1) return 'bullish';
|
||||
if (change <= -1) return 'defensive';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function formatSignedPercent(value: number | null): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 'flat';
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function buildItemNote(change: number | null, relatedHeadline?: string): string {
|
||||
const stance = getStance(change);
|
||||
const moveNote = stance === 'bullish'
|
||||
? 'Momentum is constructive; favor leaders over laggards.'
|
||||
: stance === 'defensive'
|
||||
? 'Price action is under pressure; protect capital first.'
|
||||
: 'Tape is balanced; wait for confirmation before pressing size.';
|
||||
return relatedHeadline
|
||||
? `${moveNote} Headline driver: ${relatedHeadline}`
|
||||
: moveNote;
|
||||
}
|
||||
|
||||
function buildRuleSummary(items: DailyMarketBriefItem[], headlineCount: number): string {
|
||||
const bullish = items.filter((item) => item.stance === 'bullish').length;
|
||||
const defensive = items.filter((item) => item.stance === 'defensive').length;
|
||||
const neutral = items.length - bullish - defensive;
|
||||
|
||||
const bias = bullish > defensive
|
||||
? 'Risk appetite is leaning positive across the tracked watchlist.'
|
||||
: defensive > bullish
|
||||
? 'The watchlist is trading defensively and breadth is soft.'
|
||||
: 'The watchlist is mixed and conviction is limited.';
|
||||
|
||||
const breadth = `Leaders: ${bullish}, neutral setups: ${neutral}, defensive names: ${defensive}.`;
|
||||
const headlines = headlineCount > 0
|
||||
? `News flow remains active with ${headlineCount} relevant headline${headlineCount === 1 ? '' : 's'} in scope.`
|
||||
: 'Headline flow is thin, so price action matters more than narrative today.';
|
||||
|
||||
return `${bias} ${breadth} ${headlines}`;
|
||||
}
|
||||
|
||||
function buildActionPlan(items: DailyMarketBriefItem[], headlineCount: number): string {
|
||||
const bullish = items.filter((item) => item.stance === 'bullish').length;
|
||||
const defensive = items.filter((item) => item.stance === 'defensive').length;
|
||||
|
||||
if (defensive > bullish) {
|
||||
return headlineCount > 0
|
||||
? 'Keep gross exposure light, wait for downside to stabilize, and let macro headlines clear before adding risk.'
|
||||
: 'Keep exposure light and wait for price to reclaim short-term momentum before adding risk.';
|
||||
}
|
||||
|
||||
if (bullish >= 2) {
|
||||
return headlineCount > 0
|
||||
? 'Lean into relative strength, but size entries around macro releases and company-specific headlines.'
|
||||
: 'Lean into the strongest names on pullbacks and avoid chasing extended opening moves.';
|
||||
}
|
||||
|
||||
return 'Stay selective, trade the cleanest relative-strength setups, and let index direction confirm before scaling.';
|
||||
}
|
||||
|
||||
function buildRiskWatch(items: DailyMarketBriefItem[], headlines: NewsItem[]): string {
|
||||
const defensive = items.filter((item) => item.stance === 'defensive').map((item) => item.display);
|
||||
const headlineTitles = headlines.slice(0, 2).map((item) => item.title);
|
||||
|
||||
if (defensive.length > 0 && headlineTitles.length > 0) {
|
||||
return `Watch ${defensive.join(', ')} for further weakness while monitoring: ${headlineTitles.join(' | ')}`;
|
||||
}
|
||||
if (defensive.length > 0) {
|
||||
return `Watch ${defensive.join(', ')} for further weakness and avoid averaging into fading momentum.`;
|
||||
}
|
||||
if (headlineTitles.length > 0) {
|
||||
return `Headline watch: ${headlineTitles.join(' | ')}`;
|
||||
}
|
||||
return 'Risk watch is centered on macro follow-through, index breadth, and any abrupt reversal in the strongest names.';
|
||||
}
|
||||
|
||||
function buildSummaryInputs(items: DailyMarketBriefItem[], headlines: NewsItem[]): string[] {
|
||||
const marketLines = items.map((item) => {
|
||||
const change = formatSignedPercent(item.change);
|
||||
const price = typeof item.price === 'number' ? ` at ${item.price.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : '';
|
||||
return `${item.name} (${item.display}) is ${change}${price}; stance is ${item.stance}.`;
|
||||
});
|
||||
|
||||
const headlineLines = headlines.slice(0, 6).map((item) => item.title.trim()).filter(Boolean);
|
||||
return [...marketLines, ...headlineLines];
|
||||
}
|
||||
|
||||
export function shouldRefreshDailyBrief(
|
||||
brief: DailyMarketBrief | null | undefined,
|
||||
timezone = 'UTC',
|
||||
now = new Date(),
|
||||
scheduleHour = DEFAULT_SCHEDULE_HOUR,
|
||||
): boolean {
|
||||
if (!brief?.available) return true;
|
||||
const resolvedTimezone = resolveTimeZone(timezone || brief.timezone);
|
||||
const dateKey = getDateKey(now, resolvedTimezone);
|
||||
if (brief.dateKey === dateKey) return false;
|
||||
return getLocalHour(now, resolvedTimezone) >= scheduleHour;
|
||||
}
|
||||
|
||||
export async function getCachedDailyMarketBrief(timezone?: string): Promise<DailyMarketBrief | null> {
|
||||
const resolvedTimezone = resolveTimeZone(timezone);
|
||||
const { getPersistentCache } = await getPersistentCacheApi();
|
||||
const envelope = await getPersistentCache<DailyMarketBrief>(getCacheKey(resolvedTimezone));
|
||||
return envelope?.data ?? null;
|
||||
}
|
||||
|
||||
export async function cacheDailyMarketBrief(brief: DailyMarketBrief): Promise<void> {
|
||||
const { setPersistentCache } = await getPersistentCacheApi();
|
||||
await setPersistentCache(getCacheKey(brief.timezone), brief);
|
||||
}
|
||||
|
||||
export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOptions): Promise<DailyMarketBrief> {
|
||||
const now = options.now || new Date();
|
||||
const timezone = resolveTimeZone(options.timezone);
|
||||
const trackedMarkets = resolveTargets(options.markets, options.targets).slice(0, DEFAULT_TARGET_COUNT);
|
||||
const relevantHeadlines = collectHeadlinePool(options.newsByCategory);
|
||||
|
||||
const items: DailyMarketBriefItem[] = trackedMarkets.map((market) => {
|
||||
const relatedHeadline = relevantHeadlines.find((headline) => matchesMarketHeadline(market, headline.title))?.title;
|
||||
return {
|
||||
symbol: market.symbol,
|
||||
name: market.name,
|
||||
display: market.display,
|
||||
price: market.price,
|
||||
change: market.change,
|
||||
stance: getStance(market.change),
|
||||
note: buildItemNote(market.change, relatedHeadline),
|
||||
...(relatedHeadline ? { relatedHeadline } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
available: false,
|
||||
title: `Daily Market Brief • ${formatTitleDate(now, timezone)}`,
|
||||
dateKey: getDateKey(now, timezone),
|
||||
timezone,
|
||||
summary: 'Market data is not available yet for the daily brief.',
|
||||
actionPlan: '',
|
||||
riskWatch: '',
|
||||
items: [],
|
||||
provider: 'rules',
|
||||
model: '',
|
||||
fallback: true,
|
||||
generatedAt: now.toISOString(),
|
||||
headlineCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const summaryInputs = buildSummaryInputs(items, relevantHeadlines);
|
||||
let summary = buildRuleSummary(items, relevantHeadlines.length);
|
||||
let provider = 'rules';
|
||||
let model = '';
|
||||
let fallback = true;
|
||||
|
||||
if (summaryInputs.length >= 2) {
|
||||
try {
|
||||
const summaryProvider = options.summarize || await getDefaultSummarizer();
|
||||
const generated = await summaryProvider(
|
||||
summaryInputs,
|
||||
undefined,
|
||||
'Daily market briefing for a tracked watchlist',
|
||||
'en',
|
||||
);
|
||||
if (generated?.summary) {
|
||||
summary = generated.summary.trim();
|
||||
provider = generated.provider;
|
||||
model = generated.model;
|
||||
fallback = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[DailyBrief] AI summarization failed, using rules-based fallback:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
title: `Daily Market Brief • ${formatTitleDate(now, timezone)}`,
|
||||
dateKey: getDateKey(now, timezone),
|
||||
timezone,
|
||||
summary,
|
||||
actionPlan: buildActionPlan(items, relevantHeadlines.length),
|
||||
riskWatch: buildRiskWatch(items, relevantHeadlines),
|
||||
items,
|
||||
provider,
|
||||
model,
|
||||
fallback,
|
||||
generatedAt: now.toISOString(),
|
||||
headlineCount: relevantHeadlines.length,
|
||||
};
|
||||
}
|
||||
@@ -40,3 +40,6 @@ export * from './cached-theater-posture';
|
||||
export * from './trade';
|
||||
export * from './supply-chain';
|
||||
export * from './breaking-news-alerts';
|
||||
export * from './daily-market-brief';
|
||||
export * from './stock-analysis-history';
|
||||
export * from './stock-backtest';
|
||||
|
||||
@@ -4,6 +4,9 @@ import { invokeTauri } from './tauri-bridge';
|
||||
export type RuntimeSecretKey =
|
||||
| 'GROQ_API_KEY'
|
||||
| 'OPENROUTER_API_KEY'
|
||||
| 'TAVILY_API_KEYS'
|
||||
| 'BRAVE_API_KEYS'
|
||||
| 'SERPAPI_API_KEYS'
|
||||
| 'FRED_API_KEY'
|
||||
| 'EIA_API_KEY'
|
||||
| 'CLOUDFLARE_API_TOKEN'
|
||||
@@ -30,6 +33,9 @@ export type RuntimeSecretKey =
|
||||
export type RuntimeFeatureId =
|
||||
| 'aiGroq'
|
||||
| 'aiOpenRouter'
|
||||
| 'stockNewsSearchTavily'
|
||||
| 'stockNewsSearchBrave'
|
||||
| 'stockNewsSearchSerpApi'
|
||||
| 'economicFred'
|
||||
| 'energyEia'
|
||||
| 'internetOutages'
|
||||
@@ -84,6 +90,9 @@ function getSidecarSecretValidateUrl(): string {
|
||||
const defaultToggles: Record<RuntimeFeatureId, boolean> = {
|
||||
aiGroq: true,
|
||||
aiOpenRouter: true,
|
||||
stockNewsSearchTavily: true,
|
||||
stockNewsSearchBrave: true,
|
||||
stockNewsSearchSerpApi: true,
|
||||
economicFred: true,
|
||||
energyEia: true,
|
||||
internetOutages: true,
|
||||
@@ -128,6 +137,27 @@ export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [
|
||||
requiredSecrets: ['OPENROUTER_API_KEY'],
|
||||
fallback: 'Falls back to local browser model only.',
|
||||
},
|
||||
{
|
||||
id: 'stockNewsSearchTavily',
|
||||
name: 'Tavily stock-news search',
|
||||
description: 'Primary targeted stock-news search provider for premium analysis enrichment.',
|
||||
requiredSecrets: ['TAVILY_API_KEYS'],
|
||||
fallback: 'Falls back to Brave, then SerpAPI, then Google News RSS.',
|
||||
},
|
||||
{
|
||||
id: 'stockNewsSearchBrave',
|
||||
name: 'Brave stock-news search',
|
||||
description: 'Fallback targeted stock-news provider for premium analysis enrichment.',
|
||||
requiredSecrets: ['BRAVE_API_KEYS'],
|
||||
fallback: 'Falls back to SerpAPI, then Google News RSS.',
|
||||
},
|
||||
{
|
||||
id: 'stockNewsSearchSerpApi',
|
||||
name: 'SerpAPI stock-news search',
|
||||
description: 'Additional targeted stock-news provider for premium analysis enrichment.',
|
||||
requiredSecrets: ['SERPAPI_API_KEYS'],
|
||||
fallback: 'Falls back to Google News RSS.',
|
||||
},
|
||||
{
|
||||
id: 'economicFred',
|
||||
name: 'FRED economic indicators',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { RuntimeSecretKey, RuntimeFeatureId } from './runtime-config';
|
||||
export const SIGNUP_URLS: Partial<Record<RuntimeSecretKey, string>> = {
|
||||
GROQ_API_KEY: 'https://console.groq.com/keys',
|
||||
OPENROUTER_API_KEY: 'https://openrouter.ai/settings/keys',
|
||||
TAVILY_API_KEYS: 'https://app.tavily.com/home',
|
||||
BRAVE_API_KEYS: 'https://api-dashboard.search.brave.com/app/keys',
|
||||
SERPAPI_API_KEYS: 'https://serpapi.com/manage-api-key',
|
||||
FRED_API_KEY: 'https://fred.stlouisfed.org/docs/api/api_key.html',
|
||||
EIA_API_KEY: 'https://www.eia.gov/opendata/register.php',
|
||||
CLOUDFLARE_API_TOKEN: 'https://dash.cloudflare.com/profile/api-tokens',
|
||||
@@ -36,6 +39,9 @@ export const MASKED_SENTINEL = '__WM_MASKED__';
|
||||
export const HUMAN_LABELS: Record<RuntimeSecretKey, string> = {
|
||||
GROQ_API_KEY: 'Groq API Key',
|
||||
OPENROUTER_API_KEY: 'OpenRouter API Key',
|
||||
TAVILY_API_KEYS: 'Tavily API Keys',
|
||||
BRAVE_API_KEYS: 'Brave Search API Keys',
|
||||
SERPAPI_API_KEYS: 'SerpAPI Keys',
|
||||
FRED_API_KEY: 'FRED API Key',
|
||||
EIA_API_KEY: 'EIA API Key',
|
||||
CLOUDFLARE_API_TOKEN: 'Cloudflare API Token',
|
||||
@@ -80,7 +86,7 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [
|
||||
{
|
||||
id: 'markets',
|
||||
label: 'Markets & Trade',
|
||||
features: ['finnhubMarkets', 'wtoTrade'],
|
||||
features: ['finnhubMarkets', 'stockNewsSearchTavily', 'stockNewsSearchBrave', 'stockNewsSearchSerpApi', 'wtoTrade'],
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
|
||||
109
src/services/stock-analysis-history.ts
Normal file
109
src/services/stock-analysis-history.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
MarketServiceClient,
|
||||
type AnalyzeStockResponse,
|
||||
} from '@/generated/client/worldmonitor/market/v1/service_client';
|
||||
|
||||
export type StockAnalysisSnapshot = AnalyzeStockResponse;
|
||||
export type StockAnalysisHistory = Record<string, StockAnalysisSnapshot[]>;
|
||||
|
||||
const client = new MarketServiceClient('', {
|
||||
fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args),
|
||||
});
|
||||
|
||||
const DEFAULT_LIMIT = 4;
|
||||
const DEFAULT_LIMIT_PER_SYMBOL = 4;
|
||||
const MAX_SNAPSHOTS_PER_SYMBOL = 32;
|
||||
export const STOCK_ANALYSIS_FRESH_MS = 15 * 60 * 1000;
|
||||
|
||||
async function getTargetSymbols(limit: number): Promise<string[]> {
|
||||
const { getStockAnalysisTargets } = await import('./stock-analysis');
|
||||
return getStockAnalysisTargets(limit).map((target) => target.symbol);
|
||||
}
|
||||
|
||||
function compareSnapshots(a: StockAnalysisSnapshot, b: StockAnalysisSnapshot): number {
|
||||
const aTime = Date.parse(a.generatedAt || '') || 0;
|
||||
const bTime = Date.parse(b.generatedAt || '') || 0;
|
||||
return bTime - aTime;
|
||||
}
|
||||
|
||||
function isSameSnapshot(a: StockAnalysisSnapshot, b: StockAnalysisSnapshot): boolean {
|
||||
return a.symbol === b.symbol
|
||||
&& a.generatedAt === b.generatedAt
|
||||
&& a.signal === b.signal
|
||||
&& a.signalScore === b.signalScore
|
||||
&& a.currentPrice === b.currentPrice;
|
||||
}
|
||||
|
||||
export function mergeStockAnalysisHistory(
|
||||
existing: StockAnalysisHistory,
|
||||
incoming: StockAnalysisSnapshot[],
|
||||
maxSnapshotsPerSymbol = MAX_SNAPSHOTS_PER_SYMBOL,
|
||||
): StockAnalysisHistory {
|
||||
const next: StockAnalysisHistory = { ...existing };
|
||||
|
||||
for (const snapshot of incoming) {
|
||||
if (!snapshot?.symbol || !snapshot.available) continue;
|
||||
const symbol = snapshot.symbol;
|
||||
const current = next[symbol] ? [...next[symbol]!] : [];
|
||||
if (!current.some((item) => isSameSnapshot(item, snapshot))) {
|
||||
current.push(snapshot);
|
||||
}
|
||||
current.sort(compareSnapshots);
|
||||
next[symbol] = current.slice(0, maxSnapshotsPerSymbol);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getLatestStockAnalysisSnapshots(history: StockAnalysisHistory, limit = DEFAULT_LIMIT): StockAnalysisSnapshot[] {
|
||||
return Object.values(history)
|
||||
.map((items) => items[0])
|
||||
.filter((item): item is StockAnalysisSnapshot => !!item?.available)
|
||||
.sort(compareSnapshots)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function hasFreshStockAnalysisHistory(
|
||||
history: StockAnalysisHistory,
|
||||
symbols: string[],
|
||||
maxAgeMs = STOCK_ANALYSIS_FRESH_MS,
|
||||
): boolean {
|
||||
if (symbols.length === 0) return false;
|
||||
const now = Date.now();
|
||||
return symbols.every((symbol) => {
|
||||
const latest = history[symbol]?.[0];
|
||||
const ts = Date.parse(latest?.generatedAt || '');
|
||||
return !!latest?.available && Number.isFinite(ts) && (now - ts) <= maxAgeMs;
|
||||
});
|
||||
}
|
||||
|
||||
export function getMissingOrStaleStockAnalysisSymbols(
|
||||
history: StockAnalysisHistory,
|
||||
symbols: string[],
|
||||
maxAgeMs = STOCK_ANALYSIS_FRESH_MS,
|
||||
): string[] {
|
||||
const now = Date.now();
|
||||
return symbols.filter((symbol) => {
|
||||
const latest = history[symbol]?.[0];
|
||||
const ts = Date.parse(latest?.generatedAt || '');
|
||||
return !(latest?.available && Number.isFinite(ts) && (now - ts) <= maxAgeMs);
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchStockAnalysisHistory(
|
||||
limit = DEFAULT_LIMIT,
|
||||
limitPerSymbol = DEFAULT_LIMIT_PER_SYMBOL,
|
||||
): Promise<StockAnalysisHistory> {
|
||||
const symbols = await getTargetSymbols(limit);
|
||||
const response = await client.getStockAnalysisHistory({
|
||||
symbols,
|
||||
limitPerSymbol,
|
||||
includeNews: true,
|
||||
});
|
||||
|
||||
const history: StockAnalysisHistory = {};
|
||||
for (const item of response.items) {
|
||||
history[item.symbol] = [...item.snapshots].sort(compareSnapshots);
|
||||
}
|
||||
return history;
|
||||
}
|
||||
67
src/services/stock-analysis.ts
Normal file
67
src/services/stock-analysis.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { MARKET_SYMBOLS } from '@/config';
|
||||
import {
|
||||
MarketServiceClient,
|
||||
type AnalyzeStockResponse,
|
||||
} from '@/generated/client/worldmonitor/market/v1/service_client';
|
||||
import { getMarketWatchlistEntries } from '@/services/market-watchlist';
|
||||
|
||||
const client = new MarketServiceClient('', {
|
||||
fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args),
|
||||
});
|
||||
|
||||
export type StockAnalysisResult = AnalyzeStockResponse;
|
||||
|
||||
export interface StockAnalysisTarget {
|
||||
symbol: string;
|
||||
name: string;
|
||||
display: string;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 4;
|
||||
|
||||
function isAnalyzableSymbol(symbol: string): boolean {
|
||||
return !symbol.startsWith('^') && !symbol.includes('=');
|
||||
}
|
||||
|
||||
export function getStockAnalysisTargets(limit = DEFAULT_LIMIT): StockAnalysisTarget[] {
|
||||
const customEntries = getMarketWatchlistEntries().filter((entry) => isAnalyzableSymbol(entry.symbol));
|
||||
const baseEntries = customEntries.length > 0
|
||||
? customEntries.map((entry) => ({
|
||||
symbol: entry.symbol,
|
||||
name: entry.name || entry.symbol,
|
||||
display: entry.display || entry.symbol,
|
||||
}))
|
||||
: MARKET_SYMBOLS.filter((entry) => isAnalyzableSymbol(entry.symbol));
|
||||
|
||||
const seen = new Set<string>();
|
||||
const targets: StockAnalysisTarget[] = [];
|
||||
for (const entry of baseEntries) {
|
||||
if (seen.has(entry.symbol)) continue;
|
||||
seen.add(entry.symbol);
|
||||
targets.push({ symbol: entry.symbol, name: entry.name, display: entry.display });
|
||||
if (targets.length >= limit) break;
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
export async function fetchStockAnalysesForTargets(targets: StockAnalysisTarget[]): Promise<StockAnalysisResult[]> {
|
||||
const results: StockAnalysisResult[] = [];
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (i > 0) await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
try {
|
||||
const result = await client.analyzeStock({
|
||||
symbol: targets[i]!.symbol,
|
||||
name: targets[i]!.name,
|
||||
includeNews: true,
|
||||
});
|
||||
if (result.available) results.push(result);
|
||||
} catch {
|
||||
// Skip failed individual analysis
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function fetchStockAnalyses(limit = DEFAULT_LIMIT): Promise<StockAnalysisResult[]> {
|
||||
return fetchStockAnalysesForTargets(getStockAnalysisTargets(limit));
|
||||
}
|
||||
89
src/services/stock-backtest.ts
Normal file
89
src/services/stock-backtest.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
MarketServiceClient,
|
||||
type BacktestStockResponse,
|
||||
} from '@/generated/client/worldmonitor/market/v1/service_client';
|
||||
|
||||
const client = new MarketServiceClient('', {
|
||||
fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args),
|
||||
});
|
||||
|
||||
export type StockBacktestResult = BacktestStockResponse;
|
||||
|
||||
const DEFAULT_LIMIT = 4;
|
||||
const DEFAULT_EVAL_WINDOW_DAYS = 10;
|
||||
export const STOCK_BACKTEST_FRESH_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function getTargets(limit: number) {
|
||||
const { getStockAnalysisTargets } = await import('./stock-analysis');
|
||||
return getStockAnalysisTargets(limit);
|
||||
}
|
||||
|
||||
export async function fetchStockBacktestsForTargets(
|
||||
targets: Array<{ symbol: string; name: string }>,
|
||||
evalWindowDays = DEFAULT_EVAL_WINDOW_DAYS,
|
||||
): Promise<StockBacktestResult[]> {
|
||||
const results: StockBacktestResult[] = [];
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (i > 0) await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
try {
|
||||
const result = await client.backtestStock({
|
||||
symbol: targets[i]!.symbol,
|
||||
name: targets[i]!.name,
|
||||
evalWindowDays,
|
||||
});
|
||||
if (result.available) results.push(result);
|
||||
} catch {
|
||||
// Skip failed individual backtest
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function fetchStockBacktests(
|
||||
limit = DEFAULT_LIMIT,
|
||||
evalWindowDays = DEFAULT_EVAL_WINDOW_DAYS,
|
||||
): Promise<StockBacktestResult[]> {
|
||||
return fetchStockBacktestsForTargets(await getTargets(limit), evalWindowDays);
|
||||
}
|
||||
|
||||
export async function fetchStoredStockBacktests(
|
||||
limit = DEFAULT_LIMIT,
|
||||
evalWindowDays = DEFAULT_EVAL_WINDOW_DAYS,
|
||||
): Promise<StockBacktestResult[]> {
|
||||
const targets = await getTargets(limit);
|
||||
const symbols = targets.map((target) => target.symbol);
|
||||
const response = await client.listStoredStockBacktests({
|
||||
symbols,
|
||||
evalWindowDays,
|
||||
});
|
||||
return response.items.filter((result) => result.available);
|
||||
}
|
||||
|
||||
export function hasFreshStoredStockBacktests(
|
||||
items: StockBacktestResult[],
|
||||
symbols: string[],
|
||||
maxAgeMs = STOCK_BACKTEST_FRESH_MS,
|
||||
): boolean {
|
||||
if (symbols.length === 0) return false;
|
||||
const bySymbol = new Map(items.map((item) => [item.symbol, item]));
|
||||
const now = Date.now();
|
||||
return symbols.every((symbol) => {
|
||||
const item = bySymbol.get(symbol);
|
||||
const ts = Date.parse(item?.generatedAt || '');
|
||||
return !!item?.available && Number.isFinite(ts) && (now - ts) <= maxAgeMs;
|
||||
});
|
||||
}
|
||||
|
||||
export function getMissingOrStaleStoredStockBacktests(
|
||||
items: StockBacktestResult[],
|
||||
symbols: string[],
|
||||
maxAgeMs = STOCK_BACKTEST_FRESH_MS,
|
||||
): string[] {
|
||||
const bySymbol = new Map(items.map((item) => [item.symbol, item]));
|
||||
const now = Date.now();
|
||||
return symbols.filter((symbol) => {
|
||||
const item = bySymbol.get(symbol);
|
||||
const ts = Date.parse(item?.generatedAt || '');
|
||||
return !(item?.available && Number.isFinite(ts) && (now - ts) <= maxAgeMs);
|
||||
});
|
||||
}
|
||||
123
tests/daily-market-brief.test.mts
Normal file
123
tests/daily-market-brief.test.mts
Normal file
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import type { MarketData, NewsItem } from '../src/types/index.ts';
|
||||
import {
|
||||
buildDailyMarketBrief,
|
||||
shouldRefreshDailyBrief,
|
||||
} from '../src/services/daily-market-brief.ts';
|
||||
|
||||
function makeNewsItem(title: string, source = 'Reuters', publishedAt = '2026-03-08T05:00:00.000Z'): NewsItem {
|
||||
return {
|
||||
source,
|
||||
title,
|
||||
link: 'https://example.com/story',
|
||||
pubDate: new Date(publishedAt),
|
||||
isAlert: false,
|
||||
};
|
||||
}
|
||||
|
||||
const markets: MarketData[] = [
|
||||
{ symbol: 'AAPL', name: 'Apple', display: 'AAPL', price: 212.45, change: 1.84 },
|
||||
{ symbol: 'MSFT', name: 'Microsoft', display: 'MSFT', price: 468.12, change: -1.26 },
|
||||
{ symbol: 'NVDA', name: 'NVIDIA', display: 'NVDA', price: 913.77, change: 0.42 },
|
||||
];
|
||||
|
||||
describe('daily market brief schedule logic', () => {
|
||||
it('does not refresh before the local schedule if a prior brief exists', () => {
|
||||
const shouldRefresh = shouldRefreshDailyBrief({
|
||||
available: true,
|
||||
title: 'Brief',
|
||||
dateKey: '2026-03-07',
|
||||
timezone: 'UTC',
|
||||
summary: '',
|
||||
actionPlan: '',
|
||||
riskWatch: '',
|
||||
items: [],
|
||||
provider: 'rules',
|
||||
model: '',
|
||||
fallback: true,
|
||||
generatedAt: '2026-03-07T23:00:00.000Z',
|
||||
headlineCount: 0,
|
||||
}, 'UTC', new Date('2026-03-08T07:00:00.000Z'));
|
||||
|
||||
assert.equal(shouldRefresh, false);
|
||||
});
|
||||
|
||||
it('refreshes after the local schedule when the brief is from a prior day', () => {
|
||||
const shouldRefresh = shouldRefreshDailyBrief({
|
||||
available: true,
|
||||
title: 'Brief',
|
||||
dateKey: '2026-03-07',
|
||||
timezone: 'UTC',
|
||||
summary: '',
|
||||
actionPlan: '',
|
||||
riskWatch: '',
|
||||
items: [],
|
||||
provider: 'rules',
|
||||
model: '',
|
||||
fallback: true,
|
||||
generatedAt: '2026-03-07T23:00:00.000Z',
|
||||
headlineCount: 0,
|
||||
}, 'UTC', new Date('2026-03-08T09:00:00.000Z'));
|
||||
|
||||
assert.equal(shouldRefresh, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDailyMarketBrief', () => {
|
||||
it('builds a brief from tracked markets and finance headlines', async () => {
|
||||
const brief = await buildDailyMarketBrief({
|
||||
markets,
|
||||
newsByCategory: {
|
||||
markets: [
|
||||
makeNewsItem('Apple extends gains after stronger iPhone cycle outlook'),
|
||||
makeNewsItem('Microsoft slides as cloud guidance softens', 'Bloomberg', '2026-03-08T04:00:00.000Z'),
|
||||
],
|
||||
economic: [
|
||||
makeNewsItem('Treasury yields steady ahead of inflation data', 'WSJ', '2026-03-08T03:00:00.000Z'),
|
||||
],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
now: new Date('2026-03-08T10:30:00.000Z'),
|
||||
targets: [
|
||||
{ symbol: 'AAPL', name: 'Apple', display: 'AAPL' },
|
||||
{ symbol: 'MSFT', name: 'Microsoft', display: 'MSFT' },
|
||||
],
|
||||
summarize: async () => ({
|
||||
summary: 'Risk appetite is mixed, with Apple leading while Microsoft weakens into macro headlines.',
|
||||
provider: 'openrouter',
|
||||
model: 'test-model',
|
||||
cached: false,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(brief.available, true);
|
||||
assert.equal(brief.items.length, 2);
|
||||
assert.equal(brief.provider, 'openrouter');
|
||||
assert.equal(brief.fallback, false);
|
||||
assert.match(brief.title, /Daily Market Brief/);
|
||||
assert.match(brief.summary, /Apple leading/i);
|
||||
assert.match(brief.actionPlan, /selective|Lean|Keep/i);
|
||||
assert.match(brief.riskWatch, /headline|Microsoft|Apple/i);
|
||||
assert.match(brief.items[0]?.note || '', /Headline driver/i);
|
||||
});
|
||||
|
||||
it('falls back to deterministic copy when summarization is unavailable', async () => {
|
||||
const brief = await buildDailyMarketBrief({
|
||||
markets,
|
||||
newsByCategory: {
|
||||
markets: [makeNewsItem('NVIDIA holds gains as chip demand remains firm')],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
now: new Date('2026-03-08T10:30:00.000Z'),
|
||||
targets: [{ symbol: 'NVDA', name: 'NVIDIA', display: 'NVDA' }],
|
||||
summarize: async () => null,
|
||||
});
|
||||
|
||||
assert.equal(brief.available, true);
|
||||
assert.equal(brief.provider, 'rules');
|
||||
assert.equal(brief.fallback, true);
|
||||
assert.match(brief.summary, /watchlist|breadth|headline flow/i);
|
||||
});
|
||||
});
|
||||
48
tests/premium-stock-gateway.test.mts
Normal file
48
tests/premium-stock-gateway.test.mts
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import { createDomainGateway } from '../server/gateway.ts';
|
||||
|
||||
const originalKeys = process.env.WORLDMONITOR_VALID_KEYS;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKeys == null) delete process.env.WORLDMONITOR_VALID_KEYS;
|
||||
else process.env.WORLDMONITOR_VALID_KEYS = originalKeys;
|
||||
});
|
||||
|
||||
describe('premium stock gateway enforcement', () => {
|
||||
it('requires a World Monitor key for premium stock RPCs even from trusted browser origins', async () => {
|
||||
const handler = createDomainGateway([
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/market/v1/analyze-stock',
|
||||
handler: async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/market/v1/list-market-quotes',
|
||||
handler: async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
},
|
||||
]);
|
||||
|
||||
process.env.WORLDMONITOR_VALID_KEYS = 'real-key-123';
|
||||
|
||||
const premiumBlocked = await handler(new Request('https://worldmonitor.app/api/market/v1/analyze-stock?symbol=AAPL', {
|
||||
headers: { Origin: 'https://worldmonitor.app' },
|
||||
}));
|
||||
assert.equal(premiumBlocked.status, 401);
|
||||
|
||||
const premiumAllowed = await handler(new Request('https://worldmonitor.app/api/market/v1/analyze-stock?symbol=AAPL', {
|
||||
headers: {
|
||||
Origin: 'https://worldmonitor.app',
|
||||
'X-WorldMonitor-Key': 'real-key-123',
|
||||
},
|
||||
}));
|
||||
assert.equal(premiumAllowed.status, 200);
|
||||
|
||||
const publicAllowed = await handler(new Request('https://worldmonitor.app/api/market/v1/list-market-quotes?symbols=AAPL', {
|
||||
headers: { Origin: 'https://worldmonitor.app' },
|
||||
}));
|
||||
assert.equal(publicAllowed.status, 200);
|
||||
});
|
||||
});
|
||||
299
tests/stock-analysis-history.test.mts
Normal file
299
tests/stock-analysis-history.test.mts
Normal file
@@ -0,0 +1,299 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
getLatestStockAnalysisSnapshots,
|
||||
mergeStockAnalysisHistory,
|
||||
type StockAnalysisSnapshot,
|
||||
} from '../src/services/stock-analysis-history.ts';
|
||||
import { analyzeStock } from '../server/worldmonitor/market/v1/analyze-stock.ts';
|
||||
import { getStockAnalysisHistory } from '../server/worldmonitor/market/v1/get-stock-analysis-history.ts';
|
||||
import { MarketServiceClient } from '../src/generated/client/worldmonitor/market/v1/service_client.ts';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalRedisUrl = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const originalRedisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
|
||||
const mockChartPayload = {
|
||||
chart: {
|
||||
result: [
|
||||
{
|
||||
meta: {
|
||||
currency: 'USD',
|
||||
regularMarketPrice: 132,
|
||||
previousClose: 131,
|
||||
},
|
||||
timestamp: Array.from({ length: 80 }, (_, index) => 1_700_000_000 + (index * 86_400)),
|
||||
indicators: {
|
||||
quote: [
|
||||
{
|
||||
open: Array.from({ length: 80 }, (_, index) => 100 + (index * 0.4)),
|
||||
high: Array.from({ length: 80 }, (_, index) => 101 + (index * 0.4)),
|
||||
low: Array.from({ length: 80 }, (_, index) => 99 + (index * 0.4)),
|
||||
close: Array.from({ length: 80 }, (_, index) => 100 + (index * 0.4)),
|
||||
volume: Array.from({ length: 80 }, (_, index) => 1_000_000 + (index * 5_000)),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockNewsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss>
|
||||
<channel>
|
||||
<item>
|
||||
<title>Apple expands AI chip roadmap</title>
|
||||
<link>https://example.com/apple-ai</link>
|
||||
<pubDate>Sat, 08 Mar 2026 10:00:00 GMT</pubDate>
|
||||
<source>Reuters</source>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
function createRedisAwareFetch() {
|
||||
const redis = new Map<string, string>();
|
||||
const sortedSets = new Map<string, Array<{ member: string; score: number }>>();
|
||||
|
||||
const upsertSortedSet = (key: string, score: number, member: string) => {
|
||||
const next = (sortedSets.get(key) ?? []).filter((item) => item.member !== member);
|
||||
next.push({ member, score });
|
||||
next.sort((a, b) => a.score - b.score || a.member.localeCompare(b.member));
|
||||
sortedSets.set(key, next);
|
||||
};
|
||||
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes('query1.finance.yahoo.com')) {
|
||||
return new Response(JSON.stringify(mockChartPayload), { status: 200 });
|
||||
}
|
||||
if (url.includes('news.google.com')) {
|
||||
return new Response(mockNewsXml, { status: 200 });
|
||||
}
|
||||
|
||||
if (url.startsWith(process.env.UPSTASH_REDIS_REST_URL || '')) {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname.startsWith('/get/')) {
|
||||
const key = decodeURIComponent(parsed.pathname.slice('/get/'.length));
|
||||
return new Response(JSON.stringify({ result: redis.get(key) ?? null }), { status: 200 });
|
||||
}
|
||||
if (parsed.pathname.startsWith('/set/')) {
|
||||
const parts = parsed.pathname.split('/');
|
||||
const key = decodeURIComponent(parts[2] || '');
|
||||
const value = decodeURIComponent(parts[3] || '');
|
||||
redis.set(key, value);
|
||||
return new Response(JSON.stringify({ result: 'OK' }), { status: 200 });
|
||||
}
|
||||
if (parsed.pathname === '/pipeline') {
|
||||
const commands = JSON.parse(typeof init?.body === 'string' ? init.body : '[]') as string[][];
|
||||
const result = commands.map((command) => {
|
||||
const [verb, key = '', ...args] = command;
|
||||
if (verb === 'GET') {
|
||||
return { result: redis.get(key) ?? null };
|
||||
}
|
||||
if (verb === 'SET') {
|
||||
redis.set(key, args[0] || '');
|
||||
return { result: 'OK' };
|
||||
}
|
||||
if (verb === 'ZADD') {
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
upsertSortedSet(key, Number(args[index] || 0), args[index + 1] || '');
|
||||
}
|
||||
return { result: 1 };
|
||||
}
|
||||
if (verb === 'ZREVRANGE') {
|
||||
const items = [...(sortedSets.get(key) ?? [])].sort((a, b) => b.score - a.score || a.member.localeCompare(b.member));
|
||||
const start = Number(args[0] || 0);
|
||||
const stop = Number(args[1] || 0);
|
||||
return { result: items.slice(start, stop + 1).map((item) => item.member) };
|
||||
}
|
||||
if (verb === 'ZREM') {
|
||||
const removals = new Set(args);
|
||||
sortedSets.set(key, (sortedSets.get(key) ?? []).filter((item) => !removals.has(item.member)));
|
||||
return { result: removals.size };
|
||||
}
|
||||
if (verb === 'EXPIRE') {
|
||||
return { result: 1 };
|
||||
}
|
||||
throw new Error(`Unexpected pipeline command: ${verb}`);
|
||||
});
|
||||
return new Response(JSON.stringify(result), { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalRedisUrl == null) delete process.env.UPSTASH_REDIS_REST_URL;
|
||||
else process.env.UPSTASH_REDIS_REST_URL = originalRedisUrl;
|
||||
if (originalRedisToken == null) delete process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
else process.env.UPSTASH_REDIS_REST_TOKEN = originalRedisToken;
|
||||
});
|
||||
|
||||
function makeSnapshot(
|
||||
symbol: string,
|
||||
generatedAt: string,
|
||||
signalScore: number,
|
||||
signal = 'Buy',
|
||||
): StockAnalysisSnapshot {
|
||||
return {
|
||||
available: true,
|
||||
symbol,
|
||||
name: symbol,
|
||||
display: symbol,
|
||||
currency: 'USD',
|
||||
currentPrice: 100 + signalScore,
|
||||
changePercent: 1.2,
|
||||
signalScore,
|
||||
signal,
|
||||
trendStatus: 'Bull',
|
||||
volumeStatus: 'Normal',
|
||||
macdStatus: 'Bullish',
|
||||
rsiStatus: 'Neutral',
|
||||
summary: `${symbol} summary`,
|
||||
action: 'Wait for confirmation.',
|
||||
confidence: 'Medium',
|
||||
technicalSummary: 'Constructive setup.',
|
||||
newsSummary: 'News stable.',
|
||||
whyNow: 'Momentum is improving.',
|
||||
bullishFactors: ['Trend remains constructive.'],
|
||||
riskFactors: ['Setup needs confirmation.'],
|
||||
supportLevels: [95],
|
||||
resistanceLevels: [110],
|
||||
headlines: [],
|
||||
ma5: 101,
|
||||
ma10: 100,
|
||||
ma20: 98,
|
||||
ma60: 92,
|
||||
biasMa5: 1,
|
||||
biasMa10: 2,
|
||||
biasMa20: 4,
|
||||
volumeRatio5d: 1.1,
|
||||
rsi12: 56,
|
||||
macdDif: 1.2,
|
||||
macdDea: 0.8,
|
||||
macdBar: 0.4,
|
||||
provider: 'rules',
|
||||
model: '',
|
||||
fallback: true,
|
||||
newsSearched: false,
|
||||
generatedAt,
|
||||
analysisId: `${symbol}:${generatedAt}`,
|
||||
analysisAt: Date.parse(generatedAt),
|
||||
stopLoss: 95,
|
||||
takeProfit: 110,
|
||||
engineVersion: 'v2',
|
||||
};
|
||||
}
|
||||
|
||||
describe('stock analysis history helpers', () => {
|
||||
it('merges snapshots per symbol, dedupes identical runs, and caps retained history', () => {
|
||||
const existing = {
|
||||
AAPL: [
|
||||
makeSnapshot('AAPL', '2026-03-08T10:00:00.000Z', 70),
|
||||
makeSnapshot('AAPL', '2026-03-07T10:00:00.000Z', 66),
|
||||
],
|
||||
};
|
||||
|
||||
const incoming = [
|
||||
makeSnapshot('AAPL', '2026-03-08T10:00:00.000Z', 70),
|
||||
makeSnapshot('AAPL', '2026-03-09T10:00:00.000Z', 74, 'Strong buy'),
|
||||
...Array.from({ length: 35 }, (_, index) =>
|
||||
makeSnapshot(
|
||||
'MSFT',
|
||||
new Date(Date.UTC(2026, 2, index + 1, 12, 0, 0)).toISOString(),
|
||||
50 + index,
|
||||
)),
|
||||
];
|
||||
|
||||
const merged = mergeStockAnalysisHistory(existing, incoming);
|
||||
|
||||
assert.equal(merged.AAPL?.length, 3);
|
||||
assert.deepEqual(
|
||||
merged.AAPL?.map((snapshot) => snapshot.generatedAt),
|
||||
[
|
||||
'2026-03-09T10:00:00.000Z',
|
||||
'2026-03-08T10:00:00.000Z',
|
||||
'2026-03-07T10:00:00.000Z',
|
||||
],
|
||||
);
|
||||
assert.equal(merged.MSFT?.length, 32);
|
||||
assert.equal(merged.MSFT?.[0]?.generatedAt, '2026-04-04T12:00:00.000Z');
|
||||
assert.equal(merged.MSFT?.at(-1)?.generatedAt, '2026-03-04T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('returns the latest snapshot per symbol ordered by recency', () => {
|
||||
const history = {
|
||||
NVDA: [
|
||||
makeSnapshot('NVDA', '2026-03-05T09:00:00.000Z', 71),
|
||||
makeSnapshot('NVDA', '2026-03-04T09:00:00.000Z', 68),
|
||||
],
|
||||
AAPL: [
|
||||
makeSnapshot('AAPL', '2026-03-08T09:00:00.000Z', 74),
|
||||
],
|
||||
MSFT: [
|
||||
makeSnapshot('MSFT', '2026-03-07T09:00:00.000Z', 69),
|
||||
],
|
||||
};
|
||||
|
||||
const latest = getLatestStockAnalysisSnapshots(history, 2);
|
||||
|
||||
assert.equal(latest.length, 2);
|
||||
assert.equal(latest[0]?.symbol, 'AAPL');
|
||||
assert.equal(latest[1]?.symbol, 'MSFT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server-backed stock analysis history', () => {
|
||||
it('stores fresh analysis snapshots in Redis and serves them back in batch', async () => {
|
||||
process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example';
|
||||
process.env.UPSTASH_REDIS_REST_TOKEN = 'token';
|
||||
globalThis.fetch = createRedisAwareFetch();
|
||||
|
||||
const analysis = await analyzeStock({} as never, {
|
||||
symbol: 'AAPL',
|
||||
name: 'Apple',
|
||||
includeNews: true,
|
||||
});
|
||||
|
||||
assert.equal(analysis.available, true);
|
||||
|
||||
const history = await getStockAnalysisHistory({} as never, {
|
||||
symbols: 'AAPL,MSFT' as never,
|
||||
limitPerSymbol: 4,
|
||||
includeNews: true,
|
||||
});
|
||||
|
||||
assert.equal(history.items.length, 1);
|
||||
assert.equal(history.items[0]?.symbol, 'AAPL');
|
||||
assert.equal(history.items[0]?.snapshots.length, 1);
|
||||
assert.equal(history.items[0]?.snapshots[0]?.signal, analysis.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarketServiceClient getStockAnalysisHistory', () => {
|
||||
it('serializes the shared history batch query parameters using generated names', async () => {
|
||||
let requestedUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
requestedUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return new Response(JSON.stringify({ items: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new MarketServiceClient('');
|
||||
await client.getStockAnalysisHistory({
|
||||
symbols: ['AAPL', 'MSFT'],
|
||||
limitPerSymbol: 4,
|
||||
includeNews: true,
|
||||
});
|
||||
|
||||
assert.match(requestedUrl, /\/api\/market\/v1\/get-stock-analysis-history\?/);
|
||||
assert.match(requestedUrl, /symbols=AAPL%2CMSFT|symbols=AAPL,MSFT/);
|
||||
assert.match(requestedUrl, /limit_per_symbol=4/);
|
||||
assert.match(requestedUrl, /include_news=true/);
|
||||
});
|
||||
});
|
||||
115
tests/stock-analysis.test.mts
Normal file
115
tests/stock-analysis.test.mts
Normal file
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import { analyzeStock } from '../server/worldmonitor/market/v1/analyze-stock.ts';
|
||||
import { MarketServiceClient } from '../src/generated/client/worldmonitor/market/v1/service_client.ts';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const mockChartPayload = {
|
||||
chart: {
|
||||
result: [
|
||||
{
|
||||
meta: {
|
||||
currency: 'USD',
|
||||
regularMarketPrice: 132,
|
||||
previousClose: 131,
|
||||
},
|
||||
timestamp: Array.from({ length: 80 }, (_, index) => 1_700_000_000 + (index * 86_400)),
|
||||
indicators: {
|
||||
quote: [
|
||||
{
|
||||
open: Array.from({ length: 80 }, (_, index) => 100 + (index * 0.4)),
|
||||
high: Array.from({ length: 80 }, (_, index) => 101 + (index * 0.4)),
|
||||
low: Array.from({ length: 80 }, (_, index) => 99 + (index * 0.4)),
|
||||
close: Array.from({ length: 80 }, (_, index) => 100 + (index * 0.4)),
|
||||
volume: Array.from({ length: 80 }, (_, index) => 1_000_000 + (index * 5_000)),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockNewsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss>
|
||||
<channel>
|
||||
<item>
|
||||
<title>Apple expands AI chip roadmap</title>
|
||||
<link>https://example.com/apple-ai</link>
|
||||
<pubDate>Sat, 08 Mar 2026 10:00:00 GMT</pubDate>
|
||||
<source>Reuters</source>
|
||||
</item>
|
||||
<item>
|
||||
<title>Apple services growth remains resilient</title>
|
||||
<link>https://example.com/apple-services</link>
|
||||
<pubDate>Sat, 08 Mar 2026 09:00:00 GMT</pubDate>
|
||||
<source>Bloomberg</source>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete process.env.GROQ_API_KEY;
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
delete process.env.OLLAMA_API_URL;
|
||||
delete process.env.OLLAMA_MODEL;
|
||||
});
|
||||
|
||||
describe('analyzeStock handler', () => {
|
||||
it('builds a structured fallback report from Yahoo history and RSS headlines', async () => {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes('query1.finance.yahoo.com')) {
|
||||
return new Response(JSON.stringify(mockChartPayload), { status: 200 });
|
||||
}
|
||||
if (url.includes('news.google.com')) {
|
||||
return new Response(mockNewsXml, { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const response = await analyzeStock({} as never, {
|
||||
symbol: 'AAPL',
|
||||
name: 'Apple',
|
||||
includeNews: true,
|
||||
});
|
||||
|
||||
assert.equal(response.available, true);
|
||||
assert.equal(response.symbol, 'AAPL');
|
||||
assert.equal(response.name, 'Apple');
|
||||
assert.equal(response.currency, 'USD');
|
||||
assert.ok(response.signal.length > 0);
|
||||
assert.ok(response.signalScore > 0);
|
||||
assert.equal(response.provider, 'rules');
|
||||
assert.equal(response.fallback, true);
|
||||
assert.equal(response.newsSearched, true);
|
||||
assert.match(response.analysisId, /^stock:/);
|
||||
assert.ok(response.analysisAt > 0);
|
||||
assert.ok(response.stopLoss > 0);
|
||||
assert.ok(response.takeProfit > 0);
|
||||
assert.equal(response.headlines.length, 2);
|
||||
assert.match(response.summary, /apple/i);
|
||||
assert.ok(response.bullishFactors.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarketServiceClient analyzeStock', () => {
|
||||
it('serializes the analyze-stock query parameters using generated names', async () => {
|
||||
let requestedUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
requestedUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return new Response(JSON.stringify({ available: false }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new MarketServiceClient('');
|
||||
await client.analyzeStock({ symbol: 'MSFT', name: 'Microsoft', includeNews: true });
|
||||
|
||||
assert.match(requestedUrl, /\/api\/market\/v1\/analyze-stock\?/);
|
||||
assert.match(requestedUrl, /symbol=MSFT/);
|
||||
assert.match(requestedUrl, /name=Microsoft/);
|
||||
assert.match(requestedUrl, /include_news=true/);
|
||||
});
|
||||
});
|
||||
266
tests/stock-backtest.test.mts
Normal file
266
tests/stock-backtest.test.mts
Normal file
@@ -0,0 +1,266 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import { backtestStock } from '../server/worldmonitor/market/v1/backtest-stock.ts';
|
||||
import { listStoredStockBacktests } from '../server/worldmonitor/market/v1/list-stored-stock-backtests.ts';
|
||||
import { MarketServiceClient } from '../src/generated/client/worldmonitor/market/v1/service_client.ts';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalRedisUrl = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const originalRedisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
|
||||
function buildReplaySeries(length = 120) {
|
||||
const candles: Array<{
|
||||
timestamp: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}> = [];
|
||||
let price = 100;
|
||||
|
||||
for (let index = 0; index < length; index++) {
|
||||
const drift = 0.28;
|
||||
const pullback = index % 14 >= 10 && index % 14 <= 12 ? -0.35 : 0;
|
||||
const noise = index % 9 === 0 ? 0.12 : index % 11 === 0 ? -0.08 : 0.04;
|
||||
const change = drift + pullback + noise;
|
||||
const open = price;
|
||||
price = Math.max(20, price + change);
|
||||
const close = price;
|
||||
const high = Math.max(open, close) + 0.7;
|
||||
const low = Math.min(open, close) - 0.6;
|
||||
const volume = index % 14 >= 10 && index % 14 <= 12 ? 780_000 : 1_120_000;
|
||||
candles.push({
|
||||
timestamp: 1_700_000_000 + (index * 86_400),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume,
|
||||
});
|
||||
}
|
||||
|
||||
return candles;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalRedisUrl == null) delete process.env.UPSTASH_REDIS_REST_URL;
|
||||
else process.env.UPSTASH_REDIS_REST_URL = originalRedisUrl;
|
||||
if (originalRedisToken == null) delete process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
else process.env.UPSTASH_REDIS_REST_TOKEN = originalRedisToken;
|
||||
});
|
||||
|
||||
function createRedisAwareBacktestFetch(mockChartPayload: unknown) {
|
||||
const redis = new Map<string, string>();
|
||||
const sortedSets = new Map<string, Array<{ member: string; score: number }>>();
|
||||
|
||||
const upsertSortedSet = (key: string, score: number, member: string) => {
|
||||
const next = (sortedSets.get(key) ?? []).filter((item) => item.member !== member);
|
||||
next.push({ member, score });
|
||||
next.sort((a, b) => a.score - b.score || a.member.localeCompare(b.member));
|
||||
sortedSets.set(key, next);
|
||||
};
|
||||
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.includes('query1.finance.yahoo.com')) {
|
||||
return new Response(JSON.stringify(mockChartPayload), { status: 200 });
|
||||
}
|
||||
|
||||
if (url.startsWith(process.env.UPSTASH_REDIS_REST_URL || '')) {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname.startsWith('/get/')) {
|
||||
const key = decodeURIComponent(parsed.pathname.slice('/get/'.length));
|
||||
return new Response(JSON.stringify({ result: redis.get(key) ?? null }), { status: 200 });
|
||||
}
|
||||
if (parsed.pathname.startsWith('/set/')) {
|
||||
const parts = parsed.pathname.split('/');
|
||||
const key = decodeURIComponent(parts[2] || '');
|
||||
const value = decodeURIComponent(parts[3] || '');
|
||||
redis.set(key, value);
|
||||
return new Response(JSON.stringify({ result: 'OK' }), { status: 200 });
|
||||
}
|
||||
if (parsed.pathname === '/pipeline') {
|
||||
const commands = JSON.parse(typeof init?.body === 'string' ? init.body : '[]') as string[][];
|
||||
const result = commands.map((command) => {
|
||||
const [verb, key = '', ...args] = command;
|
||||
if (verb === 'GET') {
|
||||
return { result: redis.get(key) ?? null };
|
||||
}
|
||||
if (verb === 'SET') {
|
||||
redis.set(key, args[0] || '');
|
||||
return { result: 'OK' };
|
||||
}
|
||||
if (verb === 'ZADD') {
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
upsertSortedSet(key, Number(args[index] || 0), args[index + 1] || '');
|
||||
}
|
||||
return { result: 1 };
|
||||
}
|
||||
if (verb === 'ZREVRANGE') {
|
||||
const items = [...(sortedSets.get(key) ?? [])].sort((a, b) => b.score - a.score || a.member.localeCompare(b.member));
|
||||
const start = Number(args[0] || 0);
|
||||
const stop = Number(args[1] || 0);
|
||||
return { result: items.slice(start, stop + 1).map((item) => item.member) };
|
||||
}
|
||||
if (verb === 'ZREM') {
|
||||
const removals = new Set(args);
|
||||
sortedSets.set(key, (sortedSets.get(key) ?? []).filter((item) => !removals.has(item.member)));
|
||||
return { result: removals.size };
|
||||
}
|
||||
if (verb === 'EXPIRE') {
|
||||
return { result: 1 };
|
||||
}
|
||||
throw new Error(`Unexpected pipeline command: ${verb}`);
|
||||
});
|
||||
return new Response(JSON.stringify(result), { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
describe('backtestStock handler', () => {
|
||||
it('replays actionable stock-analysis signals over recent Yahoo history', async () => {
|
||||
const candles = buildReplaySeries();
|
||||
const mockChartPayload = {
|
||||
chart: {
|
||||
result: [
|
||||
{
|
||||
meta: {
|
||||
currency: 'USD',
|
||||
regularMarketPrice: 148,
|
||||
previousClose: 147,
|
||||
},
|
||||
timestamp: candles.map((candle) => candle.timestamp),
|
||||
indicators: {
|
||||
quote: [
|
||||
{
|
||||
open: candles.map((candle) => candle.open),
|
||||
high: candles.map((candle) => candle.high),
|
||||
low: candles.map((candle) => candle.low),
|
||||
close: candles.map((candle) => candle.close),
|
||||
volume: candles.map((candle) => candle.volume),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes('query1.finance.yahoo.com')) {
|
||||
return new Response(JSON.stringify(mockChartPayload), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const response = await backtestStock({} as never, {
|
||||
symbol: 'AAPL',
|
||||
name: 'Apple',
|
||||
evalWindowDays: 10,
|
||||
});
|
||||
|
||||
assert.equal(response.available, true);
|
||||
assert.equal(response.symbol, 'AAPL');
|
||||
assert.equal(response.currency, 'USD');
|
||||
assert.ok(response.actionableEvaluations > 0);
|
||||
assert.ok(response.evaluations.length > 0);
|
||||
assert.match(response.evaluations[0]?.analysisId || '', /^ledger:/);
|
||||
assert.match(response.latestSignal, /buy/i);
|
||||
assert.match(response.summary, /stored analysis/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server-backed stored stock backtests', () => {
|
||||
it('stores fresh backtests in Redis and serves them back in batch', async () => {
|
||||
const candles = buildReplaySeries();
|
||||
const mockChartPayload = {
|
||||
chart: {
|
||||
result: [
|
||||
{
|
||||
meta: {
|
||||
currency: 'USD',
|
||||
regularMarketPrice: 148,
|
||||
previousClose: 147,
|
||||
},
|
||||
timestamp: candles.map((candle) => candle.timestamp),
|
||||
indicators: {
|
||||
quote: [
|
||||
{
|
||||
open: candles.map((candle) => candle.open),
|
||||
high: candles.map((candle) => candle.high),
|
||||
low: candles.map((candle) => candle.low),
|
||||
close: candles.map((candle) => candle.close),
|
||||
volume: candles.map((candle) => candle.volume),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example';
|
||||
process.env.UPSTASH_REDIS_REST_TOKEN = 'token';
|
||||
globalThis.fetch = createRedisAwareBacktestFetch(mockChartPayload);
|
||||
|
||||
const response = await backtestStock({} as never, {
|
||||
symbol: 'AAPL',
|
||||
name: 'Apple',
|
||||
evalWindowDays: 10,
|
||||
});
|
||||
|
||||
assert.equal(response.available, true);
|
||||
|
||||
const stored = await listStoredStockBacktests({} as never, {
|
||||
symbols: 'AAPL,MSFT' as never,
|
||||
evalWindowDays: 10,
|
||||
});
|
||||
|
||||
assert.equal(stored.items.length, 1);
|
||||
assert.equal(stored.items[0]?.symbol, 'AAPL');
|
||||
assert.equal(stored.items[0]?.latestSignal, response.latestSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarketServiceClient backtestStock', () => {
|
||||
it('serializes the backtest-stock query parameters using generated names', async () => {
|
||||
let requestedUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
requestedUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return new Response(JSON.stringify({ available: false, evaluations: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new MarketServiceClient('');
|
||||
await client.backtestStock({ symbol: 'MSFT', name: 'Microsoft', evalWindowDays: 7 });
|
||||
|
||||
assert.match(requestedUrl, /\/api\/market\/v1\/backtest-stock\?/);
|
||||
assert.match(requestedUrl, /symbol=MSFT/);
|
||||
assert.match(requestedUrl, /name=Microsoft/);
|
||||
assert.match(requestedUrl, /eval_window_days=7/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarketServiceClient listStoredStockBacktests', () => {
|
||||
it('serializes the stored backtest batch query parameters using generated names', async () => {
|
||||
let requestedUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
requestedUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return new Response(JSON.stringify({ items: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new MarketServiceClient('');
|
||||
await client.listStoredStockBacktests({ symbols: ['MSFT', 'NVDA'], evalWindowDays: 7 });
|
||||
|
||||
assert.match(requestedUrl, /\/api\/market\/v1\/list-stored-stock-backtests\?/);
|
||||
assert.match(requestedUrl, /symbols=MSFT%2CNVDA|symbols=MSFT,NVDA/);
|
||||
assert.match(requestedUrl, /eval_window_days=7/);
|
||||
});
|
||||
});
|
||||
148
tests/stock-news-search.test.mts
Normal file
148
tests/stock-news-search.test.mts
Normal file
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
buildStockNewsSearchQuery,
|
||||
resetStockNewsSearchStateForTests,
|
||||
searchRecentStockHeadlines,
|
||||
} from '../server/worldmonitor/market/v1/stock-news-search.ts';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete process.env.TAVILY_API_KEYS;
|
||||
delete process.env.BRAVE_API_KEYS;
|
||||
delete process.env.SERPAPI_API_KEYS;
|
||||
resetStockNewsSearchStateForTests();
|
||||
});
|
||||
|
||||
describe('stock news search query', () => {
|
||||
it('builds the same stock-news style query used by the source project', () => {
|
||||
assert.equal(buildStockNewsSearchQuery('aapl', 'Apple'), 'Apple AAPL stock latest news');
|
||||
assert.equal(buildStockNewsSearchQuery(' msft ', ''), 'MSFT stock latest news');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchRecentStockHeadlines', () => {
|
||||
it('uses Tavily first when configured', async () => {
|
||||
process.env.TAVILY_API_KEYS = 'tavily-key-1';
|
||||
const requested: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
requested.push(url);
|
||||
if (url === 'https://api.tavily.com/search') {
|
||||
return new Response(JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
title: 'Apple expands buyback after strong quarter',
|
||||
url: 'https://example.com/apple-buyback',
|
||||
published_date: '2026-03-08T12:00:00Z',
|
||||
source: 'Reuters',
|
||||
},
|
||||
],
|
||||
}), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await searchRecentStockHeadlines('AAPL', 'Apple', 5);
|
||||
|
||||
assert.equal(result.provider, 'tavily');
|
||||
assert.equal(result.headlines.length, 1);
|
||||
assert.equal(result.headlines[0]?.source, 'Reuters');
|
||||
assert.deepEqual(requested, ['https://api.tavily.com/search']);
|
||||
});
|
||||
|
||||
it('falls back from Tavily to Brave before using RSS', async () => {
|
||||
process.env.TAVILY_API_KEYS = 'tavily-key-1';
|
||||
process.env.BRAVE_API_KEYS = 'brave-key-1';
|
||||
const requested: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
requested.push(url);
|
||||
if (url === 'https://api.tavily.com/search') {
|
||||
return new Response(JSON.stringify({ error: 'rate limit' }), { status: 429 });
|
||||
}
|
||||
if (url.startsWith('https://api.search.brave.com/res/v1/web/search?')) {
|
||||
return new Response(JSON.stringify({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: 'Apple supply chain normalizes',
|
||||
url: 'https://example.com/apple-supply-chain',
|
||||
description: 'Supply chain pressure eases for Apple.',
|
||||
age: '2 hours ago',
|
||||
},
|
||||
],
|
||||
},
|
||||
}), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await searchRecentStockHeadlines('AAPL', 'Apple', 5);
|
||||
|
||||
assert.equal(result.provider, 'brave');
|
||||
assert.equal(result.headlines.length, 1);
|
||||
assert.equal(result.headlines[0]?.link, 'https://example.com/apple-supply-chain');
|
||||
assert.equal(requested.length, 2);
|
||||
assert.equal(requested[0], 'https://api.tavily.com/search');
|
||||
assert.match(requested[1] || '', /^https:\/\/api\.search\.brave\.com\/res\/v1\/web\/search\?/);
|
||||
});
|
||||
|
||||
it('falls back to Google News RSS when provider keys are unavailable', async () => {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.startsWith('https://news.google.com/rss/search?')) {
|
||||
return new Response(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss>
|
||||
<channel>
|
||||
<item>
|
||||
<title>Apple launches new enterprise AI bundle</title>
|
||||
<link>https://example.com/apple-ai-bundle</link>
|
||||
<pubDate>Sun, 08 Mar 2026 10:00:00 GMT</pubDate>
|
||||
<source>Bloomberg</source>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`, { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await searchRecentStockHeadlines('AAPL', 'Apple', 5);
|
||||
|
||||
assert.equal(result.provider, 'google-news-rss');
|
||||
assert.equal(result.headlines.length, 1);
|
||||
assert.equal(result.headlines[0]?.source, 'Bloomberg');
|
||||
});
|
||||
|
||||
it('parses SerpAPI news results when it is the first available provider', async () => {
|
||||
process.env.SERPAPI_API_KEYS = 'serp-key-1';
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.startsWith('https://serpapi.com/search.json?')) {
|
||||
return new Response(JSON.stringify({
|
||||
news_results: [
|
||||
{
|
||||
title: 'Apple opens new AI engineering hub',
|
||||
link: 'https://example.com/apple-ai-hub',
|
||||
source: 'CNBC',
|
||||
date: '3 hours ago',
|
||||
},
|
||||
],
|
||||
}), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await searchRecentStockHeadlines('AAPL', 'Apple', 5);
|
||||
|
||||
assert.equal(result.provider, 'serpapi');
|
||||
assert.equal(result.headlines.length, 1);
|
||||
assert.equal(result.headlines[0]?.source, 'CNBC');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user