bitbank-lab-mcp 0.1.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.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +388 -0
  3. package/assets/lightweight-charts.standalone.js +7 -0
  4. package/bin/bitbank-lab-mcp.js +20 -0
  5. package/lib/cache.ts +70 -0
  6. package/lib/candle-utils.ts +48 -0
  7. package/lib/candle-validate.ts +434 -0
  8. package/lib/conversions.ts +25 -0
  9. package/lib/datetime.ts +157 -0
  10. package/lib/depth-analysis.ts +51 -0
  11. package/lib/error.ts +15 -0
  12. package/lib/formatter.ts +296 -0
  13. package/lib/get-depth.ts +111 -0
  14. package/lib/http.ts +132 -0
  15. package/lib/indicator-config.ts +39 -0
  16. package/lib/indicator_buffer.ts +41 -0
  17. package/lib/indicators.ts +579 -0
  18. package/lib/logger.ts +120 -0
  19. package/lib/ma-snapshot-utils.ts +277 -0
  20. package/lib/math.ts +89 -0
  21. package/lib/pattern-diagrams.ts +562 -0
  22. package/lib/result.ts +104 -0
  23. package/lib/validate.ts +154 -0
  24. package/lib/volatility.ts +132 -0
  25. package/package.json +79 -0
  26. package/src/env.ts +4 -0
  27. package/src/handlers/analyzeCandlePatternsHandler.ts +383 -0
  28. package/src/handlers/analyzeFibonacciHandler.ts +54 -0
  29. package/src/handlers/analyzeIndicatorsHandler.ts +682 -0
  30. package/src/handlers/analyzeMarketSignalHandler.ts +272 -0
  31. package/src/handlers/analyzeMyPortfolioHandler.ts +800 -0
  32. package/src/handlers/detectPatternsHandler.ts +77 -0
  33. package/src/handlers/detectPatternsViewsHandler.ts +518 -0
  34. package/src/handlers/getTickersJpyHandler.ts +145 -0
  35. package/src/handlers/getVolatilityMetricsHandler.ts +234 -0
  36. package/src/handlers/portfolio/calc.ts +549 -0
  37. package/src/handlers/portfolio/fetch.ts +318 -0
  38. package/src/handlers/portfolio/types.ts +170 -0
  39. package/src/handlers/renderChartSvgHandler.ts +69 -0
  40. package/src/handlers/runBacktestHandler.ts +70 -0
  41. package/src/http.ts +107 -0
  42. package/src/private/auth.ts +104 -0
  43. package/src/private/client.ts +298 -0
  44. package/src/private/config.ts +25 -0
  45. package/src/private/confirmation.ts +185 -0
  46. package/src/private/schemas.ts +866 -0
  47. package/src/prompts.ts +2296 -0
  48. package/src/resources/app-resources.ts +79 -0
  49. package/src/schema/analysis.ts +942 -0
  50. package/src/schema/backtest.ts +100 -0
  51. package/src/schema/base.ts +88 -0
  52. package/src/schema/candle-validate.ts +135 -0
  53. package/src/schema/chart.ts +399 -0
  54. package/src/schema/index.ts +11 -0
  55. package/src/schema/indicators.ts +125 -0
  56. package/src/schema/market-data.ts +298 -0
  57. package/src/schema/patterns.ts +382 -0
  58. package/src/schema/types.ts +97 -0
  59. package/src/schemas.d.ts +37 -0
  60. package/src/schemas.ts +7 -0
  61. package/src/server.ts +405 -0
  62. package/src/tool-definition.ts +44 -0
  63. package/src/tool-registry.ts +174 -0
  64. package/src/types/express-shim.d.ts +9 -0
  65. package/src/types/schemas.generated.d.ts +23 -0
  66. package/tools/analyze_bb_snapshot.ts +385 -0
  67. package/tools/analyze_candle_patterns.ts +810 -0
  68. package/tools/analyze_currency_strength.ts +273 -0
  69. package/tools/analyze_ema_snapshot.ts +183 -0
  70. package/tools/analyze_fibonacci.ts +530 -0
  71. package/tools/analyze_ichimoku_snapshot.ts +606 -0
  72. package/tools/analyze_indicators.ts +691 -0
  73. package/tools/analyze_market_signal.ts +665 -0
  74. package/tools/analyze_mtf_fibonacci.ts +273 -0
  75. package/tools/analyze_mtf_sma.ts +175 -0
  76. package/tools/analyze_sma_snapshot.ts +146 -0
  77. package/tools/analyze_stoch_snapshot.ts +276 -0
  78. package/tools/analyze_support_resistance.ts +817 -0
  79. package/tools/analyze_volume_profile.ts +546 -0
  80. package/tools/chart/ichimoku-cloud.ts +113 -0
  81. package/tools/chart/render-depth.ts +139 -0
  82. package/tools/chart/render-sub-panels.ts +208 -0
  83. package/tools/chart/svg-utils.ts +102 -0
  84. package/tools/detect_macd_cross.ts +691 -0
  85. package/tools/detect_patterns.ts +424 -0
  86. package/tools/detect_whale_events.ts +181 -0
  87. package/tools/get_candles.ts +487 -0
  88. package/tools/get_flow_metrics.ts +596 -0
  89. package/tools/get_orderbook.ts +540 -0
  90. package/tools/get_ticker.ts +132 -0
  91. package/tools/get_tickers_jpy.ts +240 -0
  92. package/tools/get_transactions.ts +209 -0
  93. package/tools/get_volatility_metrics.ts +302 -0
  94. package/tools/patterns/aftermath.ts +212 -0
  95. package/tools/patterns/config.ts +151 -0
  96. package/tools/patterns/detect_doubles.ts +650 -0
  97. package/tools/patterns/detect_hs.ts +635 -0
  98. package/tools/patterns/detect_pennants.ts +373 -0
  99. package/tools/patterns/detect_triangles.ts +820 -0
  100. package/tools/patterns/detect_triples.ts +633 -0
  101. package/tools/patterns/detect_wedges.ts +1072 -0
  102. package/tools/patterns/helpers.ts +517 -0
  103. package/tools/patterns/index.ts +40 -0
  104. package/tools/patterns/regression.ts +153 -0
  105. package/tools/patterns/smoothing.ts +168 -0
  106. package/tools/patterns/swing.ts +91 -0
  107. package/tools/patterns/types.ts +193 -0
  108. package/tools/prepare_chart_data.ts +294 -0
  109. package/tools/prepare_depth_data.ts +189 -0
  110. package/tools/private/analyze_my_portfolio.ts +21 -0
  111. package/tools/private/cancel_order.ts +127 -0
  112. package/tools/private/cancel_orders.ts +121 -0
  113. package/tools/private/create_order.ts +236 -0
  114. package/tools/private/get_margin_positions.ts +134 -0
  115. package/tools/private/get_margin_status.ts +155 -0
  116. package/tools/private/get_margin_trade_history.ts +156 -0
  117. package/tools/private/get_my_assets.ts +207 -0
  118. package/tools/private/get_my_deposit_withdrawal.ts +500 -0
  119. package/tools/private/get_my_orders.ts +157 -0
  120. package/tools/private/get_my_trade_history.ts +229 -0
  121. package/tools/private/get_order.ts +95 -0
  122. package/tools/private/get_orders_info.ts +90 -0
  123. package/tools/private/preview_cancel_order.ts +172 -0
  124. package/tools/private/preview_cancel_orders.ts +137 -0
  125. package/tools/private/preview_order.ts +292 -0
  126. package/tools/render_candle_pattern_diagram.ts +389 -0
  127. package/tools/render_chart_svg.ts +799 -0
  128. package/tools/render_depth_svg.ts +274 -0
  129. package/tools/trading_process/index.ts +7 -0
  130. package/tools/trading_process/lib/backtest_engine.ts +252 -0
  131. package/tools/trading_process/lib/equity.ts +131 -0
  132. package/tools/trading_process/lib/fetch_candles.ts +181 -0
  133. package/tools/trading_process/lib/sma.ts +62 -0
  134. package/tools/trading_process/lib/strategies/bb_breakout.ts +141 -0
  135. package/tools/trading_process/lib/strategies/index.ts +52 -0
  136. package/tools/trading_process/lib/strategies/macd_cross.ts +256 -0
  137. package/tools/trading_process/lib/strategies/rsi.ts +133 -0
  138. package/tools/trading_process/lib/strategies/sma_cross.ts +214 -0
  139. package/tools/trading_process/lib/strategies/types.ts +118 -0
  140. package/tools/trading_process/lib/svg_to_png.ts +64 -0
  141. package/tools/trading_process/render_backtest_chart_generic.ts +729 -0
  142. package/tools/trading_process/run_backtest.ts +243 -0
  143. package/tools/trading_process/types.ts +85 -0
  144. package/tools/validate_candle_data.ts +260 -0
  145. package/tsconfig.json +17 -0
  146. package/ui/cancel-confirm/dist/cancel-confirm.html +99 -0
  147. package/ui/order-confirm/dist/order-confirm.html +99 -0
