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,691 @@
1
+ import { z } from 'zod';
2
+ import { dayjs } from '../lib/datetime.js';
3
+ import { formatSummary } from '../lib/formatter.js';
4
+ import { fail, failFromError, failFromValidation, ok, toStructured } from '../lib/result.js';
5
+ import { ALLOWED_PAIRS, ensurePair } from '../lib/validate.js';
6
+ import type { Pair } from '../src/schemas.js';
7
+ import type { ToolDefinition } from '../src/tool-definition.js';
8
+ import analyzeIndicators from './analyze_indicators.js';
9
+
10
+ // ── テキスト組み立て: 純粋エクスポート関数 ──
11
+
12
+ export interface MacdScreenCross {
13
+ pair: string;
14
+ type: 'golden' | 'dead';
15
+ crossDate: string | null;
16
+ barsAgo: number;
17
+ macdAtCross: number | null;
18
+ signalAtCross: number | null;
19
+ histogramDelta: number | null;
20
+ returnSinceCrossPct: number | null;
21
+ prevCross: { type: 'golden' | 'dead'; barsAgo: number } | null;
22
+ }
23
+
24
+ export interface BuildMacdScreenTextInput {
25
+ baseSummary: string;
26
+ crosses: MacdScreenCross[];
27
+ includeForming: boolean;
28
+ includeStats: boolean;
29
+ }
30
+
31
+ export function buildMacdScreenText(input: BuildMacdScreenTextInput): string {
32
+ const { baseSummary, crosses } = input;
33
+ const crossLines = crosses.map((r, i) => {
34
+ const date = r.crossDate ? String(r.crossDate).slice(0, 10) : '?';
35
+ const ret =
36
+ r.returnSinceCrossPct != null ? ` ret:${r.returnSinceCrossPct >= 0 ? '+' : ''}${r.returnSinceCrossPct}%` : '';
37
+ const hd = r.histogramDelta != null ? ` histDelta:${r.histogramDelta}` : '';
38
+ const prev = r.prevCross ? ` prev:${r.prevCross.type}(${r.prevCross.barsAgo}bars)` : '';
39
+ return `[${i}] ${r.pair} ${r.type} @${date} barsAgo:${r.barsAgo} macd:${r.macdAtCross} sig:${r.signalAtCross}${hd}${ret}${prev}`;
40
+ });
41
+ return (
42
+ baseSummary +
43
+ `\n\n📋 全${crosses.length}件のクロス詳細:\n` +
44
+ crossLines.join('\n') +
45
+ `\n\n---\n📌 含まれるもの: MACDクロス検出(種類・日付・ヒストグラム差分・リターン率・前回クロス)` +
46
+ `\n📌 含まれないもの: 他のテクニカル指標(RSI・BB等)、出来高分析、板情報` +
47
+ `\n📌 補完ツール: analyze_indicators(全指標詳細), analyze_market_signal(総合シグナル), get_flow_metrics(出来高)`
48
+ );
49
+ }
50
+
51
+ interface FormingStatus {
52
+ status: string;
53
+ estimatedCrossDays?: number | null;
54
+ completion?: number | null;
55
+ currentHistogram?: number | null;
56
+ histogramTrend?: unknown[];
57
+ currentMACD?: number | null;
58
+ currentSignal?: number | null;
59
+ lastCrossDate?: string | null;
60
+ lastCrossBarsAgo?: number | null;
61
+ lastCrossType?: string | null;
62
+ }
63
+
64
+ interface CrossStats {
65
+ totalSamples: number;
66
+ avgDay5Return?: number | null;
67
+ worstCase?: number | null;
68
+ bestCase?: number | null;
69
+ }
70
+
71
+ interface CrossHistory {
72
+ goldenCrosses: CrossPerf[];
73
+ deadCrosses: CrossPerf[];
74
+ }
75
+
76
+ export interface BuildMacdSingleTextInput {
77
+ pair: string;
78
+ lastClose: number | null;
79
+ forming: FormingStatus | null;
80
+ statistics: { golden: CrossStats; dead: CrossStats } | null;
81
+ history: CrossHistory | null;
82
+ historyDays: number;
83
+ includeForming: boolean;
84
+ includeStats: boolean;
85
+ }
86
+
87
+ export function buildMacdSingleText(input: BuildMacdSingleTextInput): string {
88
+ const { pair, lastClose, forming, statistics, history, historyDays, includeForming, includeStats } = input;
89
+ const lines: string[] = [];
90
+ const pairStr = String(pair).toUpperCase();
91
+ lines.push(lastClose != null ? `${pairStr} close=${Number(lastClose).toLocaleString('ja-JP')}円` : pairStr);
92
+
93
+ if (forming) {
94
+ const f = forming;
95
+ if (f.status === 'forming_golden' || f.status === 'forming_dead') {
96
+ const days =
97
+ f.estimatedCrossDays != null
98
+ ? f.estimatedCrossDays <= 1.5
99
+ ? '1-2日以内'
100
+ : `${Math.round(f.estimatedCrossDays)}日程度`
101
+ : '不明';
102
+ const compStr = f.completion != null ? `${Math.round((f.completion || 0) * 100)}%` : 'n/a';
103
+ const crossType = f.status === 'forming_golden' ? 'ゴールデン' : 'デッド';
104
+ const fmt = (v: unknown, d = 2) => (v == null ? 'n/a' : Number(v).toFixed(d));
105
+ const estDate = (() => {
106
+ if (f.estimatedCrossDays == null) return '不明';
107
+ try {
108
+ return dayjs()
109
+ .add(Math.max(0, Math.round(f.estimatedCrossDays)), 'day')
110
+ .format('YYYY-MM-DD');
111
+ } catch {
112
+ return '不明';
113
+ }
114
+ })();
115
+ lines.push(`${crossType}クロス形成中: 完成度${compStr}、推定クロス日 ${estDate}(あと${days})`);
116
+ lines.push(
117
+ `- ヒストグラム: ${fmt(f.currentHistogram, 2)} (直近5本: [${(Array.isArray(f.histogramTrend) ? f.histogramTrend : []).map((v: unknown) => (v == null ? 'n/a' : String(v))).join(', ')}])`,
118
+ );
119
+ lines.push(`- MACD: ${fmt(f.currentMACD, 2)} / Signal: ${fmt(f.currentSignal, 2)}`);
120
+ } else if (f.status === 'crossed_recently') {
121
+ const dateStr = f.lastCrossDate ? String(f.lastCrossDate).slice(0, 10) : '不明';
122
+ const agoStr = f.lastCrossBarsAgo != null ? `${f.lastCrossBarsAgo}日前` : '直近';
123
+ const typ = f.lastCrossType === 'dead' ? 'デッド' : 'ゴールデン';
124
+ lines.push(`${typ}クロス発生: ${dateStr}(${agoStr})`);
125
+ } else {
126
+ lines.push('現在クロス形成の兆候なし');
127
+ }
128
+ }
129
+
130
+ if (statistics && history) {
131
+ const gStats = statistics.golden;
132
+ const dStats = statistics.dead;
133
+ const goldenCrosses = history.goldenCrosses;
134
+ const deadCrosses = history.deadCrosses;
135
+ if (gStats.totalSamples > 0) {
136
+ const avgStr =
137
+ gStats.avgDay5Return != null ? `${gStats.avgDay5Return >= 0 ? '+' : ''}${gStats.avgDay5Return}%` : 'n/a';
138
+ const upCount = goldenCrosses.filter((c) => (c.performance.day5 ?? -Infinity) > 0).length;
139
+ const rangeStr =
140
+ gStats.worstCase != null && gStats.bestCase != null
141
+ ? `${gStats.worstCase >= 0 ? '+' : ''}${gStats.worstCase}% 〜 ${gStats.bestCase >= 0 ? '+' : ''}${gStats.bestCase}%`
142
+ : 'n/a';
143
+ lines.push(`過去${historyDays}日: ゴールデンクロス${goldenCrosses.length}回`);
144
+ const upPct = goldenCrosses.length ? Math.round((upCount / goldenCrosses.length) * 100) : 0;
145
+ lines.push(`- クロス後5日間: 平均${avgStr}、上昇した割合 ${upCount}/${goldenCrosses.length}回(${upPct}%)`);
146
+ lines.push(`- 範囲: ${rangeStr}`);
147
+ }
148
+ if (dStats.totalSamples > 0) {
149
+ const avgStr =
150
+ dStats.avgDay5Return != null ? `${dStats.avgDay5Return >= 0 ? '+' : ''}${dStats.avgDay5Return}%` : 'n/a';
151
+ const downCount = deadCrosses.filter((c) => (c.performance.day5 ?? Infinity) < 0).length;
152
+ const rangeStr =
153
+ dStats.worstCase != null && dStats.bestCase != null
154
+ ? `${dStats.worstCase >= 0 ? '+' : ''}${dStats.worstCase}% 〜 ${dStats.bestCase >= 0 ? '+' : ''}${dStats.bestCase}%`
155
+ : 'n/a';
156
+ lines.push(`デッドクロス${deadCrosses.length}回`);
157
+ const downPct = deadCrosses.length ? Math.round((downCount / deadCrosses.length) * 100) : 0;
158
+ lines.push(`- クロス後5日間: 平均${avgStr}、下落した割合 ${downCount}/${deadCrosses.length}回(${downPct}%)`);
159
+ lines.push(`- 範囲: ${rangeStr}`);
160
+ }
161
+ }
162
+
163
+ return (
164
+ lines.join('\n') +
165
+ `\n\n---\n📌 含まれるもの: MACD分析(${includeForming ? 'forming検出' : ''}${includeForming && includeStats ? '・' : ''}${includeStats ? '過去統計' : ''})` +
166
+ `\n📌 含まれないもの: 他のテクニカル指標(RSI・BB等)、出来高分析、板情報` +
167
+ `\n📌 補完ツール: analyze_indicators(全指標詳細), analyze_market_signal(総合シグナル), get_flow_metrics(出来高)`
168
+ );
169
+ }
170
+
171
+ // ── 共通: クロス検出ヘルパー ──
172
+
173
+ type CrossDetailed = {
174
+ pair: string;
175
+ type: 'golden' | 'dead';
176
+ crossIndex: number;
177
+ crossDate: string | null;
178
+ barsAgo: number;
179
+ macdAtCross: number | null;
180
+ signalAtCross: number | null;
181
+ histogramPrev: number | null;
182
+ histogramCurr: number | null;
183
+ histogramDelta: number | null;
184
+ prevCross: { type: 'golden' | 'dead'; barsAgo: number; date: string | null } | null;
185
+ priceAtCross: number | null;
186
+ currentPrice: number | null;
187
+ returnSinceCrossPct: number | null;
188
+ };
189
+
190
+ function diffAt(line: (number | null)[], signal: (number | null)[], i: number): number | null {
191
+ return (line[i] ?? null) != null && (signal[i] ?? null) != null ? (line[i] as number) - (signal[i] as number) : null;
192
+ }
193
+
194
+ function findPrevCross(
195
+ line: number[],
196
+ signal: number[],
197
+ before: number,
198
+ ): { idx: number; type: 'golden' | 'dead' } | null {
199
+ for (let j = before - 1; j >= 1; j--) {
200
+ const pd = diffAt(line, signal, j - 1);
201
+ const cd = diffAt(line, signal, j);
202
+ if (pd == null || cd == null) continue;
203
+ if (pd <= 0 && cd > 0) return { idx: j, type: 'golden' };
204
+ if (pd >= 0 && cd < 0) return { idx: j, type: 'dead' };
205
+ }
206
+ return null;
207
+ }
208
+
209
+ function detectCrossInRange(
210
+ line: number[],
211
+ signal: number[],
212
+ candles: Array<{ isoTime?: string | null; close?: number | null }>,
213
+ start: number,
214
+ end: number,
215
+ n: number,
216
+ pairName: string,
217
+ ): CrossDetailed | null {
218
+ for (let i = end; i >= start; i--) {
219
+ const prevDiff = diffAt(line, signal, i - 1);
220
+ const currDiff = diffAt(line, signal, i);
221
+ if (prevDiff == null || currDiff == null) continue;
222
+ const isGolden = prevDiff <= 0 && currDiff > 0;
223
+ const isDead = prevDiff >= 0 && currDiff < 0;
224
+ if (!isGolden && !isDead) continue;
225
+
226
+ const currentPrice = (candles.at(-1)?.close ?? null) as number | null;
227
+ const priceAtCross = (candles[i]?.close ?? null) as number | null;
228
+ const retPct =
229
+ priceAtCross && currentPrice != null
230
+ ? Number((((currentPrice - priceAtCross) / priceAtCross) * 100).toFixed(2))
231
+ : null;
232
+ const prev = findPrevCross(line, signal, i);
233
+
234
+ return {
235
+ pair: pairName,
236
+ type: isGolden ? 'golden' : 'dead',
237
+ crossIndex: i,
238
+ crossDate: candles[i]?.isoTime ?? null,
239
+ barsAgo: n - 1 - i,
240
+ macdAtCross: (line[i] ?? null) as number | null,
241
+ signalAtCross: (signal[i] ?? null) as number | null,
242
+ histogramPrev: prevDiff,
243
+ histogramCurr: currDiff,
244
+ histogramDelta: Number((currDiff - prevDiff).toFixed(4)),
245
+ prevCross: prev ? { type: prev.type, barsAgo: i - prev.idx, date: candles[prev.idx]?.isoTime ?? null } : null,
246
+ priceAtCross,
247
+ currentPrice,
248
+ returnSinceCrossPct: retPct,
249
+ };
250
+ }
251
+ return null;
252
+ }
253
+
254
+ // ── モード A: 複数ペアスクリーニング ──
255
+
256
+ type ScreenOpts = {
257
+ minHistogramDelta?: number;
258
+ maxBarsAgo?: number;
259
+ minReturnPct?: number;
260
+ maxReturnPct?: number;
261
+ crossType?: 'golden' | 'dead' | 'both';
262
+ sortBy?: 'date' | 'histogram' | 'return' | 'barsAgo';
263
+ sortOrder?: 'asc' | 'desc';
264
+ limit?: number;
265
+ withPrice?: boolean;
266
+ };
267
+
268
+ async function screenMode(
269
+ market: 'all' | 'jpy',
270
+ lookback: number,
271
+ pairs: string[] | undefined,
272
+ view: 'summary' | 'detailed',
273
+ screen: ScreenOpts | undefined,
274
+ ) {
275
+ const universe = pairs?.length
276
+ ? pairs.filter((p) => ALLOWED_PAIRS.has(p as Pair))
277
+ : Array.from(ALLOWED_PAIRS.values()).filter((p) => (market === 'jpy' ? p.endsWith('_jpy') : true));
278
+
279
+ const allDetailed: CrossDetailed[] = [];
280
+ const failedPairs: string[] = [];
281
+ await Promise.all(
282
+ universe.map(async (pair) => {
283
+ try {
284
+ const ind = await analyzeIndicators(pair, '1day', 120);
285
+ if (!ind?.ok) {
286
+ failedPairs.push(pair);
287
+ return;
288
+ }
289
+ const macdSeries = (ind.data?.indicators as { macd_series?: { line: number[]; signal: number[] } })
290
+ ?.macd_series;
291
+ const line = macdSeries?.line || [];
292
+ const signal = macdSeries?.signal || [];
293
+ const candles = (ind.data?.normalized || []) as Array<{ isoTime?: string | null; close?: number | null }>;
294
+ const n = line.length;
295
+ if (n < 2) return;
296
+ const start = Math.max(1, n - lookback);
297
+ const cross = detectCrossInRange(line, signal, candles, start, n - 1, n, pair as string);
298
+ if (cross) allDetailed.push(cross);
299
+ } catch {
300
+ failedPairs.push(pair);
301
+ }
302
+ }),
303
+ );
304
+
305
+ const opts = screen || {};
306
+ const crossType = opts.crossType || 'both';
307
+ const totalFound = allDetailed.length;
308
+ let filtered = allDetailed.filter((r) => {
309
+ if (crossType !== 'both' && r.type !== crossType) return false;
310
+ if (
311
+ opts.minHistogramDelta != null &&
312
+ r.histogramDelta != null &&
313
+ Math.abs(r.histogramDelta) < opts.minHistogramDelta
314
+ )
315
+ return false;
316
+ if (opts.maxBarsAgo != null && r.barsAgo > opts.maxBarsAgo) return false;
317
+ if (opts.minReturnPct != null && !(r.returnSinceCrossPct != null && r.returnSinceCrossPct >= opts.minReturnPct))
318
+ return false;
319
+ if (opts.maxReturnPct != null && !(r.returnSinceCrossPct != null && r.returnSinceCrossPct <= opts.maxReturnPct))
320
+ return false;
321
+ return true;
322
+ });
323
+
324
+ const sortBy = opts.sortBy || 'date';
325
+ const order = (opts.sortOrder || 'desc') === 'desc' ? -1 : 1;
326
+ const safeNum = (v: unknown, def = 0) => (v == null || Number.isNaN(Number(v)) ? def : Number(v));
327
+ const projReturn = (v: unknown) => (v == null ? Number.NEGATIVE_INFINITY : Number(v));
328
+ filtered.sort((a, b) => {
329
+ if (sortBy === 'histogram')
330
+ return (Math.abs(safeNum(b.histogramDelta)) - Math.abs(safeNum(a.histogramDelta))) * (order === -1 ? 1 : -1);
331
+ if (sortBy === 'return')
332
+ return (projReturn(b.returnSinceCrossPct) - projReturn(a.returnSinceCrossPct)) * (order === -1 ? 1 : -1);
333
+ if (sortBy === 'barsAgo') return (safeNum(a.barsAgo) - safeNum(b.barsAgo)) * (order === -1 ? 1 : -1);
334
+ // sortBy === 'date': compare crossDate strings directly
335
+ const dateA = a.crossDate || '';
336
+ const dateB = b.crossDate || '';
337
+ return dateA < dateB ? -1 * order : dateA > dateB ? 1 * order : 0;
338
+ });
339
+ if (opts.limit != null && opts.limit > 0) filtered = filtered.slice(0, opts.limit);
340
+
341
+ const resultsScreened = filtered.map((r) => ({
342
+ pair: r.pair,
343
+ type: r.type,
344
+ macd: r.macdAtCross as number,
345
+ signal: r.signalAtCross as number,
346
+ isoTime: r.crossDate,
347
+ }));
348
+ const brief = resultsScreened
349
+ .slice(0, 6)
350
+ .map((r) => `${r.pair}:${r.type}${r.isoTime ? `@${String(r.isoTime).slice(0, 10)}` : ''}`)
351
+ .join(', ');
352
+ const conds: string[] = [];
353
+ if (crossType !== 'both') conds.push(crossType);
354
+ if (opts.minHistogramDelta != null) conds.push(`ヒストグラム≥${opts.minHistogramDelta}`);
355
+ if (opts.maxBarsAgo != null) conds.push(`bars≤${opts.maxBarsAgo}`);
356
+ if (opts.minReturnPct != null) conds.push(`return≥${opts.minReturnPct}%`);
357
+ if (opts.maxReturnPct != null) conds.push(`return≤${opts.maxReturnPct}%`);
358
+ if (opts.limit != null) conds.push(`top${opts.limit}`);
359
+ const failedInfo = failedPairs.length > 0 ? ` | ⚠️${failedPairs.length}/${universe.length}ペア取得失敗` : '';
360
+ const condStr = conds.length ? ` (全${totalFound}件中, 条件: ${conds.join(', ')})` : '';
361
+ const baseSummary = formatSummary({
362
+ pair: 'multi',
363
+ latest: undefined,
364
+ extra: `crosses=${resultsScreened.length}${condStr}${failedInfo}${brief ? ` [${brief}]` : ''}`,
365
+ });
366
+ const screenCrosses: MacdScreenCross[] = filtered.map((r) => ({
367
+ pair: r.pair,
368
+ type: r.type,
369
+ crossDate: r.crossDate,
370
+ barsAgo: r.barsAgo,
371
+ macdAtCross: r.macdAtCross,
372
+ signalAtCross: r.signalAtCross,
373
+ histogramDelta: r.histogramDelta,
374
+ returnSinceCrossPct: r.returnSinceCrossPct,
375
+ prevCross: r.prevCross ? { type: r.prevCross.type, barsAgo: r.prevCross.barsAgo } : null,
376
+ }));
377
+ const summary = buildMacdScreenText({
378
+ baseSummary,
379
+ crosses: screenCrosses,
380
+ includeForming: false,
381
+ includeStats: false,
382
+ });
383
+ const data: Record<string, unknown> = { results: resultsScreened };
384
+ if (view === 'detailed') {
385
+ data.resultsDetailed = allDetailed;
386
+ data.screenedDetailed = filtered;
387
+ }
388
+ const meta: Record<string, unknown> = {
389
+ market,
390
+ lookback,
391
+ pairs: universe,
392
+ view,
393
+ screen: { ...opts, crossType, sortBy, sortOrder: opts.sortOrder || 'desc' },
394
+ };
395
+ if (failedPairs.length > 0) {
396
+ meta.warning = `⚠️ ${universe.length}ペア中${failedPairs.length}ペアの指標取得に失敗しました: ${failedPairs.join(', ')}`;
397
+ meta.failedPairs = failedPairs;
398
+ }
399
+ return ok(summary, data, meta);
400
+ }
401
+
402
+ // ── モード B: 単一ペア深掘り(forming + 過去統計) ──
403
+
404
+ type CrossPerf = { date: string | null; histogram: number | null; performance: Record<string, number | null> };
405
+
406
+ async function singlePairMode(
407
+ pair: string,
408
+ includeForming: boolean,
409
+ includeStats: boolean,
410
+ historyDays: number,
411
+ performanceWindows: number[],
412
+ minHistogramForForming: number,
413
+ ) {
414
+ const limit = Math.max(120, historyDays + 40);
415
+ const ind = await analyzeIndicators(pair, '1day', limit);
416
+ if (!ind?.ok) return fail(ind?.summary || 'indicators failed', ind?.meta?.errorType || 'internal');
417
+
418
+ const macd = ind?.data?.indicators?.macd_series?.line || [];
419
+ const signal = ind?.data?.indicators?.macd_series?.signal || [];
420
+ const hist = ind?.data?.indicators?.macd_series?.hist || [];
421
+ const candles: Array<{ isoTime?: string | null; close?: number }> = Array.isArray(ind?.data?.normalized)
422
+ ? ind.data.normalized
423
+ : [];
424
+ const n = Math.min(macd.length, signal.length, hist.length, candles.length);
425
+ if (n < 20) return fail('insufficient data', 'user');
426
+
427
+ const nowIdx = n - 1;
428
+ const lastClose = candles[nowIdx]?.close ?? null;
429
+
430
+ // forming detection
431
+ let forming: FormingStatus | null = null;
432
+ if (includeForming) {
433
+ const win = Math.min(5, n - 1);
434
+ const hNow = hist[nowIdx] as number | null;
435
+ const hPrev = hist[nowIdx - win] as number | null;
436
+ let completion: number | null = null;
437
+ let estimatedCrossDays: number | null = null;
438
+ let status: 'forming_golden' | 'forming_dead' | 'neutral' | 'crossed_recently' = 'neutral';
439
+ const histogramTrend: Array<number | null> = [];
440
+ for (let i = nowIdx - win + 1; i <= nowIdx; i++)
441
+ histogramTrend.push(hist[i] == null ? null : Number((hist[i] as number).toFixed(4)));
442
+
443
+ if (hNow != null && hPrev != null && Math.abs(hPrev) > 0) {
444
+ const slopePerBar = (hNow - hPrev) / win;
445
+ const movingTowardZero = (hPrev > 0 && slopePerBar < 0) || (hPrev < 0 && slopePerBar > 0);
446
+ const notCrossedYet = (hPrev < 0 && hNow < 0) || (hPrev > 0 && hNow > 0);
447
+ if (movingTowardZero && notCrossedYet && Math.abs(hNow) <= minHistogramForForming * 5) {
448
+ completion = Number((1 - Math.min(1, Math.abs(hNow) / Math.abs(hPrev))).toFixed(2));
449
+ const speed = Math.abs(slopePerBar);
450
+ estimatedCrossDays = speed > 0 ? Number((Math.abs(hNow) / speed).toFixed(1)) : null;
451
+ status = hPrev < 0 ? 'forming_golden' : 'forming_dead';
452
+ }
453
+ }
454
+
455
+ let lastCrossIdx: number | null = null;
456
+ let lastCrossType: 'golden' | 'dead' | null = null;
457
+ for (let i = nowIdx; i >= Math.max(1, nowIdx - 3); i--) {
458
+ const hp = hist[i - 1];
459
+ const hc = hist[i];
460
+ if (hp != null && hc != null) {
461
+ if (hp <= 0 && hc > 0) {
462
+ lastCrossIdx = i;
463
+ lastCrossType = 'golden';
464
+ break;
465
+ }
466
+ if (hp >= 0 && hc < 0) {
467
+ lastCrossIdx = i;
468
+ lastCrossType = 'dead';
469
+ break;
470
+ }
471
+ }
472
+ }
473
+ if (status !== 'forming_golden' && status !== 'forming_dead' && lastCrossIdx != null) status = 'crossed_recently';
474
+
475
+ forming = {
476
+ status,
477
+ completion,
478
+ estimatedCrossDays,
479
+ currentMACD: macd[nowIdx] ?? null,
480
+ currentSignal: signal[nowIdx] ?? null,
481
+ currentHistogram: hNow ?? null,
482
+ histogramTrend,
483
+ ...(status === 'crossed_recently' && lastCrossIdx != null
484
+ ? {
485
+ lastCrossType,
486
+ lastCrossDate: candles[lastCrossIdx]?.isoTime ?? null,
487
+ lastCrossBarsAgo: nowIdx - lastCrossIdx,
488
+ }
489
+ : {}),
490
+ };
491
+ }
492
+
493
+ // history + statistics
494
+ let history: CrossHistory | null = null;
495
+ let statistics: { golden: CrossStats; dead: CrossStats } | null = null;
496
+ if (includeStats) {
497
+ const msCut = dayjs().subtract(historyDays, 'day').valueOf();
498
+ const crosses: Array<{
499
+ idx: number;
500
+ type: 'golden' | 'dead';
501
+ date: string | null;
502
+ histogram: number | null;
503
+ price: number | null;
504
+ }> = [];
505
+ for (let i = 1; i < n; i++) {
506
+ const prevDiff = diffAt(macd, signal, i - 1);
507
+ const currDiff = diffAt(macd, signal, i);
508
+ if (prevDiff == null || currDiff == null) continue;
509
+ const isGolden = prevDiff <= 0 && currDiff > 0;
510
+ const isDead = prevDiff >= 0 && currDiff < 0;
511
+ if (!isGolden && !isDead) continue;
512
+ const dateStr = candles[i]?.isoTime || null;
513
+ const ts = dateStr ? dayjs(dateStr).valueOf() : NaN;
514
+ if (!Number.isFinite(ts) || ts < msCut) continue;
515
+ crosses.push({
516
+ idx: i,
517
+ type: isGolden ? 'golden' : 'dead',
518
+ date: dateStr,
519
+ histogram: hist[i] ?? null,
520
+ price: candles[i]?.close ?? null,
521
+ });
522
+ }
523
+
524
+ const performanceFor = (idx: number, basePrice: number | null): Record<string, number | null> => {
525
+ const perf: Record<string, number | null> = {};
526
+ for (const w of performanceWindows) {
527
+ const j = Math.min(n - 1, idx + w);
528
+ const priceW = candles[j]?.close ?? null;
529
+ perf[`day${String(w)}`] =
530
+ basePrice != null && priceW != null ? Number((((priceW - basePrice) / basePrice) * 100).toFixed(2)) : null;
531
+ }
532
+ return perf;
533
+ };
534
+
535
+ const goldenCrosses: CrossPerf[] = [];
536
+ const deadCrosses: CrossPerf[] = [];
537
+ for (const c of crosses) {
538
+ const perf = performanceFor(c.idx, c.price ?? null);
539
+ const item: CrossPerf = {
540
+ date: c.date,
541
+ histogram: c.histogram == null ? null : Number((c.histogram as number).toFixed(4)),
542
+ performance: perf,
543
+ };
544
+ if (c.type === 'golden') goldenCrosses.push(item);
545
+ else deadCrosses.push(item);
546
+ }
547
+
548
+ const statsOf = (list: CrossPerf[]) => {
549
+ const w = performanceWindows.includes(5) ? 5 : performanceWindows[performanceWindows.length - 1];
550
+ const pick = (it: CrossPerf) => it.performance[`day${String(w)}`];
551
+ const vals = list.map(pick).filter((v): v is number => v != null);
552
+ const avg = vals.length ? Number((vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(2)) : null;
553
+ const successRate = list.length
554
+ ? Number(((list.filter((p) => (pick(p) ?? -Infinity) > 0).length / list.length) * 100).toFixed(0))
555
+ : 0;
556
+ const bestCase = vals.length ? Math.max(...vals) : null;
557
+ const worstCase = vals.length ? Math.min(...vals) : null;
558
+ return { avgDay5Return: avg, successRate, totalSamples: list.length, bestCase, worstCase };
559
+ };
560
+
561
+ history = { goldenCrosses, deadCrosses };
562
+ statistics = { golden: statsOf(goldenCrosses), dead: statsOf(deadCrosses) };
563
+ }
564
+
565
+ // Build summary text
566
+ const summaryText = buildMacdSingleText({
567
+ pair,
568
+ lastClose,
569
+ forming,
570
+ statistics,
571
+ history,
572
+ historyDays,
573
+ includeForming,
574
+ includeStats,
575
+ });
576
+
577
+ return ok(
578
+ summaryText,
579
+ { forming, history, statistics },
580
+ { pair, historyDays, performanceWindows, minHistogramForForming, includeForming, includeStats },
581
+ );
582
+ }
583
+
584
+ // ── MCP ツール定義(tool-registry から自動収集) ──
585
+
586
+ export const toolDef: ToolDefinition = {
587
+ name: 'detect_macd_cross',
588
+ description: `[MACD Cross / Crossover / Screening] MACDクロス検出(MACD cross / crossover / golden cross / dead cross / screening)。
589
+
590
+ pair省略: 複数銘柄スクリーニング / pair指定: 単一ペア深掘り分析(forming検出・過去統計)。
591
+
592
+ screen(スクリーニング用):
593
+ - crossType: golden|dead|both
594
+ - minHistogramDelta / maxBarsAgo / minReturnPct / maxReturnPct
595
+ - sortBy: date|histogram|return|barsAgo
596
+ - limit: 上位N件`,
597
+ inputSchema: z.object({
598
+ pair: z.string().optional().describe('指定時は単一ペア深掘りモード'),
599
+ market: z.enum(['all', 'jpy']).default('all').describe('スクリーニング時の対象市場'),
600
+ pairs: z.array(z.string()).optional().describe('スクリーニング時の対象ペア限定'),
601
+ lookback: z.number().int().min(1).max(10).default(3),
602
+ view: z.enum(['summary', 'detailed']).optional().default('summary'),
603
+ includeForming: z.boolean().optional().default(true).describe('単一ペア: forming検出'),
604
+ includeStats: z.boolean().optional().default(true).describe('単一ペア: 過去統計'),
605
+ historyDays: z.number().int().min(10).max(365).optional().default(90).describe('単一ペア: 統計対象期間'),
606
+ performanceWindows: z.array(z.number().int().min(1).max(30)).optional().default([1, 3, 5, 10]),
607
+ minHistogramForForming: z.number().min(0).optional().default(0.3),
608
+ screen: z
609
+ .object({
610
+ minHistogramDelta: z.number().optional(),
611
+ maxBarsAgo: z.number().int().min(0).optional(),
612
+ minReturnPct: z.number().optional(),
613
+ maxReturnPct: z.number().optional(),
614
+ crossType: z.enum(['golden', 'dead', 'both']).optional().default('both'),
615
+ sortBy: z.enum(['date', 'histogram', 'return', 'barsAgo']).optional().default('date'),
616
+ sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
617
+ limit: z.number().int().min(1).max(100).optional(),
618
+ withPrice: z.boolean().optional(),
619
+ })
620
+ .optional(),
621
+ }),
622
+ handler: async (args: {
623
+ pair?: string;
624
+ market?: 'all' | 'jpy';
625
+ pairs?: string[];
626
+ lookback?: number;
627
+ view?: 'summary' | 'detailed';
628
+ includeForming?: boolean;
629
+ includeStats?: boolean;
630
+ historyDays?: number;
631
+ performanceWindows?: number[];
632
+ minHistogramForForming?: number;
633
+ screen?: {
634
+ minHistogramDelta?: number;
635
+ maxBarsAgo?: number;
636
+ minReturnPct?: number;
637
+ maxReturnPct?: number;
638
+ crossType?: 'golden' | 'dead' | 'both';
639
+ sortBy?: 'date' | 'histogram' | 'return' | 'barsAgo';
640
+ sortOrder?: 'asc' | 'desc';
641
+ limit?: number;
642
+ withPrice?: boolean;
643
+ };
644
+ }) => {
645
+ try {
646
+ if (args.pair) {
647
+ const chk = ensurePair(args.pair);
648
+ if (!chk.ok) return failFromValidation(chk);
649
+ return singlePairMode(
650
+ chk.pair,
651
+ args.includeForming ?? true,
652
+ args.includeStats ?? true,
653
+ args.historyDays ?? 90,
654
+ args.performanceWindows ?? [1, 3, 5, 10],
655
+ args.minHistogramForForming ?? 0.3,
656
+ );
657
+ }
658
+ const res: Awaited<ReturnType<typeof screenMode>> = await screenMode(
659
+ args.market ?? 'all',
660
+ args.lookback ?? 3,
661
+ args.pairs,
662
+ args.view ?? 'summary',
663
+ args.screen,
664
+ );
665
+ if (!res?.ok || args.view !== 'detailed') return res;
666
+ try {
667
+ const detRaw: CrossDetailed[] = Array.isArray(res?.data?.screenedDetailed)
668
+ ? (res.data.screenedDetailed as CrossDetailed[])
669
+ : Array.isArray(res?.data?.resultsDetailed)
670
+ ? (res.data.resultsDetailed as CrossDetailed[])
671
+ : [];
672
+ if (!detRaw.length) return res;
673
+ const fmtDelta = (v: number | null | undefined) =>
674
+ v == null ? 'n/a' : `${v >= 0 ? '+' : ''}${Number(v).toFixed(2)}`;
675
+ const fmtRet = (v: number | null | undefined) =>
676
+ v == null ? 'n/a' : `${v >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`;
677
+ const lines = detRaw.map((r) => {
678
+ const date = (r?.crossDate || '').slice(0, 10);
679
+ const prevDays = r?.prevCross?.barsAgo != null ? `${r.prevCross.barsAgo}日` : 'n/a';
680
+ return `${String(r.pair)}: ${String(r.type)}@${date} (ヒストグラム${fmtDelta(r?.histogramDelta)}, 前回クロスから${prevDays}${r?.returnSinceCrossPct != null ? `, ${fmtRet(r.returnSinceCrossPct)}` : ''})`;
681
+ });
682
+ const text = `${String(res?.summary || '')}\n${lines.join('\n')}`.trim();
683
+ return { content: [{ type: 'text', text }], structuredContent: toStructured(res) };
684
+ } catch {
685
+ return res;
686
+ }
687
+ } catch (e: unknown) {
688
+ return failFromError(e);
689
+ }
690
+ },
691
+ };