agentbnb 5.1.0 → 5.1.2
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 +9 -5
- package/dist/index.js +4 -1
- package/dist/{service-coordinator-5R4LQW6L.js → service-coordinator-UTKI4FRI.js} +7 -2
- package/dist/skills/agentbnb/bootstrap.js +22 -7
- package/package.json +1 -1
- package/skills/agentbnb/SKILL.md +10 -2
- package/skills/agentbnb/bootstrap.ts +17 -6
- 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,188 @@
|
|
|
1
|
+
import type { AVDailyPrice } from '../api/types.js';
|
|
2
|
+
import type { ValuationScore } from './valuation.js';
|
|
3
|
+
import type { TechnicalScore } from './technicals.js';
|
|
4
|
+
import type { FinancialHealth } from './financial-health.js';
|
|
5
|
+
import type { SentimentScore } from './sentiment.js';
|
|
6
|
+
import { sp } from './utils.js';
|
|
7
|
+
|
|
8
|
+
export type InvestmentStyle = 'growth' | 'value' | 'momentum' | 'hybrid';
|
|
9
|
+
export type SignalVerdict = 'strong_buy' | 'buy' | 'hold' | 'sell' | 'strong_sell';
|
|
10
|
+
|
|
11
|
+
interface StyleWeights {
|
|
12
|
+
valuation: number;
|
|
13
|
+
technicals: number;
|
|
14
|
+
financials: number;
|
|
15
|
+
sentiment: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getStyleWeights(style: InvestmentStyle): StyleWeights {
|
|
19
|
+
const presets: Record<InvestmentStyle, StyleWeights> = {
|
|
20
|
+
growth: { valuation: 0.15, technicals: 0.25, financials: 0.40, sentiment: 0.20 },
|
|
21
|
+
value: { valuation: 0.40, technicals: 0.15, financials: 0.30, sentiment: 0.15 },
|
|
22
|
+
momentum: { valuation: 0.10, technicals: 0.45, financials: 0.15, sentiment: 0.30 },
|
|
23
|
+
hybrid: { valuation: 0.25, technicals: 0.30, financials: 0.25, sentiment: 0.20 },
|
|
24
|
+
};
|
|
25
|
+
return presets[style];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Find local support levels from price data */
|
|
29
|
+
function findSupportLevels(daily: AVDailyPrice[], count: number): number[] {
|
|
30
|
+
const prices = daily.slice(0, 90).map((d) => sp(d.low));
|
|
31
|
+
const levels: number[] = [];
|
|
32
|
+
|
|
33
|
+
for (let i = 2; i < prices.length - 2; i++) {
|
|
34
|
+
const p = prices[i] ?? 0;
|
|
35
|
+
if (
|
|
36
|
+
p <= (prices[i - 1] ?? 0) &&
|
|
37
|
+
p <= (prices[i - 2] ?? 0) &&
|
|
38
|
+
p <= (prices[i + 1] ?? 0) &&
|
|
39
|
+
p <= (prices[i + 2] ?? 0)
|
|
40
|
+
) {
|
|
41
|
+
levels.push(parseFloat(p.toFixed(2)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Cluster nearby levels (within 1%)
|
|
46
|
+
const clustered: number[] = [];
|
|
47
|
+
for (const lvl of levels.sort((a, b) => b - a)) {
|
|
48
|
+
const isDuplicate = clustered.some((c) => Math.abs(c - lvl) / lvl < 0.01);
|
|
49
|
+
if (!isDuplicate) clustered.push(lvl);
|
|
50
|
+
if (clustered.length >= count) break;
|
|
51
|
+
}
|
|
52
|
+
return clustered;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Find local resistance levels from price data */
|
|
56
|
+
function findResistanceLevels(daily: AVDailyPrice[], count: number): number[] {
|
|
57
|
+
const prices = daily.slice(0, 90).map((d) => sp(d.high));
|
|
58
|
+
const levels: number[] = [];
|
|
59
|
+
|
|
60
|
+
for (let i = 2; i < prices.length - 2; i++) {
|
|
61
|
+
const p = prices[i] ?? 0;
|
|
62
|
+
if (
|
|
63
|
+
p >= (prices[i - 1] ?? 0) &&
|
|
64
|
+
p >= (prices[i - 2] ?? 0) &&
|
|
65
|
+
p >= (prices[i + 1] ?? 0) &&
|
|
66
|
+
p >= (prices[i + 2] ?? 0)
|
|
67
|
+
) {
|
|
68
|
+
levels.push(parseFloat(p.toFixed(2)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const clustered: number[] = [];
|
|
73
|
+
for (const lvl of levels.sort((a, b) => b - a)) {
|
|
74
|
+
const isDuplicate = clustered.some((c) => Math.abs(c - lvl) / lvl < 0.01);
|
|
75
|
+
if (!isDuplicate) clustered.push(lvl);
|
|
76
|
+
if (clustered.length >= count) break;
|
|
77
|
+
}
|
|
78
|
+
return clustered;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** How much do the four sub-scores agree? 0–1 */
|
|
82
|
+
function calculateAgreement(
|
|
83
|
+
valuation: ValuationScore,
|
|
84
|
+
technicals: TechnicalScore,
|
|
85
|
+
financials: FinancialHealth,
|
|
86
|
+
sentiment: SentimentScore,
|
|
87
|
+
): number {
|
|
88
|
+
const scores = [
|
|
89
|
+
valuation.composite,
|
|
90
|
+
technicals.composite,
|
|
91
|
+
financials.composite,
|
|
92
|
+
sentiment.composite,
|
|
93
|
+
];
|
|
94
|
+
const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
95
|
+
const variance = scores.reduce((acc, s) => acc + (s - mean) ** 2, 0) / scores.length;
|
|
96
|
+
const stdDev = Math.sqrt(variance);
|
|
97
|
+
// Low stdDev = high agreement → high confidence
|
|
98
|
+
return Math.max(0, 1 - stdDev / 30);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Data completeness: 0–1 based on whether modules have real data */
|
|
102
|
+
function calculateCompleteness(
|
|
103
|
+
valuation: ValuationScore,
|
|
104
|
+
technicals: TechnicalScore,
|
|
105
|
+
financials: FinancialHealth,
|
|
106
|
+
sentiment: SentimentScore,
|
|
107
|
+
): number {
|
|
108
|
+
let score = 0;
|
|
109
|
+
if (valuation.raw.pe > 0) score += 0.25;
|
|
110
|
+
if (technicals.raw.price > 0) score += 0.25;
|
|
111
|
+
if (financials.raw.revenueGrowthPct !== 0) score += 0.25;
|
|
112
|
+
if (sentiment.news_volume > 3) score += 0.25;
|
|
113
|
+
return score;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CompositeSignal {
|
|
117
|
+
signal: SignalVerdict;
|
|
118
|
+
confidence: number; // 0–1
|
|
119
|
+
composite_score: number; // 0–100
|
|
120
|
+
support_levels: number[];
|
|
121
|
+
resistance_levels: number[];
|
|
122
|
+
key_factors: string[];
|
|
123
|
+
risk_factors: string[];
|
|
124
|
+
data_completeness: number; // 0–1
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function generateCompositeSignal(
|
|
128
|
+
valuation: ValuationScore,
|
|
129
|
+
technicals: TechnicalScore,
|
|
130
|
+
financials: FinancialHealth,
|
|
131
|
+
sentiment: SentimentScore,
|
|
132
|
+
daily: AVDailyPrice[],
|
|
133
|
+
style: InvestmentStyle,
|
|
134
|
+
): CompositeSignal {
|
|
135
|
+
const w = getStyleWeights(style);
|
|
136
|
+
|
|
137
|
+
const composite_score =
|
|
138
|
+
valuation.composite * w.valuation +
|
|
139
|
+
technicals.composite * w.technicals +
|
|
140
|
+
financials.composite * w.financials +
|
|
141
|
+
sentiment.composite * w.sentiment;
|
|
142
|
+
|
|
143
|
+
const signal: SignalVerdict =
|
|
144
|
+
composite_score > 80 ? 'strong_buy'
|
|
145
|
+
: composite_score > 62 ? 'buy'
|
|
146
|
+
: composite_score > 42 ? 'hold'
|
|
147
|
+
: composite_score > 25 ? 'sell'
|
|
148
|
+
: 'strong_sell';
|
|
149
|
+
|
|
150
|
+
const agreementScore = calculateAgreement(valuation, technicals, financials, sentiment);
|
|
151
|
+
const data_completeness = calculateCompleteness(valuation, technicals, financials, sentiment);
|
|
152
|
+
const confidence = parseFloat((agreementScore * 0.6 + data_completeness * 0.4).toFixed(2));
|
|
153
|
+
|
|
154
|
+
const support_levels = findSupportLevels(daily, 3);
|
|
155
|
+
const resistance_levels = findResistanceLevels(daily, 3);
|
|
156
|
+
|
|
157
|
+
// Key positive factors
|
|
158
|
+
const key_factors: string[] = [];
|
|
159
|
+
if (valuation.verdict === 'undervalued') key_factors.push(`Undervalued: composite valuation ${valuation.composite.toFixed(0)}/100`);
|
|
160
|
+
if (financials.growth_score > 70) key_factors.push(`Strong growth: revenue +${financials.raw.revenueGrowthPct.toFixed(1)}% YoY`);
|
|
161
|
+
if (technicals.signals.some((s) => s.type === 'bullish' && s.strength >= 4)) {
|
|
162
|
+
const sig = technicals.signals.find((s) => s.type === 'bullish' && s.strength >= 4);
|
|
163
|
+
if (sig) key_factors.push(`Technical: ${sig.name}`);
|
|
164
|
+
}
|
|
165
|
+
if (sentiment.bullish_ratio > 0.7) key_factors.push(`Bullish sentiment: ${(sentiment.bullish_ratio * 100).toFixed(0)}% positive coverage`);
|
|
166
|
+
for (const flag of financials.green_flags.slice(0, 2)) key_factors.push(flag);
|
|
167
|
+
|
|
168
|
+
// Key risk factors
|
|
169
|
+
const risk_factors: string[] = [];
|
|
170
|
+
if (valuation.verdict === 'expensive') risk_factors.push(`Expensive valuation: composite ${valuation.composite.toFixed(0)}/100`);
|
|
171
|
+
if (technicals.signals.some((s) => s.type === 'bearish' && s.strength >= 4)) {
|
|
172
|
+
const sig = technicals.signals.find((s) => s.type === 'bearish' && s.strength >= 4);
|
|
173
|
+
if (sig) risk_factors.push(`Technical: ${sig.name}`);
|
|
174
|
+
}
|
|
175
|
+
for (const flag of financials.red_flags.slice(0, 2)) risk_factors.push(flag);
|
|
176
|
+
if (sentiment.bullish_ratio < 0.3) risk_factors.push(`Negative sentiment: only ${(sentiment.bullish_ratio * 100).toFixed(0)}% positive coverage`);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
signal,
|
|
180
|
+
confidence,
|
|
181
|
+
composite_score: parseFloat(composite_score.toFixed(1)),
|
|
182
|
+
support_levels,
|
|
183
|
+
resistance_levels,
|
|
184
|
+
key_factors: key_factors.slice(0, 3),
|
|
185
|
+
risk_factors: risk_factors.slice(0, 3),
|
|
186
|
+
data_completeness,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -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
|
+
}
|