@lightcone-ai/daemon 0.9.79 → 0.10.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/mcp-servers/mysql/index.js +13 -5
- package/mcp-servers/mysql/manifest.json +16 -0
- package/mcp-servers/official/company-fundamentals/index.js +34 -0
- package/mcp-servers/official/company-fundamentals/manifest.json +14 -0
- package/mcp-servers/official/compliance-check/index.js +49 -0
- package/mcp-servers/official/compliance-check/manifest.json +14 -0
- package/mcp-servers/official/industry-report/index.js +34 -0
- package/mcp-servers/official/industry-report/manifest.json +14 -0
- package/mcp-servers/official/market-data-query/index.js +34 -0
- package/mcp-servers/official/market-data-query/manifest.json +14 -0
- package/mcp-servers/official/portfolio-analysis/index.js +74 -0
- package/mcp-servers/official/portfolio-analysis/manifest.json +14 -0
- package/mcp-servers/official/portfolio-read/index.js +34 -0
- package/mcp-servers/official/portfolio-read/manifest.json +14 -0
- package/mcp-servers/official/research-fetch/index.js +35 -0
- package/mcp-servers/official/research-fetch/manifest.json +14 -0
- package/mcp-servers/official/risk-metrics/index.js +34 -0
- package/mcp-servers/official/risk-metrics/manifest.json +14 -0
- package/mcp-servers/official-common/fixtures.js +273 -0
- package/mcp-servers/official-common/server.js +34 -0
- package/mcp-servers/platform/manifest.json +15 -0
- package/mcp-servers/portfolio-analysis/core.js +592 -0
- package/mcp-servers/portfolio-analysis/index.js +45 -0
- package/mcp-servers/portfolio-analysis/package-lock.json +1139 -0
- package/mcp-servers/portfolio-analysis/package.json +10 -0
- package/mcp-servers/portfolio-read/core.js +330 -0
- package/mcp-servers/portfolio-read/index.js +127 -0
- package/mcp-servers/portfolio-read/package-lock.json +1243 -0
- package/mcp-servers/portfolio-read/package.json +11 -0
- package/mcp-servers/publisher/index.js +14 -14
- package/mcp-servers/publisher/manifest.json +16 -0
- package/package.json +1 -1
- package/src/agent-manager.js +761 -188
- package/src/chat-bridge.js +567 -92
- package/src/connection.js +1 -1
- package/src/drivers/claude.js +48 -45
- package/src/drivers/codex.js +110 -8
- package/src/drivers/kimi.js +80 -35
- package/src/governance-state.js +89 -0
- package/src/index.js +34 -16
- package/src/lease-window.js +8 -0
- package/src/mcp-config.js +52 -23
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
const DEFAULT_ASSET_CLASS = 'equity';
|
|
2
|
+
const DISCLAIMER = '以上内容为信息整合与分析思路,不构成投资建议。具体决策请结合自身情况,投资有风险。';
|
|
3
|
+
|
|
4
|
+
function normalizeText(value) {
|
|
5
|
+
return String(value ?? '').trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeHeader(value) {
|
|
9
|
+
return normalizeText(value)
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/\s+/g, '_')
|
|
12
|
+
.replace(/[-/]+/g, '_');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toNumber(value) {
|
|
16
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
17
|
+
const raw = normalizeText(value);
|
|
18
|
+
if (!raw) return null;
|
|
19
|
+
const cleaned = raw
|
|
20
|
+
.replace(/,/g, '')
|
|
21
|
+
.replace(/\$/g, '')
|
|
22
|
+
.replace(/¥/g, '')
|
|
23
|
+
.replace(/%/g, '%');
|
|
24
|
+
const percent = cleaned.endsWith('%');
|
|
25
|
+
const numeric = Number(percent ? cleaned.slice(0, -1) : cleaned);
|
|
26
|
+
if (!Number.isFinite(numeric)) return null;
|
|
27
|
+
return percent ? numeric / 100 : numeric;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function round(value, digits = 6) {
|
|
31
|
+
if (!Number.isFinite(value)) return null;
|
|
32
|
+
const factor = 10 ** digits;
|
|
33
|
+
return Math.round(value * factor) / factor;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseMaybeJson(input, label) {
|
|
37
|
+
if (input == null) return null;
|
|
38
|
+
if (typeof input === 'string') {
|
|
39
|
+
const trimmed = input.trim();
|
|
40
|
+
if (!trimmed) return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(trimmed);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`${label} is not valid JSON: ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (typeof input === 'object') return input;
|
|
48
|
+
throw new Error(`${label} must be an object/array or JSON string`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pickValue(raw, keys) {
|
|
52
|
+
for (const key of keys) {
|
|
53
|
+
if (Object.prototype.hasOwnProperty.call(raw, key) && normalizeText(raw[key])) {
|
|
54
|
+
return raw[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeHolding(raw, index) {
|
|
61
|
+
const normalized = {};
|
|
62
|
+
for (const [key, value] of Object.entries(raw ?? {})) {
|
|
63
|
+
normalized[normalizeHeader(key)] = value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const symbol = normalizeText(
|
|
67
|
+
pickValue(normalized, ['symbol', 'ticker', 'code', 'security_code', '证券代码', '股票代码'])
|
|
68
|
+
).toUpperCase();
|
|
69
|
+
if (!symbol) return null;
|
|
70
|
+
|
|
71
|
+
const name = normalizeText(
|
|
72
|
+
pickValue(normalized, ['name', 'asset_name', 'security_name', '证券名称', '股票名称'])
|
|
73
|
+
) || symbol;
|
|
74
|
+
|
|
75
|
+
const quantity = toNumber(pickValue(normalized, ['quantity', 'qty', 'shares', 'position', '持仓', '数量']));
|
|
76
|
+
const marketPrice = toNumber(pickValue(normalized, ['market_price', 'price', 'last_price', 'close', '现价', '最新价']));
|
|
77
|
+
const avgCost = toNumber(pickValue(normalized, ['avg_cost', 'average_cost', 'cost', 'cost_price', '买入均价', '成本价']));
|
|
78
|
+
|
|
79
|
+
let marketValue = toNumber(
|
|
80
|
+
pickValue(normalized, ['market_value', 'value', 'position_value', '持仓市值', '市值'])
|
|
81
|
+
);
|
|
82
|
+
if (marketValue == null && quantity != null && marketPrice != null) marketValue = quantity * marketPrice;
|
|
83
|
+
if (marketValue == null && quantity != null && avgCost != null) marketValue = quantity * avgCost;
|
|
84
|
+
|
|
85
|
+
const sector = normalizeText(
|
|
86
|
+
pickValue(normalized, ['sector', 'industry', '行业', '板块'])
|
|
87
|
+
) || 'Unknown';
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
id: `pos_${index + 1}`,
|
|
91
|
+
symbol,
|
|
92
|
+
name,
|
|
93
|
+
sector,
|
|
94
|
+
asset_class: normalizeText(pickValue(normalized, ['asset_class', 'asset_type', '资产类别'])) || DEFAULT_ASSET_CLASS,
|
|
95
|
+
quantity: round(quantity, 6),
|
|
96
|
+
market_price: round(marketPrice, 6),
|
|
97
|
+
avg_cost: round(avgCost, 6),
|
|
98
|
+
market_value: round(marketValue, 2),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeHoldingsInput(holdingsInput) {
|
|
103
|
+
const parsed = parseMaybeJson(holdingsInput, 'portfolio_json');
|
|
104
|
+
const list = Array.isArray(parsed)
|
|
105
|
+
? parsed
|
|
106
|
+
: (Array.isArray(parsed?.holdings) ? parsed.holdings : []);
|
|
107
|
+
|
|
108
|
+
if (list.length === 0) {
|
|
109
|
+
throw new Error('portfolio_json must contain holdings array with at least one position');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const holdings = [];
|
|
113
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
114
|
+
const normalized = normalizeHolding(list[i], i);
|
|
115
|
+
if (normalized) holdings.push(normalized);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (holdings.length === 0) {
|
|
119
|
+
throw new Error('portfolio_json has no valid holdings after normalization');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const valued = holdings.filter(item => item.market_value != null && item.market_value > 0);
|
|
123
|
+
const usingEqualWeight = valued.length === 0;
|
|
124
|
+
const totalMarketValue = usingEqualWeight
|
|
125
|
+
? holdings.length
|
|
126
|
+
: valued.reduce((sum, item) => sum + item.market_value, 0);
|
|
127
|
+
|
|
128
|
+
const weighted = holdings.map(item => {
|
|
129
|
+
const numerator = usingEqualWeight ? 1 : (item.market_value ?? 0);
|
|
130
|
+
const weight = totalMarketValue > 0 ? numerator / totalMarketValue : 0;
|
|
131
|
+
return {
|
|
132
|
+
...item,
|
|
133
|
+
weight,
|
|
134
|
+
effective_market_value: usingEqualWeight ? null : item.market_value,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
holdings: weighted,
|
|
140
|
+
total_market_value: usingEqualWeight ? null : round(totalMarketValue, 2),
|
|
141
|
+
using_equal_weight: usingEqualWeight,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeDate(value, fallback) {
|
|
146
|
+
const text = normalizeText(value);
|
|
147
|
+
if (!text) return fallback;
|
|
148
|
+
const ms = Date.parse(text);
|
|
149
|
+
if (!Number.isFinite(ms)) return fallback;
|
|
150
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeSeries(symbol, value) {
|
|
154
|
+
const parsed = parseMaybeJson(value, `market_data_json.${symbol}`);
|
|
155
|
+
|
|
156
|
+
let list = parsed;
|
|
157
|
+
if (!Array.isArray(list)) {
|
|
158
|
+
if (Array.isArray(parsed?.prices)) list = parsed.prices;
|
|
159
|
+
else if (Array.isArray(parsed?.history)) list = parsed.history;
|
|
160
|
+
else if (Array.isArray(parsed?.data)) list = parsed.data;
|
|
161
|
+
else list = [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (list.length === 0) return [];
|
|
165
|
+
|
|
166
|
+
if (typeof list[0] === 'number') {
|
|
167
|
+
return list
|
|
168
|
+
.map((close, idx) => ({ date: `idx_${idx}`, close: toNumber(close) }))
|
|
169
|
+
.filter(item => item.close != null && item.close > 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rows = [];
|
|
173
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
174
|
+
const item = list[i] ?? {};
|
|
175
|
+
const date = normalizeDate(item.date ?? item.day ?? item.trade_date ?? item.time, `idx_${i}`);
|
|
176
|
+
const close = toNumber(item.close ?? item.price ?? item.value ?? item.last);
|
|
177
|
+
if (close == null || close <= 0) continue;
|
|
178
|
+
rows.push({ date, close });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
rows.sort((a, b) => a.date.localeCompare(b.date));
|
|
182
|
+
return rows;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeMarketDataInput(marketDataInput) {
|
|
186
|
+
const parsed = parseMaybeJson(marketDataInput, 'market_data_json');
|
|
187
|
+
if (!parsed) return {};
|
|
188
|
+
|
|
189
|
+
if (Array.isArray(parsed)) {
|
|
190
|
+
const bySymbol = {};
|
|
191
|
+
for (const item of parsed) {
|
|
192
|
+
const symbol = normalizeText(item?.symbol).toUpperCase();
|
|
193
|
+
if (!symbol) continue;
|
|
194
|
+
bySymbol[symbol] = normalizeSeries(symbol, item?.series ?? item?.prices ?? item?.history ?? []);
|
|
195
|
+
}
|
|
196
|
+
return bySymbol;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const bySymbol = {};
|
|
200
|
+
for (const [symbol, series] of Object.entries(parsed)) {
|
|
201
|
+
const key = normalizeText(symbol).toUpperCase();
|
|
202
|
+
if (!key) continue;
|
|
203
|
+
bySymbol[key] = normalizeSeries(key, series);
|
|
204
|
+
}
|
|
205
|
+
return bySymbol;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function pearsonCorrelation(xs, ys) {
|
|
209
|
+
if (xs.length !== ys.length || xs.length < 2) return null;
|
|
210
|
+
|
|
211
|
+
const n = xs.length;
|
|
212
|
+
const sumX = xs.reduce((acc, v) => acc + v, 0);
|
|
213
|
+
const sumY = ys.reduce((acc, v) => acc + v, 0);
|
|
214
|
+
const avgX = sumX / n;
|
|
215
|
+
const avgY = sumY / n;
|
|
216
|
+
|
|
217
|
+
let numerator = 0;
|
|
218
|
+
let denomX = 0;
|
|
219
|
+
let denomY = 0;
|
|
220
|
+
for (let i = 0; i < n; i += 1) {
|
|
221
|
+
const dx = xs[i] - avgX;
|
|
222
|
+
const dy = ys[i] - avgY;
|
|
223
|
+
numerator += dx * dy;
|
|
224
|
+
denomX += dx * dx;
|
|
225
|
+
denomY += dy * dy;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (denomX <= 0 || denomY <= 0) return null;
|
|
229
|
+
return numerator / Math.sqrt(denomX * denomY);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildReturnMap(series) {
|
|
233
|
+
const returns = new Map();
|
|
234
|
+
for (let i = 1; i < series.length; i += 1) {
|
|
235
|
+
const prev = series[i - 1]?.close;
|
|
236
|
+
const curr = series[i]?.close;
|
|
237
|
+
if (!Number.isFinite(prev) || !Number.isFinite(curr) || prev <= 0) continue;
|
|
238
|
+
returns.set(series[i].date, (curr / prev) - 1);
|
|
239
|
+
}
|
|
240
|
+
return returns;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function buildCorrelationSection(holdings, marketDataBySymbol) {
|
|
244
|
+
const symbols = holdings
|
|
245
|
+
.map(item => item.symbol)
|
|
246
|
+
.filter(symbol => (marketDataBySymbol[symbol] ?? []).length >= 3);
|
|
247
|
+
|
|
248
|
+
if (symbols.length < 2) {
|
|
249
|
+
return {
|
|
250
|
+
available: false,
|
|
251
|
+
matrix: [],
|
|
252
|
+
avg_pairwise_correlation: null,
|
|
253
|
+
observations: 0,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const maxSymbols = 8;
|
|
258
|
+
const chosen = holdings
|
|
259
|
+
.filter(item => symbols.includes(item.symbol))
|
|
260
|
+
.sort((a, b) => b.weight - a.weight)
|
|
261
|
+
.slice(0, maxSymbols)
|
|
262
|
+
.map(item => item.symbol);
|
|
263
|
+
|
|
264
|
+
const returnsBySymbol = Object.fromEntries(
|
|
265
|
+
chosen.map(symbol => [symbol, buildReturnMap(marketDataBySymbol[symbol])])
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const matrix = [];
|
|
269
|
+
const corrValues = [];
|
|
270
|
+
|
|
271
|
+
for (let i = 0; i < chosen.length; i += 1) {
|
|
272
|
+
for (let j = i + 1; j < chosen.length; j += 1) {
|
|
273
|
+
const a = chosen[i];
|
|
274
|
+
const b = chosen[j];
|
|
275
|
+
const mapA = returnsBySymbol[a];
|
|
276
|
+
const mapB = returnsBySymbol[b];
|
|
277
|
+
const commonDates = [...mapA.keys()].filter(date => mapB.has(date));
|
|
278
|
+
const xs = commonDates.map(date => mapA.get(date));
|
|
279
|
+
const ys = commonDates.map(date => mapB.get(date));
|
|
280
|
+
const corr = pearsonCorrelation(xs, ys);
|
|
281
|
+
if (corr == null) continue;
|
|
282
|
+
|
|
283
|
+
const rounded = round(corr, 4);
|
|
284
|
+
matrix.push({ pair: [a, b], correlation: rounded, observations: commonDates.length });
|
|
285
|
+
corrValues.push(corr);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const avg = corrValues.length > 0
|
|
290
|
+
? corrValues.reduce((sum, value) => sum + value, 0) / corrValues.length
|
|
291
|
+
: null;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
available: matrix.length > 0,
|
|
295
|
+
matrix,
|
|
296
|
+
avg_pairwise_correlation: avg == null ? null : round(avg, 4),
|
|
297
|
+
observations: matrix.reduce((sum, item) => sum + item.observations, 0),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildDrawdownSection(holdings, marketDataBySymbol, maxPoints = 120) {
|
|
302
|
+
const symbols = holdings
|
|
303
|
+
.map(item => item.symbol)
|
|
304
|
+
.filter(symbol => (marketDataBySymbol[symbol] ?? []).length >= 2);
|
|
305
|
+
if (symbols.length === 0) {
|
|
306
|
+
return {
|
|
307
|
+
available: false,
|
|
308
|
+
max_drawdown: null,
|
|
309
|
+
curve: [],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const maps = {};
|
|
314
|
+
const baseClose = {};
|
|
315
|
+
const allDates = new Set();
|
|
316
|
+
|
|
317
|
+
for (const symbol of symbols) {
|
|
318
|
+
const series = marketDataBySymbol[symbol];
|
|
319
|
+
maps[symbol] = new Map(series.map(item => [item.date, item.close]));
|
|
320
|
+
baseClose[symbol] = series[0].close;
|
|
321
|
+
for (const item of series) allDates.add(item.date);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const sortedDates = [...allDates].sort();
|
|
325
|
+
const lastClose = {};
|
|
326
|
+
const curve = [];
|
|
327
|
+
|
|
328
|
+
for (const date of sortedDates) {
|
|
329
|
+
const availableSymbols = [];
|
|
330
|
+
for (const symbol of symbols) {
|
|
331
|
+
if (maps[symbol].has(date)) {
|
|
332
|
+
lastClose[symbol] = maps[symbol].get(date);
|
|
333
|
+
}
|
|
334
|
+
if (Number.isFinite(lastClose[symbol]) && Number.isFinite(baseClose[symbol]) && baseClose[symbol] > 0) {
|
|
335
|
+
availableSymbols.push(symbol);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (availableSymbols.length === 0) continue;
|
|
340
|
+
|
|
341
|
+
const totalWeight = availableSymbols.reduce((sum, symbol) => {
|
|
342
|
+
const weight = holdings.find(item => item.symbol === symbol)?.weight ?? 0;
|
|
343
|
+
return sum + weight;
|
|
344
|
+
}, 0);
|
|
345
|
+
|
|
346
|
+
if (totalWeight <= 0) continue;
|
|
347
|
+
|
|
348
|
+
const portfolioIndex = availableSymbols.reduce((sum, symbol) => {
|
|
349
|
+
const weight = holdings.find(item => item.symbol === symbol)?.weight ?? 0;
|
|
350
|
+
const normalizedPrice = lastClose[symbol] / baseClose[symbol];
|
|
351
|
+
return sum + (weight / totalWeight) * normalizedPrice;
|
|
352
|
+
}, 0) * 100;
|
|
353
|
+
|
|
354
|
+
curve.push({ date, value: round(portfolioIndex, 4) });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (curve.length < 2) {
|
|
358
|
+
return {
|
|
359
|
+
available: false,
|
|
360
|
+
max_drawdown: null,
|
|
361
|
+
curve,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let peak = curve[0].value;
|
|
366
|
+
let maxDrawdown = 0;
|
|
367
|
+
const withDrawdown = curve.map(point => {
|
|
368
|
+
if (point.value > peak) peak = point.value;
|
|
369
|
+
const drawdown = peak > 0 ? (point.value - peak) / peak : 0;
|
|
370
|
+
if (drawdown < maxDrawdown) maxDrawdown = drawdown;
|
|
371
|
+
return {
|
|
372
|
+
...point,
|
|
373
|
+
drawdown: round(drawdown, 4),
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
available: true,
|
|
379
|
+
max_drawdown: round(maxDrawdown, 4),
|
|
380
|
+
curve: downsampleCurve(withDrawdown, maxPoints),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function downsampleCurve(curve, maxPoints) {
|
|
385
|
+
if (!Number.isFinite(maxPoints) || maxPoints <= 0 || curve.length <= maxPoints) return curve;
|
|
386
|
+
if (maxPoints < 3) return [curve[0], curve[curve.length - 1]].filter(Boolean);
|
|
387
|
+
|
|
388
|
+
const step = (curve.length - 1) / (maxPoints - 1);
|
|
389
|
+
const result = [];
|
|
390
|
+
for (let i = 0; i < maxPoints; i += 1) {
|
|
391
|
+
const idx = Math.round(i * step);
|
|
392
|
+
result.push(curve[idx]);
|
|
393
|
+
}
|
|
394
|
+
result[0] = curve[0];
|
|
395
|
+
result[result.length - 1] = curve[curve.length - 1];
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildAttributionSection(holdings, marketDataBySymbol) {
|
|
400
|
+
const bySymbol = [];
|
|
401
|
+
const bySectorMap = new Map();
|
|
402
|
+
|
|
403
|
+
for (const holding of holdings) {
|
|
404
|
+
const series = marketDataBySymbol[holding.symbol] ?? [];
|
|
405
|
+
if (series.length < 2) continue;
|
|
406
|
+
const first = series[0].close;
|
|
407
|
+
const last = series[series.length - 1].close;
|
|
408
|
+
if (!Number.isFinite(first) || !Number.isFinite(last) || first <= 0) continue;
|
|
409
|
+
|
|
410
|
+
const periodReturn = (last / first) - 1;
|
|
411
|
+
const contribution = holding.weight * periodReturn;
|
|
412
|
+
bySymbol.push({
|
|
413
|
+
symbol: holding.symbol,
|
|
414
|
+
weight: round(holding.weight, 4),
|
|
415
|
+
period_return: round(periodReturn, 4),
|
|
416
|
+
contribution: round(contribution, 4),
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const sector = holding.sector || 'Unknown';
|
|
420
|
+
const current = bySectorMap.get(sector) ?? 0;
|
|
421
|
+
bySectorMap.set(sector, current + contribution);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const bySector = [...bySectorMap.entries()]
|
|
425
|
+
.map(([sector, contribution]) => ({ sector, contribution: round(contribution, 4) }))
|
|
426
|
+
.sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution));
|
|
427
|
+
|
|
428
|
+
const portfolioReturn = bySymbol.reduce((sum, item) => sum + (item.contribution ?? 0), 0);
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
available: bySymbol.length > 0,
|
|
432
|
+
estimated_period_return: bySymbol.length > 0 ? round(portfolioReturn, 4) : null,
|
|
433
|
+
by_symbol: bySymbol,
|
|
434
|
+
by_sector: bySector,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function buildConcentrationSection(holdings) {
|
|
439
|
+
const sorted = [...holdings].sort((a, b) => b.weight - a.weight);
|
|
440
|
+
const hhi = sorted.reduce((sum, item) => sum + (item.weight ** 2), 0);
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
hhi: round(hhi, 4),
|
|
444
|
+
effective_position_count: hhi > 0 ? round(1 / hhi, 2) : null,
|
|
445
|
+
top1_weight: sorted[0] ? round(sorted[0].weight, 4) : null,
|
|
446
|
+
top3_weight: round(sorted.slice(0, 3).reduce((sum, item) => sum + item.weight, 0), 4),
|
|
447
|
+
top5_weight: round(sorted.slice(0, 5).reduce((sum, item) => sum + item.weight, 0), 4),
|
|
448
|
+
top_positions: sorted.slice(0, 10).map(item => ({
|
|
449
|
+
symbol: item.symbol,
|
|
450
|
+
name: item.name,
|
|
451
|
+
sector: item.sector,
|
|
452
|
+
weight: round(item.weight, 4),
|
|
453
|
+
market_value: item.market_value,
|
|
454
|
+
})),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function buildSectorDistribution(holdings) {
|
|
459
|
+
const bySector = new Map();
|
|
460
|
+
for (const item of holdings) {
|
|
461
|
+
const sector = item.sector || 'Unknown';
|
|
462
|
+
const current = bySector.get(sector) ?? { market_value: 0, weight: 0, count: 0 };
|
|
463
|
+
const marketValue = Number.isFinite(item.market_value) ? item.market_value : 0;
|
|
464
|
+
bySector.set(sector, {
|
|
465
|
+
market_value: current.market_value + marketValue,
|
|
466
|
+
weight: current.weight + item.weight,
|
|
467
|
+
count: current.count + 1,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return [...bySector.entries()]
|
|
472
|
+
.map(([sector, value]) => ({
|
|
473
|
+
sector,
|
|
474
|
+
position_count: value.count,
|
|
475
|
+
weight: round(value.weight, 4),
|
|
476
|
+
market_value: round(value.market_value, 2),
|
|
477
|
+
}))
|
|
478
|
+
.sort((a, b) => b.weight - a.weight);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function buildRiskFlags({ concentration, sectorDistribution, correlation, drawdown, missingMarketDataSymbols }) {
|
|
482
|
+
const flags = [];
|
|
483
|
+
|
|
484
|
+
if ((concentration.top1_weight ?? 0) >= 0.35) {
|
|
485
|
+
flags.push({
|
|
486
|
+
code: 'high_single_name_concentration',
|
|
487
|
+
severity: 'high',
|
|
488
|
+
message: `Top1 weight ${(concentration.top1_weight * 100).toFixed(1)}% exceeds 35%`,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if ((concentration.top3_weight ?? 0) >= 0.7) {
|
|
493
|
+
flags.push({
|
|
494
|
+
code: 'high_top3_concentration',
|
|
495
|
+
severity: 'medium',
|
|
496
|
+
message: `Top3 weight ${(concentration.top3_weight * 100).toFixed(1)}% exceeds 70%`,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if ((concentration.hhi ?? 0) >= 0.18) {
|
|
501
|
+
flags.push({
|
|
502
|
+
code: 'hhi_high',
|
|
503
|
+
severity: 'medium',
|
|
504
|
+
message: `HHI ${concentration.hhi.toFixed(3)} indicates elevated concentration`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const unknownSector = sectorDistribution.find(item => item.sector === 'Unknown');
|
|
509
|
+
if ((unknownSector?.weight ?? 0) >= 0.25) {
|
|
510
|
+
flags.push({
|
|
511
|
+
code: 'sector_data_incomplete',
|
|
512
|
+
severity: 'low',
|
|
513
|
+
message: `Unknown sector weight ${(unknownSector.weight * 100).toFixed(1)}% is high`,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if ((correlation.avg_pairwise_correlation ?? 0) >= 0.75) {
|
|
518
|
+
flags.push({
|
|
519
|
+
code: 'high_correlation_cluster',
|
|
520
|
+
severity: 'medium',
|
|
521
|
+
message: `Average pairwise correlation ${correlation.avg_pairwise_correlation.toFixed(2)} is high`,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if ((drawdown.max_drawdown ?? 0) <= -0.2) {
|
|
526
|
+
flags.push({
|
|
527
|
+
code: 'drawdown_risk',
|
|
528
|
+
severity: 'high',
|
|
529
|
+
message: `Estimated max drawdown ${(drawdown.max_drawdown * 100).toFixed(1)}% is beyond 20%`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (missingMarketDataSymbols.length > 0) {
|
|
534
|
+
flags.push({
|
|
535
|
+
code: 'market_data_missing',
|
|
536
|
+
severity: 'low',
|
|
537
|
+
message: `Missing historical data for symbols: ${missingMarketDataSymbols.join(', ')}`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return flags;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function analyzePortfolioReport({
|
|
545
|
+
holdingsInput,
|
|
546
|
+
marketDataInput = null,
|
|
547
|
+
asOf = null,
|
|
548
|
+
maxDrawdownPoints = 120,
|
|
549
|
+
} = {}) {
|
|
550
|
+
const normalized = normalizeHoldingsInput(holdingsInput);
|
|
551
|
+
const marketDataBySymbol = normalizeMarketDataInput(marketDataInput);
|
|
552
|
+
|
|
553
|
+
const holdings = normalized.holdings;
|
|
554
|
+
const missingMarketDataSymbols = holdings
|
|
555
|
+
.map(item => item.symbol)
|
|
556
|
+
.filter(symbol => (marketDataBySymbol[symbol] ?? []).length < 2);
|
|
557
|
+
|
|
558
|
+
const concentration = buildConcentrationSection(holdings);
|
|
559
|
+
const sectorDistribution = buildSectorDistribution(holdings);
|
|
560
|
+
const correlation = buildCorrelationSection(holdings, marketDataBySymbol);
|
|
561
|
+
const drawdown = buildDrawdownSection(holdings, marketDataBySymbol, maxDrawdownPoints);
|
|
562
|
+
const attribution = buildAttributionSection(holdings, marketDataBySymbol);
|
|
563
|
+
|
|
564
|
+
const riskFlags = buildRiskFlags({
|
|
565
|
+
concentration,
|
|
566
|
+
sectorDistribution,
|
|
567
|
+
correlation,
|
|
568
|
+
drawdown,
|
|
569
|
+
missingMarketDataSymbols,
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
as_of: asOf || new Date().toISOString(),
|
|
574
|
+
source_risk: 'normal',
|
|
575
|
+
portfolio_summary: {
|
|
576
|
+
position_count: holdings.length,
|
|
577
|
+
total_market_value: normalized.total_market_value,
|
|
578
|
+
weight_mode: normalized.using_equal_weight ? 'equal_weight_fallback' : 'market_value',
|
|
579
|
+
missing_market_data_count: missingMarketDataSymbols.length,
|
|
580
|
+
},
|
|
581
|
+
concentration,
|
|
582
|
+
sector_distribution: sectorDistribution,
|
|
583
|
+
correlation,
|
|
584
|
+
drawdown,
|
|
585
|
+
attribution,
|
|
586
|
+
risk_flags: riskFlags,
|
|
587
|
+
compliance: {
|
|
588
|
+
contains_trade_recommendation: false,
|
|
589
|
+
disclaimer: DISCLAIMER,
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { analyzePortfolioReport } from './core.js';
|
|
7
|
+
|
|
8
|
+
const server = new McpServer({ name: 'portfolio-analysis', version: '0.1.0' });
|
|
9
|
+
|
|
10
|
+
server.tool(
|
|
11
|
+
'portfolio_analyze',
|
|
12
|
+
'Analyze a portfolio using holdings and optional historical market data. Returns concentration, sector distribution, correlation, drawdown, attribution, and risk flags. No trade recommendation is produced.',
|
|
13
|
+
{
|
|
14
|
+
portfolio_json: z.string().describe('Portfolio JSON string. Accepts either { holdings: [...] } or [...] from portfolio_read output.'),
|
|
15
|
+
market_data_json: z.string().optional().describe('Optional market history JSON keyed by symbol. Each symbol can map to [{date,close}] or {prices:[...]}.'),
|
|
16
|
+
as_of: z.string().optional().describe('As-of timestamp override (ISO8601 recommended).'),
|
|
17
|
+
max_drawdown_points: z.number().optional().describe('Maximum points returned in drawdown curve, default 120.'),
|
|
18
|
+
},
|
|
19
|
+
async ({ portfolio_json, market_data_json, as_of, max_drawdown_points }) => {
|
|
20
|
+
try {
|
|
21
|
+
const report = analyzePortfolioReport({
|
|
22
|
+
holdingsInput: portfolio_json,
|
|
23
|
+
marketDataInput: market_data_json,
|
|
24
|
+
asOf: as_of,
|
|
25
|
+
maxDrawdownPoints: max_drawdown_points,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
content: [{
|
|
30
|
+
type: 'text',
|
|
31
|
+
text: JSON.stringify(report, null, 2),
|
|
32
|
+
}],
|
|
33
|
+
};
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return {
|
|
36
|
+
isError: true,
|
|
37
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const transport = new StdioServerTransport();
|
|
44
|
+
await server.connect(transport);
|
|
45
|
+
console.error('[portfolio-analysis] MCP Server started');
|