@kernel.chat/kbot 3.69.1 → 3.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/a2a.d.ts +50 -5
- package/dist/a2a.js +305 -44
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +43 -4
- package/dist/doctor.js +53 -7
- package/dist/machine.d.ts +1 -0
- package/dist/machine.js +17 -1
- package/dist/serve.js +3 -2
- package/dist/tools/a2a.d.ts +2 -0
- package/dist/tools/a2a.js +233 -0
- package/dist/tools/ai-analysis.d.ts +2 -0
- package/dist/tools/ai-analysis.js +677 -0
- package/dist/tools/financial-analysis.d.ts +2 -0
- package/dist/tools/financial-analysis.js +945 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/music-gen.d.ts +2 -0
- package/dist/tools/music-gen.js +1006 -0
- package/dist/tools/threat-intel.d.ts +2 -0
- package/dist/tools/threat-intel.js +1619 -0
- package/package.json +2 -2
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
// kbot Financial Analysis Pipeline — Multi-agent coordinated market intelligence
|
|
2
|
+
// Inspired by TradingAgents (multi-perspective analysis with specialist roles).
|
|
3
|
+
// Uses free APIs: Yahoo Finance, CoinGecko, DuckDuckGo, Google News RSS.
|
|
4
|
+
// No premium data feeds required — all real-time data via web search + public APIs.
|
|
5
|
+
//
|
|
6
|
+
// Three tools:
|
|
7
|
+
// 1. market_analysis — Deep multi-perspective analysis of a single ticker/topic
|
|
8
|
+
// 2. portfolio_review — Portfolio-wide risk, allocation, and rebalancing analysis
|
|
9
|
+
// 3. market_briefing — Quick morning market summary with index moves + news
|
|
10
|
+
import { registerTool } from './index.js';
|
|
11
|
+
// ── Shared Helpers ──
|
|
12
|
+
async function fetchJSON(url, timeout = 12_000) {
|
|
13
|
+
const res = await fetch(url, {
|
|
14
|
+
headers: { 'User-Agent': 'KBot/3.61 (Financial Analysis)' },
|
|
15
|
+
signal: AbortSignal.timeout(timeout),
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
19
|
+
return res.json();
|
|
20
|
+
}
|
|
21
|
+
async function fetchText(url, timeout = 10_000) {
|
|
22
|
+
const res = await fetch(url, {
|
|
23
|
+
headers: { 'User-Agent': 'KBot/3.61 (Financial Analysis)' },
|
|
24
|
+
signal: AbortSignal.timeout(timeout),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
throw new Error(`HTTP ${res.status}`);
|
|
28
|
+
return res.text();
|
|
29
|
+
}
|
|
30
|
+
function fmt(n, decimals = 2) {
|
|
31
|
+
return n.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
32
|
+
}
|
|
33
|
+
function pct(n) {
|
|
34
|
+
return `${n >= 0 ? '+' : ''}${fmt(n)}%`;
|
|
35
|
+
}
|
|
36
|
+
// ── Technical Analysis Primitives ──
|
|
37
|
+
function sma(data, period) {
|
|
38
|
+
const result = [];
|
|
39
|
+
for (let i = period - 1; i < data.length; i++) {
|
|
40
|
+
const slice = data.slice(i - period + 1, i + 1);
|
|
41
|
+
result.push(slice.reduce((a, b) => a + b, 0) / period);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
function ema(data, period) {
|
|
46
|
+
if (!data.length)
|
|
47
|
+
return [];
|
|
48
|
+
const k = 2 / (period + 1);
|
|
49
|
+
const result = [data[0]];
|
|
50
|
+
for (let i = 1; i < data.length; i++) {
|
|
51
|
+
result.push(data[i] * k + result[i - 1] * (1 - k));
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
function rsi(closes, period = 14) {
|
|
56
|
+
const gains = [];
|
|
57
|
+
const losses = [];
|
|
58
|
+
for (let i = 1; i < closes.length; i++) {
|
|
59
|
+
const diff = closes[i] - closes[i - 1];
|
|
60
|
+
gains.push(diff > 0 ? diff : 0);
|
|
61
|
+
losses.push(diff < 0 ? -diff : 0);
|
|
62
|
+
}
|
|
63
|
+
const result = [];
|
|
64
|
+
let avgGain = gains.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
|
65
|
+
let avgLoss = losses.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
|
66
|
+
for (let i = period; i < gains.length; i++) {
|
|
67
|
+
avgGain = (avgGain * (period - 1) + gains[i]) / period;
|
|
68
|
+
avgLoss = (avgLoss * (period - 1) + losses[i]) / period;
|
|
69
|
+
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
|
70
|
+
result.push(100 - 100 / (1 + rs));
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
function standardDeviation(data) {
|
|
75
|
+
if (data.length < 2)
|
|
76
|
+
return 0;
|
|
77
|
+
const mean = data.reduce((a, b) => a + b, 0) / data.length;
|
|
78
|
+
const variance = data.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (data.length - 1);
|
|
79
|
+
return Math.sqrt(variance);
|
|
80
|
+
}
|
|
81
|
+
function dailyReturns(closes) {
|
|
82
|
+
const returns = [];
|
|
83
|
+
for (let i = 1; i < closes.length; i++) {
|
|
84
|
+
returns.push((closes[i] - closes[i - 1]) / closes[i - 1]);
|
|
85
|
+
}
|
|
86
|
+
return returns;
|
|
87
|
+
}
|
|
88
|
+
function annualizedVolatility(closes) {
|
|
89
|
+
const returns = dailyReturns(closes);
|
|
90
|
+
if (returns.length < 2)
|
|
91
|
+
return 0;
|
|
92
|
+
return standardDeviation(returns) * Math.sqrt(252) * 100; // annualized, as %
|
|
93
|
+
}
|
|
94
|
+
function maxDrawdown(closes) {
|
|
95
|
+
let peak = closes[0];
|
|
96
|
+
let maxDd = 0;
|
|
97
|
+
for (const price of closes) {
|
|
98
|
+
if (price > peak)
|
|
99
|
+
peak = price;
|
|
100
|
+
const dd = (peak - price) / peak;
|
|
101
|
+
if (dd > maxDd)
|
|
102
|
+
maxDd = dd;
|
|
103
|
+
}
|
|
104
|
+
return maxDd * 100; // as %
|
|
105
|
+
}
|
|
106
|
+
function sharpeProxy(closes, riskFreeRate = 0.05) {
|
|
107
|
+
const returns = dailyReturns(closes);
|
|
108
|
+
if (returns.length < 10)
|
|
109
|
+
return 0;
|
|
110
|
+
const avgReturn = returns.reduce((a, b) => a + b, 0) / returns.length;
|
|
111
|
+
const annualReturn = avgReturn * 252;
|
|
112
|
+
const vol = standardDeviation(returns) * Math.sqrt(252);
|
|
113
|
+
if (vol === 0)
|
|
114
|
+
return 0;
|
|
115
|
+
return (annualReturn - riskFreeRate) / vol;
|
|
116
|
+
}
|
|
117
|
+
// ── Yahoo Finance Helpers ──
|
|
118
|
+
async function yahooQuote(symbol) {
|
|
119
|
+
try {
|
|
120
|
+
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=5d`;
|
|
121
|
+
const data = await fetchJSON(url);
|
|
122
|
+
const result = data.chart?.result?.[0];
|
|
123
|
+
return result || null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function yahooHistory(symbol, range, interval) {
|
|
130
|
+
try {
|
|
131
|
+
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=${interval}&range=${range}`;
|
|
132
|
+
const data = await fetchJSON(url);
|
|
133
|
+
return data.chart?.result?.[0] || null;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function extractCloses(result) {
|
|
140
|
+
const quotes = result.indicators?.quote?.[0];
|
|
141
|
+
if (!quotes?.close)
|
|
142
|
+
return [];
|
|
143
|
+
return quotes.close.filter((c) => c != null);
|
|
144
|
+
}
|
|
145
|
+
function extractVolumes(result) {
|
|
146
|
+
const quotes = result.indicators?.quote?.[0];
|
|
147
|
+
if (!quotes?.volume)
|
|
148
|
+
return [];
|
|
149
|
+
return quotes.volume.filter((v) => v != null);
|
|
150
|
+
}
|
|
151
|
+
// ── News Search Helper ──
|
|
152
|
+
async function searchNews(query, maxResults = 5) {
|
|
153
|
+
const results = [];
|
|
154
|
+
// DuckDuckGo instant answers
|
|
155
|
+
try {
|
|
156
|
+
const encoded = encodeURIComponent(query);
|
|
157
|
+
const data = await fetchJSON(`https://api.duckduckgo.com/?q=${encoded}&format=json&no_html=1&skip_disambig=1`, 8_000);
|
|
158
|
+
if (data.AbstractText) {
|
|
159
|
+
results.push(String(data.AbstractText).slice(0, 300));
|
|
160
|
+
}
|
|
161
|
+
const topics = data.RelatedTopics;
|
|
162
|
+
if (topics?.length) {
|
|
163
|
+
for (const topic of topics.slice(0, 3)) {
|
|
164
|
+
if (topic.Text)
|
|
165
|
+
results.push(topic.Text.slice(0, 200));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch { /* continue */ }
|
|
170
|
+
// Google News RSS feed for financial news
|
|
171
|
+
try {
|
|
172
|
+
const encoded = encodeURIComponent(query);
|
|
173
|
+
const xml = await fetchText(`https://news.google.com/rss/search?q=${encoded}&hl=en-US&gl=US&ceid=US:en`, 8_000);
|
|
174
|
+
const titleMatches = xml.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/g) || [];
|
|
175
|
+
for (const match of titleMatches.slice(1, maxResults + 1)) { // skip feed title
|
|
176
|
+
const headline = match.replace(/<title><!\[CDATA\[/, '').replace(/\]\]><\/title>/, '');
|
|
177
|
+
if (headline)
|
|
178
|
+
results.push(headline);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch { /* continue */ }
|
|
182
|
+
return results.slice(0, maxResults);
|
|
183
|
+
}
|
|
184
|
+
// ── Reddit Sentiment Helper ──
|
|
185
|
+
async function redditSentiment(query) {
|
|
186
|
+
const BULLISH = new Set([
|
|
187
|
+
'bullish', 'moon', 'pump', 'buy', 'long', 'breakout', 'rally', 'surge',
|
|
188
|
+
'gains', 'undervalued', 'uptrend', 'bounce', 'recovery', 'soaring',
|
|
189
|
+
'fomo', 'accumulate', 'hodl', 'diamond',
|
|
190
|
+
]);
|
|
191
|
+
const BEARISH = new Set([
|
|
192
|
+
'bearish', 'crash', 'dump', 'sell', 'short', 'breakdown', 'plunge', 'tank',
|
|
193
|
+
'overvalued', 'bubble', 'correction', 'decline', 'fear', 'panic',
|
|
194
|
+
'liquidation', 'scam', 'fraud',
|
|
195
|
+
]);
|
|
196
|
+
let allText = '';
|
|
197
|
+
let postCount = 0;
|
|
198
|
+
const subreddits = ['wallstreetbets', 'stocks', 'investing'];
|
|
199
|
+
for (const sub of subreddits) {
|
|
200
|
+
try {
|
|
201
|
+
const data = await fetchJSON(`https://www.reddit.com/r/${sub}/search.json?q=${encodeURIComponent(query)}&restrict_sr=on&sort=new&limit=10&t=week`, 8_000);
|
|
202
|
+
const posts = data.data?.children || [];
|
|
203
|
+
for (const post of posts) {
|
|
204
|
+
const title = post.data?.title || '';
|
|
205
|
+
const selftext = post.data?.selftext || '';
|
|
206
|
+
allText += ` ${title} ${selftext}`;
|
|
207
|
+
postCount++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch { /* continue */ }
|
|
211
|
+
}
|
|
212
|
+
const words = allText.toLowerCase().split(/\W+/);
|
|
213
|
+
let bullish = 0;
|
|
214
|
+
let bearish = 0;
|
|
215
|
+
for (const w of words) {
|
|
216
|
+
if (BULLISH.has(w))
|
|
217
|
+
bullish++;
|
|
218
|
+
if (BEARISH.has(w))
|
|
219
|
+
bearish++;
|
|
220
|
+
}
|
|
221
|
+
const total = bullish + bearish;
|
|
222
|
+
const score = total === 0 ? 0 : (bullish - bearish) / total;
|
|
223
|
+
const label = score > 0.2 ? 'BULLISH' : score < -0.2 ? 'BEARISH' : 'NEUTRAL';
|
|
224
|
+
const summary = total === 0
|
|
225
|
+
? 'No significant sentiment signals found'
|
|
226
|
+
: `${bullish} bullish vs ${bearish} bearish signals across ${postCount} posts`;
|
|
227
|
+
return { score, label, posts: postCount, summary };
|
|
228
|
+
}
|
|
229
|
+
// ── Sector Classification ──
|
|
230
|
+
const SECTOR_MAP = {
|
|
231
|
+
// Tech
|
|
232
|
+
AAPL: { sector: 'Technology', geography: 'US', divYield: 0.5 },
|
|
233
|
+
MSFT: { sector: 'Technology', geography: 'US', divYield: 0.7 },
|
|
234
|
+
GOOGL: { sector: 'Technology', geography: 'US' },
|
|
235
|
+
GOOG: { sector: 'Technology', geography: 'US' },
|
|
236
|
+
META: { sector: 'Technology', geography: 'US', divYield: 0.4 },
|
|
237
|
+
AMZN: { sector: 'Technology', geography: 'US' },
|
|
238
|
+
NVDA: { sector: 'Technology', geography: 'US', divYield: 0.03 },
|
|
239
|
+
TSM: { sector: 'Technology', geography: 'Taiwan', divYield: 1.5 },
|
|
240
|
+
AVGO: { sector: 'Technology', geography: 'US', divYield: 1.3 },
|
|
241
|
+
AMD: { sector: 'Technology', geography: 'US' },
|
|
242
|
+
CRM: { sector: 'Technology', geography: 'US' },
|
|
243
|
+
ORCL: { sector: 'Technology', geography: 'US', divYield: 1.2 },
|
|
244
|
+
INTC: { sector: 'Technology', geography: 'US', divYield: 1.5 },
|
|
245
|
+
// Finance
|
|
246
|
+
JPM: { sector: 'Finance', geography: 'US', divYield: 2.3 },
|
|
247
|
+
BAC: { sector: 'Finance', geography: 'US', divYield: 2.5 },
|
|
248
|
+
WFC: { sector: 'Finance', geography: 'US', divYield: 2.8 },
|
|
249
|
+
GS: { sector: 'Finance', geography: 'US', divYield: 2.5 },
|
|
250
|
+
V: { sector: 'Finance', geography: 'US', divYield: 0.7 },
|
|
251
|
+
MA: { sector: 'Finance', geography: 'US', divYield: 0.5 },
|
|
252
|
+
BLK: { sector: 'Finance', geography: 'US', divYield: 2.2 },
|
|
253
|
+
// Healthcare
|
|
254
|
+
UNH: { sector: 'Healthcare', geography: 'US', divYield: 1.4 },
|
|
255
|
+
JNJ: { sector: 'Healthcare', geography: 'US', divYield: 3.0 },
|
|
256
|
+
LLY: { sector: 'Healthcare', geography: 'US', divYield: 0.7 },
|
|
257
|
+
PFE: { sector: 'Healthcare', geography: 'US', divYield: 5.5 },
|
|
258
|
+
ABBV: { sector: 'Healthcare', geography: 'US', divYield: 3.5 },
|
|
259
|
+
MRK: { sector: 'Healthcare', geography: 'US', divYield: 2.5 },
|
|
260
|
+
// Energy
|
|
261
|
+
XOM: { sector: 'Energy', geography: 'US', divYield: 3.3 },
|
|
262
|
+
CVX: { sector: 'Energy', geography: 'US', divYield: 4.0 },
|
|
263
|
+
COP: { sector: 'Energy', geography: 'US', divYield: 2.8 },
|
|
264
|
+
// Consumer
|
|
265
|
+
PG: { sector: 'Consumer Staples', geography: 'US', divYield: 2.4 },
|
|
266
|
+
KO: { sector: 'Consumer Staples', geography: 'US', divYield: 3.0 },
|
|
267
|
+
PEP: { sector: 'Consumer Staples', geography: 'US', divYield: 2.7 },
|
|
268
|
+
MCD: { sector: 'Consumer Staples', geography: 'US', divYield: 2.2 },
|
|
269
|
+
WMT: { sector: 'Consumer Staples', geography: 'US', divYield: 1.4 },
|
|
270
|
+
COST: { sector: 'Consumer Staples', geography: 'US', divYield: 0.6 },
|
|
271
|
+
TSLA: { sector: 'Consumer Discretionary', geography: 'US' },
|
|
272
|
+
// Industrial
|
|
273
|
+
BA: { sector: 'Industrials', geography: 'US' },
|
|
274
|
+
CAT: { sector: 'Industrials', geography: 'US', divYield: 1.6 },
|
|
275
|
+
LMT: { sector: 'Industrials', geography: 'US', divYield: 2.7 },
|
|
276
|
+
GE: { sector: 'Industrials', geography: 'US', divYield: 0.6 },
|
|
277
|
+
// Real Estate / REITs
|
|
278
|
+
O: { sector: 'Real Estate', geography: 'US', divYield: 5.5 },
|
|
279
|
+
AMT: { sector: 'Real Estate', geography: 'US', divYield: 3.0 },
|
|
280
|
+
// Utilities
|
|
281
|
+
NEE: { sector: 'Utilities', geography: 'US', divYield: 2.8 },
|
|
282
|
+
DUK: { sector: 'Utilities', geography: 'US', divYield: 3.8 },
|
|
283
|
+
// Telecom
|
|
284
|
+
VZ: { sector: 'Telecom', geography: 'US', divYield: 6.5 },
|
|
285
|
+
T: { sector: 'Telecom', geography: 'US', divYield: 6.0 },
|
|
286
|
+
// Materials
|
|
287
|
+
LIN: { sector: 'Materials', geography: 'US', divYield: 1.2 },
|
|
288
|
+
// ETFs — broad
|
|
289
|
+
SPY: { sector: 'ETF - US Equity', geography: 'US', divYield: 1.3 },
|
|
290
|
+
QQQ: { sector: 'ETF - Tech', geography: 'US', divYield: 0.6 },
|
|
291
|
+
IWM: { sector: 'ETF - Small Cap', geography: 'US', divYield: 1.2 },
|
|
292
|
+
DIA: { sector: 'ETF - Dow Jones', geography: 'US', divYield: 1.6 },
|
|
293
|
+
VTI: { sector: 'ETF - Total Market', geography: 'US', divYield: 1.3 },
|
|
294
|
+
VOO: { sector: 'ETF - S&P 500', geography: 'US', divYield: 1.3 },
|
|
295
|
+
VEA: { sector: 'ETF - Intl Developed', geography: 'International', divYield: 3.1 },
|
|
296
|
+
VWO: { sector: 'ETF - Emerging Markets', geography: 'Emerging Markets', divYield: 3.4 },
|
|
297
|
+
BND: { sector: 'ETF - Bonds', geography: 'US', divYield: 3.5 },
|
|
298
|
+
AGG: { sector: 'ETF - Bonds', geography: 'US', divYield: 3.4 },
|
|
299
|
+
GLD: { sector: 'ETF - Gold', geography: 'Global' },
|
|
300
|
+
TLT: { sector: 'ETF - Long Treasuries', geography: 'US', divYield: 3.8 },
|
|
301
|
+
ARKK: { sector: 'ETF - Innovation', geography: 'US' },
|
|
302
|
+
XLE: { sector: 'ETF - Energy', geography: 'US', divYield: 3.5 },
|
|
303
|
+
XLF: { sector: 'ETF - Financials', geography: 'US', divYield: 1.7 },
|
|
304
|
+
XLK: { sector: 'ETF - Technology', geography: 'US', divYield: 0.7 },
|
|
305
|
+
XLV: { sector: 'ETF - Healthcare', geography: 'US', divYield: 1.5 },
|
|
306
|
+
SCHD: { sector: 'ETF - Dividend Growth', geography: 'US', divYield: 3.5 },
|
|
307
|
+
};
|
|
308
|
+
function lookupSector(ticker) {
|
|
309
|
+
const info = SECTOR_MAP[ticker.toUpperCase()];
|
|
310
|
+
if (info)
|
|
311
|
+
return { sector: info.sector, geography: info.geography, divYield: info.divYield ?? 0 };
|
|
312
|
+
// Fallback heuristic
|
|
313
|
+
if (ticker.startsWith('^'))
|
|
314
|
+
return { sector: 'Index', geography: 'US', divYield: 0 };
|
|
315
|
+
return { sector: 'Unknown', geography: 'Unknown', divYield: 0 };
|
|
316
|
+
}
|
|
317
|
+
// ── MAGI (Modified Adjusted Gross Income) Estimator ──
|
|
318
|
+
// Important for ACA (Affordable Care Act) subsidy calculations.
|
|
319
|
+
// MAGI includes: wages, interest, dividends, capital gains, rental income, etc.
|
|
320
|
+
function estimateMAGIImpact(holdings) {
|
|
321
|
+
let totalDividends = 0;
|
|
322
|
+
const notes = [];
|
|
323
|
+
for (const h of holdings) {
|
|
324
|
+
const info = lookupSector(h.ticker);
|
|
325
|
+
const divIncome = h.totalValue * (info.divYield / 100);
|
|
326
|
+
totalDividends += divIncome;
|
|
327
|
+
if (info.divYield > 3) {
|
|
328
|
+
notes.push(`${h.ticker}: High dividend yield (${fmt(info.divYield)}%) adds ~$${fmt(divIncome, 0)} to MAGI`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (totalDividends > 0) {
|
|
332
|
+
notes.push(`Total estimated dividend income: $${fmt(totalDividends, 0)}/year`);
|
|
333
|
+
notes.push('Note: Qualified dividends are taxed at capital gains rates, but ALL dividends count toward MAGI');
|
|
334
|
+
notes.push('ACA subsidy cliff: MAGI > 400% FPL eliminates premium tax credits');
|
|
335
|
+
}
|
|
336
|
+
return { estimatedDividends: totalDividends, notes };
|
|
337
|
+
}
|
|
338
|
+
// ════════════════════════════════════════
|
|
339
|
+
// TOOL 1: market_analysis
|
|
340
|
+
// ════════════════════════════════════════
|
|
341
|
+
async function runMarketAnalysis(ticker) {
|
|
342
|
+
const symbol = ticker.toUpperCase();
|
|
343
|
+
const lines = [`# Market Analysis: ${symbol}`, '', `*Generated ${new Date().toISOString().split('T')[0]} by kbot Financial Analysis Pipeline*`, ''];
|
|
344
|
+
// ── Agent 1: Fundamentals ──
|
|
345
|
+
lines.push('## 1. Fundamentals');
|
|
346
|
+
const quoteData = await yahooQuote(symbol);
|
|
347
|
+
if (quoteData) {
|
|
348
|
+
const meta = quoteData.meta || {};
|
|
349
|
+
const price = meta.regularMarketPrice ?? 0;
|
|
350
|
+
const prevClose = meta.chartPreviousClose ?? meta.previousClose ?? price;
|
|
351
|
+
const change = price - prevClose;
|
|
352
|
+
const changePct = prevClose ? (change / prevClose) * 100 : 0;
|
|
353
|
+
lines.push(`- **Price**: $${fmt(price)}`, `- **Change**: ${pct(changePct)} ($${fmt(Math.abs(change))})`, `- **Exchange**: ${meta.exchangeName || 'N/A'}`, `- **Currency**: ${meta.currency || 'USD'}`);
|
|
354
|
+
const sectorInfo = lookupSector(symbol);
|
|
355
|
+
if (sectorInfo.sector !== 'Unknown') {
|
|
356
|
+
lines.push(`- **Sector**: ${sectorInfo.sector}`);
|
|
357
|
+
if (sectorInfo.divYield > 0)
|
|
358
|
+
lines.push(`- **Est. Dividend Yield**: ${fmt(sectorInfo.divYield)}%`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
lines.push('*Could not fetch fundamental data. Ticker may be invalid or API unavailable.*');
|
|
363
|
+
}
|
|
364
|
+
lines.push('');
|
|
365
|
+
// ── Agent 2: Technical Analysis ──
|
|
366
|
+
lines.push('## 2. Technical Analysis');
|
|
367
|
+
const histData = await yahooHistory(symbol, '6mo', '1d');
|
|
368
|
+
const closes = histData ? extractCloses(histData) : [];
|
|
369
|
+
const volumes = histData ? extractVolumes(histData) : [];
|
|
370
|
+
if (closes.length >= 30) {
|
|
371
|
+
const current = closes[closes.length - 1];
|
|
372
|
+
// Moving averages
|
|
373
|
+
const sma20vals = sma(closes, 20);
|
|
374
|
+
const sma50vals = sma(closes, Math.min(50, Math.floor(closes.length * 0.8)));
|
|
375
|
+
const currentSma20 = sma20vals[sma20vals.length - 1];
|
|
376
|
+
const currentSma50 = sma50vals[sma50vals.length - 1];
|
|
377
|
+
// RSI
|
|
378
|
+
const rsiVals = rsi(closes, 14);
|
|
379
|
+
const currentRsi = rsiVals.length > 0 ? rsiVals[rsiVals.length - 1] : 50;
|
|
380
|
+
const rsiSignal = currentRsi > 70 ? 'OVERBOUGHT' : currentRsi < 30 ? 'OVERSOLD' : 'NEUTRAL';
|
|
381
|
+
// MACD
|
|
382
|
+
const ema12vals = ema(closes, 12);
|
|
383
|
+
const ema26vals = ema(closes, 26);
|
|
384
|
+
const macdLine = ema12vals[ema12vals.length - 1] - ema26vals[ema26vals.length - 1];
|
|
385
|
+
const macdSignal = macdLine > 0 ? 'BULLISH' : 'BEARISH';
|
|
386
|
+
// Trend
|
|
387
|
+
const trend = current > currentSma20 && currentSma20 > currentSma50 ? 'UPTREND'
|
|
388
|
+
: current < currentSma20 && currentSma20 < currentSma50 ? 'DOWNTREND'
|
|
389
|
+
: 'SIDEWAYS';
|
|
390
|
+
// Volatility
|
|
391
|
+
const vol = annualizedVolatility(closes);
|
|
392
|
+
const mdd = maxDrawdown(closes);
|
|
393
|
+
const sharpe = sharpeProxy(closes);
|
|
394
|
+
// Volume trend
|
|
395
|
+
const recentVol = volumes.slice(-5);
|
|
396
|
+
const olderVol = volumes.slice(-20, -5);
|
|
397
|
+
const avgRecent = recentVol.length ? recentVol.reduce((a, b) => a + b, 0) / recentVol.length : 0;
|
|
398
|
+
const avgOlder = olderVol.length ? olderVol.reduce((a, b) => a + b, 0) / olderVol.length : 0;
|
|
399
|
+
const volTrend = avgOlder > 0 ? ((avgRecent - avgOlder) / avgOlder * 100) : 0;
|
|
400
|
+
lines.push(`| Indicator | Value | Signal |`, `|-----------|-------|--------|`, `| Trend | — | **${trend}** |`, `| RSI (14) | ${fmt(currentRsi)} | ${rsiSignal} |`, `| SMA (20) | $${fmt(currentSma20)} | Price ${current > currentSma20 ? 'above' : 'below'} |`, `| SMA (50) | $${fmt(currentSma50)} | Price ${current > currentSma50 ? 'above' : 'below'} |`, `| MACD | ${fmt(macdLine, 4)} | ${macdSignal} |`, `| Volatility (ann.) | ${fmt(vol)}% | ${vol > 40 ? 'HIGH' : vol > 20 ? 'MODERATE' : 'LOW'} |`, `| Max Drawdown (6mo) | ${fmt(mdd)}% | ${mdd > 20 ? 'SEVERE' : mdd > 10 ? 'NOTABLE' : 'MILD'} |`, `| Sharpe Ratio (est.) | ${fmt(sharpe)} | ${sharpe > 1 ? 'GOOD' : sharpe > 0 ? 'FAIR' : 'POOR'} |`, `| Volume Trend (5d vs 20d) | ${pct(volTrend)} | ${volTrend > 20 ? 'RISING' : volTrend < -20 ? 'FALLING' : 'STABLE'} |`);
|
|
401
|
+
// Signal tally
|
|
402
|
+
let bullish = 0;
|
|
403
|
+
let bearish = 0;
|
|
404
|
+
if (currentRsi < 30)
|
|
405
|
+
bullish++;
|
|
406
|
+
if (currentRsi > 70)
|
|
407
|
+
bearish++;
|
|
408
|
+
if (current > currentSma20)
|
|
409
|
+
bullish++;
|
|
410
|
+
else
|
|
411
|
+
bearish++;
|
|
412
|
+
if (current > currentSma50)
|
|
413
|
+
bullish++;
|
|
414
|
+
else
|
|
415
|
+
bearish++;
|
|
416
|
+
if (macdLine > 0)
|
|
417
|
+
bullish++;
|
|
418
|
+
else
|
|
419
|
+
bearish++;
|
|
420
|
+
if (trend === 'UPTREND')
|
|
421
|
+
bullish++;
|
|
422
|
+
if (trend === 'DOWNTREND')
|
|
423
|
+
bearish++;
|
|
424
|
+
lines.push('', `**Technical Signal**: ${bullish > bearish ? 'BULLISH' : bearish > bullish ? 'BEARISH' : 'NEUTRAL'} (${bullish} bull / ${bearish} bear)`);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
lines.push('*Insufficient price history for technical analysis (need >= 30 data points).*');
|
|
428
|
+
}
|
|
429
|
+
lines.push('');
|
|
430
|
+
// ── Agent 3: Sentiment ──
|
|
431
|
+
lines.push('## 3. Social Sentiment');
|
|
432
|
+
const sentiment = await redditSentiment(symbol);
|
|
433
|
+
lines.push(`- **Reddit Signal**: **${sentiment.label}** (score: ${fmt(sentiment.score)})`, `- **Posts Scanned**: ${sentiment.posts}`, `- **Summary**: ${sentiment.summary}`);
|
|
434
|
+
lines.push('');
|
|
435
|
+
// ── Agent 4: News ──
|
|
436
|
+
lines.push('## 4. Recent News');
|
|
437
|
+
const newsItems = await searchNews(`${symbol} stock market news`, 6);
|
|
438
|
+
if (newsItems.length > 0) {
|
|
439
|
+
for (const item of newsItems) {
|
|
440
|
+
lines.push(`- ${item}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
lines.push('*No recent news found via free sources.*');
|
|
445
|
+
}
|
|
446
|
+
lines.push('');
|
|
447
|
+
// ── Agent 5: Risk Assessment ──
|
|
448
|
+
lines.push('## 5. Risk Assessment');
|
|
449
|
+
if (closes.length >= 20) {
|
|
450
|
+
const vol = annualizedVolatility(closes);
|
|
451
|
+
const mdd = maxDrawdown(closes);
|
|
452
|
+
const beta = vol / 16; // rough approximation (S&P ~16% vol)
|
|
453
|
+
// Risk score 1-10 based on volatility + drawdown
|
|
454
|
+
const volScore = Math.min(5, vol / 10); // 0-5 from vol
|
|
455
|
+
const ddScore = Math.min(5, mdd / 10); // 0-5 from drawdown
|
|
456
|
+
const riskScore = Math.min(10, Math.round(volScore + ddScore));
|
|
457
|
+
lines.push(`| Risk Factor | Value | Rating |`, `|-------------|-------|--------|`, `| Annualized Volatility | ${fmt(vol)}% | ${vol > 40 ? 'HIGH' : vol > 20 ? 'MODERATE' : 'LOW'} |`, `| Max Drawdown (6mo) | ${fmt(mdd)}% | ${mdd > 20 ? 'HIGH' : mdd > 10 ? 'MODERATE' : 'LOW'} |`, `| Beta (est.) | ${fmt(beta)} | ${beta > 1.5 ? 'HIGH' : beta > 0.8 ? 'MARKET' : 'LOW'} |`, '', `**Overall Risk Score**: ${riskScore}/10 (${riskScore >= 7 ? 'High Risk' : riskScore >= 4 ? 'Moderate Risk' : 'Low Risk'})`);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
lines.push('*Insufficient data for risk assessment.*');
|
|
461
|
+
}
|
|
462
|
+
lines.push('');
|
|
463
|
+
// ── Agent 6: Synthesis — Bull/Bear Case + Confidence ──
|
|
464
|
+
lines.push('## 6. Synthesis');
|
|
465
|
+
// Compute confidence from signal agreement
|
|
466
|
+
let bullSignals = 0;
|
|
467
|
+
let bearSignals = 0;
|
|
468
|
+
let totalSignals = 0;
|
|
469
|
+
// Technical signals
|
|
470
|
+
if (closes.length >= 30) {
|
|
471
|
+
const current = closes[closes.length - 1];
|
|
472
|
+
const sma20v = sma(closes, 20);
|
|
473
|
+
const sma50v = sma(closes, Math.min(50, Math.floor(closes.length * 0.8)));
|
|
474
|
+
if (current > sma20v[sma20v.length - 1])
|
|
475
|
+
bullSignals++;
|
|
476
|
+
else
|
|
477
|
+
bearSignals++;
|
|
478
|
+
if (current > sma50v[sma50v.length - 1])
|
|
479
|
+
bullSignals++;
|
|
480
|
+
else
|
|
481
|
+
bearSignals++;
|
|
482
|
+
totalSignals += 2;
|
|
483
|
+
const rsiV = rsi(closes, 14);
|
|
484
|
+
const lastRsi = rsiV[rsiV.length - 1] ?? 50;
|
|
485
|
+
if (lastRsi < 40)
|
|
486
|
+
bullSignals++;
|
|
487
|
+
if (lastRsi > 60)
|
|
488
|
+
bearSignals++;
|
|
489
|
+
totalSignals++;
|
|
490
|
+
const ema12v = ema(closes, 12);
|
|
491
|
+
const ema26v = ema(closes, 26);
|
|
492
|
+
if (ema12v[ema12v.length - 1] > ema26v[ema26v.length - 1])
|
|
493
|
+
bullSignals++;
|
|
494
|
+
else
|
|
495
|
+
bearSignals++;
|
|
496
|
+
totalSignals++;
|
|
497
|
+
}
|
|
498
|
+
// Sentiment signal
|
|
499
|
+
if (sentiment.score > 0.15)
|
|
500
|
+
bullSignals++;
|
|
501
|
+
if (sentiment.score < -0.15)
|
|
502
|
+
bearSignals++;
|
|
503
|
+
totalSignals++;
|
|
504
|
+
const dominant = bullSignals > bearSignals ? 'BULLISH' : bearSignals > bullSignals ? 'BEARISH' : 'NEUTRAL';
|
|
505
|
+
const agreement = totalSignals > 0 ? Math.max(bullSignals, bearSignals) / totalSignals : 0;
|
|
506
|
+
const confidence = Math.round(agreement * 100);
|
|
507
|
+
lines.push(`**Overall Signal**: **${dominant}** (${confidence}% confidence)`, '');
|
|
508
|
+
// Bull case
|
|
509
|
+
const bullPoints = [];
|
|
510
|
+
if (closes.length >= 30) {
|
|
511
|
+
const current = closes[closes.length - 1];
|
|
512
|
+
const sma20v = sma(closes, 20);
|
|
513
|
+
if (current > sma20v[sma20v.length - 1])
|
|
514
|
+
bullPoints.push('Trading above 20-day SMA');
|
|
515
|
+
const rsiV = rsi(closes, 14);
|
|
516
|
+
const lastRsi = rsiV[rsiV.length - 1] ?? 50;
|
|
517
|
+
if (lastRsi < 40)
|
|
518
|
+
bullPoints.push('RSI in oversold territory — potential bounce');
|
|
519
|
+
if (annualizedVolatility(closes) < 25)
|
|
520
|
+
bullPoints.push('Low volatility suggests stability');
|
|
521
|
+
}
|
|
522
|
+
if (sentiment.label === 'BULLISH')
|
|
523
|
+
bullPoints.push('Positive social sentiment on Reddit');
|
|
524
|
+
if (bullPoints.length === 0)
|
|
525
|
+
bullPoints.push('Limited bullish signals at this time');
|
|
526
|
+
lines.push('### Bull Case');
|
|
527
|
+
for (const p of bullPoints)
|
|
528
|
+
lines.push(`- ${p}`);
|
|
529
|
+
lines.push('');
|
|
530
|
+
// Bear case
|
|
531
|
+
const bearPoints = [];
|
|
532
|
+
if (closes.length >= 30) {
|
|
533
|
+
const current = closes[closes.length - 1];
|
|
534
|
+
const sma50v = sma(closes, Math.min(50, Math.floor(closes.length * 0.8)));
|
|
535
|
+
if (current < sma50v[sma50v.length - 1])
|
|
536
|
+
bearPoints.push('Trading below 50-day SMA');
|
|
537
|
+
const rsiV = rsi(closes, 14);
|
|
538
|
+
const lastRsi = rsiV[rsiV.length - 1] ?? 50;
|
|
539
|
+
if (lastRsi > 60)
|
|
540
|
+
bearPoints.push('RSI trending toward overbought');
|
|
541
|
+
if (maxDrawdown(closes) > 15)
|
|
542
|
+
bearPoints.push(`Recent drawdown of ${fmt(maxDrawdown(closes))}%`);
|
|
543
|
+
if (annualizedVolatility(closes) > 35)
|
|
544
|
+
bearPoints.push('Elevated volatility increases downside risk');
|
|
545
|
+
}
|
|
546
|
+
if (sentiment.label === 'BEARISH')
|
|
547
|
+
bearPoints.push('Negative social sentiment on Reddit');
|
|
548
|
+
if (bearPoints.length === 0)
|
|
549
|
+
bearPoints.push('Limited bearish signals at this time');
|
|
550
|
+
lines.push('### Bear Case');
|
|
551
|
+
for (const p of bearPoints)
|
|
552
|
+
lines.push(`- ${p}`);
|
|
553
|
+
lines.push('');
|
|
554
|
+
lines.push('---', '*This analysis aggregates 5 specialist perspectives (fundamentals, technical, sentiment, news, risk) into a unified view. Not financial advice.*');
|
|
555
|
+
return lines.join('\n');
|
|
556
|
+
}
|
|
557
|
+
async function runPortfolioReview(holdings, totalValue) {
|
|
558
|
+
const portfolioValue = totalValue || 100_000; // default to $100K for calculations
|
|
559
|
+
const lines = [
|
|
560
|
+
'# Portfolio Review',
|
|
561
|
+
'',
|
|
562
|
+
`*Generated ${new Date().toISOString().split('T')[0]} by kbot Financial Analysis Pipeline*`,
|
|
563
|
+
`*Portfolio Value: $${fmt(portfolioValue, 0)}*`,
|
|
564
|
+
'',
|
|
565
|
+
];
|
|
566
|
+
// Normalize weights
|
|
567
|
+
const totalWeight = holdings.reduce((s, h) => s + h.weight, 0);
|
|
568
|
+
const normalized = holdings.map(h => ({
|
|
569
|
+
...h,
|
|
570
|
+
weight: (h.weight / totalWeight) * 100,
|
|
571
|
+
targetWeight: h.targetWeight != null ? h.targetWeight : undefined,
|
|
572
|
+
value: (h.weight / totalWeight) * portfolioValue,
|
|
573
|
+
}));
|
|
574
|
+
// ── Section 1: Holdings Overview ──
|
|
575
|
+
lines.push('## 1. Holdings Overview');
|
|
576
|
+
lines.push('', '| Ticker | Weight | Value | Sector | Geography |', '|--------|--------|-------|--------|-----------|');
|
|
577
|
+
for (const h of normalized.sort((a, b) => b.weight - a.weight)) {
|
|
578
|
+
const info = lookupSector(h.ticker);
|
|
579
|
+
lines.push(`| ${h.ticker} | ${fmt(h.weight)}% | $${fmt(h.value, 0)} | ${info.sector} | ${info.geography} |`);
|
|
580
|
+
}
|
|
581
|
+
lines.push('');
|
|
582
|
+
// ── Section 2: Sector Allocation ──
|
|
583
|
+
lines.push('## 2. Sector Allocation');
|
|
584
|
+
const sectorWeights = new Map();
|
|
585
|
+
const geoWeights = new Map();
|
|
586
|
+
for (const h of normalized) {
|
|
587
|
+
const info = lookupSector(h.ticker);
|
|
588
|
+
sectorWeights.set(info.sector, (sectorWeights.get(info.sector) || 0) + h.weight);
|
|
589
|
+
geoWeights.set(info.geography, (geoWeights.get(info.geography) || 0) + h.weight);
|
|
590
|
+
}
|
|
591
|
+
lines.push('', '### By Sector', '');
|
|
592
|
+
const sortedSectors = Array.from(sectorWeights.entries()).sort((a, b) => b[1] - a[1]);
|
|
593
|
+
for (const [sector, weight] of sortedSectors) {
|
|
594
|
+
const bar = '█'.repeat(Math.round(weight / 2));
|
|
595
|
+
lines.push(`- **${sector}**: ${fmt(weight)}% ${bar}`);
|
|
596
|
+
}
|
|
597
|
+
lines.push('', '### By Geography', '');
|
|
598
|
+
const sortedGeo = Array.from(geoWeights.entries()).sort((a, b) => b[1] - a[1]);
|
|
599
|
+
for (const [geo, weight] of sortedGeo) {
|
|
600
|
+
const bar = '█'.repeat(Math.round(weight / 2));
|
|
601
|
+
lines.push(`- **${geo}**: ${fmt(weight)}% ${bar}`);
|
|
602
|
+
}
|
|
603
|
+
lines.push('');
|
|
604
|
+
// ── Section 3: Concentration Risk ──
|
|
605
|
+
lines.push('## 3. Concentration Risk');
|
|
606
|
+
const sortedByWeight = [...normalized].sort((a, b) => b.weight - a.weight);
|
|
607
|
+
const top1 = sortedByWeight[0]?.weight ?? 0;
|
|
608
|
+
const top3 = sortedByWeight.slice(0, 3).reduce((s, h) => s + h.weight, 0);
|
|
609
|
+
const top5 = sortedByWeight.slice(0, 5).reduce((s, h) => s + h.weight, 0);
|
|
610
|
+
// Herfindahl-Hirschman Index
|
|
611
|
+
const hhi = normalized.reduce((sum, h) => sum + (h.weight / 100) ** 2, 0);
|
|
612
|
+
const effectivePositions = hhi > 0 ? 1 / hhi : normalized.length;
|
|
613
|
+
// Concentration score: 1 (diversified) to 10 (concentrated)
|
|
614
|
+
const concScore = Math.min(10, Math.round(hhi * 100));
|
|
615
|
+
lines.push(`| Metric | Value |`, `|--------|-------|`, `| Top 1 holding | ${sortedByWeight[0]?.ticker ?? '?'} at ${fmt(top1)}% |`, `| Top 3 holdings | ${fmt(top3)}% of portfolio |`, `| Top 5 holdings | ${fmt(top5)}% of portfolio |`, `| HHI (Herfindahl) | ${fmt(hhi, 4)} |`, `| Effective # of positions | ${fmt(effectivePositions, 1)} |`, `| **Concentration Score** | **${concScore}/10** (${concScore >= 7 ? 'High' : concScore >= 4 ? 'Moderate' : 'Low'}) |`);
|
|
616
|
+
lines.push('');
|
|
617
|
+
if (top1 > 25)
|
|
618
|
+
lines.push(`> **Warning**: ${sortedByWeight[0]?.ticker} at ${fmt(top1)}% exceeds 25% single-position limit.`, '');
|
|
619
|
+
if (sortedSectors.length > 0 && sortedSectors[0][1] > 50) {
|
|
620
|
+
lines.push(`> **Warning**: ${sortedSectors[0][0]} sector at ${fmt(sortedSectors[0][1])}% — consider diversification.`, '');
|
|
621
|
+
}
|
|
622
|
+
// ── Section 4: Dividend & Income Estimate ──
|
|
623
|
+
lines.push('## 4. Estimated Dividend Income');
|
|
624
|
+
const holdingsForMagi = normalized.map(h => ({
|
|
625
|
+
ticker: h.ticker,
|
|
626
|
+
weight: h.weight,
|
|
627
|
+
totalValue: h.value,
|
|
628
|
+
}));
|
|
629
|
+
let totalDivYield = 0;
|
|
630
|
+
lines.push('', '| Ticker | Weight | Est. Yield | Annual Income |', '|--------|--------|-----------|---------------|');
|
|
631
|
+
for (const h of normalized.sort((a, b) => b.weight - a.weight)) {
|
|
632
|
+
const info = lookupSector(h.ticker);
|
|
633
|
+
const income = h.value * (info.divYield / 100);
|
|
634
|
+
totalDivYield += info.divYield * (h.weight / 100);
|
|
635
|
+
if (info.divYield > 0) {
|
|
636
|
+
lines.push(`| ${h.ticker} | ${fmt(h.weight)}% | ${fmt(info.divYield)}% | $${fmt(income, 0)} |`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const totalDivIncome = normalized.reduce((sum, h) => {
|
|
640
|
+
const info = lookupSector(h.ticker);
|
|
641
|
+
return sum + h.value * (info.divYield / 100);
|
|
642
|
+
}, 0);
|
|
643
|
+
lines.push('', `**Portfolio Dividend Yield**: ${fmt(totalDivYield)}%`, `**Estimated Annual Income**: $${fmt(totalDivIncome, 0)}`, `**Estimated Monthly Income**: $${fmt(totalDivIncome / 12, 0)}`);
|
|
644
|
+
lines.push('');
|
|
645
|
+
// ── Section 5: MAGI Impact ──
|
|
646
|
+
lines.push('## 5. MAGI Impact Estimate (ACA-Aware)');
|
|
647
|
+
const magi = estimateMAGIImpact(holdingsForMagi);
|
|
648
|
+
if (magi.notes.length > 0) {
|
|
649
|
+
for (const note of magi.notes) {
|
|
650
|
+
lines.push(`- ${note}`);
|
|
651
|
+
}
|
|
652
|
+
lines.push('', '> **ACA Consideration**: Dividend income adds directly to MAGI. For ACA subsidy', '> optimization, consider holding high-dividend assets in tax-advantaged accounts', '> (IRA, 401k) and using growth stocks in taxable accounts.');
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
lines.push('*No significant dividend income affecting MAGI.*');
|
|
656
|
+
}
|
|
657
|
+
lines.push('');
|
|
658
|
+
// ── Section 6: Rebalancing ──
|
|
659
|
+
lines.push('## 6. Rebalancing Suggestions');
|
|
660
|
+
const hasTargets = normalized.some(h => h.targetWeight != null);
|
|
661
|
+
if (hasTargets) {
|
|
662
|
+
lines.push('', '| Ticker | Current | Target | Drift | Action |', '|--------|---------|--------|-------|--------|');
|
|
663
|
+
for (const h of normalized) {
|
|
664
|
+
if (h.targetWeight == null)
|
|
665
|
+
continue;
|
|
666
|
+
const drift = h.weight - h.targetWeight;
|
|
667
|
+
const action = Math.abs(drift) < 1 ? 'OK'
|
|
668
|
+
: drift > 0 ? `Sell $${fmt(Math.abs(drift / 100) * portfolioValue, 0)}`
|
|
669
|
+
: `Buy $${fmt(Math.abs(drift / 100) * portfolioValue, 0)}`;
|
|
670
|
+
const flag = Math.abs(drift) > 5 ? ' ⚠' : '';
|
|
671
|
+
lines.push(`| ${h.ticker} | ${fmt(h.weight)}% | ${fmt(h.targetWeight)}% | ${pct(drift)} | ${action}${flag} |`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
lines.push('*No target weights specified. General suggestions:*', '');
|
|
676
|
+
if (top1 > 30)
|
|
677
|
+
lines.push(`- **Trim ${sortedByWeight[0]?.ticker}** — ${fmt(top1)}% is heavily concentrated`);
|
|
678
|
+
if (sortedSectors.length > 0 && sortedSectors[0][1] > 50) {
|
|
679
|
+
lines.push(`- **Diversify away from ${sortedSectors[0][0]}** — ${fmt(sortedSectors[0][1])}% sector concentration`);
|
|
680
|
+
}
|
|
681
|
+
if (normalized.length < 10)
|
|
682
|
+
lines.push('- **Add positions** — fewer than 10 holdings increases idiosyncratic risk');
|
|
683
|
+
if (!sortedGeo.some(([geo]) => geo !== 'US' && geo !== 'Unknown')) {
|
|
684
|
+
lines.push('- **Add international exposure** — 100% US concentration adds geographic risk (consider VEA, VWO)');
|
|
685
|
+
}
|
|
686
|
+
const bondWeight = sortedSectors.find(([s]) => s.includes('Bond'))?.[1] ?? 0;
|
|
687
|
+
if (bondWeight === 0) {
|
|
688
|
+
lines.push('- **Consider bonds** — 0% fixed income allocation (BND, AGG, TLT for diversification)');
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
lines.push('');
|
|
692
|
+
// ── Section 7: Overall Risk Score ──
|
|
693
|
+
lines.push('## 7. Overall Portfolio Risk Score');
|
|
694
|
+
// Composite risk: concentration + sector risk + no bonds penalty + no intl penalty
|
|
695
|
+
let riskScore = concScore * 0.4; // 40% from concentration
|
|
696
|
+
if (sortedSectors.length > 0 && sortedSectors[0][1] > 50)
|
|
697
|
+
riskScore += 2;
|
|
698
|
+
if (!sortedGeo.some(([geo]) => geo !== 'US' && geo !== 'Unknown'))
|
|
699
|
+
riskScore += 1;
|
|
700
|
+
const bondAlloc = sortedSectors.find(([s]) => s.includes('Bond'))?.[1] ?? 0;
|
|
701
|
+
if (bondAlloc === 0)
|
|
702
|
+
riskScore += 1;
|
|
703
|
+
if (normalized.length < 5)
|
|
704
|
+
riskScore += 2;
|
|
705
|
+
riskScore = Math.min(10, Math.max(1, Math.round(riskScore)));
|
|
706
|
+
const riskLabel = riskScore >= 8 ? 'High Risk — significant concentration/diversification issues'
|
|
707
|
+
: riskScore >= 5 ? 'Moderate Risk — some diversification improvements recommended'
|
|
708
|
+
: 'Low Risk — well-diversified portfolio';
|
|
709
|
+
lines.push(`**Risk Score**: ${riskScore}/10 — ${riskLabel}`, '');
|
|
710
|
+
lines.push('---', '*Portfolio analysis uses reference data for sector classification and dividend yields. Actual yields vary. Not financial advice.*');
|
|
711
|
+
return lines.join('\n');
|
|
712
|
+
}
|
|
713
|
+
// ════════════════════════════════════════
|
|
714
|
+
// TOOL 3: market_briefing
|
|
715
|
+
// ════════════════════════════════════════
|
|
716
|
+
async function runMarketBriefing() {
|
|
717
|
+
const today = new Date().toISOString().split('T')[0];
|
|
718
|
+
const lines = [
|
|
719
|
+
`# Market Briefing — ${today}`,
|
|
720
|
+
'',
|
|
721
|
+
'*Generated by kbot Financial Analysis Pipeline*',
|
|
722
|
+
'',
|
|
723
|
+
];
|
|
724
|
+
// ── Major Indices ──
|
|
725
|
+
lines.push('## Major Indices');
|
|
726
|
+
const indices = [
|
|
727
|
+
{ symbol: '^GSPC', name: 'S&P 500' },
|
|
728
|
+
{ symbol: '^IXIC', name: 'NASDAQ Composite' },
|
|
729
|
+
{ symbol: '^DJI', name: 'Dow Jones' },
|
|
730
|
+
{ symbol: '^RUT', name: 'Russell 2000' },
|
|
731
|
+
{ symbol: '^VIX', name: 'VIX (Fear Index)' },
|
|
732
|
+
];
|
|
733
|
+
lines.push('', '| Index | Price | Change | Signal |', '|-------|-------|--------|--------|');
|
|
734
|
+
for (const idx of indices) {
|
|
735
|
+
try {
|
|
736
|
+
const data = await yahooQuote(idx.symbol);
|
|
737
|
+
if (data) {
|
|
738
|
+
const meta = data.meta || {};
|
|
739
|
+
const price = meta.regularMarketPrice ?? 0;
|
|
740
|
+
const prev = meta.chartPreviousClose ?? meta.previousClose ?? price;
|
|
741
|
+
const change = price - prev;
|
|
742
|
+
const changePct = prev ? (change / prev) * 100 : 0;
|
|
743
|
+
const signal = idx.symbol === '^VIX'
|
|
744
|
+
? (price > 30 ? 'FEAR' : price > 20 ? 'CAUTION' : 'CALM')
|
|
745
|
+
: (changePct > 0.5 ? 'BULLISH' : changePct < -0.5 ? 'BEARISH' : 'FLAT');
|
|
746
|
+
lines.push(`| ${idx.name} | ${fmt(price, 0)} | ${pct(changePct)} | ${signal} |`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
lines.push(`| ${idx.name} | — | — | DATA N/A |`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
lines.push('');
|
|
754
|
+
// ── Notable Movers ──
|
|
755
|
+
lines.push('## Notable Movers');
|
|
756
|
+
// Check a basket of popular stocks for big movers
|
|
757
|
+
const watchlist = [
|
|
758
|
+
'AAPL', 'MSFT', 'GOOGL', 'AMZN', 'NVDA', 'META', 'TSLA',
|
|
759
|
+
'JPM', 'V', 'UNH', 'XOM', 'LLY', 'AVGO', 'AMD', 'CRM',
|
|
760
|
+
'COIN', 'MSTR', 'PLTR', 'RIVN', 'SMCI',
|
|
761
|
+
];
|
|
762
|
+
const movers = [];
|
|
763
|
+
const batchSize = 5;
|
|
764
|
+
for (let i = 0; i < watchlist.length; i += batchSize) {
|
|
765
|
+
const batch = watchlist.slice(i, i + batchSize);
|
|
766
|
+
const results = await Promise.all(batch.map(async (sym) => {
|
|
767
|
+
try {
|
|
768
|
+
const data = await yahooQuote(sym);
|
|
769
|
+
if (!data)
|
|
770
|
+
return null;
|
|
771
|
+
const meta = data.meta || {};
|
|
772
|
+
const price = meta.regularMarketPrice ?? 0;
|
|
773
|
+
const prev = meta.chartPreviousClose ?? meta.previousClose ?? price;
|
|
774
|
+
const changePct = prev ? ((price - prev) / prev) * 100 : 0;
|
|
775
|
+
return { symbol: sym, price, changePct };
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
}));
|
|
781
|
+
movers.push(...results.filter((r) => r !== null));
|
|
782
|
+
}
|
|
783
|
+
// Sort by absolute change to find notable movers
|
|
784
|
+
const sorted = movers.sort((a, b) => Math.abs(b.changePct) - Math.abs(a.changePct));
|
|
785
|
+
const notable = sorted.filter(m => Math.abs(m.changePct) > 1).slice(0, 8);
|
|
786
|
+
if (notable.length > 0) {
|
|
787
|
+
lines.push('', '| Stock | Price | Change | Direction |', '|-------|-------|--------|-----------|');
|
|
788
|
+
for (const m of notable) {
|
|
789
|
+
lines.push(`| ${m.symbol} | $${fmt(m.price)} | ${pct(m.changePct)} | ${m.changePct > 0 ? 'UP' : 'DOWN'} |`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
lines.push('*No stocks moved more than 1% today in the watchlist.*');
|
|
794
|
+
}
|
|
795
|
+
lines.push('');
|
|
796
|
+
// ── Crypto Snapshot ──
|
|
797
|
+
lines.push('## Crypto Snapshot');
|
|
798
|
+
try {
|
|
799
|
+
const cryptoData = await fetchJSON('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=usd&include_24hr_change=true', 10_000);
|
|
800
|
+
lines.push('', '| Coin | Price | 24h Change |', '|------|-------|------------|');
|
|
801
|
+
const cryptoMap = { bitcoin: 'BTC', ethereum: 'ETH', solana: 'SOL' };
|
|
802
|
+
for (const [id, data] of Object.entries(cryptoData)) {
|
|
803
|
+
const change = data.usd_24h_change ?? 0;
|
|
804
|
+
lines.push(`| ${cryptoMap[id] || id} | $${fmt(data.usd, data.usd < 10 ? 4 : 0)} | ${pct(change)} |`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
lines.push('*Crypto data unavailable.*');
|
|
809
|
+
}
|
|
810
|
+
lines.push('');
|
|
811
|
+
// ── Market News ──
|
|
812
|
+
lines.push('## Market News');
|
|
813
|
+
const newsItems = await searchNews('stock market economy today', 6);
|
|
814
|
+
if (newsItems.length > 0) {
|
|
815
|
+
for (const item of newsItems) {
|
|
816
|
+
lines.push(`- ${item}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
lines.push('*No market news available via free sources.*');
|
|
821
|
+
}
|
|
822
|
+
lines.push('');
|
|
823
|
+
// ── Market Mood ──
|
|
824
|
+
lines.push('## Market Mood');
|
|
825
|
+
// Derive mood from VIX + index performance
|
|
826
|
+
const sp500 = movers.find(m => false); // indices are separate
|
|
827
|
+
let mood = 'NEUTRAL';
|
|
828
|
+
let moodExplain = 'Mixed signals';
|
|
829
|
+
// Fetch VIX for mood
|
|
830
|
+
try {
|
|
831
|
+
const vixData = await yahooQuote('^VIX');
|
|
832
|
+
if (vixData) {
|
|
833
|
+
const vix = vixData.meta?.regularMarketPrice ?? 20;
|
|
834
|
+
if (vix > 30) {
|
|
835
|
+
mood = 'FEARFUL';
|
|
836
|
+
moodExplain = `VIX at ${fmt(vix)} — elevated fear. Historically a contrarian buy signal.`;
|
|
837
|
+
}
|
|
838
|
+
else if (vix > 20) {
|
|
839
|
+
mood = 'CAUTIOUS';
|
|
840
|
+
moodExplain = `VIX at ${fmt(vix)} — above-average uncertainty.`;
|
|
841
|
+
}
|
|
842
|
+
else if (vix < 13) {
|
|
843
|
+
mood = 'COMPLACENT';
|
|
844
|
+
moodExplain = `VIX at ${fmt(vix)} — low fear. Be alert for surprises.`;
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
mood = 'CALM';
|
|
848
|
+
moodExplain = `VIX at ${fmt(vix)} — normal range.`;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
catch { /* keep default */ }
|
|
853
|
+
lines.push(`**Mood**: ${mood}`, `**Why**: ${moodExplain}`, '');
|
|
854
|
+
lines.push('---', `*Data from Yahoo Finance and CoinGecko. Prices may be delayed ~15 min. ${today}*`);
|
|
855
|
+
return lines.join('\n');
|
|
856
|
+
}
|
|
857
|
+
// ════════════════════════════════════════
|
|
858
|
+
// Registration
|
|
859
|
+
// ════════════════════════════════════════
|
|
860
|
+
export function registerFinancialAnalysisTools() {
|
|
861
|
+
registerTool({
|
|
862
|
+
name: 'market_analysis',
|
|
863
|
+
description: 'Run a coordinated multi-perspective financial analysis on a stock or crypto ticker. Combines 5 specialist perspectives — fundamentals, technical analysis (RSI, SMA, MACD, volatility), social sentiment (Reddit), recent news, and risk assessment — into a unified report with bull/bear case and confidence score. Inspired by TradingAgents multi-agent architecture.',
|
|
864
|
+
parameters: {
|
|
865
|
+
ticker: {
|
|
866
|
+
type: 'string',
|
|
867
|
+
description: 'Stock ticker (e.g. "AAPL", "NVDA", "SPY") or crypto symbol (will use Yahoo Finance)',
|
|
868
|
+
required: true,
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
tier: 'free',
|
|
872
|
+
timeout: 60_000, // up to 60s — fetches from multiple sources
|
|
873
|
+
async execute(args) {
|
|
874
|
+
const ticker = String(args.ticker).toUpperCase().trim();
|
|
875
|
+
if (!ticker)
|
|
876
|
+
return 'Error: ticker is required.';
|
|
877
|
+
try {
|
|
878
|
+
return await runMarketAnalysis(ticker);
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
return `Error running market analysis for ${ticker}: ${err instanceof Error ? err.message : String(err)}`;
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
registerTool({
|
|
886
|
+
name: 'portfolio_review',
|
|
887
|
+
description: 'Analyze a portfolio of holdings for risk, diversification, income, and rebalancing opportunities. Provides sector/geography breakdown, concentration risk (HHI), dividend yield estimate, MAGI impact for ACA-conscious investors, rebalancing suggestions, and an overall risk score (1-10). Pass holdings as a JSON array of {ticker, weight} objects.',
|
|
888
|
+
parameters: {
|
|
889
|
+
holdings: {
|
|
890
|
+
type: 'string',
|
|
891
|
+
description: 'JSON array of holdings, e.g. [{"ticker":"AAPL","weight":30},{"ticker":"MSFT","weight":20},{"ticker":"VOO","weight":50}]. Weight can be percentage or dollar amount — it will be normalized.',
|
|
892
|
+
required: true,
|
|
893
|
+
},
|
|
894
|
+
total_value: {
|
|
895
|
+
type: 'number',
|
|
896
|
+
description: 'Total portfolio value in USD (default: 100000). Used for dollar-amount calculations.',
|
|
897
|
+
default: 100_000,
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
tier: 'free',
|
|
901
|
+
timeout: 30_000,
|
|
902
|
+
async execute(args) {
|
|
903
|
+
let holdings;
|
|
904
|
+
try {
|
|
905
|
+
const raw = typeof args.holdings === 'string' ? JSON.parse(args.holdings) : args.holdings;
|
|
906
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
907
|
+
return 'Error: holdings must be a non-empty JSON array of {ticker, weight} objects.';
|
|
908
|
+
}
|
|
909
|
+
holdings = raw.map((h) => ({
|
|
910
|
+
ticker: String(h.ticker || '').toUpperCase(),
|
|
911
|
+
weight: Number(h.weight) || 0,
|
|
912
|
+
targetWeight: h.targetWeight != null ? Number(h.targetWeight) : undefined,
|
|
913
|
+
})).filter(h => h.ticker && h.weight > 0);
|
|
914
|
+
if (holdings.length === 0)
|
|
915
|
+
return 'Error: no valid holdings found. Each needs a ticker and positive weight.';
|
|
916
|
+
}
|
|
917
|
+
catch (err) {
|
|
918
|
+
return `Error parsing holdings JSON: ${err instanceof Error ? err.message : String(err)}. Expected format: [{"ticker":"AAPL","weight":30}]`;
|
|
919
|
+
}
|
|
920
|
+
const totalValue = Number(args.total_value) || 100_000;
|
|
921
|
+
try {
|
|
922
|
+
return await runPortfolioReview(holdings, totalValue);
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
return `Error running portfolio review: ${err instanceof Error ? err.message : String(err)}`;
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
registerTool({
|
|
930
|
+
name: 'market_briefing',
|
|
931
|
+
description: 'Generate a quick morning market briefing. Covers major index performance (S&P 500, NASDAQ, Dow, Russell 2000, VIX), notable stock movers, crypto snapshot (BTC/ETH/SOL), market news headlines, and overall market mood assessment. No parameters needed — just run it for a daily market overview.',
|
|
932
|
+
parameters: {},
|
|
933
|
+
tier: 'free',
|
|
934
|
+
timeout: 90_000, // fetches many quotes
|
|
935
|
+
async execute() {
|
|
936
|
+
try {
|
|
937
|
+
return await runMarketBriefing();
|
|
938
|
+
}
|
|
939
|
+
catch (err) {
|
|
940
|
+
return `Error generating market briefing: ${err instanceof Error ? err.message : String(err)}`;
|
|
941
|
+
}
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
//# sourceMappingURL=financial-analysis.js.map
|