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,546 @@
1
+ /**
2
+ * analyze_volume_profile — 約定データから Volume Profile + VWAP + 約定サイズ分布を算出
3
+ *
4
+ * 内部で getTransactions を呼び出し、get_flow_metrics と同様のマージ戦略で
5
+ * 約定データを収集。そこから3つの指標を導出する:
6
+ *
7
+ * 1. VWAP (出来高加重平均価格) + ±1σ/2σ バンド
8
+ * 2. Volume Profile (価格帯別出来高分布 + POC + Value Area)
9
+ * 3. Trade Size Distribution (約定サイズ別の分類 + 大口偏り)
10
+ */
11
+
12
+ import type { z } from 'zod';
13
+ import { dayjs, toDisplayTime, toIsoWithTz } from '../lib/datetime.js';
14
+ import { formatPair, formatPercent, formatPrice } from '../lib/formatter.js';
15
+ import { fail, failFromError, failFromValidation, ok } from '../lib/result.js';
16
+ import { createMeta, ensurePair } from '../lib/validate.js';
17
+ import {
18
+ type AnalyzeVolumeProfileDataSchemaOut,
19
+ AnalyzeVolumeProfileInputSchema,
20
+ type AnalyzeVolumeProfileMetaSchemaOut,
21
+ AnalyzeVolumeProfileOutputSchema,
22
+ } from '../src/schemas.js';
23
+ import type { ToolDefinition } from '../src/tool-definition.js';
24
+ import getTransactions from './get_transactions.js';
25
+
26
+ type Tx = { price: number; amount: number; side: 'buy' | 'sell'; timestampMs: number; isoTime: string };
27
+
28
+ // ── Transaction fetch helpers (shared pattern with get_flow_metrics) ──
29
+
30
+ function mergeTxResults(results: unknown[]): { txs: Tx[]; totalCount: number; failedCount: number } {
31
+ const seen = new Set<string>();
32
+ const merged: Tx[] = [];
33
+ let failedCount = 0;
34
+ for (const res of results) {
35
+ const r = res as { ok?: boolean; data?: { normalized?: Tx[] } } | null;
36
+ if (r?.ok && Array.isArray(r.data?.normalized)) {
37
+ for (const tx of r.data.normalized as Tx[]) {
38
+ const key = `${tx.timestampMs}:${tx.price}:${tx.amount}:${tx.side}`;
39
+ if (!seen.has(key)) {
40
+ seen.add(key);
41
+ merged.push(tx);
42
+ }
43
+ }
44
+ } else {
45
+ failedCount++;
46
+ }
47
+ }
48
+ return { txs: merged, totalCount: results.length, failedCount };
49
+ }
50
+
51
+ type FetchResult = { ok: true; txs: Tx[]; fetchWarning?: string } | { ok: false; errorType: string; summary: string };
52
+
53
+ function extractUpstreamError(results: unknown[]): { errorType: string; summary: string } | null {
54
+ for (const res of results) {
55
+ const r = res as { ok?: boolean; meta?: { errorType?: string }; summary?: string } | null;
56
+ if (r && r.ok === false && r.meta?.errorType) {
57
+ return { errorType: r.meta.errorType, summary: r.summary ?? 'upstream error' };
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function partialFailureWarning(totalCount: number, failedCount: number): string | undefined {
64
+ if (failedCount === 0) return undefined;
65
+ return `⚠️ ${totalCount}件中${failedCount}件のAPI取得に失敗しました。データが不完全な可能性があります。`;
66
+ }
67
+
68
+ async function fetchTransactions(pair: string, hours?: number, limit?: number): Promise<FetchResult> {
69
+ if (hours != null && hours > 0) {
70
+ const nowMs = Date.now();
71
+ const sinceMs = nowMs - hours * 3600_000;
72
+ const sinceDayjs = dayjs(sinceMs).tz('Asia/Tokyo');
73
+ const nowDayjs = dayjs(nowMs).tz('Asia/Tokyo');
74
+
75
+ const dates: string[] = [];
76
+ let d = sinceDayjs.startOf('day');
77
+ while (d.isBefore(nowDayjs) || d.isSame(nowDayjs, 'day')) {
78
+ dates.push(d.format('YYYYMMDD'));
79
+ d = d.add(1, 'day');
80
+ }
81
+
82
+ const fetches: Promise<unknown>[] = dates.map((ds) => getTransactions(pair, 1000, ds));
83
+ fetches.push(getTransactions(pair, 1000));
84
+ const results = await Promise.all(fetches);
85
+ const { txs: mergedTxs, totalCount, failedCount } = mergeTxResults(results);
86
+ if (mergedTxs.length === 0) {
87
+ const upstreamErr = extractUpstreamError(results);
88
+ if (upstreamErr) return { ok: false, ...upstreamErr };
89
+ }
90
+ if (failedCount > 0 && failedCount >= totalCount / 2) {
91
+ return {
92
+ ok: false,
93
+ errorType: 'upstream',
94
+ summary: `API取得の過半数が失敗しました(${totalCount}件中${failedCount}件失敗)`,
95
+ };
96
+ }
97
+ return {
98
+ ok: true,
99
+ txs: mergedTxs
100
+ .filter((t) => t.timestampMs >= sinceMs && t.timestampMs <= nowMs)
101
+ .sort((a, b) => a.timestampMs - b.timestampMs),
102
+ fetchWarning: partialFailureWarning(totalCount, failedCount),
103
+ };
104
+ }
105
+
106
+ // Count-based
107
+ const lim = limit ?? 500;
108
+ const latestRes = await getTransactions(pair, Math.min(lim, 1000));
109
+ const latestR = latestRes as { ok?: boolean; data?: { normalized?: Tx[] } };
110
+ const latestTxs: Tx[] = latestR?.ok && Array.isArray(latestR.data?.normalized) ? latestR.data.normalized : [];
111
+ if (latestTxs.length === 0) {
112
+ const upstreamErr = extractUpstreamError([latestRes]);
113
+ if (upstreamErr) return { ok: false, ...upstreamErr };
114
+ }
115
+ if (latestTxs.length >= lim) return { ok: true, txs: latestTxs.slice(-lim) };
116
+
117
+ // Supplement with previous days
118
+ const todayJst = dayjs().tz('Asia/Tokyo');
119
+ const supplementFetches: Promise<unknown>[] = [
120
+ getTransactions(pair, 1000, todayJst.subtract(1, 'day').format('YYYYMMDD')),
121
+ ];
122
+ if (lim > 500) {
123
+ supplementFetches.push(getTransactions(pair, 1000, todayJst.subtract(2, 'day').format('YYYYMMDD')));
124
+ }
125
+ const supplementResults = await Promise.all(supplementFetches);
126
+ const allResults = [latestRes, ...supplementResults];
127
+ const { txs: mergedTxs, totalCount, failedCount } = mergeTxResults(allResults);
128
+ if (mergedTxs.length === 0) {
129
+ const upstreamErr = extractUpstreamError(allResults);
130
+ if (upstreamErr) return { ok: false, ...upstreamErr };
131
+ }
132
+ if (failedCount > 0 && failedCount >= totalCount / 2) {
133
+ return {
134
+ ok: false,
135
+ errorType: 'upstream',
136
+ summary: `API取得の過半数が失敗しました(${totalCount}件中${failedCount}件失敗)`,
137
+ };
138
+ }
139
+ return {
140
+ ok: true,
141
+ txs: mergedTxs.sort((a, b) => a.timestampMs - b.timestampMs).slice(-lim),
142
+ fetchWarning: partialFailureWarning(totalCount, failedCount),
143
+ };
144
+ }
145
+
146
+ // ── VWAP Calculation ──
147
+
148
+ function calcVwap(txs: Tx[]) {
149
+ let sumPV = 0;
150
+ let sumV = 0;
151
+ for (const t of txs) {
152
+ sumPV += t.price * t.amount;
153
+ sumV += t.amount;
154
+ }
155
+ const vwap = sumV > 0 ? sumPV / sumV : 0;
156
+
157
+ // Weighted standard deviation
158
+ let sumWeightedSqDiff = 0;
159
+ for (const t of txs) {
160
+ sumWeightedSqDiff += t.amount * (t.price - vwap) ** 2;
161
+ }
162
+ const stdDev = sumV > 0 ? Math.sqrt(sumWeightedSqDiff / sumV) : 0;
163
+
164
+ return { vwap, stdDev };
165
+ }
166
+
167
+ // ── Volume Profile Calculation ──
168
+
169
+ function calcVolumeProfile(txs: Tx[], bins: number, valueAreaPct: number) {
170
+ const prices = txs.map((t) => t.price);
171
+ const priceLow = Math.min(...prices);
172
+ const priceHigh = Math.max(...prices);
173
+ const range = priceHigh - priceLow;
174
+
175
+ // Guard against zero range (all trades at same price)
176
+ if (range === 0) {
177
+ // All trades at same price — single-bin profile with exact price
178
+ const singleBin = { low: priceLow, high: priceLow, buyVolume: 0, sellVolume: 0, totalVolume: 0 };
179
+ for (const t of txs) {
180
+ if (t.side === 'buy') singleBin.buyVolume += t.amount;
181
+ else singleBin.sellVolume += t.amount;
182
+ singleBin.totalVolume += t.amount;
183
+ }
184
+ const totalVolume = singleBin.totalVolume;
185
+ const isJpy = true;
186
+ const fmtSingle = () => {
187
+ const p = isJpy ? Math.round(priceLow).toLocaleString('ja-JP') : priceLow.toFixed(2);
188
+ return `${p}〜${p}`;
189
+ };
190
+ const binResult = {
191
+ low: Number(priceLow.toFixed(2)),
192
+ high: Number(priceLow.toFixed(2)),
193
+ label: fmtSingle(),
194
+ buyVolume: Number(singleBin.buyVolume.toFixed(8)),
195
+ sellVolume: Number(singleBin.sellVolume.toFixed(8)),
196
+ totalVolume: Number(singleBin.totalVolume.toFixed(8)),
197
+ pct: 100,
198
+ dominant:
199
+ singleBin.buyVolume > singleBin.sellVolume * 1.2
200
+ ? ('buy' as const)
201
+ : singleBin.sellVolume > singleBin.buyVolume * 1.2
202
+ ? ('sell' as const)
203
+ : ('balanced' as const),
204
+ };
205
+ return {
206
+ bins: [binResult],
207
+ poc: {
208
+ price: Number(priceLow.toFixed(2)),
209
+ volume: Number(totalVolume.toFixed(8)),
210
+ binIndex: 0,
211
+ },
212
+ valueArea: {
213
+ high: Number(priceLow.toFixed(2)),
214
+ low: Number(priceLow.toFixed(2)),
215
+ volume: Number(totalVolume.toFixed(8)),
216
+ pct: 100,
217
+ },
218
+ };
219
+ }
220
+ const step = range / bins;
221
+ const adjustedLow = priceLow;
222
+
223
+ const profileBins: Array<{
224
+ low: number;
225
+ high: number;
226
+ buyVolume: number;
227
+ sellVolume: number;
228
+ totalVolume: number;
229
+ }> = [];
230
+ for (let i = 0; i < bins; i++) {
231
+ profileBins.push({
232
+ low: adjustedLow + i * step,
233
+ high: adjustedLow + (i + 1) * step,
234
+ buyVolume: 0,
235
+ sellVolume: 0,
236
+ totalVolume: 0,
237
+ });
238
+ }
239
+
240
+ // Distribute trades into bins
241
+ for (const t of txs) {
242
+ let idx = Math.floor((t.price - adjustedLow) / step);
243
+ if (idx >= bins) idx = bins - 1;
244
+ if (idx < 0) idx = 0;
245
+ if (t.side === 'buy') profileBins[idx].buyVolume += t.amount;
246
+ else profileBins[idx].sellVolume += t.amount;
247
+ profileBins[idx].totalVolume += t.amount;
248
+ }
249
+
250
+ const totalVolume = profileBins.reduce((s, b) => s + b.totalVolume, 0);
251
+
252
+ // POC (Point of Control): bin with highest volume
253
+ let pocIdx = 0;
254
+ let pocVol = 0;
255
+ for (let i = 0; i < profileBins.length; i++) {
256
+ if (profileBins[i].totalVolume > pocVol) {
257
+ pocVol = profileBins[i].totalVolume;
258
+ pocIdx = i;
259
+ }
260
+ }
261
+ const pocPrice = (profileBins[pocIdx].low + profileBins[pocIdx].high) / 2;
262
+
263
+ // Value Area: expand from POC until covering valueAreaPct of total volume
264
+ const targetVol = totalVolume * valueAreaPct;
265
+ let vaVol = profileBins[pocIdx].totalVolume;
266
+ let vaLow = pocIdx;
267
+ let vaHigh = pocIdx;
268
+ while (vaVol < targetVol && (vaLow > 0 || vaHigh < bins - 1)) {
269
+ const lowCandidate = vaLow > 0 ? profileBins[vaLow - 1].totalVolume : -1;
270
+ const highCandidate = vaHigh < bins - 1 ? profileBins[vaHigh + 1].totalVolume : -1;
271
+ if (lowCandidate >= highCandidate && lowCandidate >= 0) {
272
+ vaLow--;
273
+ vaVol += profileBins[vaLow].totalVolume;
274
+ } else if (highCandidate >= 0) {
275
+ vaHigh++;
276
+ vaVol += profileBins[vaHigh].totalVolume;
277
+ } else {
278
+ break;
279
+ }
280
+ }
281
+
282
+ const isJpy = true; // This tool always operates on JPY pairs primarily
283
+ const fmtBin = (b: (typeof profileBins)[0]) => {
284
+ const lo = isJpy ? Math.round(b.low).toLocaleString('ja-JP') : b.low.toFixed(2);
285
+ const hi = isJpy ? Math.round(b.high).toLocaleString('ja-JP') : b.high.toFixed(2);
286
+ return `${lo}〜${hi}`;
287
+ };
288
+
289
+ return {
290
+ bins: profileBins.map((b, _i) => ({
291
+ low: Number(b.low.toFixed(2)),
292
+ high: Number(b.high.toFixed(2)),
293
+ label: fmtBin(b),
294
+ buyVolume: Number(b.buyVolume.toFixed(8)),
295
+ sellVolume: Number(b.sellVolume.toFixed(8)),
296
+ totalVolume: Number(b.totalVolume.toFixed(8)),
297
+ pct: totalVolume > 0 ? Number(((b.totalVolume / totalVolume) * 100).toFixed(1)) : 0,
298
+ dominant:
299
+ b.buyVolume > b.sellVolume * 1.2
300
+ ? ('buy' as const)
301
+ : b.sellVolume > b.buyVolume * 1.2
302
+ ? ('sell' as const)
303
+ : ('balanced' as const),
304
+ })),
305
+ poc: {
306
+ price: Number(pocPrice.toFixed(2)),
307
+ volume: Number(pocVol.toFixed(8)),
308
+ binIndex: pocIdx,
309
+ },
310
+ valueArea: {
311
+ high: Number(profileBins[vaHigh].high.toFixed(2)),
312
+ low: Number(profileBins[vaLow].low.toFixed(2)),
313
+ volume: Number(vaVol.toFixed(8)),
314
+ pct: totalVolume > 0 ? Number(((vaVol / totalVolume) * 100).toFixed(1)) : 0,
315
+ },
316
+ };
317
+ }
318
+
319
+ // ── Trade Size Distribution ──
320
+
321
+ function calcTradeSizeDistribution(txs: Tx[]) {
322
+ const amounts = txs.map((t) => t.amount).sort((a, b) => a - b);
323
+ const p25 = amounts[Math.floor(amounts.length * 0.25)] ?? 0;
324
+ const p75 = amounts[Math.floor(amounts.length * 0.75)] ?? 0;
325
+ const p95 = amounts[Math.floor(amounts.length * 0.95)] ?? 0;
326
+
327
+ const categories = [
328
+ { label: '小口', minSize: 0, maxSize: p25, filter: (a: number) => a <= p25 },
329
+ { label: '中口', minSize: p25, maxSize: p75, filter: (a: number) => a > p25 && a <= p75 },
330
+ { label: '大口', minSize: p75, maxSize: p95, filter: (a: number) => a > p75 && a <= p95 },
331
+ { label: '特大口', minSize: p95, maxSize: null as number | null, filter: (a: number) => a > p95 },
332
+ ];
333
+
334
+ const totalVolume = txs.reduce((s, t) => s + t.amount, 0);
335
+
336
+ const result = categories.map((c) => {
337
+ const matching = txs.filter((t) => c.filter(t.amount));
338
+ const vol = matching.reduce((s, t) => s + t.amount, 0);
339
+ const buyVol = matching.filter((t) => t.side === 'buy').reduce((s, t) => s + t.amount, 0);
340
+ const sellVol = matching.filter((t) => t.side === 'sell').reduce((s, t) => s + t.amount, 0);
341
+ return {
342
+ label: c.label,
343
+ minSize: Number(c.minSize.toFixed(8)),
344
+ maxSize: c.maxSize != null ? Number(c.maxSize.toFixed(8)) : null,
345
+ count: matching.length,
346
+ volume: Number(vol.toFixed(8)),
347
+ pct: totalVolume > 0 ? Number(((vol / totalVolume) * 100).toFixed(1)) : 0,
348
+ buyVolume: Number(buyVol.toFixed(8)),
349
+ sellVolume: Number(sellVol.toFixed(8)),
350
+ };
351
+ });
352
+
353
+ // Large trade bias (大口 + 特大口)
354
+ const largeTxs = txs.filter((t) => t.amount > p75);
355
+ const largeBuyVol = largeTxs.filter((t) => t.side === 'buy').reduce((s, t) => s + t.amount, 0);
356
+ const largeSellVol = largeTxs.filter((t) => t.side === 'sell').reduce((s, t) => s + t.amount, 0);
357
+ const ratio = largeSellVol > 0 ? Number((largeBuyVol / largeSellVol).toFixed(2)) : largeBuyVol > 0 ? null : null;
358
+ const interpretation =
359
+ ratio == null
360
+ ? largeBuyVol > 0
361
+ ? '大口は買い一色'
362
+ : '大口取引なし'
363
+ : ratio > 1.3
364
+ ? '大口は買い優勢(蓄積の可能性)'
365
+ : ratio < 0.7
366
+ ? '大口は売り優勢(分配の可能性)'
367
+ : '大口は買い売り均衡';
368
+
369
+ return {
370
+ categories: result,
371
+ thresholds: { p25: Number(p25.toFixed(8)), p75: Number(p75.toFixed(8)), p95: Number(p95.toFixed(8)) },
372
+ largeTradeBias: {
373
+ buyVolume: Number(largeBuyVol.toFixed(8)),
374
+ sellVolume: Number(largeSellVol.toFixed(8)),
375
+ ratio,
376
+ interpretation,
377
+ },
378
+ };
379
+ }
380
+
381
+ // ── Main ──
382
+
383
+ export default async function analyzeVolumeProfile(
384
+ pair: string = 'btc_jpy',
385
+ hours?: number,
386
+ limit: number = 500,
387
+ bins: number = 20,
388
+ valueAreaPct: number = 0.7,
389
+ tz: string = 'Asia/Tokyo',
390
+ ) {
391
+ const chk = ensurePair(pair);
392
+ if (!chk.ok) return failFromValidation(chk, AnalyzeVolumeProfileOutputSchema);
393
+
394
+ try {
395
+ const fetchResult = await fetchTransactions(chk.pair, hours, limit);
396
+ if (!fetchResult.ok) {
397
+ return AnalyzeVolumeProfileOutputSchema.parse(fail(fetchResult.summary, fetchResult.errorType));
398
+ }
399
+ const txs = fetchResult.txs;
400
+ if (txs.length < 10) {
401
+ return AnalyzeVolumeProfileOutputSchema.parse(fail('約定データが不足しています(10件未満)', 'user'));
402
+ }
403
+
404
+ const currentPrice = txs[txs.length - 1].price;
405
+ const { vwap, stdDev } = calcVwap(txs);
406
+ const profile = calcVolumeProfile(txs, bins, valueAreaPct);
407
+ const tradeSizes = calcTradeSizeDistribution(txs);
408
+
409
+ // VWAP position classification
410
+ const dev = currentPrice - vwap;
411
+ const deviationPct = vwap > 0 ? Number(((dev / vwap) * 100).toFixed(2)) : 0;
412
+ const position =
413
+ dev > 2 * stdDev
414
+ ? ('above_2sigma' as const)
415
+ : dev > stdDev
416
+ ? ('above_1sigma' as const)
417
+ : dev < -2 * stdDev
418
+ ? ('below_2sigma' as const)
419
+ : dev < -stdDev
420
+ ? ('below_1sigma' as const)
421
+ : ('at_vwap' as const);
422
+
423
+ const positionLabel: Record<string, string> = {
424
+ above_2sigma: '大幅に割高(+2σ超)→ 短期反落リスク高',
425
+ above_1sigma: 'やや割高(+1σ超)→ 利確検討圏',
426
+ at_vwap: 'VWAP近辺(±1σ以内)→ フェアバリュー圏',
427
+ below_1sigma: 'やや割安(-1σ超)→ 押し目検討圏',
428
+ below_2sigma: '大幅に割安(-2σ超)→ 短期反発期待',
429
+ };
430
+
431
+ // Time range info
432
+ const startMs = txs[0].timestampMs;
433
+ const endMs = txs[txs.length - 1].timestampMs;
434
+ const durationMin = Math.round((endMs - startMs) / 60_000);
435
+ const totalVolume = txs.reduce((s, t) => s + t.amount, 0);
436
+ const priceHigh = Math.max(...txs.map((t) => t.price));
437
+ const priceLow = Math.min(...txs.map((t) => t.price));
438
+
439
+ const data = {
440
+ vwap: {
441
+ price: Number(vwap.toFixed(2)),
442
+ stdDev: Number(stdDev.toFixed(2)),
443
+ bands: {
444
+ upper2sigma: Number((vwap + 2 * stdDev).toFixed(2)),
445
+ upper1sigma: Number((vwap + stdDev).toFixed(2)),
446
+ lower1sigma: Number((vwap - stdDev).toFixed(2)),
447
+ lower2sigma: Number((vwap - 2 * stdDev).toFixed(2)),
448
+ },
449
+ currentPrice,
450
+ deviationPct,
451
+ position,
452
+ interpretation: positionLabel[position],
453
+ },
454
+ profile,
455
+ tradeSizes,
456
+ params: {
457
+ totalTrades: txs.length,
458
+ totalVolume: Number(totalVolume.toFixed(8)),
459
+ priceRange: { high: priceHigh, low: priceLow },
460
+ timeRange: {
461
+ start: toIsoWithTz(startMs, tz) ?? '',
462
+ end: toIsoWithTz(endMs, tz) ?? '',
463
+ durationMin,
464
+ },
465
+ bins,
466
+ valueAreaPct,
467
+ },
468
+ };
469
+
470
+ // Build summary text
471
+ const pairDisplay = formatPair(chk.pair);
472
+ const fmtPx = (p: number) => formatPrice(p, chk.pair);
473
+ const rangeStr = `${toDisplayTime(startMs, tz) ?? '?'}〜${toDisplayTime(endMs, tz) ?? '?'}`;
474
+
475
+ const topBins = [...profile.bins].sort((a, b) => b.totalVolume - a.totalVolume).slice(0, 5);
476
+ const profileText = topBins
477
+ .map((b, i) => {
478
+ const bar = '█'.repeat(Math.max(1, Math.round(b.pct / 3)));
479
+ return ` ${i + 1}. ${b.label}円: ${bar} ${b.pct}% (買${b.buyVolume.toFixed(4)}/売${b.sellVolume.toFixed(4)}) [${b.dominant}]`;
480
+ })
481
+ .join('\n');
482
+
483
+ const summaryLines: string[] = [];
484
+ if (fetchResult.fetchWarning) summaryLines.push(fetchResult.fetchWarning);
485
+ const summary = [
486
+ ...summaryLines,
487
+ `${pairDisplay} Volume Profile & VWAP (${txs.length}件, ${durationMin}分間)`,
488
+ `期間: ${rangeStr}`,
489
+ '',
490
+ '📊 VWAP:',
491
+ ` VWAP: ${fmtPx(vwap)} (σ=${fmtPx(stdDev)})`,
492
+ ` バンド: +2σ=${fmtPx(vwap + 2 * stdDev)} / +1σ=${fmtPx(vwap + stdDev)} / -1σ=${fmtPx(vwap - stdDev)} / -2σ=${fmtPx(vwap - 2 * stdDev)}`,
493
+ ` 現在値: ${fmtPx(currentPrice)} (VWAP比 ${formatPercent(deviationPct, { sign: true })})`,
494
+ ` 判定: ${positionLabel[position]}`,
495
+ '',
496
+ '📈 Volume Profile (出来高上位5帯):',
497
+ profileText,
498
+ ` POC: ${fmtPx(profile.poc.price)} (最大出来高価格帯)`,
499
+ ` Value Area: ${fmtPx(profile.valueArea.low)}〜${fmtPx(profile.valueArea.high)} (${profile.valueArea.pct}%)`,
500
+ '',
501
+ `💰 約定サイズ分布 (閾値: P25=${tradeSizes.thresholds.p25}, P75=${tradeSizes.thresholds.p75}, P95=${tradeSizes.thresholds.p95}):`,
502
+ ` 分類基準: 小口≤P25, 中口P25–P75, 大口P75–P95, 特大口>P95`,
503
+ ...tradeSizes.categories.map(
504
+ (c) =>
505
+ ` ${c.label}: ${c.count}件 ${c.volume.toFixed(4)} (${c.pct}%) 買${c.buyVolume.toFixed(4)}/売${c.sellVolume.toFixed(4)}`,
506
+ ),
507
+ ` 大口偏り: ${tradeSizes.largeTradeBias.interpretation}`,
508
+ '',
509
+ `---`,
510
+ `📌 含まれるもの: VWAP+σバンド、価格帯別出来高分布(POC/VA)、約定サイズ分布`,
511
+ `📌 含まれないもの: 時系列フロー(CVD等)、板情報、テクニカル指標`,
512
+ `📌 補完ツール: get_flow_metrics(CVD・スパイク), get_orderbook(板情報), analyze_indicators(指標)`,
513
+ ].join('\n');
514
+
515
+ const metaExtra: Record<string, unknown> = { count: txs.length };
516
+ if (fetchResult.fetchWarning) metaExtra.warning = fetchResult.fetchWarning;
517
+ const meta = createMeta(chk.pair, metaExtra);
518
+ return AnalyzeVolumeProfileOutputSchema.parse(
519
+ ok<z.infer<typeof AnalyzeVolumeProfileDataSchemaOut>, z.infer<typeof AnalyzeVolumeProfileMetaSchemaOut>>(
520
+ summary,
521
+ data,
522
+ meta as z.infer<typeof AnalyzeVolumeProfileMetaSchemaOut>,
523
+ ),
524
+ );
525
+ } catch (e: unknown) {
526
+ return failFromError(e, { schema: AnalyzeVolumeProfileOutputSchema });
527
+ }
528
+ }
529
+
530
+ // ── MCP ツール定義(tool-registry から自動収集) ──
531
+ export const toolDef: ToolDefinition = {
532
+ name: 'analyze_volume_profile',
533
+ description: `[Volume Profile / VWAP / POC] 出来高プロファイル分析(volume profile / VWAP / POC / value area)。VWAP±σバンド・価格帯別出来高・約定サイズ分布を算出。hours で期間指定(デフォルト4h、最大24h)。`,
534
+ inputSchema: AnalyzeVolumeProfileInputSchema,
535
+ handler: async (rawInput: Record<string, unknown>) => {
536
+ const parsed = AnalyzeVolumeProfileInputSchema.parse(rawInput);
537
+ return analyzeVolumeProfile(
538
+ parsed.pair,
539
+ 'hours' in rawInput ? parsed.hours : undefined,
540
+ parsed.limit,
541
+ parsed.bins,
542
+ parsed.valueAreaPct,
543
+ parsed.tz,
544
+ );
545
+ },
546
+ };
@@ -0,0 +1,113 @@
1
+ /**
2
+ * chart/ichimoku-cloud — 一目均衡表の雲(先行スパン A/B)の SVG パス生成。
3
+ *
4
+ * spanA と spanB の交差点で緑雲と赤雲を切り替える。
5
+ */
6
+
7
+ /** 雲パス生成のコンテキスト */
8
+ export interface CloudContext {
9
+ /** X 座標変換: barIndex → SVG x */
10
+ x: (i: number) => number;
11
+ /** Y 座標変換: price → SVG y */
12
+ y: (v: number) => number;
13
+ /** pastBuffer(生データの前方バッファ長) */
14
+ pastBuffer: number;
15
+ /** 描画領域のバー数 */
16
+ displayLength: number;
17
+ /** 先行シフト幅 */
18
+ forwardShift: number;
19
+ }
20
+
21
+ /**
22
+ * spanA / spanB の交差で色を切り替えながら雲ポリゴンのパスを生成する。
23
+ */
24
+ export function createCloudPaths(
25
+ spanA: Array<number | null> | undefined,
26
+ spanB: Array<number | null> | undefined,
27
+ offset: number,
28
+ ctx: CloudContext,
29
+ ): { greenCloudPath: string; redCloudPath: string } {
30
+ let greenCloudPath = '';
31
+ let redCloudPath = '';
32
+ let currentTop: Array<{ x: number; y: number }> = [];
33
+ let currentBottom: Array<{ x: number; y: number }> = [];
34
+ let currentIsGreen: boolean | null = null;
35
+
36
+ const minPosIndex = -1;
37
+ const maxPosIndex = ctx.displayLength + ctx.forwardShift + 1;
38
+
39
+ const pushPolygon = () => {
40
+ if (currentTop.length < 2 || currentBottom.length < 2) return;
41
+ const polygon = `M ${[...currentTop, ...currentBottom.slice().reverse()].map((p) => `${p.x},${p.y}`).join(' L ')} Z`;
42
+ if (currentIsGreen) greenCloudPath += polygon;
43
+ else redCloudPath += polygon;
44
+ };
45
+
46
+ const getPosIndex = (i: number) => i - ctx.pastBuffer + offset;
47
+ const toPoint = (i: number, yVal: number) => ({ x: ctx.x(getPosIndex(i)), y: ctx.y(yVal) });
48
+
49
+ const len = Math.max(spanA?.length || 0, spanB?.length || 0);
50
+ for (let i = 0; i < len - 1; i++) {
51
+ const a0 = spanA?.[i] as number | null;
52
+ const b0 = spanB?.[i] as number | null;
53
+ const a1 = spanA?.[i + 1] as number | null;
54
+ const b1 = spanB?.[i + 1] as number | null;
55
+ if (
56
+ a0 == null ||
57
+ b0 == null ||
58
+ a1 == null ||
59
+ b1 == null ||
60
+ !Number.isFinite(a0) ||
61
+ !Number.isFinite(b0) ||
62
+ !Number.isFinite(a1) ||
63
+ !Number.isFinite(b1)
64
+ ) {
65
+ pushPolygon();
66
+ currentTop = [];
67
+ currentBottom = [];
68
+ currentIsGreen = null;
69
+ continue;
70
+ }
71
+
72
+ // 描画領域外のセグメントをスキップ
73
+ const posIndex0 = getPosIndex(i);
74
+ const posIndex1 = getPosIndex(i + 1);
75
+ if (posIndex1 < minPosIndex || posIndex0 > maxPosIndex) {
76
+ pushPolygon();
77
+ currentTop = [];
78
+ currentBottom = [];
79
+ currentIsGreen = null;
80
+ continue;
81
+ }
82
+
83
+ const isGreen0 = a0 >= b0;
84
+ const isGreen1 = a1 >= b1;
85
+ if (currentIsGreen === null) {
86
+ currentIsGreen = isGreen0;
87
+ currentTop.push(toPoint(i, currentIsGreen ? a0 : b0));
88
+ currentBottom.push(toPoint(i, currentIsGreen ? b0 : a0));
89
+ }
90
+ if (isGreen0 === isGreen1) {
91
+ currentTop.push(toPoint(i + 1, currentIsGreen ? a1 : b1));
92
+ currentBottom.push(toPoint(i + 1, currentIsGreen ? b1 : a1));
93
+ continue;
94
+ }
95
+ // 交点の線形補間
96
+ const da = a1 - a0;
97
+ const db = b1 - b0;
98
+ const denom = da - db;
99
+ const t = denom === 0 ? 0 : (a0 - b0) / denom;
100
+ const tClamped = Math.max(0, Math.min(1, t));
101
+ const xi = i + tClamped;
102
+ const yi = a0 + tClamped * da;
103
+ const pInt = toPoint(xi, yi);
104
+ currentTop.push(pInt);
105
+ currentBottom.push(pInt);
106
+ pushPolygon();
107
+ currentIsGreen = isGreen1;
108
+ currentTop = [pInt, toPoint(i + 1, currentIsGreen ? a1 : b1)];
109
+ currentBottom = [pInt, toPoint(i + 1, currentIsGreen ? b1 : a1)];
110
+ }
111
+ pushPolygon();
112
+ return { greenCloudPath, redCloudPath };
113
+ }