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,635 @@
1
+ /**
2
+ * Head & Shoulders / Inverse Head & Shoulders 検出(完成済み+形成中)
3
+ * detect_patterns.ts Section 3 から抽出
4
+ */
5
+ import { generatePatternDiagram } from '../../lib/pattern-diagrams.js';
6
+ import { finalizeConf, periodScoreDays } from './helpers.js';
7
+ import { clamp01, marginFromRelDev, relDev } from './regression.js';
8
+ import type { DeduplicablePattern, DetectContext, DetectResult } from './types.js';
9
+
10
+ // ── 定数 ──
11
+
12
+ const RELAXED_FACTORS = [
13
+ { shoulder: 1.6, head: 0.6, tag: 'x1.6_0.6' },
14
+ { shoulder: 2.0, head: 0.4, tag: 'x2.0_0.4' },
15
+ ] as const;
16
+
17
+ const FORMING_RIGHT_TOLERANCE_PCT = 0.08;
18
+ const FORMING_MAX_DAYS = 90;
19
+ const FORMING_MIN_DAYS = 21;
20
+ const FORMING_MIN_COMPLETION = 0.4;
21
+
22
+ // ── Helper: Strict Inverse H&S (L-H-L-H-L) ──
23
+
24
+ function findStrictInverseHS(ctx: DetectContext): { patterns: DeduplicablePattern[]; found: boolean } {
25
+ const { candles, pivots, tolerancePct, minDist, near, debugCandidates } = ctx;
26
+ const patterns: DeduplicablePattern[] = [];
27
+ let found = false;
28
+
29
+ for (let i = 0; i < pivots.length - 4; i++) {
30
+ const p0 = pivots[i],
31
+ p1 = pivots[i + 1],
32
+ p2 = pivots[i + 2],
33
+ p3 = pivots[i + 3],
34
+ p4 = pivots[i + 4];
35
+ if (!(p0.kind === 'L' && p1.kind === 'H' && p2.kind === 'L' && p3.kind === 'H' && p4.kind === 'L')) continue;
36
+ if (
37
+ p1.idx - p0.idx < minDist ||
38
+ p2.idx - p1.idx < minDist ||
39
+ p3.idx - p2.idx < minDist ||
40
+ p4.idx - p3.idx < minDist
41
+ )
42
+ continue;
43
+ const shouldersNear = near(p0.price, p4.price);
44
+ const headLower = p2.price < Math.min(p0.price, p4.price) * (1 - tolerancePct);
45
+ if (shouldersNear && headLower) {
46
+ const start = candles[p0.idx].isoTime;
47
+ const end = candles[p4.idx].isoTime;
48
+ if (start && end) {
49
+ const neckline = [
50
+ { x: p1.idx, y: p1.price },
51
+ { x: p3.idx, y: p3.price },
52
+ ];
53
+ const tolMargin = marginFromRelDev(relDev(p0.price, p4.price), tolerancePct);
54
+ const symmetry = clamp01(1 - relDev(p0.price, p4.price));
55
+ const per = periodScoreDays(start, end);
56
+ const base = (tolMargin + symmetry + per) / 3;
57
+ const confidence = finalizeConf(base, 'inverse_head_and_shoulders');
58
+ const nlAvg = (Number(p1.price) + Number(p3.price)) / 2;
59
+ const diagram = generatePatternDiagram(
60
+ 'inverse_head_and_shoulders',
61
+ [
62
+ { ...p0, date: candles[p0.idx]?.isoTime },
63
+ { ...p1, date: candles[p1.idx]?.isoTime },
64
+ { ...p2, date: candles[p2.idx]?.isoTime },
65
+ { ...p3, date: candles[p3.idx]?.isoTime },
66
+ { ...p4, date: candles[p4.idx]?.isoTime },
67
+ ],
68
+ { price: nlAvg },
69
+ { start, end },
70
+ );
71
+ const ihsNlAvg = (p1.price + p3.price) / 2;
72
+ const ihsTarget = Math.round(ihsNlAvg + (ihsNlAvg - p2.price));
73
+
74
+ patterns.push({
75
+ type: 'inverse_head_and_shoulders',
76
+ confidence,
77
+ range: { start, end },
78
+ pivots: [p0, p1, p2, p3, p4],
79
+ neckline,
80
+ trendlineLabel: 'ネックライン',
81
+ breakoutTarget: ihsTarget,
82
+ targetMethod: 'neckline_projection' as const,
83
+ structureDiagram: diagram,
84
+ });
85
+ found = true;
86
+ debugCandidates.push({
87
+ type: 'inverse_head_and_shoulders',
88
+ accepted: true,
89
+ indices: [p0.idx, p1.idx, p2.idx, p3.idx, p4.idx],
90
+ points: [
91
+ { role: 'left_shoulder', idx: p0.idx, price: p0.price, isoTime: candles[p0.idx]?.isoTime },
92
+ { role: 'peak1', idx: p1.idx, price: p1.price, isoTime: candles[p1.idx]?.isoTime },
93
+ { role: 'head', idx: p2.idx, price: p2.price, isoTime: candles[p2.idx]?.isoTime },
94
+ { role: 'peak2', idx: p3.idx, price: p3.price, isoTime: candles[p3.idx]?.isoTime },
95
+ { role: 'right_shoulder', idx: p4.idx, price: p4.price, isoTime: candles[p4.idx]?.isoTime },
96
+ ],
97
+ });
98
+ }
99
+ } else {
100
+ const reason = !shouldersNear ? 'shoulders_not_near' : !headLower ? 'head_not_lower' : 'unknown';
101
+ debugCandidates.push({
102
+ type: 'inverse_head_and_shoulders',
103
+ accepted: false,
104
+ reason,
105
+ details: {
106
+ leftShoulder: p0.price,
107
+ rightShoulder: p4.price,
108
+ shouldersDiff: Math.abs(p0.price - p4.price),
109
+ shouldersDiffPct: Math.abs(p0.price - p4.price) / Math.max(1, Math.max(p0.price, p4.price)),
110
+ head: p2.price,
111
+ thresholdPct: tolerancePct,
112
+ },
113
+ indices: [p0.idx, p1.idx, p2.idx, p3.idx, p4.idx],
114
+ });
115
+ }
116
+ }
117
+
118
+ return { patterns, found };
119
+ }
120
+
121
+ // ── Helper: Strict H&S (H-L-H-L-H) ──
122
+
123
+ function findStrictHS(ctx: DetectContext): { patterns: DeduplicablePattern[]; found: boolean } {
124
+ const { candles, pivots, tolerancePct, minDist, near, debugCandidates } = ctx;
125
+ const patterns: DeduplicablePattern[] = [];
126
+ let found = false;
127
+
128
+ for (let i = 0; i < pivots.length - 4; i++) {
129
+ const p0 = pivots[i],
130
+ p1 = pivots[i + 1],
131
+ p2 = pivots[i + 2],
132
+ p3 = pivots[i + 3],
133
+ p4 = pivots[i + 4];
134
+ if (!(p0.kind === 'H' && p1.kind === 'L' && p2.kind === 'H' && p3.kind === 'L' && p4.kind === 'H')) continue;
135
+ if (
136
+ p1.idx - p0.idx < minDist ||
137
+ p2.idx - p1.idx < minDist ||
138
+ p3.idx - p2.idx < minDist ||
139
+ p4.idx - p3.idx < minDist
140
+ )
141
+ continue;
142
+ const shouldersNear = near(p0.price, p4.price);
143
+ const headHigher = p2.price > Math.max(p0.price, p4.price) * (1 + tolerancePct);
144
+ if (shouldersNear && headHigher) {
145
+ const start = candles[p0.idx].isoTime;
146
+ const end = candles[p4.idx].isoTime;
147
+ if (start && end) {
148
+ const neckline = [
149
+ { x: p1.idx, y: p1.price },
150
+ { x: p3.idx, y: p3.price },
151
+ ];
152
+ const tolMargin = marginFromRelDev(relDev(p0.price, p4.price), tolerancePct);
153
+ const symmetry = clamp01(1 - relDev(p0.price, p4.price));
154
+ const per = periodScoreDays(start, end);
155
+ const base = (tolMargin + symmetry + per) / 3;
156
+ const confidence = finalizeConf(base, 'head_and_shoulders');
157
+ const nlAvg = (Number(p1.price) + Number(p3.price)) / 2;
158
+ const diagram = generatePatternDiagram(
159
+ 'head_and_shoulders',
160
+ [
161
+ { ...p0, date: candles[p0.idx]?.isoTime },
162
+ { ...p1, date: candles[p1.idx]?.isoTime },
163
+ { ...p2, date: candles[p2.idx]?.isoTime },
164
+ { ...p3, date: candles[p3.idx]?.isoTime },
165
+ { ...p4, date: candles[p4.idx]?.isoTime },
166
+ ],
167
+ { price: nlAvg },
168
+ { start, end },
169
+ );
170
+ const hsNlAvg = (p1.price + p3.price) / 2;
171
+ const hsTarget = Math.round(hsNlAvg - (p2.price - hsNlAvg));
172
+
173
+ patterns.push({
174
+ type: 'head_and_shoulders',
175
+ confidence,
176
+ range: { start, end },
177
+ pivots: [p0, p1, p2, p3, p4],
178
+ neckline,
179
+ trendlineLabel: 'ネックライン',
180
+ breakoutTarget: hsTarget,
181
+ targetMethod: 'neckline_projection' as const,
182
+ structureDiagram: diagram,
183
+ });
184
+ found = true;
185
+ debugCandidates.push({
186
+ type: 'head_and_shoulders',
187
+ accepted: true,
188
+ indices: [p0.idx, p1.idx, p2.idx, p3.idx, p4.idx],
189
+ points: [
190
+ { role: 'left_shoulder', idx: p0.idx, price: p0.price, isoTime: candles[p0.idx]?.isoTime },
191
+ { role: 'valley1', idx: p1.idx, price: p1.price, isoTime: candles[p1.idx]?.isoTime },
192
+ { role: 'head', idx: p2.idx, price: p2.price, isoTime: candles[p2.idx]?.isoTime },
193
+ { role: 'valley2', idx: p3.idx, price: p3.price, isoTime: candles[p3.idx]?.isoTime },
194
+ { role: 'right_shoulder', idx: p4.idx, price: p4.price, isoTime: candles[p4.idx]?.isoTime },
195
+ ],
196
+ });
197
+ }
198
+ } else {
199
+ const reason = !shouldersNear ? 'shoulders_not_near' : !headHigher ? 'head_not_higher' : 'unknown';
200
+ debugCandidates.push({
201
+ type: 'head_and_shoulders',
202
+ accepted: false,
203
+ reason,
204
+ details: {
205
+ leftShoulder: p0.price,
206
+ rightShoulder: p4.price,
207
+ shouldersDiff: Math.abs(p0.price - p4.price),
208
+ shouldersDiffPct: Math.abs(p0.price - p4.price) / Math.max(1, Math.max(p0.price, p4.price)),
209
+ head: p2.price,
210
+ thresholdPct: tolerancePct,
211
+ },
212
+ indices: [p0.idx, p1.idx, p2.idx, p3.idx, p4.idx],
213
+ });
214
+ }
215
+ }
216
+
217
+ return { patterns, found };
218
+ }
219
+
220
+ // ── Helper: Relaxed H&S fallback ──
221
+
222
+ function findRelaxedHS(ctx: DetectContext): DeduplicablePattern | null {
223
+ const { candles, pivots, allValleys, tolerancePct, minDist, debugCandidates } = ctx;
224
+
225
+ for (const factors of RELAXED_FACTORS) {
226
+ for (let i = 0; i < pivots.length - 4; i++) {
227
+ const p0 = pivots[i],
228
+ p1 = pivots[i + 1],
229
+ p2 = pivots[i + 2],
230
+ p3 = pivots[i + 3],
231
+ p4 = pivots[i + 4];
232
+ if (!(p0.kind === 'H' && p1.kind === 'L' && p2.kind === 'H' && p3.kind === 'L' && p4.kind === 'H')) continue;
233
+ if (
234
+ p1.idx - p0.idx < minDist ||
235
+ p2.idx - p1.idx < minDist ||
236
+ p3.idx - p2.idx < minDist ||
237
+ p4.idx - p3.idx < minDist
238
+ )
239
+ continue;
240
+ const shouldersNearRelaxed =
241
+ Math.abs(p0.price - p4.price) / Math.max(1, Math.max(p0.price, p4.price)) <= tolerancePct * factors.shoulder;
242
+ const headHigherRelaxed = p2.price > Math.max(p0.price, p4.price) * (1 + tolerancePct * factors.head);
243
+ if (!shouldersNearRelaxed || !headHigherRelaxed) continue;
244
+ const start = candles[p0.idx].isoTime;
245
+ const end = candles[p4.idx].isoTime;
246
+ if (!start || !end) continue;
247
+ const valleyBetween = allValleys.filter((v: { idx: number }) => v.idx > p0.idx && v.idx < p4.idx);
248
+ const postValleys = allValleys.filter((v: { idx: number }) => v.idx > p2.idx);
249
+ const minValley = valleyBetween.length
250
+ ? valleyBetween.reduce((m, v) => (v.price < m.price ? v : m))
251
+ : postValleys.length
252
+ ? postValleys.reduce((m, v) => (v.price < m.price ? v : m))
253
+ : null;
254
+ const nlY = minValley ? minValley.price : Math.min(p1.price, p3.price);
255
+ const neckline = [
256
+ { x: p1.idx, y: nlY },
257
+ { x: p3.idx, y: nlY },
258
+ ];
259
+ const tolMargin = marginFromRelDev(relDev(p0.price, p4.price), tolerancePct * factors.shoulder);
260
+ const symmetry = clamp01(1 - relDev(p0.price, p4.price));
261
+ const per = periodScoreDays(start, end);
262
+ const base = (tolMargin + symmetry + per) / 3;
263
+ const confidence = finalizeConf(base * 0.95, 'head_and_shoulders');
264
+ const nlAvg = (Number(p1.price) + Number(p3.price)) / 2;
265
+ const diagram = generatePatternDiagram(
266
+ 'head_and_shoulders',
267
+ [
268
+ { ...p0, date: candles[p0.idx]?.isoTime },
269
+ { ...p1, date: candles[p1.idx]?.isoTime },
270
+ { ...p2, date: candles[p2.idx]?.isoTime },
271
+ { ...p3, date: candles[p3.idx]?.isoTime },
272
+ { ...p4, date: candles[p4.idx]?.isoTime },
273
+ ],
274
+ { price: nlAvg },
275
+ { start, end },
276
+ );
277
+ const hsRelTarget = Math.round(nlY - (p2.price - nlY));
278
+ debugCandidates.push({
279
+ type: 'head_and_shoulders',
280
+ accepted: true,
281
+ reason: 'fallback_relaxed',
282
+ indices: [p0.idx, p1.idx, p2.idx, p3.idx, p4.idx],
283
+ });
284
+ return {
285
+ type: 'head_and_shoulders',
286
+ confidence,
287
+ range: { start, end },
288
+ pivots: [p0, p1, p2, p3, p4],
289
+ neckline,
290
+ trendlineLabel: 'ネックライン',
291
+ breakoutTarget: hsRelTarget,
292
+ targetMethod: 'neckline_projection' as const,
293
+ structureDiagram: diagram,
294
+ _fallback: `relaxed_hs_${factors.tag}`,
295
+ };
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+
301
+ // ── Helper: Relaxed Inverse H&S fallback ──
302
+
303
+ function findRelaxedInverseHS(ctx: DetectContext): DeduplicablePattern | null {
304
+ const { candles, pivots, allPeaks, tolerancePct, minDist, debugCandidates } = ctx;
305
+
306
+ for (const factors of RELAXED_FACTORS) {
307
+ for (let i = 0; i < pivots.length - 4; i++) {
308
+ const p0 = pivots[i],
309
+ p1 = pivots[i + 1],
310
+ p2 = pivots[i + 2],
311
+ p3 = pivots[i + 3],
312
+ p4 = pivots[i + 4];
313
+ if (!(p0.kind === 'L' && p1.kind === 'H' && p2.kind === 'L' && p3.kind === 'H' && p4.kind === 'L')) continue;
314
+ if (
315
+ p1.idx - p0.idx < minDist ||
316
+ p2.idx - p1.idx < minDist ||
317
+ p3.idx - p2.idx < minDist ||
318
+ p4.idx - p3.idx < minDist
319
+ )
320
+ continue;
321
+ const shouldersNearRelaxed =
322
+ Math.abs(p0.price - p4.price) / Math.max(1, Math.max(p0.price, p4.price)) <= tolerancePct * factors.shoulder;
323
+ const headLowerRelaxed = p2.price < Math.min(p0.price, p4.price) * (1 - tolerancePct * factors.head);
324
+ if (!(shouldersNearRelaxed && headLowerRelaxed)) continue;
325
+ const start = candles[p0.idx].isoTime;
326
+ const end = candles[p4.idx].isoTime;
327
+ if (!start || !end) continue;
328
+ const peaksBetween = allPeaks.filter((v: { idx: number }) => v.idx > p0.idx && v.idx < p4.idx);
329
+ const postPeaks = allPeaks.filter((v: { idx: number }) => v.idx > p2.idx);
330
+ const maxPeak = peaksBetween.length
331
+ ? peaksBetween.reduce((m, v) => (v.price > m.price ? v : m))
332
+ : postPeaks.length
333
+ ? postPeaks.reduce((m, v) => (v.price > m.price ? v : m))
334
+ : null;
335
+ const nlY = maxPeak ? maxPeak.price : Math.max(p1.price, p3.price);
336
+ const neckline = [
337
+ { x: p1.idx, y: nlY },
338
+ { x: p3.idx, y: nlY },
339
+ ];
340
+ const tolMargin = marginFromRelDev(relDev(p0.price, p4.price), tolerancePct * factors.shoulder);
341
+ const symmetry = clamp01(1 - relDev(p0.price, p4.price));
342
+ const per = periodScoreDays(start, end);
343
+ const base = (tolMargin + symmetry + per) / 3;
344
+ const confidence = finalizeConf(base * 0.95, 'inverse_head_and_shoulders');
345
+ const nlAvg = (Number(p1.price) + Number(p3.price)) / 2;
346
+ const diagram = generatePatternDiagram(
347
+ 'inverse_head_and_shoulders',
348
+ [
349
+ { ...p0, date: candles[p0.idx]?.isoTime },
350
+ { ...p1, date: candles[p1.idx]?.isoTime },
351
+ { ...p2, date: candles[p2.idx]?.isoTime },
352
+ { ...p3, date: candles[p3.idx]?.isoTime },
353
+ { ...p4, date: candles[p4.idx]?.isoTime },
354
+ ],
355
+ { price: nlAvg },
356
+ { start, end },
357
+ );
358
+ const ihsRelTarget = Math.round(nlY + (nlY - p2.price));
359
+ debugCandidates.push({
360
+ type: 'inverse_head_and_shoulders',
361
+ accepted: true,
362
+ reason: 'fallback_relaxed',
363
+ indices: [p0.idx, p1.idx, p2.idx, p3.idx, p4.idx],
364
+ });
365
+ return {
366
+ type: 'inverse_head_and_shoulders',
367
+ confidence,
368
+ range: { start, end },
369
+ pivots: [p0, p1, p2, p3, p4],
370
+ neckline,
371
+ trendlineLabel: 'ネックライン',
372
+ breakoutTarget: ihsRelTarget,
373
+ targetMethod: 'neckline_projection' as const,
374
+ structureDiagram: diagram,
375
+ _fallback: `relaxed_ihs_${factors.tag}`,
376
+ };
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+
382
+ // ── Helper: 形成中 H&S ──
383
+
384
+ function tryFormingHS(ctx: DetectContext): DeduplicablePattern | null {
385
+ const { candles, allPeaks, allValleys } = ctx;
386
+ const lastIdx = candles.length - 1;
387
+ const currentPrice = Number(candles[lastIdx]?.close ?? NaN);
388
+ const isoAt = (i: number) => candles[i]?.isoTime || '';
389
+ const daysPerBar = ctx.type === '1day' ? 1 : ctx.type === '1week' ? 7 : 1;
390
+
391
+ const confirmedPeaks = allPeaks.filter((p) => p.idx < lastIdx - 2);
392
+ if (confirmedPeaks.length < 2) return null;
393
+
394
+ const head = confirmedPeaks.reduce((best, p) => (p.price > best.price ? p : best), confirmedPeaks[0]);
395
+
396
+ // 左肩: 頭より左のピークで、頭より3%以上低い
397
+ const leftCandidates = confirmedPeaks.filter((p) => p.idx < head.idx && head.price > p.price * 1.03);
398
+ if (leftCandidates.length < 1) return null;
399
+ const left = leftCandidates[leftCandidates.length - 1];
400
+
401
+ // 頭後の谷を探す
402
+ const postHeadValley = allValleys.find((v) => v.idx > head.idx && v.idx < lastIdx - 1);
403
+ if (!postHeadValley) return null;
404
+
405
+ // 右肩候補
406
+ const rightPeakCandidates = allPeaks.filter(
407
+ (p) =>
408
+ p.idx > postHeadValley.idx &&
409
+ p.price < head.price &&
410
+ Math.abs(p.price - left.price) / Math.max(1, left.price) <= FORMING_RIGHT_TOLERANCE_PCT,
411
+ );
412
+
413
+ let rightShoulder: { idx: number; price: number } | null = rightPeakCandidates.length
414
+ ? rightPeakCandidates[rightPeakCandidates.length - 1]
415
+ : null;
416
+ let isProvisional = false;
417
+
418
+ // 確定右肩がない場合、現在価格が左肩近傍なら暫定右肩
419
+ if (!rightShoulder) {
420
+ const nearLeft = Math.abs(currentPrice - left.price) / Math.max(1, left.price) <= FORMING_RIGHT_TOLERANCE_PCT;
421
+ if (nearLeft && currentPrice < head.price && currentPrice > postHeadValley.price) {
422
+ rightShoulder = { idx: lastIdx, price: currentPrice };
423
+ isProvisional = true;
424
+ }
425
+ }
426
+ if (!rightShoulder) return null;
427
+
428
+ // 完成度計算
429
+ const closeness =
430
+ 1 - Math.abs(rightShoulder.price - left.price) / Math.max(1e-12, left.price * FORMING_RIGHT_TOLERANCE_PCT);
431
+ const progress = Math.max(0, Math.min(1, closeness));
432
+ const completion = Math.min(1, (0.75 + 0.25 * progress) * (isProvisional ? 0.9 : 1.0));
433
+ if (completion < FORMING_MIN_COMPLETION) return null;
434
+
435
+ const formationBars = Math.max(0, rightShoulder.idx - left.idx);
436
+ const patternDays = Math.round(formationBars * daysPerBar);
437
+ if (patternDays < FORMING_MIN_DAYS || patternDays > FORMING_MAX_DAYS) return null;
438
+
439
+ // ネックライン
440
+ const preHeadValleys = allValleys.filter((v) => v.idx > left.idx && v.idx < head.idx);
441
+ const preHeadValley = preHeadValleys.length
442
+ ? preHeadValleys.reduce((best, v) => (v.price < best.price ? v : best), preHeadValleys[0])
443
+ : null;
444
+
445
+ const neckline = preHeadValley
446
+ ? [
447
+ { x: preHeadValley.idx, y: preHeadValley.price },
448
+ { x: postHeadValley.idx, y: postHeadValley.price },
449
+ ]
450
+ : [
451
+ { x: left.idx, y: postHeadValley.price },
452
+ { x: postHeadValley.idx, y: postHeadValley.price },
453
+ ];
454
+
455
+ const confBase = Math.min(1, Math.max(0, 0.6 * closeness + 0.4 * progress));
456
+ const confidence = Math.round(confBase * (isProvisional ? 0.9 : 1.0) * 100) / 100;
457
+ const start = isoAt(left.idx);
458
+ const end = isoAt(rightShoulder.idx);
459
+
460
+ const formHsNl = neckline[0].y;
461
+ const formHsTarget = Math.round(formHsNl - (head.price - formHsNl));
462
+
463
+ return {
464
+ type: 'head_and_shoulders',
465
+ confidence,
466
+ range: { start, end },
467
+ status: 'forming',
468
+ pivots: [
469
+ { idx: left.idx, price: left.price, kind: 'H' as const },
470
+ { idx: head.idx, price: head.price, kind: 'H' as const },
471
+ { idx: postHeadValley.idx, price: postHeadValley.price, kind: 'L' as const },
472
+ { idx: rightShoulder.idx, price: rightShoulder.price, kind: 'H' as const },
473
+ ],
474
+ neckline,
475
+ trendlineLabel: 'ネックライン',
476
+ breakoutTarget: formHsTarget,
477
+ targetMethod: 'neckline_projection' as const,
478
+ completionPct: Math.round(completion * 100),
479
+ _method: isProvisional ? 'forming_hs_provisional' : 'forming_hs',
480
+ };
481
+ }
482
+
483
+ // ── Helper: 形成中 Inverse H&S ──
484
+
485
+ function tryFormingInverseHS(ctx: DetectContext): DeduplicablePattern | null {
486
+ const { candles, allPeaks, allValleys } = ctx;
487
+ const lastIdx = candles.length - 1;
488
+ const currentPrice = Number(candles[lastIdx]?.close ?? NaN);
489
+ const isoAt = (i: number) => candles[i]?.isoTime || '';
490
+ const daysPerBar = ctx.type === '1day' ? 1 : ctx.type === '1week' ? 7 : 1;
491
+
492
+ const confirmedValleys = allValleys.filter((v) => v.idx < lastIdx - 2);
493
+ if (confirmedValleys.length < 2) return null;
494
+
495
+ const head = confirmedValleys.reduce((best, v) => (v.price < best.price ? v : best), confirmedValleys[0]);
496
+
497
+ // 左肩: 頭より左の谷で、頭より3%以上高い
498
+ const leftCandidates = confirmedValleys.filter((v) => v.idx < head.idx && head.price < v.price * 0.97);
499
+ if (leftCandidates.length < 1) return null;
500
+ const left = leftCandidates[leftCandidates.length - 1];
501
+
502
+ // 頭後のピークを探す
503
+ const postHeadPeak = allPeaks.find((p) => p.idx > head.idx && p.idx < lastIdx - 1);
504
+ if (!postHeadPeak) return null;
505
+
506
+ // 右肩候補
507
+ const rightValleyCandidates = allValleys.filter(
508
+ (v) =>
509
+ v.idx > postHeadPeak.idx &&
510
+ v.price > head.price &&
511
+ Math.abs(v.price - left.price) / Math.max(1, left.price) <= FORMING_RIGHT_TOLERANCE_PCT,
512
+ );
513
+
514
+ let rightShoulder: { idx: number; price: number } | null = rightValleyCandidates.length
515
+ ? rightValleyCandidates[rightValleyCandidates.length - 1]
516
+ : null;
517
+ let isProvisional = false;
518
+
519
+ // 確定右肩がない場合、現在価格が左肩近傍なら暫定右肩
520
+ if (!rightShoulder) {
521
+ const nearLeft = Math.abs(currentPrice - left.price) / Math.max(1, left.price) <= FORMING_RIGHT_TOLERANCE_PCT;
522
+ if (nearLeft && currentPrice > head.price && currentPrice < postHeadPeak.price) {
523
+ rightShoulder = { idx: lastIdx, price: currentPrice };
524
+ isProvisional = true;
525
+ }
526
+ }
527
+ if (!rightShoulder) return null;
528
+
529
+ // 完成度計算
530
+ const closeness =
531
+ 1 - Math.abs(rightShoulder.price - left.price) / Math.max(1e-12, left.price * FORMING_RIGHT_TOLERANCE_PCT);
532
+ const progress = Math.max(0, Math.min(1, closeness));
533
+ const completion = Math.min(1, (0.75 + 0.25 * progress) * (isProvisional ? 0.9 : 1.0));
534
+ if (completion < FORMING_MIN_COMPLETION) return null;
535
+
536
+ const formationBars = Math.max(0, rightShoulder.idx - left.idx);
537
+ const patternDays = Math.round(formationBars * daysPerBar);
538
+ if (patternDays < FORMING_MIN_DAYS || patternDays > FORMING_MAX_DAYS) return null;
539
+
540
+ // ネックライン
541
+ const preHeadPeaks = allPeaks.filter((p) => p.idx > left.idx && p.idx < head.idx);
542
+ const preHeadPeak = preHeadPeaks.length
543
+ ? preHeadPeaks.reduce((best, p) => (p.price > best.price ? p : best), preHeadPeaks[0])
544
+ : null;
545
+
546
+ const neckline = preHeadPeak
547
+ ? [
548
+ { x: preHeadPeak.idx, y: preHeadPeak.price },
549
+ { x: postHeadPeak.idx, y: postHeadPeak.price },
550
+ ]
551
+ : [
552
+ { x: left.idx, y: postHeadPeak.price },
553
+ { x: postHeadPeak.idx, y: postHeadPeak.price },
554
+ ];
555
+
556
+ const confBase = Math.min(1, Math.max(0, 0.6 * closeness + 0.4 * progress));
557
+ const confidence = Math.round(confBase * (isProvisional ? 0.9 : 1.0) * 100) / 100;
558
+ const start = isoAt(left.idx);
559
+ const end = isoAt(rightShoulder.idx);
560
+
561
+ const formIhsNl = neckline[0].y;
562
+ const formIhsTarget = Math.round(formIhsNl + (formIhsNl - head.price));
563
+
564
+ return {
565
+ type: 'inverse_head_and_shoulders',
566
+ confidence,
567
+ range: { start, end },
568
+ status: 'forming',
569
+ pivots: [
570
+ { idx: left.idx, price: left.price, kind: 'L' as const },
571
+ { idx: head.idx, price: head.price, kind: 'L' as const },
572
+ { idx: postHeadPeak.idx, price: postHeadPeak.price, kind: 'H' as const },
573
+ { idx: rightShoulder.idx, price: rightShoulder.price, kind: 'L' as const },
574
+ ],
575
+ neckline,
576
+ trendlineLabel: 'ネックライン',
577
+ breakoutTarget: formIhsTarget,
578
+ targetMethod: 'neckline_projection' as const,
579
+ completionPct: Math.round(completion * 100),
580
+ _method: isProvisional ? 'forming_ihs_provisional' : 'forming_ihs',
581
+ };
582
+ }
583
+
584
+ // ── Main ──
585
+
586
+ export function detectHeadAndShoulders(ctx: DetectContext): DetectResult {
587
+ const { want, includeForming } = ctx;
588
+ const patterns: DeduplicablePattern[] = [];
589
+
590
+ // 3) Inverse H&S
591
+ let foundInverseHS = false;
592
+ if (want.size === 0 || want.has('inverse_head_and_shoulders')) {
593
+ const result = findStrictInverseHS(ctx);
594
+ patterns.push(...result.patterns);
595
+ foundInverseHS = result.found;
596
+ }
597
+
598
+ // 3b) H&S
599
+ let foundHS = false;
600
+ if (want.size === 0 || want.has('head_and_shoulders')) {
601
+ const result = findStrictHS(ctx);
602
+ patterns.push(...result.patterns);
603
+ foundHS = result.found;
604
+ }
605
+
606
+ // Relaxed fallback
607
+ if (!foundHS && (want.size === 0 || want.has('head_and_shoulders'))) {
608
+ const relaxed = findRelaxedHS(ctx);
609
+ if (relaxed) {
610
+ patterns.push(relaxed);
611
+ foundHS = true;
612
+ }
613
+ }
614
+ if (!foundInverseHS && (want.size === 0 || want.has('inverse_head_and_shoulders'))) {
615
+ const relaxed = findRelaxedInverseHS(ctx);
616
+ if (relaxed) {
617
+ patterns.push(relaxed);
618
+ foundInverseHS = true;
619
+ }
620
+ }
621
+
622
+ // 3c) 形成中 H&S
623
+ if (includeForming && (want.size === 0 || want.has('head_and_shoulders'))) {
624
+ const forming = tryFormingHS(ctx);
625
+ if (forming) patterns.push(forming);
626
+ }
627
+
628
+ // 3d) 形成中 Inverse H&S
629
+ if (includeForming && (want.size === 0 || want.has('inverse_head_and_shoulders'))) {
630
+ const forming = tryFormingInverseHS(ctx);
631
+ if (forming) patterns.push(forming);
632
+ }
633
+
634
+ return { patterns, found: { head_and_shoulders: foundHS, inverse_head_and_shoulders: foundInverseHS } };
635
+ }