@@ -0,0 +1,145 @@
1
+ import { z } from 'zod';
2
+ import { formatPercent, formatPrice, formatVolumeJPY } from '../../lib/formatter.js';
3
+ import getTickersJpy from '../../tools/get_tickers_jpy.js';
4
+ import type { ToolDefinition } from '../tool-definition.js';
5
+
6
+ export interface NormalizedTicker {
7
+ pair: string;
8
+ lastN: number | null;
9
+ openN: number | null;
10
+ highN: number | null;
11
+ lowN: number | null;
12
+ buyN: number | null;
13
+ sellN: number | null;
14
+ changeN: number | null;
15
+ volN: number | null;
16
+ volumeInJPY: number | null;
17
+ }
18
+
19
+ /** ranked ビューのテキスト組み立て — テスト可能な純粋関数 */
20
+ export function buildTickersJpyRankedText(
21
+ totalItems: number,
22
+ ranked: NormalizedTicker[],
23
+ sortBy: string,
24
+ order: string,
25
+ limit: number,
26
+ ): string {
27
+ const lines = ranked.map((r, i) => {
28
+ const chg = formatPercent(r.changeN, { sign: true, digits: 2 });
29
+ const px = formatPrice(r.lastN);
30
+ const volTxt = formatVolumeJPY(r.volumeInJPY);
31
+ return `${i + 1}. ${String(r.pair).toUpperCase().replace('_', '/')} ${chg}(${px}、出来高${volTxt})`;
32
+ });
33
+ return [`全${totalItems}ペア取得(sortBy=${sortBy}, ${order}, top${limit})`, '', lines.join('\n')].join('\n');
34
+ }
35
+
36
+ /** items ビューのテキスト組み立て — テスト可能な純粋関数 */
37
+ export function buildTickersJpyItemsText(items: NormalizedTicker[]): string {
38
+ const lines: string[] = [];
39
+ lines.push(`全${items.length}ペア取得`);
40
+ lines.push('');
41
+ const top5 = items.slice(0, 5);
42
+ for (const it of top5) {
43
+ const pairDisplay = String(it.pair).toUpperCase().replace('_', '/');
44
+ const priceStr = formatPrice(it.lastN);
45
+ const changeStr = formatPercent(it.changeN, { sign: true, digits: 2 });
46
+ const volStr = formatVolumeJPY(it.volumeInJPY);
47
+ lines.push(`${pairDisplay}: ${priceStr} (${changeStr}) 出来高${volStr}`);
48
+ }
49
+ if (items.length > 5) {
50
+ lines.push(`... 他${items.length - 5}ペア`);
51
+ }
52
+ return lines.join('\n');
53
+ }
54
+
55
+ const InputSchema = z.object({
56
+ view: z.enum(['items', 'ranked']).optional().default('ranked'),
57
+ sortBy: z.enum(['change24h', 'volume', 'name']).optional().default('change24h'),
58
+ order: z.enum(['asc', 'desc']).optional().default('desc'),
59
+ limit: z.number().int().min(1).max(50).optional().default(5),
60
+ });
61
+
62
+ export const toolDef: ToolDefinition = {
63
+ name: 'get_tickers_jpy',
64
+ description:
65
+ '[All Tickers / Market Overview] 全JPYペアのティッカー一覧(tickers / ranking / market overview)を取得。変化率・出来高でランキング表示可能。',
66
+ inputSchema: InputSchema,
67
+ handler: async (args: Record<string, unknown>) => {
68
+ const parsed = InputSchema.parse(args);
69
+ const { view, sortBy, order, limit } = parsed;
70
+ const res = await getTickersJpy();
71
+ if (!res?.ok) return res;
72
+ const items = (Array.isArray(res?.data) ? res.data : []) as Array<{
73
+ pair: string;
74
+ last?: unknown;
75
+ open?: unknown;
76
+ high?: unknown;
77
+ low?: unknown;
78
+ buy?: unknown;
79
+ sell?: unknown;
80
+ change24h?: unknown;
81
+ change24hPct?: unknown;
82
+ vol?: unknown;
83
+ [key: string]: unknown;
84
+ }>;
85
+
86
+ // normalize numeric fields(open/high/low 追加)
87
+ const norm = items.map((it) => {
88
+ const lastN = it?.last != null ? Number(it.last) : null;
89
+ const openN = it?.open != null ? Number(it.open) : null;
90
+ const highN = it?.high != null ? Number(it.high) : null;
91
+ const lowN = it?.low != null ? Number(it.low) : null;
92
+ const buyN = it?.buy != null ? Number(it.buy) : null;
93
+ const sellN = it?.sell != null ? Number(it.sell) : null;
94
+ const change = it?.change24h ?? it?.change24hPct;
95
+ const changeN =
96
+ change != null
97
+ ? Number(change)
98
+ : openN != null && openN > 0 && lastN != null
99
+ ? Number((((lastN - openN) / openN) * 100).toFixed(2))
100
+ : null;
101
+ const volN = it?.vol != null ? Number(it.vol) : null;
102
+ const volumeInJPY =
103
+ volN != null && lastN != null && Number.isFinite(volN) && Number.isFinite(lastN) ? volN * lastN : null;
104
+ return { ...it, lastN, openN, highN, lowN, buyN, sellN, changeN, volN, volumeInJPY };
105
+ });
106
+
107
+ // ranking logic
108
+ const cmpNum = (a?: number | null, b?: number | null) => {
109
+ const aa = a == null || Number.isNaN(a) ? -Infinity : a;
110
+ const bb = b == null || Number.isNaN(b) ? -Infinity : b;
111
+ return aa - bb;
112
+ };
113
+ const sorted = [...norm].sort((a, b) => {
114
+ if (sortBy === 'name') {
115
+ return String(a.pair).localeCompare(String(b.pair));
116
+ }
117
+ if (sortBy === 'volume') {
118
+ return cmpNum(a.volumeInJPY, b.volumeInJPY);
119
+ }
120
+ return cmpNum(a.changeN, b.changeN);
121
+ });
122
+ if ((order || 'desc') === 'desc') sorted.reverse();
123
+ const ranked = sorted.slice(0, Number(limit || 5));
124
+
125
+ if (view === 'ranked') {
126
+ const text = buildTickersJpyRankedText(items.length, ranked, sortBy, order, limit);
127
+ return {
128
+ content: [{ type: 'text', text }],
129
+ structuredContent: {
130
+ ok: true,
131
+ summary: `ranked ${ranked.length}/${items.length}`,
132
+ data: { items: norm, ranked },
133
+ meta: res?.meta ?? {},
134
+ } as Record<string, unknown>,
135
+ };
136
+ }
137
+
138
+ // view=items: 全データ一覧(上位5件をサマリ表示)
139
+ const text = buildTickersJpyItemsText(norm);
140
+ return {
141
+ content: [{ type: 'text', text }],
142
+ structuredContent: { ...res, data: { items: norm } } as Record<string, unknown>,
143
+ };
144
+ },
145
+ };
@@ -0,0 +1,234 @@
1
+ import type { z } from 'zod';
2
+ import { toIsoTime } from '../../lib/datetime.js';
3
+ import {
4
+ formatCurrency,
5
+ formatCurrencyShort,
6
+ formatPercent,
7
+ formatPriceJPY,
8
+ formatTrendArrow,
9
+ } from '../../lib/formatter.js';
10
+ import { stddev } from '../../lib/math.js';
11
+ import getVolatilityMetrics from '../../tools/get_volatility_metrics.js';
12
+ import { GetVolMetricsInputSchema } from '../schemas.js';
13
+ import type { ToolDefinition } from '../tool-definition.js';
14
+
15
+ export interface VolViewInput {
16
+ pair: string;
17
+ type: string;
18
+ lastClose: number | null;
19
+ ann: boolean;
20
+ annFactor: number;
21
+ annFactorFull: number;
22
+ sampleSize: number | string;
23
+ rvAnn: number | null;
24
+ pkAnn: number | null;
25
+ gkAnn: number | null;
26
+ rsAnn: number | null;
27
+ atrAbs: number | null;
28
+ atrPct: number | null;
29
+ tagsAll: string[];
30
+ rolling: Array<{
31
+ window: number;
32
+ rv_std: number;
33
+ rv_std_ann?: number;
34
+ atr?: number;
35
+ parkinson?: number;
36
+ garmanKlass?: number;
37
+ rogersSatchell?: number;
38
+ }>;
39
+ }
40
+
41
+ /** beginner ビューのテキスト組み立て */
42
+ export function buildVolatilityBeginnerText(input: VolViewInput): string {
43
+ const { pair, type, lastClose, atrAbs, atrPct, rvAnn, tagsAll } = input;
44
+ const rvPct = formatPercent(rvAnn, { multiply: true, digits: 0 });
45
+ const atrJpy = formatPriceJPY(atrAbs);
46
+ const atrPctStr = formatPercent(atrPct, { multiply: true });
47
+ const closeStr = formatPriceJPY(lastClose);
48
+ return [
49
+ `${String(pair).toUpperCase()} [${String(type)}] 現在価格: ${closeStr}`,
50
+ `・年間のおおよその動き: 約${rvPct}(1年でこのくらい上下しやすい目安)`,
51
+ `・1日の平均的な動き: 約${atrJpy}(約${atrPctStr})`,
52
+ tagsAll.length ? `・今の傾向: ${tagsAll.map((t) => t.replaceAll('_', ' ')).join(', ')}` : null,
53
+ ]
54
+ .filter(Boolean)
55
+ .join('\n');
56
+ }
57
+
58
+ /** summary ビューのテキスト組み立て */
59
+ export function buildVolatilitySummaryText(input: VolViewInput): string {
60
+ const { pair, type, sampleSize, rvAnn, pkAnn, gkAnn, rsAnn, atrAbs, tagsAll } = input;
61
+ const fp = (x: number | null) => formatPercent(x, { multiply: true });
62
+ const fmtCurrShort = (p: string, v: number | null) => formatCurrencyShort(v, p);
63
+ return `${String(pair).toUpperCase()} [${String(type)}] samples=${sampleSize ?? 'n/a'} RV=${fp(rvAnn)} ATR=${fmtCurrShort(pair, atrAbs)} PK=${fp(pkAnn)} GK=${fp(gkAnn)} RS=${fp(rsAnn)} Tags: ${tagsAll.join(', ')}`;
64
+ }
65
+
66
+ export interface VolDetailedInput extends VolViewInput {
67
+ series?: {
68
+ ts: number[];
69
+ close: number[];
70
+ ret: number[];
71
+ };
72
+ }
73
+
74
+ /** detailed/full ビューのテキスト組み立て */
75
+ export function buildVolatilityDetailedText(input: VolDetailedInput, view: 'detailed' | 'full'): string {
76
+ const { pair, type, lastClose, ann, annFactor, sampleSize, rvAnn, pkAnn, gkAnn, rsAnn, atrAbs, tagsAll, rolling } =
77
+ input;
78
+ const fp = (x: number | null | undefined) => formatPercent(x, { multiply: true });
79
+ const fmtCurr = (p: string, v: number | null) => formatCurrency(v, p);
80
+
81
+ const windowsList = rolling.map((r) => r.window).join('/');
82
+ const header = `${String(pair).toUpperCase()} [${String(type)}] close=${lastClose != null ? Number(lastClose).toLocaleString('ja-JP') : 'n/a'}\n`;
83
+ const block1 = `【Volatility Metrics${ann ? ' (annualized)' : ''}, ${sampleSize ?? 'n/a'} samples】\nRV (std): ${fp(rvAnn)}\nATR: ${fmtCurr(pair, atrAbs)}\nParkinson: ${fp(pkAnn)}\nGarman-Klass: ${fp(gkAnn)}\nRogers-Satchell: ${fp(rsAnn)}`;
84
+
85
+ const maxW = rolling.length ? Math.max(...rolling.map((r) => r.window)) : null;
86
+ const baseVal =
87
+ maxW != null
88
+ ? (rolling.find((r) => r.window === maxW)?.rv_std_ann ??
89
+ ((rolling.find((r) => r.window === maxW)?.rv_std ?? null) as number) * (ann ? annFactor : 1))
90
+ : null;
91
+ const trendLines = rolling.map((r) => {
92
+ const now = r.rv_std_ann ?? (r.rv_std != null ? r.rv_std * (ann ? annFactor : 1) : null);
93
+ return `${r.window}-day RV: ${fp(now)} ${formatTrendArrow(now, baseVal)}`;
94
+ });
95
+
96
+ let text =
97
+ header +
98
+ '\n' +
99
+ block1 +
100
+ '\n\n' +
101
+ `【Rolling Trends (${windowsList}-day windows)】\n` +
102
+ trendLines.join('\n') +
103
+ '\n\n' +
104
+ `【Assessment】\nTags: ${tagsAll.join(', ')}`;
105
+
106
+ if (view === 'full' && input.series) {
107
+ const { ts: tsArr, close: cArr, ret: retArr } = input.series;
108
+ const firstIso = tsArr.length ? (toIsoTime(tsArr[0]) ?? 'n/a') : 'n/a';
109
+ const lastIso = tsArr.length ? (toIsoTime(tsArr[tsArr.length - 1]) ?? 'n/a') : 'n/a';
110
+ const minClose = cArr.length ? Math.min(...cArr) : null;
111
+ const maxClose = cArr.length ? Math.max(...cArr) : null;
112
+ const mean = retArr.length ? retArr.reduce((s, v) => s + v, 0) / retArr.length : null;
113
+ const std = retArr.length ? stddev(retArr) : null;
114
+ text += `\n\n【Series】\nTotal: ${sampleSize ?? cArr.length} candles\nFirst: ${firstIso} , Last: ${lastIso}\nClose range: ${minClose != null ? Number(minClose).toLocaleString('ja-JP') : 'n/a'} - ${maxClose != null ? Number(maxClose).toLocaleString('ja-JP') : 'n/a'} JPY\nReturns: mean=${formatPercent(mean, { multiply: true, digits: 2 })}, std=${formatPercent(std, { multiply: true, digits: 2 })}${ann ? ' (base interval)' : ''}`;
115
+ }
116
+ return text;
117
+ }
118
+
119
+ export const toolDef: ToolDefinition = {
120
+ name: 'get_volatility_metrics',
121
+ description:
122
+ '[Volatility / ATR / RV] ボラティリティ指標(volatility / ATR / realized vol)を算出。RV・ATR・Parkinson・Garman-Klass・Rogers-Satchell。年率換算対応。',
123
+ inputSchema: GetVolMetricsInputSchema,
124
+ handler: async ({
125
+ pair,
126
+ type,
127
+ limit,
128
+ windows,
129
+ useLogReturns,
130
+ annualize,
131
+ view,
132
+ }: z.infer<typeof GetVolMetricsInputSchema>) => {
133
+ const res = await getVolatilityMetrics(pair, type, limit, windows, { useLogReturns, annualize });
134
+ if (!res?.ok) return res;
135
+ const meta = res?.data?.meta || {};
136
+ const a = res?.data?.aggregates || {};
137
+ const roll: VolViewInput['rolling'] = Array.isArray(res?.data?.rolling) ? res.data.rolling : [];
138
+ const closeSeries: number[] = Array.isArray(res?.data?.series?.close) ? res.data.series.close : [];
139
+ const lastClose = closeSeries.at(-1) ?? null;
140
+ const ann = !!meta.annualize;
141
+ const baseMs = Number(meta.baseIntervalMs ?? 0);
142
+ const annFactorFull = baseMs > 0 ? Math.sqrt((365 * 24 * 3600 * 1000) / baseMs) : 1;
143
+ const annFactor = ann ? annFactorFull : 1;
144
+ const rvAnn = a.rv_std_ann != null ? a.rv_std_ann : a.rv_std != null ? a.rv_std * annFactor : null;
145
+ const pkAnn = a.parkinson != null ? a.parkinson * (ann ? annFactor : 1) : null;
146
+ const gkAnn = a.garmanKlass != null ? a.garmanKlass * (ann ? annFactor : 1) : null;
147
+ const rsAnn = a.rogersSatchell != null ? a.rogersSatchell * (ann ? annFactor : 1) : null;
148
+ const atrAbs = a.atr != null ? a.atr : null;
149
+ const atrPct = lastClose ? (atrAbs as number) / lastClose : null;
150
+
151
+ // tags: base + derived
152
+ const tagsBase: string[] = Array.isArray(res?.data?.tags) ? [...res.data.tags] : [];
153
+ const tagsDerived: string[] = [];
154
+ // Always use annualized values for tag thresholds (consistent regardless of annualize flag)
155
+ if (Array.isArray(roll) && roll.length >= 2) {
156
+ const minW = Math.min(...roll.map((r) => r.window));
157
+ const maxW = Math.max(...roll.map((r) => r.window));
158
+ const short = roll.find((r) => r.window === minW);
159
+ const long = roll.find((r) => r.window === maxW);
160
+ const shortVal = short
161
+ ? (short.rv_std_ann ?? (short.rv_std != null ? short.rv_std * annFactorFull : null))
162
+ : null;
163
+ const longVal = long ? (long.rv_std_ann ?? (long.rv_std != null ? long.rv_std * annFactorFull : null)) : null;
164
+ if (shortVal != null && longVal != null) {
165
+ if (shortVal > longVal * 1.05) tagsDerived.push('expanding_vol');
166
+ else if (shortVal < longVal * 0.95) tagsDerived.push('contracting_vol');
167
+ if (shortVal > 0.4) tagsDerived.push('high_short_term_vol');
168
+ }
169
+ }
170
+ // Use annualized RV for threshold comparison even when annualize=false
171
+ const rvAnnForTags = a.rv_std_ann ?? (a.rv_std != null ? a.rv_std * annFactorFull : null);
172
+ if (rvAnnForTags != null) {
173
+ if (rvAnnForTags > 0.5) tagsDerived.push('high_vol');
174
+ if (rvAnnForTags < 0.2) tagsDerived.push('low_vol');
175
+ }
176
+ if (rvAnn != null && atrPct != null && rvAnn > 0) {
177
+ const diff = Math.abs(atrPct - rvAnn) / rvAnn;
178
+ if (diff > 0.2) tagsDerived.push('atr_divergence');
179
+ }
180
+ const tagsAll = [...new Set([...(tagsBase || []), ...tagsDerived])];
181
+
182
+ const viewInput: VolViewInput = {
183
+ pair,
184
+ type,
185
+ lastClose,
186
+ ann,
187
+ annFactor,
188
+ annFactorFull,
189
+ sampleSize: meta.sampleSize ?? 'n/a',
190
+ rvAnn,
191
+ pkAnn,
192
+ gkAnn,
193
+ rsAnn,
194
+ atrAbs,
195
+ atrPct,
196
+ tagsAll,
197
+ rolling: roll,
198
+ };
199
+
200
+ // beginner view (plain language for non-experts)
201
+ if (view === 'beginner') {
202
+ const text = buildVolatilityBeginnerText(viewInput);
203
+ return {
204
+ content: [{ type: 'text', text }],
205
+ structuredContent: { ...res, data: { ...res.data, tags: tagsAll } } as Record<string, unknown>,
206
+ };
207
+ }
208
+
209
+ // summary view
210
+ if (view === 'summary') {
211
+ const text = buildVolatilitySummaryText(viewInput);
212
+ return {
213
+ content: [{ type: 'text', text }],
214
+ structuredContent: { ...res, data: { ...res.data, tags: tagsAll } } as Record<string, unknown>,
215
+ };
216
+ }
217
+
218
+ // detailed/full
219
+ const series = res?.data?.series || {};
220
+ const detailedInput: VolDetailedInput = {
221
+ ...viewInput,
222
+ series: {
223
+ ts: Array.isArray(series.ts) ? series.ts : [],
224
+ close: Array.isArray(series.close) ? series.close : [],
225
+ ret: Array.isArray(series.ret) ? series.ret : [],
226
+ },
227
+ };
228
+ const text = buildVolatilityDetailedText(detailedInput, view === 'full' ? 'full' : 'detailed');
229
+ return {
230
+ content: [{ type: 'text', text }],
231
+ structuredContent: { ...res, data: { ...res.data, tags: tagsAll } } as Record<string, unknown>,
232
+ };
233
+ },
234
+ };