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,596 @@
1
+ import { dayjs, toDisplayTime, toIsoTime, toIsoWithTz } from '../lib/datetime.js';
2
+ import { formatSummary } from '../lib/formatter.js';
3
+ import { fail, failFromError, failFromValidation, ok } from '../lib/result.js';
4
+ import { createMeta, ensurePair, validateLimit } from '../lib/validate.js';
5
+ import { GetFlowMetricsInputSchema, GetFlowMetricsOutputSchema } from '../src/schemas.js';
6
+ import type { ToolDefinition } from '../src/tool-definition.js';
7
+ import getTransactions from './get_transactions.js';
8
+
9
+ type Tx = { price: number; amount: number; side: 'buy' | 'sell'; timestampMs: number; isoTime: string };
10
+
11
+ export interface FlowMetricsBucket {
12
+ timestampMs: number;
13
+ isoTime: string;
14
+ isoTimeJST?: string;
15
+ displayTime?: string;
16
+ buyVolume: number;
17
+ sellVolume: number;
18
+ totalVolume: number;
19
+ cvd: number;
20
+ zscore: number | null;
21
+ spike: 'notice' | 'warning' | 'strong' | null;
22
+ }
23
+
24
+ export interface BuildFlowMetricsTextInput {
25
+ baseSummary: string;
26
+ dataWarning?: string;
27
+ totalTrades: number;
28
+ buyVolume: number;
29
+ sellVolume: number;
30
+ netVolume: number;
31
+ aggressorRatio: number;
32
+ cvd: number;
33
+ buckets: FlowMetricsBucket[];
34
+ bucketMs: number;
35
+ /** "summary" はバケット行を省略, "compact" は非ゼロバケットのみ, "full" は全件 */
36
+ bucketsMode?: 'summary' | 'compact' | 'full';
37
+ }
38
+
39
+ /** テキスト組み立て(フロー分析結果)— テスト可能な純粋関数 */
40
+ export function buildFlowMetricsText(input: BuildFlowMetricsTextInput): string {
41
+ const {
42
+ baseSummary,
43
+ dataWarning,
44
+ totalTrades,
45
+ buyVolume,
46
+ sellVolume,
47
+ netVolume,
48
+ aggressorRatio,
49
+ cvd,
50
+ buckets,
51
+ bucketMs,
52
+ bucketsMode = 'full',
53
+ } = input;
54
+ const warningLine = dataWarning ? `\n${dataWarning}` : '';
55
+ const aggregatesLine = `\naggregates: totalTrades=${totalTrades} buyVol=${Number(buyVolume.toFixed(4))} sellVol=${Number(sellVolume.toFixed(4))} netVol=${Number(netVolume.toFixed(4))} aggRatio=${aggressorRatio} finalCvd=${Number(cvd.toFixed(4))}`;
56
+ const footer =
57
+ `\n\n---\n📌 含まれるもの: 時系列バケット(買い/売り出来高・CVD・Zスコア・スパイク)、集計値` +
58
+ `\n📌 含まれないもの: 個別約定の詳細、OHLCV価格データ、板情報、テクニカル指標` +
59
+ `\n📌 補完ツール: get_transactions(個別約定), get_candles(OHLCV), get_orderbook(板情報), analyze_indicators(指標)`;
60
+
61
+ if (bucketsMode === 'summary') {
62
+ return baseSummary + warningLine + aggregatesLine + footer;
63
+ }
64
+
65
+ const displayBuckets =
66
+ bucketsMode === 'compact' ? buckets.filter((b) => b.buyVolume > 0 || b.sellVolume > 0) : buckets;
67
+ const bucketLines = displayBuckets.map((b, i) => {
68
+ const t = b.displayTime || b.isoTimeJST || b.isoTime || '?';
69
+ const sp = b.spike ? ` spike:${b.spike}` : '';
70
+ return `[${i}] ${t} buy:${b.buyVolume} sell:${b.sellVolume} cvd:${b.cvd} z:${b.zscore ?? 'n/a'}${sp}`;
71
+ });
72
+ const label =
73
+ bucketsMode === 'compact'
74
+ ? `\n\n📋 非ゼロ${displayBuckets.length}/${buckets.length}件のバケット (${bucketMs}ms間隔):\n`
75
+ : `\n\n📋 全${displayBuckets.length}件のバケット (${bucketMs}ms間隔):\n`;
76
+ return baseSummary + warningLine + aggregatesLine + label + bucketLines.join('\n') + footer;
77
+ }
78
+
79
+ type FetchFailure = { label: string; errorType: string; message: string };
80
+
81
+ type TxResultLike = {
82
+ ok?: boolean;
83
+ data?: { normalized?: Tx[] };
84
+ summary?: string;
85
+ meta?: { errorType?: string };
86
+ } | null;
87
+
88
+ /** 複数の getTransactions 結果をマージし重複を除去する(失敗詳細も返す) */
89
+ function mergeTxResults(
90
+ results: unknown[],
91
+ labels?: string[],
92
+ ): { txs: Tx[]; totalCount: number; failures: FetchFailure[] } {
93
+ const seen = new Set<string>();
94
+ const merged: Tx[] = [];
95
+ const failures: FetchFailure[] = [];
96
+ for (let i = 0; i < results.length; i++) {
97
+ const r = results[i] as TxResultLike;
98
+ if (r?.ok && Array.isArray(r.data?.normalized)) {
99
+ for (const tx of r.data.normalized as Tx[]) {
100
+ const key = `${tx.timestampMs}:${tx.price}:${tx.amount}:${tx.side}`;
101
+ if (!seen.has(key)) {
102
+ seen.add(key);
103
+ merged.push(tx);
104
+ }
105
+ }
106
+ } else {
107
+ failures.push({
108
+ label: labels?.[i] ?? `#${i}`,
109
+ errorType: r?.meta?.errorType ?? 'unknown',
110
+ message: r?.summary ?? 'unknown error',
111
+ });
112
+ }
113
+ }
114
+ return { txs: merged, totalCount: results.length, failures };
115
+ }
116
+
117
+ /** 失敗詳細をフォーマットする("20260420(network: HTTP 503 ...)" 形式) */
118
+ function formatFailures(failures: FetchFailure[]): string {
119
+ return failures.map((f) => `${f.label}(${f.errorType}: ${f.message})`).join(', ');
120
+ }
121
+
122
+ export default async function getFlowMetrics(
123
+ pair: string = 'btc_jpy',
124
+ limit: number = 100,
125
+ date?: string,
126
+ bucketMs: number = 60_000,
127
+ tz: string = 'Asia/Tokyo',
128
+ hours?: number,
129
+ ) {
130
+ const chk = ensurePair(pair);
131
+ if (!chk.ok) return failFromValidation(chk, GetFlowMetricsOutputSchema);
132
+
133
+ try {
134
+ let txs: Tx[];
135
+ let fetchWarning: string | undefined;
136
+
137
+ if (hours != null && hours > 0) {
138
+ // === 時間範囲ベースの取得 ===
139
+ const nowMs = Date.now();
140
+ const sinceMs = nowMs - hours * 3600_000;
141
+
142
+ // bitbank の /transactions/{YYYYMMDD} は JST 基準の日付アーカイブ。
143
+ // 当日分はアーカイブが未生成で 404 を返すことがあるため、当日 URL の失敗は
144
+ // fatal 扱いせず /transactions (latest) からの補完にフォールバックする。
145
+ const sinceDayjs = dayjs(sinceMs).tz('Asia/Tokyo');
146
+ const nowDayjs = dayjs(nowMs).tz('Asia/Tokyo');
147
+ const todayStr = nowDayjs.format('YYYYMMDD');
148
+
149
+ // 必要な日付を YYYYMMDD (JST) 形式で列挙(古い順)
150
+ const dates: string[] = [];
151
+ let d = sinceDayjs.startOf('day');
152
+ while (d.isBefore(nowDayjs) || d.isSame(nowDayjs, 'day')) {
153
+ dates.push(d.format('YYYYMMDD'));
154
+ d = d.add(1, 'day');
155
+ }
156
+
157
+ // 日付ベース取得(authoritative: 時間範囲をカバー)と latest(supplement: 直近数分の補完)を区別。
158
+ // 当日分は日付指定だと直近数分が欠ける場合があるため latest も併用する。
159
+ const dateResults = await Promise.all(dates.map((ds) => getTransactions(chk.pair, 1000, ds)));
160
+ const latestResult = await getTransactions(chk.pair, 1000);
161
+
162
+ // 失敗した date 取得を一度だけリトライ(fetchJsonWithRateLimit の内部リトライより長い間隔)
163
+ const retryIdx: number[] = [];
164
+ for (let i = 0; i < dateResults.length; i++) {
165
+ const r = dateResults[i] as TxResultLike;
166
+ if (!r?.ok) retryIdx.push(i);
167
+ }
168
+ if (retryIdx.length > 0) {
169
+ await new Promise((resolve) => setTimeout(resolve, 500));
170
+ const retried = await Promise.all(retryIdx.map((i) => getTransactions(chk.pair, 1000, dates[i])));
171
+ for (let j = 0; j < retryIdx.length; j++) {
172
+ const r = retried[j] as TxResultLike;
173
+ if (r?.ok) dateResults[retryIdx[j]] = retried[j];
174
+ }
175
+ }
176
+
177
+ const dateMerge = mergeTxResults(dateResults, dates);
178
+ const latestMerge = mergeTxResults([latestResult], ['latest']);
179
+
180
+ // 当日 (JST) のアーカイブ欠如は許容: その分は latest から補う。
181
+ // fatal 扱いするのは「過去日が要求されたのに全て失敗」または「当日のみ要求で latest も失敗」
182
+ const nonTodayFailures = dateMerge.failures.filter((f) => f.label !== todayStr);
183
+ const todayFailed = dateMerge.failures.some((f) => f.label === todayStr);
184
+ const historicalRequested = dates.some((ds) => ds !== todayStr);
185
+ const historicalAllFailed = historicalRequested && dateMerge.txs.length === 0 && nonTodayFailures.length > 0;
186
+
187
+ if (historicalAllFailed) {
188
+ return GetFlowMetricsOutputSchema.parse(
189
+ fail(
190
+ `日付ベースの取得が全て失敗しました(${dateMerge.failures.length}件: ${formatFailures(dateMerge.failures)})`,
191
+ 'upstream',
192
+ ),
193
+ );
194
+ }
195
+
196
+ // 過去日が無く (today のみ) かつ today + latest 両方失敗 → 取得手段なし
197
+ if (!historicalRequested && todayFailed && latestMerge.txs.length === 0) {
198
+ const allFailures = [...dateMerge.failures, ...latestMerge.failures];
199
+ return GetFlowMetricsOutputSchema.parse(
200
+ fail(
201
+ `日付ベース取得(当日 ${todayStr})と latest の両方が失敗しました(${allFailures.length}件: ${formatFailures(allFailures)})`,
202
+ 'upstream',
203
+ ),
204
+ );
205
+ }
206
+
207
+ // 部分失敗は警告のみ(latest 失敗は直近数分の欠落、一部 date 失敗は該当日のカバレッジ不足)
208
+ const warnMsgs: string[] = [];
209
+ if (nonTodayFailures.length > 0) {
210
+ warnMsgs.push(
211
+ `⚠️ 日付ベース取得で ${dateMerge.totalCount}件中 ${nonTodayFailures.length}件失敗: ${formatFailures(nonTodayFailures)}`,
212
+ );
213
+ }
214
+ if (todayFailed) {
215
+ warnMsgs.push(
216
+ `⚠️ 当日 (${todayStr}) アーカイブが未公開または取得失敗のため /transactions (latest) から補完しました`,
217
+ );
218
+ }
219
+ if (latestMerge.failures.length > 0) {
220
+ warnMsgs.push(
221
+ `⚠️ 最新約定の補完取得に失敗 (${formatFailures(latestMerge.failures)}) — 直近数分のデータが欠落している可能性があります`,
222
+ );
223
+ }
224
+ if (warnMsgs.length > 0) fetchWarning = warnMsgs.join('\n');
225
+
226
+ // date + latest を統合して重複除去
227
+ const combined = new Set<string>();
228
+ const allTxs: Tx[] = [];
229
+ for (const tx of [...dateMerge.txs, ...latestMerge.txs]) {
230
+ const key = `${tx.timestampMs}:${tx.price}:${tx.amount}:${tx.side}`;
231
+ if (!combined.has(key)) {
232
+ combined.add(key);
233
+ allTxs.push(tx);
234
+ }
235
+ }
236
+
237
+ txs = allTxs
238
+ .filter((t) => t.timestampMs >= sinceMs && t.timestampMs <= nowMs)
239
+ .sort((a, b) => a.timestampMs - b.timestampMs);
240
+ } else {
241
+ // === 件数ベース取得 ===
242
+ const lim = validateLimit(limit, 1, 2000);
243
+ if (!lim.ok) return failFromValidation(lim, GetFlowMetricsOutputSchema);
244
+
245
+ if (date) {
246
+ // 明示的な日付指定がある場合はそのまま取得。
247
+ // 当日 (JST) はアーカイブ未生成で 404 の可能性があるため latest にフォールバック。
248
+ const txRes = await getTransactions(chk.pair, Math.min(lim.value, 1000), date);
249
+ const isTodayJst = date === dayjs().tz('Asia/Tokyo').format('YYYYMMDD');
250
+ if (!txRes?.ok) {
251
+ if (isTodayJst) {
252
+ const latestRes = await getTransactions(chk.pair, Math.min(lim.value, 1000));
253
+ if (!latestRes?.ok) {
254
+ return GetFlowMetricsOutputSchema.parse(
255
+ fail(
256
+ `date=${date} (today JST) アーカイブ未公開かつ latest 取得も失敗: ${txRes?.summary || 'unknown'} / ${latestRes?.summary || 'unknown'}`,
257
+ latestRes?.meta?.errorType || 'upstream',
258
+ ),
259
+ );
260
+ }
261
+ fetchWarning = `⚠️ 当日 (${date}) のアーカイブは未公開のため /transactions (latest) から取得しました`;
262
+ txs = latestRes.data.normalized as Tx[];
263
+ } else {
264
+ return GetFlowMetricsOutputSchema.parse(
265
+ fail(txRes?.summary || 'failed', txRes?.meta?.errorType || 'internal'),
266
+ );
267
+ }
268
+ } else {
269
+ txs = txRes.data.normalized as Tx[];
270
+ }
271
+ } else {
272
+ // 日付指定なし: latest で取得し、不足なら日付ベースで補完
273
+ const latestRes = await getTransactions(chk.pair, Math.min(lim.value, 1000));
274
+ const latestR = latestRes as { ok?: boolean; data?: { normalized?: Tx[] } };
275
+ const latestOk = !!latestR?.ok;
276
+ const latestTxs: Tx[] = latestOk && Array.isArray(latestR.data?.normalized) ? latestR.data.normalized : [];
277
+
278
+ if (latestTxs.length >= lim.value) {
279
+ txs = latestTxs;
280
+ } else {
281
+ // latest の返却数が不足 → 前日・前々日の日付ベース取得で補完
282
+ // bitbank の latest エンドポイントは約60件のみ返却するため
283
+ const todayJst = dayjs().tz('Asia/Tokyo');
284
+ const supplementFetches: Promise<unknown>[] = [
285
+ getTransactions(chk.pair, 1000, todayJst.subtract(1, 'day').format('YYYYMMDD')),
286
+ ];
287
+ if (lim.value > 500) {
288
+ supplementFetches.push(getTransactions(chk.pair, 1000, todayJst.subtract(2, 'day').format('YYYYMMDD')));
289
+ }
290
+ const supplementResults = await Promise.all(supplementFetches);
291
+ const allResults = [latestRes, ...supplementResults];
292
+ const labels = ['latest', ...supplementFetches.map((_, i) => `supplement-${i + 1}`)];
293
+ const { txs: merged, totalCount, failures } = mergeTxResults(allResults, labels);
294
+ // 全て失敗した場合は network エラーとして返す
295
+ if (merged.length === 0 && failures.length > 0) {
296
+ return GetFlowMetricsOutputSchema.parse(
297
+ fail(`upstream fetch all failed (${formatFailures(failures)})`, 'network'),
298
+ );
299
+ }
300
+ // 過半数失敗なら fail
301
+ if (failures.length > 0 && failures.length >= totalCount / 2) {
302
+ return GetFlowMetricsOutputSchema.parse(
303
+ fail(
304
+ `API取得の過半数が失敗しました(${totalCount}件中${failures.length}件失敗: ${formatFailures(failures)})`,
305
+ 'upstream',
306
+ ),
307
+ );
308
+ }
309
+ if (failures.length > 0) {
310
+ fetchWarning = `⚠️ ${totalCount}件中 ${failures.length}件のAPI取得に失敗しました: ${formatFailures(failures)}`;
311
+ }
312
+ txs = merged.sort((a, b) => a.timestampMs - b.timestampMs).slice(-lim.value);
313
+ }
314
+ }
315
+ }
316
+ if (!Array.isArray(txs) || txs.length === 0) {
317
+ return GetFlowMetricsOutputSchema.parse(
318
+ ok(
319
+ 'no transactions',
320
+ {
321
+ source: 'transactions',
322
+ params: { bucketMs },
323
+ aggregates: {
324
+ totalTrades: 0,
325
+ buyTrades: 0,
326
+ sellTrades: 0,
327
+ buyVolume: 0,
328
+ sellVolume: 0,
329
+ netVolume: 0,
330
+ aggressorRatio: 0,
331
+ finalCvd: 0,
332
+ },
333
+ series: { buckets: [] },
334
+ },
335
+ createMeta(chk.pair, { count: 0, bucketMs }),
336
+ ),
337
+ );
338
+ }
339
+
340
+ // バケット分割
341
+ const t0 = txs[0].timestampMs;
342
+ const buckets: Array<{ ts: number; buys: number; sells: number; vBuy: number; vSell: number }> = [];
343
+ const idx = (ms: number) => Math.floor((ms - t0) / bucketMs);
344
+ for (const t of txs) {
345
+ const k = idx(t.timestampMs);
346
+ while (buckets.length <= k)
347
+ buckets.push({ ts: t0 + buckets.length * bucketMs, buys: 0, sells: 0, vBuy: 0, vSell: 0 });
348
+ if (t.side === 'buy') {
349
+ buckets[k].buys++;
350
+ buckets[k].vBuy += t.amount;
351
+ } else {
352
+ buckets[k].sells++;
353
+ buckets[k].vSell += t.amount;
354
+ }
355
+ }
356
+
357
+ // CVD とスパイク
358
+ const outBuckets: Array<{
359
+ timestampMs: number;
360
+ isoTime: string;
361
+ isoTimeJST?: string;
362
+ displayTime?: string;
363
+ buyVolume: number;
364
+ sellVolume: number;
365
+ totalVolume: number;
366
+ cvd: number;
367
+ zscore: number | null;
368
+ spike: 'notice' | 'warning' | 'strong' | null;
369
+ }> = [];
370
+ let cvd = 0;
371
+ const vols = buckets.map((b) => b.vBuy + b.vSell);
372
+ const mean = vols.reduce((a, b) => a + b, 0) / Math.max(1, vols.length);
373
+ const variance = vols.reduce((s, v) => s + (v - mean) ** 2, 0) / Math.max(1, vols.length);
374
+ const stdev = Math.sqrt(variance);
375
+ const spikeLevel = (z: number): 'notice' | 'warning' | 'strong' | null => {
376
+ if (!Number.isFinite(z)) return null;
377
+ if (z >= 3) return 'strong';
378
+ if (z >= 2) return 'warning';
379
+ if (z >= 1.5) return 'notice';
380
+ return null;
381
+ };
382
+
383
+ for (const b of buckets) {
384
+ const vol = b.vBuy + b.vSell;
385
+ cvd += b.vBuy - b.vSell;
386
+ const z = stdev > 0 ? (vol - mean) / stdev : 0;
387
+ const ts = b.ts + bucketMs - 1;
388
+ outBuckets.push({
389
+ timestampMs: ts,
390
+ isoTime: toIsoTime(ts) ?? '',
391
+ isoTimeJST: toIsoWithTz(ts, tz) ?? undefined,
392
+ displayTime: toDisplayTime(ts, tz) ?? undefined,
393
+ buyVolume: Number(b.vBuy.toFixed(8)),
394
+ sellVolume: Number(b.vSell.toFixed(8)),
395
+ totalVolume: Number(vol.toFixed(8)),
396
+ cvd: Number(cvd.toFixed(8)),
397
+ zscore: Number.isFinite(z) ? Number(z.toFixed(2)) : null,
398
+ spike: spikeLevel(z),
399
+ });
400
+ }
401
+
402
+ const totalTrades = txs.length;
403
+ const buyTrades = txs.filter((t) => t.side === 'buy').length;
404
+ const sellTrades = totalTrades - buyTrades;
405
+ const buyVolume = txs.filter((t) => t.side === 'buy').reduce((s, t) => s + t.amount, 0);
406
+ const sellVolume = txs.filter((t) => t.side === 'sell').reduce((s, t) => s + t.amount, 0);
407
+ const netVolume = buyVolume - sellVolume;
408
+ const aggressorRatio = totalTrades > 0 ? Number((buyTrades / totalTrades).toFixed(3)) : 0;
409
+
410
+ // 実際の取得範囲を計算
411
+ const actualStartMs = txs[0]?.timestampMs;
412
+ const actualEndMs = txs[txs.length - 1]?.timestampMs;
413
+ const actualDurationMin = actualStartMs && actualEndMs ? Math.round((actualEndMs - actualStartMs) / 60_000) : 0;
414
+
415
+ // データ不足警告
416
+ const warnings: string[] = [];
417
+ if (fetchWarning) warnings.push(fetchWarning);
418
+ if (hours != null && hours > 0 && actualDurationMin > 0) {
419
+ const requestedMin = hours * 60;
420
+ const coveragePct = Math.round((actualDurationMin / requestedMin) * 100);
421
+ if (coveragePct < 80) {
422
+ warnings.push(
423
+ `⚠️ ${hours}時間分をリクエストしましたが、取得できたデータは約${actualDurationMin}分間(カバー率${coveragePct}%)です。bitbank API の返却上限による制約の可能性があります。`,
424
+ );
425
+ }
426
+ }
427
+ const dataWarning = warnings.length > 0 ? warnings.join('\n') : undefined;
428
+
429
+ // スパイク情報を集計(spike が null でないものをフィルタ)
430
+ const spikes = outBuckets.filter((b) => b.spike !== null);
431
+ let spikeInfo = '';
432
+ if (spikes.length > 0) {
433
+ const spikeDetails = spikes
434
+ .slice(0, 3)
435
+ .map((s) => {
436
+ const time = s.displayTime || s.isoTime || '';
437
+ const level = s.spike === 'strong' ? '🚨強' : s.spike === 'warning' ? '⚠️中' : '📈弱';
438
+ const direction = s.cvd > 0 ? '買い' : '売り';
439
+ return `${time}(${level}${direction})`;
440
+ })
441
+ .join(', ');
442
+ spikeInfo = ` | スパイク${spikes.length}件: ${spikeDetails}`;
443
+ } else {
444
+ spikeInfo = ' | スパイクなし';
445
+ }
446
+
447
+ const rangeLabel =
448
+ actualStartMs && actualEndMs
449
+ ? ` (${toDisplayTime(actualStartMs, tz) ?? '?'}〜${toDisplayTime(actualEndMs, tz) ?? '?'}, ${actualDurationMin}分間)`
450
+ : '';
451
+ const baseSummary = formatSummary({
452
+ pair: chk.pair,
453
+ latest: txs.at(-1)?.price,
454
+ extra: `trades=${totalTrades} buy%=${(aggressorRatio * 100).toFixed(1)} CVD=${cvd.toFixed(2)}${spikeInfo}${rangeLabel}`,
455
+ });
456
+ // Result の summary は "summary" モード(集計値のみ、バケット行なし)。
457
+ // 呼び出し側 (handler) が view に応じて content テキストを差し替える。
458
+ const summary = buildFlowMetricsText({
459
+ baseSummary,
460
+ dataWarning,
461
+ totalTrades,
462
+ buyVolume,
463
+ sellVolume,
464
+ netVolume,
465
+ aggressorRatio,
466
+ cvd,
467
+ buckets: outBuckets,
468
+ bucketMs,
469
+ bucketsMode: 'summary',
470
+ });
471
+
472
+ const data = {
473
+ source: 'transactions' as const,
474
+ params: { bucketMs },
475
+ aggregates: {
476
+ totalTrades,
477
+ buyTrades,
478
+ sellTrades,
479
+ buyVolume: Number(buyVolume.toFixed(8)),
480
+ sellVolume: Number(sellVolume.toFixed(8)),
481
+ netVolume: Number(netVolume.toFixed(8)),
482
+ aggressorRatio,
483
+ finalCvd: Number(cvd.toFixed(8)),
484
+ },
485
+ series: { buckets: outBuckets },
486
+ };
487
+
488
+ const offsetMin = dayjs().utcOffset();
489
+ const offset = `${offsetMin >= 0 ? '+' : '-'}${String(Math.floor(Math.abs(offsetMin) / 60)).padStart(2, '0')}:${String(Math.abs(offsetMin) % 60).padStart(2, '0')}`;
490
+ const metaExtra: Record<string, unknown> = {
491
+ count: totalTrades,
492
+ bucketMs,
493
+ timezone: tz,
494
+ timezoneOffset: offset,
495
+ serverTime: toIsoWithTz(Date.now(), tz) ?? undefined,
496
+ };
497
+ if (hours != null) {
498
+ metaExtra.hours = hours;
499
+ metaExtra.mode = 'time_range';
500
+ }
501
+ if (actualStartMs && actualEndMs) {
502
+ metaExtra.actualRange = {
503
+ start: toIsoWithTz(actualStartMs, tz) ?? toIsoTime(actualStartMs),
504
+ end: toIsoWithTz(actualEndMs, tz) ?? toIsoTime(actualEndMs),
505
+ durationMinutes: actualDurationMin,
506
+ };
507
+ }
508
+ if (dataWarning) {
509
+ metaExtra.warning = dataWarning;
510
+ }
511
+ const meta = createMeta(chk.pair, metaExtra);
512
+ return GetFlowMetricsOutputSchema.parse(ok(summary, data, meta));
513
+ } catch (e: unknown) {
514
+ return failFromError(e, { schema: GetFlowMetricsOutputSchema });
515
+ }
516
+ }
517
+
518
+ // ── MCP ツール定義(tool-registry から自動収集) ──
519
+ export const toolDef: ToolDefinition = {
520
+ name: 'get_flow_metrics',
521
+ description: `[Flow / CVD / Buy-Sell Pressure] 資金フロー分析(flow / CVD / aggressor ratio / buy-sell pressure)。約定データからCVD・アグレッサー比・スパイクを検出。hours(推奨)で時間範囲指定、または limit で件数指定。`,
522
+ inputSchema: GetFlowMetricsInputSchema,
523
+ handler: async ({
524
+ pair,
525
+ limit,
526
+ date,
527
+ bucketMs,
528
+ view,
529
+ bucketsN,
530
+ tz,
531
+ hours,
532
+ }: {
533
+ pair?: string;
534
+ limit?: number;
535
+ date?: string;
536
+ bucketMs?: number;
537
+ view?: 'summary' | 'compact' | 'buckets' | 'full';
538
+ bucketsN?: number;
539
+ tz?: string;
540
+ hours?: number;
541
+ }) => {
542
+ const res = await getFlowMetrics(
543
+ pair,
544
+ Number(limit),
545
+ date,
546
+ Number(bucketMs),
547
+ tz,
548
+ hours != null ? Number(hours) : undefined,
549
+ );
550
+ if (!res?.ok) return res;
551
+
552
+ const effectiveView = view ?? 'summary';
553
+ const buckets = (res?.data?.series?.buckets ?? []) as FlowMetricsBucket[];
554
+
555
+ // view=summary: バケットを structuredContent からも除外してトークン消費を抑える
556
+ if (effectiveView === 'summary') {
557
+ const { buckets: _omit, ...restSeries } = (res.data.series ?? {}) as { buckets?: unknown };
558
+ const data = { ...res.data, series: restSeries } as typeof res.data;
559
+ const trimmed = { ...res, data };
560
+ return { content: [{ type: 'text', text: res.summary }], structuredContent: trimmed as Record<string, unknown> };
561
+ }
562
+
563
+ // view=compact: 非ゼロバケットのみ
564
+ if (effectiveView === 'compact') {
565
+ const nonZero = buckets.filter((b) => b.buyVolume > 0 || b.sellVolume > 0);
566
+ const data = {
567
+ ...res.data,
568
+ series: { ...res.data.series, buckets: nonZero },
569
+ } as typeof res.data;
570
+ const trimmed = { ...res, data };
571
+ const fmt = (b: FlowMetricsBucket) =>
572
+ `${b.displayTime || b.isoTime} buy=${b.buyVolume} sell=${b.sellVolume} total=${b.totalVolume} cvd=${b.cvd}${b.spike ? ` spike=${b.spike}` : ''}`;
573
+ const text = `${res.summary}\n\nNon-zero ${nonZero.length}/${buckets.length} buckets:\n${nonZero.map(fmt).join('\n')}`;
574
+ return { content: [{ type: 'text', text }], structuredContent: trimmed as Record<string, unknown> };
575
+ }
576
+
577
+ const agg = res?.data?.aggregates ?? {};
578
+ const n = Number(bucketsN ?? 10);
579
+ const last = buckets.slice(-n);
580
+ const fmt = (b: FlowMetricsBucket) =>
581
+ `${b.displayTime || b.isoTime} buy=${b.buyVolume} sell=${b.sellVolume} total=${b.totalVolume} cvd=${b.cvd}${b.spike ? ` spike=${b.spike}` : ''}`;
582
+ const actualRange = res?.meta?.actualRange;
583
+ const rangeStr = actualRange
584
+ ? ` 実取得範囲: ${actualRange.start}〜${actualRange.end}(${actualRange.durationMinutes}分間)`
585
+ : '';
586
+ const warnStr = res?.meta?.warning ? `\n${res.meta.warning}` : '';
587
+ let text = `${String(pair).toUpperCase()} Flow Metrics (bucketMs=${res?.data?.params?.bucketMs ?? bucketMs})${rangeStr}\n`;
588
+ text += `Totals: trades=${agg.totalTrades} buyVol=${agg.buyVolume} sellVol=${agg.sellVolume} net=${agg.netVolume} buy%=${(agg.aggressorRatio * 100 || 0).toFixed(1)} CVD=${agg.finalCvd}${warnStr}`;
589
+ if (effectiveView === 'buckets') {
590
+ text += `\n\nRecent ${last.length} buckets:\n${last.map(fmt).join('\n')}`;
591
+ return { content: [{ type: 'text', text }], structuredContent: res as Record<string, unknown> };
592
+ }
593
+ text += `\n\nAll buckets:\n${buckets.map(fmt).join('\n')}`;
594
+ return { content: [{ type: 'text', text }], structuredContent: res as Record<string, unknown> };
595
+ },
596
+ };