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
package/dist/cli/index.js
CHANGED
|
@@ -1552,7 +1552,7 @@ program.command("serve").description("Start the AgentBnB gateway server").option
|
|
|
1552
1552
|
process.exit(1);
|
|
1553
1553
|
}
|
|
1554
1554
|
const { ProcessGuard } = await import("../process-guard-CC7CNRQJ.js");
|
|
1555
|
-
const { ServiceCoordinator } = await import("../service-coordinator-
|
|
1555
|
+
const { ServiceCoordinator } = await import("../service-coordinator-UTKI4FRI.js");
|
|
1556
1556
|
const port = opts.port ? parseInt(opts.port, 10) : config.gateway_port;
|
|
1557
1557
|
const registryPort = parseInt(opts.registryPort, 10);
|
|
1558
1558
|
if (!Number.isFinite(port) || !Number.isFinite(registryPort)) {
|
package/dist/index.js
CHANGED
|
@@ -1929,10 +1929,13 @@ var CommandExecutor = class {
|
|
|
1929
1929
|
const timeout = cmdConfig.timeout_ms ?? 3e4;
|
|
1930
1930
|
const cwd = cmdConfig.working_dir ?? process.cwd();
|
|
1931
1931
|
let stdout;
|
|
1932
|
+
const env = { ...process.env };
|
|
1933
|
+
delete env["CLAUDECODE"];
|
|
1932
1934
|
try {
|
|
1933
|
-
const result = await execFileAsync2("/bin/sh", ["-c", interpolatedCommand], {
|
|
1935
|
+
const result = await execFileAsync2("/bin/sh", ["-c", `${interpolatedCommand} < /dev/null`], {
|
|
1934
1936
|
timeout,
|
|
1935
1937
|
cwd,
|
|
1938
|
+
env,
|
|
1936
1939
|
maxBuffer: 10 * 1024 * 1024
|
|
1937
1940
|
// 10 MB
|
|
1938
1941
|
});
|
|
@@ -777,10 +777,13 @@ var CommandExecutor = class {
|
|
|
777
777
|
const timeout = cmdConfig.timeout_ms ?? 3e4;
|
|
778
778
|
const cwd = cmdConfig.working_dir ?? process.cwd();
|
|
779
779
|
let stdout;
|
|
780
|
+
const env = { ...process.env };
|
|
781
|
+
delete env["CLAUDECODE"];
|
|
780
782
|
try {
|
|
781
|
-
const result = await execFileAsync2("/bin/sh", ["-c", interpolatedCommand], {
|
|
783
|
+
const result = await execFileAsync2("/bin/sh", ["-c", `${interpolatedCommand} < /dev/null`], {
|
|
782
784
|
timeout,
|
|
783
785
|
cwd,
|
|
786
|
+
env,
|
|
784
787
|
maxBuffer: 10 * 1024 * 1024
|
|
785
788
|
// 10 MB
|
|
786
789
|
});
|
|
@@ -3234,8 +3237,10 @@ function createRegistryServer(opts) {
|
|
|
3234
3237
|
const __filename = fileURLToPath(import.meta.url);
|
|
3235
3238
|
const __dirname = dirname(__filename);
|
|
3236
3239
|
const hubDistCandidates = [
|
|
3240
|
+
join(__dirname, "../hub/dist"),
|
|
3241
|
+
// When in dist/ (tsup chunk, e.g. dist/server-XYZ.js)
|
|
3237
3242
|
join(__dirname, "../../hub/dist"),
|
|
3238
|
-
// When
|
|
3243
|
+
// When in dist/registry/ or dist/cli/
|
|
3239
3244
|
join(__dirname, "../../../hub/dist")
|
|
3240
3245
|
// Fallback for alternative layouts
|
|
3241
3246
|
];
|
|
@@ -934,10 +934,13 @@ var CommandExecutor = class {
|
|
|
934
934
|
const timeout = cmdConfig.timeout_ms ?? 3e4;
|
|
935
935
|
const cwd = cmdConfig.working_dir ?? process.cwd();
|
|
936
936
|
let stdout;
|
|
937
|
+
const env = { ...process.env };
|
|
938
|
+
delete env["CLAUDECODE"];
|
|
937
939
|
try {
|
|
938
|
-
const result = await execFileAsync2("/bin/sh", ["-c", interpolatedCommand], {
|
|
940
|
+
const result = await execFileAsync2("/bin/sh", ["-c", `${interpolatedCommand} < /dev/null`], {
|
|
939
941
|
timeout,
|
|
940
942
|
cwd,
|
|
943
|
+
env,
|
|
941
944
|
maxBuffer: 10 * 1024 * 1024
|
|
942
945
|
// 10 MB
|
|
943
946
|
});
|
|
@@ -4035,8 +4038,10 @@ function createRegistryServer(opts) {
|
|
|
4035
4038
|
const __filename = fileURLToPath(import.meta.url);
|
|
4036
4039
|
const __dirname = dirname2(__filename);
|
|
4037
4040
|
const hubDistCandidates = [
|
|
4041
|
+
join3(__dirname, "../hub/dist"),
|
|
4042
|
+
// When in dist/ (tsup chunk, e.g. dist/server-XYZ.js)
|
|
4038
4043
|
join3(__dirname, "../../hub/dist"),
|
|
4039
|
-
// When
|
|
4044
|
+
// When in dist/registry/ or dist/cli/
|
|
4040
4045
|
join3(__dirname, "../../../hub/dist")
|
|
4041
4046
|
// Fallback for alternative layouts
|
|
4042
4047
|
];
|
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentbnb/skill-deep-stock-analyst",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Deep quantitative stock analysis: 12-endpoint Alpha Vantage pipeline + 5 analysis modules + Gemini thesis",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"test": "node --experimental-vm-modules node_modules/.bin/jest --testPathPattern=tests/",
|
|
11
|
+
"dev": "tsx src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@google/generative-ai": "^0.21.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^22.0.0",
|
|
18
|
+
"typescript": "^5.5.0",
|
|
19
|
+
"tsx": "^4.19.0"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AVOverview,
|
|
3
|
+
AVIncomeStatement,
|
|
4
|
+
AVBalanceSheet,
|
|
5
|
+
AVCashFlow,
|
|
6
|
+
AVEarnings,
|
|
7
|
+
} from '../api/types.js';
|
|
8
|
+
import {
|
|
9
|
+
scoreMetric,
|
|
10
|
+
scoreMetricInverse,
|
|
11
|
+
weightedAvg,
|
|
12
|
+
calcYoYGrowth,
|
|
13
|
+
scoreGrowth,
|
|
14
|
+
scoreSurprise,
|
|
15
|
+
countConsecutiveBeats,
|
|
16
|
+
sp,
|
|
17
|
+
} from './utils.js';
|
|
18
|
+
|
|
19
|
+
export interface FinancialHealth {
|
|
20
|
+
profitability_score: number;
|
|
21
|
+
growth_score: number;
|
|
22
|
+
leverage_score: number;
|
|
23
|
+
efficiency_score: number;
|
|
24
|
+
composite: number;
|
|
25
|
+
red_flags: string[];
|
|
26
|
+
green_flags: string[];
|
|
27
|
+
raw: {
|
|
28
|
+
grossMarginPct: number;
|
|
29
|
+
operatingMarginPct: number;
|
|
30
|
+
netMarginPct: number;
|
|
31
|
+
roe: number;
|
|
32
|
+
debtToEquity: number;
|
|
33
|
+
currentRatio: number;
|
|
34
|
+
revenueGrowthPct: number;
|
|
35
|
+
earningsGrowthPct: number;
|
|
36
|
+
lastSurpisePct: number;
|
|
37
|
+
consecutiveBeats: number;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function calcInterestCoverage(report: AVIncomeStatement['annualReports'][number] | undefined): number {
|
|
42
|
+
if (!report) return 5;
|
|
43
|
+
const ebit = sp(report.ebit);
|
|
44
|
+
const interest = sp(report.interestExpense);
|
|
45
|
+
if (interest === 0) return 10; // no debt
|
|
46
|
+
return Math.abs(ebit / interest);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function calcROIC(
|
|
50
|
+
income: AVIncomeStatement['annualReports'][number] | undefined,
|
|
51
|
+
balance: AVBalanceSheet['annualReports'][number] | undefined,
|
|
52
|
+
): number {
|
|
53
|
+
if (!income || !balance) return 0;
|
|
54
|
+
const nopat = sp(income.ebit) * 0.79; // rough 21% tax
|
|
55
|
+
const investedCapital =
|
|
56
|
+
sp(balance.totalShareholderEquity) + sp(balance.longTermDebt) + sp(balance.shortTermDebt);
|
|
57
|
+
if (investedCapital === 0) return 0;
|
|
58
|
+
return nopat / investedCapital;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function analyzeFinancialHealth(
|
|
62
|
+
overview: AVOverview,
|
|
63
|
+
income: AVIncomeStatement,
|
|
64
|
+
balance: AVBalanceSheet,
|
|
65
|
+
cashflow: AVCashFlow,
|
|
66
|
+
earnings: AVEarnings,
|
|
67
|
+
): FinancialHealth {
|
|
68
|
+
const red_flags: string[] = [];
|
|
69
|
+
const green_flags: string[] = [];
|
|
70
|
+
|
|
71
|
+
// === Profitability ===
|
|
72
|
+
const grossProfit = sp(overview.GrossProfitTTM);
|
|
73
|
+
const revenue = sp(overview.RevenueTTM);
|
|
74
|
+
const grossMarginPct = revenue > 0 ? (grossProfit / revenue) * 100 : 0;
|
|
75
|
+
const operatingMarginPct = sp(overview.OperatingMarginTTM) * 100;
|
|
76
|
+
const netMarginPct = sp(overview.ProfitMargin) * 100;
|
|
77
|
+
const roe = sp(overview.ReturnOnEquityTTM) * 100;
|
|
78
|
+
|
|
79
|
+
if (grossMarginPct > 60) green_flags.push(`Gross margin ${grossMarginPct.toFixed(1)}% — strong pricing power`);
|
|
80
|
+
if (netMarginPct < 0) red_flags.push(`Net margin negative at ${netMarginPct.toFixed(1)}%`);
|
|
81
|
+
if (roe > 20) green_flags.push(`ROE ${roe.toFixed(1)}% — excellent capital efficiency`);
|
|
82
|
+
if (roe < 0) red_flags.push(`ROE negative at ${roe.toFixed(1)}% — destroying shareholder value`);
|
|
83
|
+
|
|
84
|
+
const profitability_score = weightedAvg([
|
|
85
|
+
[scoreMetricInverse(grossMarginPct, { excellent: 60, good: 40, fair: 25, poor: 10 }), 0.30],
|
|
86
|
+
[scoreMetricInverse(operatingMarginPct, { excellent: 25, good: 15, fair: 8, poor: 0 }), 0.35],
|
|
87
|
+
[scoreMetricInverse(roe, { excellent: 25, good: 15, fair: 8, poor: 0 }), 0.35],
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
// === Growth ===
|
|
91
|
+
const revenueGrowthPct = calcYoYGrowth(income.quarterlyReports, 'totalRevenue');
|
|
92
|
+
const earningsGrowthPct = calcYoYGrowth(income.quarterlyReports, 'netIncome');
|
|
93
|
+
const fcfGrowthPct = calcYoYGrowth(cashflow.quarterlyReports, 'operatingCashflow');
|
|
94
|
+
|
|
95
|
+
const lastEarnings = earnings.quarterlyEarnings[0];
|
|
96
|
+
const lastSurpisePct = sp(lastEarnings?.surprisePercentage);
|
|
97
|
+
if (lastSurpisePct > 10) green_flags.push(`Last earnings beat estimates by ${lastSurpisePct.toFixed(1)}%`);
|
|
98
|
+
if (lastSurpisePct < -10) red_flags.push(`Last earnings missed estimates by ${Math.abs(lastSurpisePct).toFixed(1)}%`);
|
|
99
|
+
|
|
100
|
+
const consecutiveBeats = countConsecutiveBeats(earnings.quarterlyEarnings);
|
|
101
|
+
if (consecutiveBeats >= 4) green_flags.push(`${consecutiveBeats} consecutive quarters of earnings beats`);
|
|
102
|
+
if (revenueGrowthPct > 20) green_flags.push(`Revenue growing ${revenueGrowthPct.toFixed(1)}% YoY`);
|
|
103
|
+
if (revenueGrowthPct < -10) red_flags.push(`Revenue declining ${Math.abs(revenueGrowthPct).toFixed(1)}% YoY`);
|
|
104
|
+
|
|
105
|
+
const growth_score = weightedAvg([
|
|
106
|
+
[scoreGrowth(revenueGrowthPct), 0.35],
|
|
107
|
+
[scoreGrowth(earningsGrowthPct), 0.35],
|
|
108
|
+
[scoreGrowth(fcfGrowthPct), 0.20],
|
|
109
|
+
[scoreSurprise(lastSurpisePct), 0.10],
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
// === Leverage ===
|
|
113
|
+
const debtToEquity = sp(overview.DebtToEquity);
|
|
114
|
+
const currentRatio = sp(overview.CurrentRatio);
|
|
115
|
+
const interestCoverage = calcInterestCoverage(income.annualReports[0]);
|
|
116
|
+
|
|
117
|
+
if (debtToEquity > 2.0) red_flags.push(`Debt/Equity ${debtToEquity.toFixed(2)} — heavily leveraged`);
|
|
118
|
+
if (currentRatio < 1.0) red_flags.push(`Current ratio ${currentRatio.toFixed(2)} — potential liquidity risk`);
|
|
119
|
+
if (currentRatio > 2.0) green_flags.push(`Current ratio ${currentRatio.toFixed(2)} — strong liquidity`);
|
|
120
|
+
if (interestCoverage < 2.0) red_flags.push(`Interest coverage ${interestCoverage.toFixed(1)}x — thin margin`);
|
|
121
|
+
if (debtToEquity < 0.3) green_flags.push(`Low leverage: Debt/Equity ${debtToEquity.toFixed(2)}`);
|
|
122
|
+
|
|
123
|
+
const leverage_score = weightedAvg([
|
|
124
|
+
[scoreMetric(debtToEquity, { excellent: 0.3, good: 0.8, fair: 1.5, poor: 3.0 }), 0.40],
|
|
125
|
+
[scoreMetricInverse(currentRatio, { excellent: 2.5, good: 1.5, fair: 1.0, poor: 0.5 }), 0.30],
|
|
126
|
+
[scoreMetricInverse(interestCoverage, { excellent: 10, good: 5, fair: 2, poor: 1 }), 0.30],
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
// === Efficiency ===
|
|
130
|
+
const totalAssets = sp(balance.annualReports[0]?.totalAssets);
|
|
131
|
+
const assetTurnover = totalAssets > 0 ? revenue / totalAssets : 0;
|
|
132
|
+
const roic = calcROIC(income.annualReports[0], balance.annualReports[0]);
|
|
133
|
+
|
|
134
|
+
const efficiency_score = weightedAvg([
|
|
135
|
+
[scoreMetricInverse(assetTurnover, { excellent: 1.5, good: 1.0, fair: 0.5, poor: 0.2 }), 0.50],
|
|
136
|
+
[scoreMetricInverse(roic * 100, { excellent: 20, good: 12, fair: 7, poor: 0 }), 0.50],
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const composite = weightedAvg([
|
|
140
|
+
[profitability_score, 0.30],
|
|
141
|
+
[growth_score, 0.30],
|
|
142
|
+
[leverage_score, 0.20],
|
|
143
|
+
[efficiency_score, 0.20],
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
profitability_score,
|
|
148
|
+
growth_score,
|
|
149
|
+
leverage_score,
|
|
150
|
+
efficiency_score,
|
|
151
|
+
composite,
|
|
152
|
+
red_flags,
|
|
153
|
+
green_flags,
|
|
154
|
+
raw: {
|
|
155
|
+
grossMarginPct,
|
|
156
|
+
operatingMarginPct,
|
|
157
|
+
netMarginPct,
|
|
158
|
+
roe,
|
|
159
|
+
debtToEquity,
|
|
160
|
+
currentRatio,
|
|
161
|
+
revenueGrowthPct,
|
|
162
|
+
earningsGrowthPct,
|
|
163
|
+
lastSurpisePct,
|
|
164
|
+
consecutiveBeats,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AVNewsSentiment } from '../api/types.js';
|
|
2
|
+
import { mapRange, sp } from './utils.js';
|
|
3
|
+
|
|
4
|
+
export interface SentimentScore {
|
|
5
|
+
news_sentiment: number; // -1 to +1
|
|
6
|
+
news_volume: number; // relevant articles in feed
|
|
7
|
+
bullish_ratio: number; // 0–1
|
|
8
|
+
topic_breakdown: Record<string, number>; // topic → avg relevance
|
|
9
|
+
key_headlines: string[]; // top 5 by sentiment magnitude
|
|
10
|
+
composite: number; // 0–100
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function analyzeSentiment(newsData: AVNewsSentiment, ticker: string): SentimentScore {
|
|
14
|
+
const articles = newsData.feed ?? [];
|
|
15
|
+
const t = ticker.toUpperCase();
|
|
16
|
+
|
|
17
|
+
// Filter to articles where this ticker has relevance_score > 0.5
|
|
18
|
+
const relevant = articles.filter((a) => {
|
|
19
|
+
const ts = a.ticker_sentiment?.find(
|
|
20
|
+
(s) => s.ticker === t && sp(s.relevance_score) > 0.5,
|
|
21
|
+
);
|
|
22
|
+
return !!ts;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
let totalSentiment = 0;
|
|
26
|
+
let bullishCount = 0;
|
|
27
|
+
|
|
28
|
+
for (const article of relevant) {
|
|
29
|
+
const ts = article.ticker_sentiment.find((s) => s.ticker === t);
|
|
30
|
+
if (!ts) continue;
|
|
31
|
+
const score = sp(ts.ticker_sentiment_score);
|
|
32
|
+
totalSentiment += score;
|
|
33
|
+
if (score > 0.15) bullishCount++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const news_sentiment = relevant.length > 0 ? totalSentiment / relevant.length : 0;
|
|
37
|
+
const bullish_ratio = relevant.length > 0 ? bullishCount / relevant.length : 0.5;
|
|
38
|
+
|
|
39
|
+
// Topic breakdown: topic → avg relevance across articles
|
|
40
|
+
const topicMap: Record<string, number[]> = {};
|
|
41
|
+
for (const article of relevant) {
|
|
42
|
+
for (const topic of article.topics ?? []) {
|
|
43
|
+
if (!topicMap[topic.topic]) topicMap[topic.topic] = [];
|
|
44
|
+
topicMap[topic.topic]!.push(sp(topic.relevance_score));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const topic_breakdown: Record<string, number> = {};
|
|
48
|
+
for (const [topic, scores] of Object.entries(topicMap)) {
|
|
49
|
+
topic_breakdown[topic] = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Top 5 headlines sorted by overall sentiment magnitude
|
|
53
|
+
const key_headlines = relevant
|
|
54
|
+
.sort((a, b) =>
|
|
55
|
+
Math.abs(sp(b.overall_sentiment_score)) - Math.abs(sp(a.overall_sentiment_score)),
|
|
56
|
+
)
|
|
57
|
+
.slice(0, 5)
|
|
58
|
+
.map((a) => `[${a.overall_sentiment_label}] ${a.title}`);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
news_sentiment,
|
|
62
|
+
news_volume: relevant.length,
|
|
63
|
+
bullish_ratio,
|
|
64
|
+
topic_breakdown,
|
|
65
|
+
key_headlines,
|
|
66
|
+
composite: mapRange(news_sentiment, -1, 1, 0, 100),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|