agentbnb 5.1.0 → 5.1.1
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/cli/index.js +1 -1
- package/dist/index.js +4 -1
- package/dist/{service-coordinator-5R4LQW6L.js → service-coordinator-UTKI4FRI.js} +7 -2
- package/dist/skills/agentbnb/bootstrap.js +7 -2
- package/package.json +1 -1
- package/skills/deep-stock-analyst/package.json +24 -0
- package/skills/deep-stock-analyst/src/analysis/financial-health.ts +167 -0
- package/skills/deep-stock-analyst/src/analysis/sentiment.ts +68 -0
- package/skills/deep-stock-analyst/src/analysis/signal.ts +188 -0
- package/skills/deep-stock-analyst/src/analysis/technicals.ts +318 -0
- package/skills/deep-stock-analyst/src/analysis/utils.ts +137 -0
- package/skills/deep-stock-analyst/src/analysis/valuation.ts +95 -0
- package/skills/deep-stock-analyst/src/api/alpha-vantage.ts +133 -0
- package/skills/deep-stock-analyst/src/api/types.ts +238 -0
- package/skills/deep-stock-analyst/src/index.ts +84 -0
- package/skills/deep-stock-analyst/src/llm/thesis.ts +101 -0
- package/skills/deep-stock-analyst/src/orchestrator.ts +228 -0
- package/skills/deep-stock-analyst/tsconfig.json +21 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AVDailyPrice,
|
|
3
|
+
AVRSIEntry,
|
|
4
|
+
AVMACDEntry,
|
|
5
|
+
AVBBandsEntry,
|
|
6
|
+
AVStochEntry,
|
|
7
|
+
AVADXEntry,
|
|
8
|
+
} from '../api/types.js';
|
|
9
|
+
import { calcSMA, calcAvgVolume, formatNumber, mapRange, weightedAvg, sp } from './utils.js';
|
|
10
|
+
|
|
11
|
+
export interface TechnicalSignal {
|
|
12
|
+
type: 'bullish' | 'bearish' | 'neutral';
|
|
13
|
+
name: string;
|
|
14
|
+
strength: number; // 1–5
|
|
15
|
+
description: string;
|
|
16
|
+
detected_at: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TechnicalScore {
|
|
20
|
+
trend_score: number;
|
|
21
|
+
momentum_score: number;
|
|
22
|
+
volatility_score: number;
|
|
23
|
+
strength_score: number;
|
|
24
|
+
composite: number;
|
|
25
|
+
regime: 'strong_uptrend' | 'uptrend' | 'consolidation' | 'downtrend' | 'strong_downtrend';
|
|
26
|
+
signals: TechnicalSignal[];
|
|
27
|
+
raw: {
|
|
28
|
+
rsi: number;
|
|
29
|
+
macdHist: number;
|
|
30
|
+
adx: number;
|
|
31
|
+
bbPosition: number;
|
|
32
|
+
bandWidth: number;
|
|
33
|
+
price: number;
|
|
34
|
+
sma20: number;
|
|
35
|
+
sma50: number;
|
|
36
|
+
sma200: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function calcSMAAlignment(
|
|
41
|
+
price: number,
|
|
42
|
+
sma20: number,
|
|
43
|
+
sma50: number,
|
|
44
|
+
sma100: number,
|
|
45
|
+
sma200: number,
|
|
46
|
+
): number {
|
|
47
|
+
let score = 0;
|
|
48
|
+
// Perfect bull: price > SMA20 > SMA50 > SMA100 > SMA200 = 100
|
|
49
|
+
if (price > sma20) score += 25;
|
|
50
|
+
if (sma20 > sma50) score += 25;
|
|
51
|
+
if (sma50 > sma100) score += 25;
|
|
52
|
+
if (sma100 > sma200) score += 25;
|
|
53
|
+
return score;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function detectRSIDivergence(
|
|
57
|
+
daily: AVDailyPrice[],
|
|
58
|
+
rsi: AVRSIEntry[],
|
|
59
|
+
lookback: number,
|
|
60
|
+
): TechnicalSignal | null {
|
|
61
|
+
if (daily.length < lookback || rsi.length < lookback) return null;
|
|
62
|
+
|
|
63
|
+
// Check last `lookback` bars for price making new high but RSI declining (bearish divergence)
|
|
64
|
+
// or price making new low but RSI rising (bullish divergence)
|
|
65
|
+
const prices = daily.slice(0, lookback).map((d) => sp(d.close));
|
|
66
|
+
const rsiVals = rsi.slice(0, lookback).map((r) => sp(r.RSI));
|
|
67
|
+
|
|
68
|
+
const priceMin = Math.min(...prices);
|
|
69
|
+
const priceMax = Math.max(...prices);
|
|
70
|
+
const rsiMin = Math.min(...rsiVals);
|
|
71
|
+
const rsiMax = Math.max(...rsiVals);
|
|
72
|
+
|
|
73
|
+
const latestPrice = prices[0] ?? 0;
|
|
74
|
+
const latestRSI = rsiVals[0] ?? 50;
|
|
75
|
+
|
|
76
|
+
// Bullish divergence: price near low but RSI well above its low
|
|
77
|
+
if (
|
|
78
|
+
latestPrice <= priceMin * 1.02 &&
|
|
79
|
+
latestRSI >= rsiMin * 1.15 &&
|
|
80
|
+
latestRSI < 50
|
|
81
|
+
) {
|
|
82
|
+
return {
|
|
83
|
+
type: 'bullish',
|
|
84
|
+
name: 'RSI Bullish Divergence',
|
|
85
|
+
strength: 4,
|
|
86
|
+
description: `Price near ${lookback}-day low but RSI (${latestRSI.toFixed(1)}) is diverging upward`,
|
|
87
|
+
detected_at: daily[0]?.date ?? '',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Bearish divergence: price near high but RSI well below its high
|
|
92
|
+
if (
|
|
93
|
+
latestPrice >= priceMax * 0.98 &&
|
|
94
|
+
latestRSI <= rsiMax * 0.85 &&
|
|
95
|
+
latestRSI > 50
|
|
96
|
+
) {
|
|
97
|
+
return {
|
|
98
|
+
type: 'bearish',
|
|
99
|
+
name: 'RSI Bearish Divergence',
|
|
100
|
+
strength: 4,
|
|
101
|
+
description: `Price near ${lookback}-day high but RSI (${latestRSI.toFixed(1)}) is diverging downward`,
|
|
102
|
+
detected_at: daily[0]?.date ?? '',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function crossedAbove(
|
|
110
|
+
daily: AVDailyPrice[],
|
|
111
|
+
fastPeriod: number,
|
|
112
|
+
slowPeriod: number,
|
|
113
|
+
withinDays: number,
|
|
114
|
+
): boolean {
|
|
115
|
+
for (let i = 0; i < withinDays; i++) {
|
|
116
|
+
const slice = daily.slice(i);
|
|
117
|
+
const fastNow = calcSMA(slice, fastPeriod);
|
|
118
|
+
const slowNow = calcSMA(slice, slowPeriod);
|
|
119
|
+
const fastPrev = calcSMA(daily.slice(i + 1), fastPeriod);
|
|
120
|
+
const slowPrev = calcSMA(daily.slice(i + 1), slowPeriod);
|
|
121
|
+
if (fastNow > slowNow && fastPrev <= slowPrev) return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function crossedBelow(
|
|
127
|
+
daily: AVDailyPrice[],
|
|
128
|
+
fastPeriod: number,
|
|
129
|
+
slowPeriod: number,
|
|
130
|
+
withinDays: number,
|
|
131
|
+
): boolean {
|
|
132
|
+
for (let i = 0; i < withinDays; i++) {
|
|
133
|
+
const slice = daily.slice(i);
|
|
134
|
+
const fastNow = calcSMA(slice, fastPeriod);
|
|
135
|
+
const slowNow = calcSMA(slice, slowPeriod);
|
|
136
|
+
const fastPrev = calcSMA(daily.slice(i + 1), fastPeriod);
|
|
137
|
+
const slowPrev = calcSMA(daily.slice(i + 1), slowPeriod);
|
|
138
|
+
if (fastNow < slowNow && fastPrev >= slowPrev) return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function determineRegime(
|
|
144
|
+
composite: number,
|
|
145
|
+
trendScore: number,
|
|
146
|
+
strengthScore: number,
|
|
147
|
+
): TechnicalScore['regime'] {
|
|
148
|
+
if (composite > 80 && strengthScore > 70) return 'strong_uptrend';
|
|
149
|
+
if (composite > 60) return 'uptrend';
|
|
150
|
+
if (composite > 40) return 'consolidation';
|
|
151
|
+
if (composite > 20 && strengthScore > 70) return 'strong_downtrend';
|
|
152
|
+
return 'downtrend';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function analyzeTechnicals(
|
|
156
|
+
daily: AVDailyPrice[],
|
|
157
|
+
rsi: AVRSIEntry[],
|
|
158
|
+
macd: AVMACDEntry[],
|
|
159
|
+
bbands: AVBBandsEntry[],
|
|
160
|
+
stoch: AVStochEntry[],
|
|
161
|
+
adx: AVADXEntry[],
|
|
162
|
+
): TechnicalScore {
|
|
163
|
+
if (daily.length === 0) {
|
|
164
|
+
return {
|
|
165
|
+
trend_score: 50, momentum_score: 50, volatility_score: 50, strength_score: 50,
|
|
166
|
+
composite: 50, regime: 'consolidation', signals: [],
|
|
167
|
+
raw: { rsi: 50, macdHist: 0, adx: 20, bbPosition: 0.5, bandWidth: 0.05, price: 0, sma20: 0, sma50: 0, sma200: 0 },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const price = sp(daily[0]?.close);
|
|
172
|
+
|
|
173
|
+
// === Trend Score: SMA alignment ===
|
|
174
|
+
const sma20 = calcSMA(daily, 20);
|
|
175
|
+
const sma50 = calcSMA(daily, 50);
|
|
176
|
+
const sma100 = calcSMA(daily, 100);
|
|
177
|
+
const sma200 = calcSMA(daily, 200);
|
|
178
|
+
const trend_score = calcSMAAlignment(price, sma20, sma50, sma100, sma200);
|
|
179
|
+
|
|
180
|
+
// === Momentum Score: RSI + MACD ===
|
|
181
|
+
const latestRSI = sp(rsi[0]?.RSI ?? '50');
|
|
182
|
+
const macdHist = sp(macd[0]?.MACD_Hist ?? '0');
|
|
183
|
+
|
|
184
|
+
// RSI: oversold (<30) = bullish setup; overbought (>70) = bearish
|
|
185
|
+
const rsi_component =
|
|
186
|
+
latestRSI < 30 ? 80
|
|
187
|
+
: latestRSI > 70 ? 30
|
|
188
|
+
: mapRange(latestRSI, 30, 70, 40, 70);
|
|
189
|
+
|
|
190
|
+
// MACD histogram positive and growing = bullish momentum
|
|
191
|
+
const macd_component =
|
|
192
|
+
macdHist > 0
|
|
193
|
+
? mapRange(macdHist, 0, 2, 50, 90)
|
|
194
|
+
: mapRange(macdHist, -2, 0, 10, 50);
|
|
195
|
+
|
|
196
|
+
const momentum_score = rsi_component * 0.5 + macd_component * 0.5;
|
|
197
|
+
|
|
198
|
+
// === Volatility Score: Bollinger position ===
|
|
199
|
+
const upperBand = sp(bbands[0]?.['Real Upper Band'] ?? '0');
|
|
200
|
+
const lowerBand = sp(bbands[0]?.['Real Lower Band'] ?? '0');
|
|
201
|
+
const middleBand = sp(bbands[0]?.['Real Middle Band'] ?? '0');
|
|
202
|
+
const bandRange = upperBand - lowerBand;
|
|
203
|
+
const bandWidth = middleBand > 0 ? bandRange / middleBand : 0;
|
|
204
|
+
const bbPosition = bandRange > 0 ? (price - lowerBand) / bandRange : 0.5;
|
|
205
|
+
const volatility_score = bbPosition * 100;
|
|
206
|
+
|
|
207
|
+
// === Strength Score: ADX ===
|
|
208
|
+
const latestADX = sp(adx[0]?.ADX ?? '20');
|
|
209
|
+
const strength_score = mapRange(latestADX, 10, 50, 0, 100);
|
|
210
|
+
|
|
211
|
+
// === Signal Detection ===
|
|
212
|
+
const signals: TechnicalSignal[] = [];
|
|
213
|
+
const today = daily[0]?.date ?? '';
|
|
214
|
+
|
|
215
|
+
// Golden Cross / Death Cross (SMA50 vs SMA200)
|
|
216
|
+
if (crossedAbove(daily, 50, 200, 5)) {
|
|
217
|
+
signals.push({
|
|
218
|
+
type: 'bullish', name: 'Golden Cross', strength: 5,
|
|
219
|
+
description: 'SMA50 crossed above SMA200 within last 5 trading days',
|
|
220
|
+
detected_at: today,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (crossedBelow(daily, 50, 200, 5)) {
|
|
224
|
+
signals.push({
|
|
225
|
+
type: 'bearish', name: 'Death Cross', strength: 5,
|
|
226
|
+
description: 'SMA50 crossed below SMA200 within last 5 trading days',
|
|
227
|
+
detected_at: today,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// RSI Divergence
|
|
232
|
+
const rsiDiv = detectRSIDivergence(daily, rsi, 20);
|
|
233
|
+
if (rsiDiv) signals.push(rsiDiv);
|
|
234
|
+
|
|
235
|
+
// MACD Bullish Crossover (hist went positive)
|
|
236
|
+
const prevMacdHist = sp(macd[1]?.MACD_Hist ?? '0');
|
|
237
|
+
if (macdHist > 0 && prevMacdHist < 0) {
|
|
238
|
+
signals.push({
|
|
239
|
+
type: 'bullish', name: 'MACD Bullish Crossover', strength: 3,
|
|
240
|
+
description: 'MACD crossed above signal line',
|
|
241
|
+
detected_at: today,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (macdHist < 0 && prevMacdHist > 0) {
|
|
245
|
+
signals.push({
|
|
246
|
+
type: 'bearish', name: 'MACD Bearish Crossover', strength: 3,
|
|
247
|
+
description: 'MACD crossed below signal line',
|
|
248
|
+
detected_at: today,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Bollinger Squeeze (very narrow band → major move incoming)
|
|
253
|
+
if (bandWidth < 0.05) {
|
|
254
|
+
signals.push({
|
|
255
|
+
type: 'neutral', name: 'Bollinger Squeeze', strength: 4,
|
|
256
|
+
description: `Band width at ${(bandWidth * 100).toFixed(1)}% — extremely compressed. Major move incoming.`,
|
|
257
|
+
detected_at: today,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Stochastic oversold reversal
|
|
262
|
+
const stochK = sp(stoch[0]?.SlowK ?? '50');
|
|
263
|
+
const stochD = sp(stoch[0]?.SlowD ?? '50');
|
|
264
|
+
const prevStochK = sp(stoch[1]?.SlowK ?? '50');
|
|
265
|
+
if (stochK < 20 && stochK > stochD && prevStochK <= stochD) {
|
|
266
|
+
signals.push({
|
|
267
|
+
type: 'bullish', name: 'Stochastic Oversold Reversal', strength: 3,
|
|
268
|
+
description: `Stoch K(${stochK.toFixed(1)}) crossing above D(${stochD.toFixed(1)}) in oversold territory`,
|
|
269
|
+
detected_at: today,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Volume Spike (2x 20-day average)
|
|
274
|
+
const avgVol20 = calcAvgVolume(daily, 20);
|
|
275
|
+
const todayVol = sp(daily[0]?.volume ?? '0');
|
|
276
|
+
if (avgVol20 > 0 && todayVol > avgVol20 * 2) {
|
|
277
|
+
signals.push({
|
|
278
|
+
type: 'neutral', name: 'Volume Spike', strength: 4,
|
|
279
|
+
description: `Volume ${formatNumber(todayVol)} is ${(todayVol / avgVol20).toFixed(1)}x the 20-day average`,
|
|
280
|
+
detected_at: today,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Price above/below 200 SMA
|
|
285
|
+
if (sma200 > 0) {
|
|
286
|
+
if (price > sma200 * 1.10) {
|
|
287
|
+
signals.push({
|
|
288
|
+
type: 'bullish', name: 'Strong Uptrend', strength: 2,
|
|
289
|
+
description: `Price is ${(((price / sma200) - 1) * 100).toFixed(1)}% above 200-day SMA`,
|
|
290
|
+
detected_at: today,
|
|
291
|
+
});
|
|
292
|
+
} else if (price < sma200 * 0.90) {
|
|
293
|
+
signals.push({
|
|
294
|
+
type: 'bearish', name: 'Strong Downtrend', strength: 2,
|
|
295
|
+
description: `Price is ${(((sma200 / price) - 1) * 100).toFixed(1)}% below 200-day SMA`,
|
|
296
|
+
detected_at: today,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const composite = weightedAvg([
|
|
302
|
+
[trend_score, 0.30],
|
|
303
|
+
[momentum_score, 0.30],
|
|
304
|
+
[volatility_score, 0.15],
|
|
305
|
+
[strength_score, 0.25],
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
trend_score,
|
|
310
|
+
momentum_score,
|
|
311
|
+
volatility_score,
|
|
312
|
+
strength_score,
|
|
313
|
+
composite,
|
|
314
|
+
regime: determineRegime(composite, trend_score, strength_score),
|
|
315
|
+
signals,
|
|
316
|
+
raw: { rsi: latestRSI, macdHist, adx: latestADX, bbPosition, bandWidth, price, sma20, sma50, sma200 },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** Shared math utilities — all pure functions, no LLM. */
|
|
2
|
+
|
|
3
|
+
export interface ScoreThresholds {
|
|
4
|
+
excellent: number;
|
|
5
|
+
good: number;
|
|
6
|
+
fair: number;
|
|
7
|
+
poor: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Score a metric where lower is better (e.g. P/E ratio, debt/equity).
|
|
12
|
+
* Returns 0–100, where 100 = excellent (very cheap / low risk).
|
|
13
|
+
*/
|
|
14
|
+
export function scoreMetric(value: number, t: ScoreThresholds): number {
|
|
15
|
+
if (isNaN(value) || !isFinite(value)) return 50;
|
|
16
|
+
if (value <= t.excellent) return 100;
|
|
17
|
+
if (value <= t.good) return mapRange(value, t.excellent, t.good, 100, 75);
|
|
18
|
+
if (value <= t.fair) return mapRange(value, t.good, t.fair, 75, 50);
|
|
19
|
+
if (value <= t.poor) return mapRange(value, t.fair, t.poor, 50, 25);
|
|
20
|
+
return Math.max(0, 25 - ((value - t.poor) / t.poor) * 25);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Score a metric where higher is better (e.g. gross margin, ROE, FCF yield).
|
|
25
|
+
* Returns 0–100, where 100 = excellent (high margin / high return).
|
|
26
|
+
*/
|
|
27
|
+
export function scoreMetricInverse(value: number, t: ScoreThresholds): number {
|
|
28
|
+
if (isNaN(value) || !isFinite(value)) return 50;
|
|
29
|
+
if (value >= t.excellent) return 100;
|
|
30
|
+
if (value >= t.good) return mapRange(value, t.good, t.excellent, 75, 100);
|
|
31
|
+
if (value >= t.fair) return mapRange(value, t.fair, t.good, 50, 75);
|
|
32
|
+
if (value >= t.poor) return mapRange(value, t.poor, t.fair, 25, 50);
|
|
33
|
+
return Math.max(0, ((value - t.poor) / t.poor) * 25);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Weighted average: [[value, weight], ...] → number */
|
|
37
|
+
export function weightedAvg(pairs: [number, number][]): number {
|
|
38
|
+
let totalWeight = 0;
|
|
39
|
+
let totalValue = 0;
|
|
40
|
+
for (const [val, w] of pairs) {
|
|
41
|
+
if (!isNaN(val) && isFinite(val)) {
|
|
42
|
+
totalValue += val * w;
|
|
43
|
+
totalWeight += w;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return totalWeight > 0 ? totalValue / totalWeight : 50;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Linear interpolation: map x from [inMin,inMax] to [outMin,outMax] */
|
|
50
|
+
export function mapRange(
|
|
51
|
+
x: number,
|
|
52
|
+
inMin: number,
|
|
53
|
+
inMax: number,
|
|
54
|
+
outMin: number,
|
|
55
|
+
outMax: number,
|
|
56
|
+
): number {
|
|
57
|
+
if (inMax === inMin) return (outMin + outMax) / 2;
|
|
58
|
+
const clamped = Math.max(inMin, Math.min(inMax, x));
|
|
59
|
+
return outMin + ((clamped - inMin) / (inMax - inMin)) * (outMax - outMin);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Simple Moving Average over the last `period` closing prices */
|
|
63
|
+
export function calcSMA(prices: { close: string }[], period: number): number {
|
|
64
|
+
const slice = prices.slice(0, period);
|
|
65
|
+
if (slice.length === 0) return 0;
|
|
66
|
+
const sum = slice.reduce((acc, p) => acc + parseFloat(p.close), 0);
|
|
67
|
+
return sum / slice.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Average volume over `period` days */
|
|
71
|
+
export function calcAvgVolume(prices: { volume: string }[], period: number): number {
|
|
72
|
+
const slice = prices.slice(0, period);
|
|
73
|
+
if (slice.length === 0) return 0;
|
|
74
|
+
const sum = slice.reduce((acc, p) => acc + parseFloat(p.volume), 0);
|
|
75
|
+
return sum / slice.length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Format large numbers: 1234567 → "1.23M" */
|
|
79
|
+
export function formatNumber(n: number): string {
|
|
80
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
|
|
81
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
|
|
82
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
83
|
+
return n.toFixed(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Safe parse: returns NaN-safe float */
|
|
87
|
+
export function sp(v: unknown): number {
|
|
88
|
+
const n = parseFloat(String(v ?? 'NaN'));
|
|
89
|
+
return isFinite(n) ? n : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** YoY growth rate from quarterly reports (Q0 vs Q4) */
|
|
93
|
+
export function calcYoYGrowth(
|
|
94
|
+
reports: Array<Record<string, string>>,
|
|
95
|
+
field: string,
|
|
96
|
+
): number {
|
|
97
|
+
const current = sp(reports[0]?.[field]);
|
|
98
|
+
const prior = sp(reports[4]?.[field]);
|
|
99
|
+
if (prior === 0) return 0;
|
|
100
|
+
return ((current - prior) / Math.abs(prior)) * 100;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Score growth rate: 0–100 */
|
|
104
|
+
export function scoreGrowth(pct: number): number {
|
|
105
|
+
if (pct >= 30) return 100;
|
|
106
|
+
if (pct >= 15) return mapRange(pct, 15, 30, 70, 100);
|
|
107
|
+
if (pct >= 5) return mapRange(pct, 5, 15, 50, 70);
|
|
108
|
+
if (pct >= 0) return mapRange(pct, 0, 5, 40, 50);
|
|
109
|
+
if (pct >= -10) return mapRange(pct, -10, 0, 20, 40);
|
|
110
|
+
return Math.max(0, 20 + pct * 2);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Score earnings surprise percentage: 0–100 */
|
|
114
|
+
export function scoreSurprise(pct: number): number {
|
|
115
|
+
if (pct >= 10) return 100;
|
|
116
|
+
if (pct >= 5) return 80;
|
|
117
|
+
if (pct >= 0) return 60;
|
|
118
|
+
if (pct >= -5) return 40;
|
|
119
|
+
return 20;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Count consecutive earnings beats from quarterly earnings array */
|
|
123
|
+
export function countConsecutiveBeats(
|
|
124
|
+
earnings: Array<{ surprisePercentage: string }>,
|
|
125
|
+
): number {
|
|
126
|
+
let count = 0;
|
|
127
|
+
for (const e of earnings) {
|
|
128
|
+
if (sp(e.surprisePercentage) > 0) count++;
|
|
129
|
+
else break;
|
|
130
|
+
}
|
|
131
|
+
return count;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Clamp to [0, 100] */
|
|
135
|
+
export function clamp100(n: number): number {
|
|
136
|
+
return Math.max(0, Math.min(100, n));
|
|
137
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { AVOverview } from '../api/types.js';
|
|
2
|
+
import { scoreMetric, scoreMetricInverse, weightedAvg, sp } from './utils.js';
|
|
3
|
+
|
|
4
|
+
export interface ValuationScore {
|
|
5
|
+
pe_score: number;
|
|
6
|
+
ps_score: number;
|
|
7
|
+
peg_score: number;
|
|
8
|
+
fcf_yield_score: number;
|
|
9
|
+
ev_ebitda_score: number;
|
|
10
|
+
composite: number;
|
|
11
|
+
verdict: 'undervalued' | 'fair' | 'overvalued' | 'expensive';
|
|
12
|
+
raw: {
|
|
13
|
+
pe: number;
|
|
14
|
+
forwardPE: number;
|
|
15
|
+
peg: number;
|
|
16
|
+
ps: number;
|
|
17
|
+
evEbitda: number;
|
|
18
|
+
fcfYieldPct: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function calculateValuation(overview: AVOverview): ValuationScore {
|
|
23
|
+
const pe = sp(overview.PERatio);
|
|
24
|
+
const forwardPE = sp(overview.ForwardPE);
|
|
25
|
+
const peg = sp(overview.PEGRatio);
|
|
26
|
+
const ps = sp(overview.PriceToSalesRatioTTM);
|
|
27
|
+
const evEbitda = sp(overview.EVToEBITDA);
|
|
28
|
+
const marketCap = sp(overview.MarketCapitalization);
|
|
29
|
+
const ocf = sp(overview.OperatingCashflowTTM);
|
|
30
|
+
|
|
31
|
+
// FCF Yield = Operating Cash Flow / Market Cap (as percentage)
|
|
32
|
+
const fcfYieldPct = marketCap > 0 ? (ocf / marketCap) * 100 : 0;
|
|
33
|
+
|
|
34
|
+
// Score each metric: 100 = very cheap, 0 = very expensive
|
|
35
|
+
// Thresholds calibrated to S&P 500 historical medians
|
|
36
|
+
const pe_score = scoreMetric(pe > 0 ? pe : forwardPE, {
|
|
37
|
+
excellent: 12,
|
|
38
|
+
good: 18,
|
|
39
|
+
fair: 25,
|
|
40
|
+
poor: 40,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const peg_score = scoreMetric(peg, {
|
|
44
|
+
excellent: 0.5,
|
|
45
|
+
good: 1.0,
|
|
46
|
+
fair: 1.5,
|
|
47
|
+
poor: 2.5,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const fcf_yield_score = scoreMetricInverse(fcfYieldPct, {
|
|
51
|
+
excellent: 8,
|
|
52
|
+
good: 5,
|
|
53
|
+
fair: 3,
|
|
54
|
+
poor: 1,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const ev_ebitda_score = scoreMetric(evEbitda, {
|
|
58
|
+
excellent: 8,
|
|
59
|
+
good: 12,
|
|
60
|
+
fair: 18,
|
|
61
|
+
poor: 30,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const ps_score = scoreMetric(ps, {
|
|
65
|
+
excellent: 1,
|
|
66
|
+
good: 3,
|
|
67
|
+
fair: 6,
|
|
68
|
+
poor: 12,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const composite = weightedAvg([
|
|
72
|
+
[pe_score, 0.25],
|
|
73
|
+
[peg_score, 0.20],
|
|
74
|
+
[fcf_yield_score, 0.25],
|
|
75
|
+
[ev_ebitda_score, 0.15],
|
|
76
|
+
[ps_score, 0.15],
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const verdict: ValuationScore['verdict'] =
|
|
80
|
+
composite > 70 ? 'undervalued'
|
|
81
|
+
: composite > 50 ? 'fair'
|
|
82
|
+
: composite > 30 ? 'overvalued'
|
|
83
|
+
: 'expensive';
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
pe_score,
|
|
87
|
+
ps_score,
|
|
88
|
+
peg_score,
|
|
89
|
+
fcf_yield_score,
|
|
90
|
+
ev_ebitda_score,
|
|
91
|
+
composite,
|
|
92
|
+
verdict,
|
|
93
|
+
raw: { pe, forwardPE, peg, ps, evEbitda, fcfYieldPct },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alpha Vantage API client.
|
|
3
|
+
* Free key: 25 req/day, 5 req/min → serialize calls with 200ms gap.
|
|
4
|
+
* Standard analysis = 12 calls.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AllAVData,
|
|
9
|
+
AVDailyPrice,
|
|
10
|
+
AVRSIEntry,
|
|
11
|
+
AVMACDEntry,
|
|
12
|
+
AVBBandsEntry,
|
|
13
|
+
AVStochEntry,
|
|
14
|
+
AVADXEntry,
|
|
15
|
+
AVOverview,
|
|
16
|
+
AVIncomeStatement,
|
|
17
|
+
AVBalanceSheet,
|
|
18
|
+
AVCashFlow,
|
|
19
|
+
AVEarnings,
|
|
20
|
+
AVNewsSentiment,
|
|
21
|
+
} from './types.js';
|
|
22
|
+
|
|
23
|
+
const BASE_URL = 'https://www.alphavantage.co/query';
|
|
24
|
+
const RATE_DELAY_MS = 200; // 5 req/min on free key → be conservative
|
|
25
|
+
|
|
26
|
+
async function sleep(ms: number): Promise<void> {
|
|
27
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function avFetch(params: Record<string, string>, apiKey: string): Promise<unknown> {
|
|
31
|
+
const url = new URL(BASE_URL);
|
|
32
|
+
url.searchParams.set('apikey', apiKey);
|
|
33
|
+
for (const [k, v] of Object.entries(params)) {
|
|
34
|
+
url.searchParams.set(k, v);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const res = await fetch(url.toString());
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new Error(`Alpha Vantage HTTP ${res.status}: ${url.searchParams.get('function')}`);
|
|
40
|
+
}
|
|
41
|
+
const data = await res.json() as Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
// AV returns error messages as JSON object with "Information" or "Note" keys
|
|
44
|
+
if (typeof data['Information'] === 'string' && data['Information'].includes('rate limit')) {
|
|
45
|
+
throw new Error(`Alpha Vantage rate limit hit: ${data['Information']}`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof data['Note'] === 'string') {
|
|
48
|
+
throw new Error(`Alpha Vantage note (likely rate limited): ${data['Note']}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse TIME_SERIES_DAILY_ADJUSTED into flat array sorted newest-first */
|
|
55
|
+
function parseDailySeries(raw: unknown): AVDailyPrice[] {
|
|
56
|
+
const data = raw as Record<string, unknown>;
|
|
57
|
+
const series = data['Time Series (Daily)'] as Record<string, Record<string, string>> | undefined;
|
|
58
|
+
if (!series) return [];
|
|
59
|
+
|
|
60
|
+
return Object.entries(series)
|
|
61
|
+
.map(([date, v]) => ({
|
|
62
|
+
date,
|
|
63
|
+
open: v['1. open'] ?? '0',
|
|
64
|
+
high: v['2. high'] ?? '0',
|
|
65
|
+
low: v['3. low'] ?? '0',
|
|
66
|
+
close: v['4. close'] ?? '0',
|
|
67
|
+
adjustedClose: v['5. adjusted close'] ?? '0',
|
|
68
|
+
volume: v['6. volume'] ?? '0',
|
|
69
|
+
dividendAmount: v['7. dividend amount'] ?? '0',
|
|
70
|
+
splitCoefficient: v['8. split coefficient'] ?? '1',
|
|
71
|
+
}))
|
|
72
|
+
.sort((a, b) => b.date.localeCompare(a.date)); // newest first
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parse a technical indicator response into flat array sorted newest-first */
|
|
76
|
+
function parseIndicator<T>(raw: unknown, key: string): T[] {
|
|
77
|
+
const data = raw as Record<string, unknown>;
|
|
78
|
+
const series = data[key] as Record<string, T> | undefined;
|
|
79
|
+
if (!series) return [];
|
|
80
|
+
return Object.entries(series)
|
|
81
|
+
.sort(([a], [b]) => b.localeCompare(a)) // newest first
|
|
82
|
+
.map(([, v]) => v);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fetch all 12 Alpha Vantage endpoints for a standard analysis.
|
|
87
|
+
* Calls are serialized with a short delay to avoid rate limits.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchAllData(ticker: string, apiKey: string): Promise<AllAVData> {
|
|
90
|
+
const t = ticker.toUpperCase();
|
|
91
|
+
const results: unknown[] = [];
|
|
92
|
+
|
|
93
|
+
const calls: Array<Record<string, string>> = [
|
|
94
|
+
{ function: 'OVERVIEW', symbol: t },
|
|
95
|
+
{ function: 'INCOME_STATEMENT', symbol: t },
|
|
96
|
+
{ function: 'BALANCE_SHEET', symbol: t },
|
|
97
|
+
{ function: 'CASH_FLOW', symbol: t },
|
|
98
|
+
{ function: 'EARNINGS', symbol: t },
|
|
99
|
+
{ function: 'TIME_SERIES_DAILY_ADJUSTED', symbol: t, outputsize: 'full' },
|
|
100
|
+
{ function: 'RSI', symbol: t, interval: 'daily', time_period: '14', series_type: 'close' },
|
|
101
|
+
{ function: 'MACD', symbol: t, interval: 'daily', series_type: 'close' },
|
|
102
|
+
{ function: 'BBANDS', symbol: t, interval: 'daily', time_period: '20', series_type: 'close' },
|
|
103
|
+
{ function: 'STOCH', symbol: t, interval: 'daily' },
|
|
104
|
+
{ function: 'ADX', symbol: t, interval: 'daily', time_period: '14' },
|
|
105
|
+
{ function: 'NEWS_SENTIMENT', tickers: t, limit: '50', sort: 'LATEST' },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < calls.length; i++) {
|
|
109
|
+
const call = calls[i];
|
|
110
|
+
if (i > 0) await sleep(RATE_DELAY_MS);
|
|
111
|
+
results.push(await avFetch(call!, apiKey));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [
|
|
115
|
+
overview, income, balance, cashflow, earnings,
|
|
116
|
+
dailyRaw, rsiRaw, macdRaw, bbandsRaw, stochRaw, adxRaw, newsRaw,
|
|
117
|
+
] = results;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
overview: overview as AVOverview,
|
|
121
|
+
income: income as AVIncomeStatement,
|
|
122
|
+
balance: balance as AVBalanceSheet,
|
|
123
|
+
cashflow: cashflow as AVCashFlow,
|
|
124
|
+
earnings: earnings as AVEarnings,
|
|
125
|
+
daily: parseDailySeries(dailyRaw),
|
|
126
|
+
rsi: parseIndicator<AVRSIEntry>(rsiRaw, 'Technical Analysis: RSI'),
|
|
127
|
+
macd: parseIndicator<AVMACDEntry>(macdRaw, 'Technical Analysis: MACD'),
|
|
128
|
+
bbands: parseIndicator<AVBBandsEntry>(bbandsRaw, 'Technical Analysis: BBANDS'),
|
|
129
|
+
stoch: parseIndicator<AVStochEntry>(stochRaw, 'Technical Analysis: STOCH'),
|
|
130
|
+
adx: parseIndicator<AVADXEntry>(adxRaw, 'Technical Analysis: ADX'),
|
|
131
|
+
news: newsRaw as AVNewsSentiment,
|
|
132
|
+
};
|
|
133
|
+
}
|