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.
- package/LICENSE +21 -0
- package/README.md +388 -0
- package/assets/lightweight-charts.standalone.js +7 -0
- package/bin/bitbank-lab-mcp.js +20 -0
- package/lib/cache.ts +70 -0
- package/lib/candle-utils.ts +48 -0
- package/lib/candle-validate.ts +434 -0
- package/lib/conversions.ts +25 -0
- package/lib/datetime.ts +157 -0
- package/lib/depth-analysis.ts +51 -0
- package/lib/error.ts +15 -0
- package/lib/formatter.ts +296 -0
- package/lib/get-depth.ts +111 -0
- package/lib/http.ts +132 -0
- package/lib/indicator-config.ts +39 -0
- package/lib/indicator_buffer.ts +41 -0
- package/lib/indicators.ts +579 -0
- package/lib/logger.ts +120 -0
- package/lib/ma-snapshot-utils.ts +277 -0
- package/lib/math.ts +89 -0
- package/lib/pattern-diagrams.ts +562 -0
- package/lib/result.ts +104 -0
- package/lib/validate.ts +154 -0
- package/lib/volatility.ts +132 -0
- package/package.json +79 -0
- package/src/env.ts +4 -0
- package/src/handlers/analyzeCandlePatternsHandler.ts +383 -0
- package/src/handlers/analyzeFibonacciHandler.ts +54 -0
- package/src/handlers/analyzeIndicatorsHandler.ts +682 -0
- package/src/handlers/analyzeMarketSignalHandler.ts +272 -0
- package/src/handlers/analyzeMyPortfolioHandler.ts +800 -0
- package/src/handlers/detectPatternsHandler.ts +77 -0
- package/src/handlers/detectPatternsViewsHandler.ts +518 -0
- package/src/handlers/getTickersJpyHandler.ts +145 -0
- package/src/handlers/getVolatilityMetricsHandler.ts +234 -0
- package/src/handlers/portfolio/calc.ts +549 -0
- package/src/handlers/portfolio/fetch.ts +318 -0
- package/src/handlers/portfolio/types.ts +170 -0
- package/src/handlers/renderChartSvgHandler.ts +69 -0
- package/src/handlers/runBacktestHandler.ts +70 -0
- package/src/http.ts +107 -0
- package/src/private/auth.ts +104 -0
- package/src/private/client.ts +298 -0
- package/src/private/config.ts +25 -0
- package/src/private/confirmation.ts +185 -0
- package/src/private/schemas.ts +866 -0
- package/src/prompts.ts +2296 -0
- package/src/resources/app-resources.ts +79 -0
- package/src/schema/analysis.ts +942 -0
- package/src/schema/backtest.ts +100 -0
- package/src/schema/base.ts +88 -0
- package/src/schema/candle-validate.ts +135 -0
- package/src/schema/chart.ts +399 -0
- package/src/schema/index.ts +11 -0
- package/src/schema/indicators.ts +125 -0
- package/src/schema/market-data.ts +298 -0
- package/src/schema/patterns.ts +382 -0
- package/src/schema/types.ts +97 -0
- package/src/schemas.d.ts +37 -0
- package/src/schemas.ts +7 -0
- package/src/server.ts +405 -0
- package/src/tool-definition.ts +44 -0
- package/src/tool-registry.ts +174 -0
- package/src/types/express-shim.d.ts +9 -0
- package/src/types/schemas.generated.d.ts +23 -0
- package/tools/analyze_bb_snapshot.ts +385 -0
- package/tools/analyze_candle_patterns.ts +810 -0
- package/tools/analyze_currency_strength.ts +273 -0
- package/tools/analyze_ema_snapshot.ts +183 -0
- package/tools/analyze_fibonacci.ts +530 -0
- package/tools/analyze_ichimoku_snapshot.ts +606 -0
- package/tools/analyze_indicators.ts +691 -0
- package/tools/analyze_market_signal.ts +665 -0
- package/tools/analyze_mtf_fibonacci.ts +273 -0
- package/tools/analyze_mtf_sma.ts +175 -0
- package/tools/analyze_sma_snapshot.ts +146 -0
- package/tools/analyze_stoch_snapshot.ts +276 -0
- package/tools/analyze_support_resistance.ts +817 -0
- package/tools/analyze_volume_profile.ts +546 -0
- package/tools/chart/ichimoku-cloud.ts +113 -0
- package/tools/chart/render-depth.ts +139 -0
- package/tools/chart/render-sub-panels.ts +208 -0
- package/tools/chart/svg-utils.ts +102 -0
- package/tools/detect_macd_cross.ts +691 -0
- package/tools/detect_patterns.ts +424 -0
- package/tools/detect_whale_events.ts +181 -0
- package/tools/get_candles.ts +487 -0
- package/tools/get_flow_metrics.ts +596 -0
- package/tools/get_orderbook.ts +540 -0
- package/tools/get_ticker.ts +132 -0
- package/tools/get_tickers_jpy.ts +240 -0
- package/tools/get_transactions.ts +209 -0
- package/tools/get_volatility_metrics.ts +302 -0
- package/tools/patterns/aftermath.ts +212 -0
- package/tools/patterns/config.ts +151 -0
- package/tools/patterns/detect_doubles.ts +650 -0
- package/tools/patterns/detect_hs.ts +635 -0
- package/tools/patterns/detect_pennants.ts +373 -0
- package/tools/patterns/detect_triangles.ts +820 -0
- package/tools/patterns/detect_triples.ts +633 -0
- package/tools/patterns/detect_wedges.ts +1072 -0
- package/tools/patterns/helpers.ts +517 -0
- package/tools/patterns/index.ts +40 -0
- package/tools/patterns/regression.ts +153 -0
- package/tools/patterns/smoothing.ts +168 -0
- package/tools/patterns/swing.ts +91 -0
- package/tools/patterns/types.ts +193 -0
- package/tools/prepare_chart_data.ts +294 -0
- package/tools/prepare_depth_data.ts +189 -0
- package/tools/private/analyze_my_portfolio.ts +21 -0
- package/tools/private/cancel_order.ts +127 -0
- package/tools/private/cancel_orders.ts +121 -0
- package/tools/private/create_order.ts +236 -0
- package/tools/private/get_margin_positions.ts +134 -0
- package/tools/private/get_margin_status.ts +155 -0
- package/tools/private/get_margin_trade_history.ts +156 -0
- package/tools/private/get_my_assets.ts +207 -0
- package/tools/private/get_my_deposit_withdrawal.ts +500 -0
- package/tools/private/get_my_orders.ts +157 -0
- package/tools/private/get_my_trade_history.ts +229 -0
- package/tools/private/get_order.ts +95 -0
- package/tools/private/get_orders_info.ts +90 -0
- package/tools/private/preview_cancel_order.ts +172 -0
- package/tools/private/preview_cancel_orders.ts +137 -0
- package/tools/private/preview_order.ts +292 -0
- package/tools/render_candle_pattern_diagram.ts +389 -0
- package/tools/render_chart_svg.ts +799 -0
- package/tools/render_depth_svg.ts +274 -0
- package/tools/trading_process/index.ts +7 -0
- package/tools/trading_process/lib/backtest_engine.ts +252 -0
- package/tools/trading_process/lib/equity.ts +131 -0
- package/tools/trading_process/lib/fetch_candles.ts +181 -0
- package/tools/trading_process/lib/sma.ts +62 -0
- package/tools/trading_process/lib/strategies/bb_breakout.ts +141 -0
- package/tools/trading_process/lib/strategies/index.ts +52 -0
- package/tools/trading_process/lib/strategies/macd_cross.ts +256 -0
- package/tools/trading_process/lib/strategies/rsi.ts +133 -0
- package/tools/trading_process/lib/strategies/sma_cross.ts +214 -0
- package/tools/trading_process/lib/strategies/types.ts +118 -0
- package/tools/trading_process/lib/svg_to_png.ts +64 -0
- package/tools/trading_process/render_backtest_chart_generic.ts +729 -0
- package/tools/trading_process/run_backtest.ts +243 -0
- package/tools/trading_process/types.ts +85 -0
- package/tools/validate_candle_data.ts +260 -0
- package/tsconfig.json +17 -0
- package/ui/cancel-confirm/dist/cancel-confirm.html +99 -0
- package/ui/order-confirm/dist/order-confirm.html +99 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Double Top / Double Bottom 検出(完成済み+形成中)
|
|
3
|
+
* detect_patterns.ts Section 2 から抽出
|
|
4
|
+
*/
|
|
5
|
+
import { EPSILON } from '../../lib/math.js';
|
|
6
|
+
import { generatePatternDiagram } from '../../lib/pattern-diagrams.js';
|
|
7
|
+
import { deduplicatePatterns, finalizeConf, periodScoreDays } from './helpers.js';
|
|
8
|
+
import { clamp01, marginFromRelDev, relDev } from './regression.js';
|
|
9
|
+
import type { Pivot } from './swing.js';
|
|
10
|
+
import { type CandleData, type DetectContext, type DetectResult, type PatternEntry, pushCand } from './types.js';
|
|
11
|
+
|
|
12
|
+
// ── Configuration ──
|
|
13
|
+
const MIN_PIVOT_DISTANCE_BARS = 5;
|
|
14
|
+
const MIN_PATTERN_HEIGHT_PCT = 0.03;
|
|
15
|
+
const MIN_DEPTH_PCT = 0.05;
|
|
16
|
+
const BREAKOUT_BUFFER_PCT = 0.015;
|
|
17
|
+
const MAX_BARS_FROM_EXTREMUM = 20;
|
|
18
|
+
const RELAXED_TOLERANCE_FACTOR = 1.3;
|
|
19
|
+
const RELAXED_CONFIDENCE_PENALTY = 0.85;
|
|
20
|
+
const MAX_FORMING_DAYS = 90;
|
|
21
|
+
const FORMING_PEAK_TOLERANCE_PCT = 0.05;
|
|
22
|
+
const FORMING_BASE_COMPLETION = 0.66;
|
|
23
|
+
const FORMING_COMPLETION_RANGE = 0.34;
|
|
24
|
+
const MIN_FORMING_COMPLETION = 0.4;
|
|
25
|
+
const MIN_PATTERN_DAYS = 14;
|
|
26
|
+
const FORMING_TOLERANCE_MULTIPLIER = 1.5;
|
|
27
|
+
const FORMING_VALLEY_INVALID_PCT = 0.02;
|
|
28
|
+
|
|
29
|
+
type Pcand = (arg: Parameters<typeof pushCand>[1]) => void;
|
|
30
|
+
|
|
31
|
+
// ── Helper: ブレイクアウトインデックス検出 ──
|
|
32
|
+
|
|
33
|
+
function findBreakoutIdx(
|
|
34
|
+
candles: CandleData[],
|
|
35
|
+
afterIdx: number,
|
|
36
|
+
necklinePrice: number,
|
|
37
|
+
direction: 'below' | 'above',
|
|
38
|
+
): number {
|
|
39
|
+
const end = Math.min(afterIdx + MAX_BARS_FROM_EXTREMUM + 1, candles.length);
|
|
40
|
+
for (let k = afterIdx + 1; k < end; k++) {
|
|
41
|
+
const closeK = Number(candles[k]?.close ?? NaN);
|
|
42
|
+
if (!Number.isFinite(closeK)) continue;
|
|
43
|
+
if (direction === 'below' && closeK < necklinePrice * (1 - BREAKOUT_BUFFER_PCT)) return k;
|
|
44
|
+
if (direction === 'above' && closeK > necklinePrice * (1 + BREAKOUT_BUFFER_PCT)) return k;
|
|
45
|
+
}
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Helper: ダブルトップのサイズ検証(不合格理由 or null) ──
|
|
50
|
+
|
|
51
|
+
function validateTopSize(a: Pivot, b: Pivot, c: Pivot): string | null {
|
|
52
|
+
const heightPct = Math.abs(a.price - b.price) / Math.max(1, Math.max(a.price, b.price));
|
|
53
|
+
if (heightPct < MIN_PATTERN_HEIGHT_PCT) return 'pattern_too_small';
|
|
54
|
+
const peakAvg = (a.price + c.price) / 2;
|
|
55
|
+
const valleyDepthPct = (peakAvg - b.price) / Math.max(1, peakAvg);
|
|
56
|
+
if (valleyDepthPct < MIN_DEPTH_PCT) return 'valley_too_shallow';
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Helper: ダブルボトムのサイズ検証(不合格理由 or null) ──
|
|
61
|
+
|
|
62
|
+
function validateBottomSize(a: Pivot, b: Pivot, c: Pivot): string | null {
|
|
63
|
+
const heightPct = Math.abs(a.price - b.price) / Math.max(1, Math.max(a.price, b.price));
|
|
64
|
+
if (heightPct < MIN_PATTERN_HEIGHT_PCT) return 'pattern_too_small';
|
|
65
|
+
const valleyAvg = (a.price + c.price) / 2;
|
|
66
|
+
const peakHeightPct = (b.price - valleyAvg) / Math.max(1, valleyAvg);
|
|
67
|
+
if (peakHeightPct < MIN_DEPTH_PCT) return 'peak_too_shallow';
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Helper: relaxed fallback ダブルトップ検索 ──
|
|
72
|
+
|
|
73
|
+
function findRelaxedDoubleTop(
|
|
74
|
+
pivots: Pivot[],
|
|
75
|
+
candles: CandleData[],
|
|
76
|
+
tolerancePct: number,
|
|
77
|
+
factor: number,
|
|
78
|
+
minDistDB: number,
|
|
79
|
+
pcand: Pcand,
|
|
80
|
+
): PatternEntry | null {
|
|
81
|
+
const tolRelax = tolerancePct * factor;
|
|
82
|
+
const nearRelaxed = (x: number, y: number) => Math.abs(x - y) <= Math.max(x, y) * tolRelax;
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < pivots.length - 3; i++) {
|
|
85
|
+
const a = pivots[i],
|
|
86
|
+
b = pivots[i + 1],
|
|
87
|
+
c = pivots[i + 2];
|
|
88
|
+
if (!(a.kind === 'H' && b.kind === 'L' && c.kind === 'H')) continue;
|
|
89
|
+
if (b.idx - a.idx < minDistDB || c.idx - b.idx < minDistDB) continue;
|
|
90
|
+
|
|
91
|
+
const sizeReason = validateTopSize(a, b, c);
|
|
92
|
+
if (sizeReason) {
|
|
93
|
+
const reason = sizeReason === 'valley_too_shallow' ? 'valley_too_shallow_relaxed' : sizeReason;
|
|
94
|
+
pcand({ type: 'double_top', accepted: false, reason, idxs: [a.idx, b.idx, c.idx] });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!nearRelaxed(a.price, c.price)) {
|
|
98
|
+
pcand({
|
|
99
|
+
type: 'double_top',
|
|
100
|
+
accepted: false,
|
|
101
|
+
reason: 'peaks_not_equal_relaxed',
|
|
102
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
103
|
+
pts: [
|
|
104
|
+
{ role: 'peak1', idx: a.idx, price: a.price },
|
|
105
|
+
{ role: 'peak2', idx: c.idx, price: c.price },
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const necklinePrice = b.price;
|
|
112
|
+
const breakoutIdx = findBreakoutIdx(candles, c.idx, necklinePrice, 'below');
|
|
113
|
+
if (breakoutIdx < 0) {
|
|
114
|
+
pcand({
|
|
115
|
+
type: 'double_top',
|
|
116
|
+
accepted: false,
|
|
117
|
+
reason: 'no_breakout_relaxed',
|
|
118
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
119
|
+
pts: [
|
|
120
|
+
{ role: 'peak1', idx: a.idx, price: a.price },
|
|
121
|
+
{ role: 'valley', idx: b.idx, price: b.price },
|
|
122
|
+
{ role: 'peak2', idx: c.idx, price: c.price },
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const start = candles[a.idx].isoTime,
|
|
129
|
+
end = candles[breakoutIdx].isoTime;
|
|
130
|
+
if (!start || !end) continue;
|
|
131
|
+
|
|
132
|
+
const neckline = [
|
|
133
|
+
{ x: a.idx, y: necklinePrice },
|
|
134
|
+
{ x: breakoutIdx, y: necklinePrice },
|
|
135
|
+
];
|
|
136
|
+
const tolMargin = marginFromRelDev(relDev(a.price, c.price), tolRelax);
|
|
137
|
+
const symmetry = clamp01(1 - relDev(a.price, c.price));
|
|
138
|
+
const per = periodScoreDays(start, end);
|
|
139
|
+
const base = (tolMargin + symmetry + per) / 3;
|
|
140
|
+
const confidence = finalizeConf(base * RELAXED_CONFIDENCE_PENALTY, 'double_top');
|
|
141
|
+
const diagram = generatePatternDiagram(
|
|
142
|
+
'double_top',
|
|
143
|
+
[
|
|
144
|
+
{ ...a, date: candles[a.idx]?.isoTime },
|
|
145
|
+
{ ...b, date: candles[b.idx]?.isoTime },
|
|
146
|
+
{ ...c, date: candles[c.idx]?.isoTime },
|
|
147
|
+
],
|
|
148
|
+
{ price: necklinePrice },
|
|
149
|
+
{ start, end },
|
|
150
|
+
);
|
|
151
|
+
const dtRelAvgPeak = (a.price + c.price) / 2;
|
|
152
|
+
const dtRelTarget = Math.round(necklinePrice - (dtRelAvgPeak - necklinePrice));
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
type: 'double_top',
|
|
156
|
+
confidence,
|
|
157
|
+
range: { start, end },
|
|
158
|
+
pivots: [a, b, c],
|
|
159
|
+
neckline,
|
|
160
|
+
trendlineLabel: 'ネックライン',
|
|
161
|
+
breakout: { idx: breakoutIdx, price: Number(candles[breakoutIdx]?.close ?? NaN) },
|
|
162
|
+
breakoutBarIndex: breakoutIdx,
|
|
163
|
+
breakoutTarget: dtRelTarget,
|
|
164
|
+
targetMethod: 'neckline_projection' as const,
|
|
165
|
+
structureDiagram: diagram,
|
|
166
|
+
_fallback: `relaxed_double_x${factor}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Helper: relaxed fallback ダブルボトム検索 ──
|
|
173
|
+
|
|
174
|
+
function findRelaxedDoubleBottom(
|
|
175
|
+
pivots: Pivot[],
|
|
176
|
+
candles: CandleData[],
|
|
177
|
+
tolerancePct: number,
|
|
178
|
+
factor: number,
|
|
179
|
+
minDistDB: number,
|
|
180
|
+
pcand: Pcand,
|
|
181
|
+
): PatternEntry | null {
|
|
182
|
+
const tolRelax = tolerancePct * factor;
|
|
183
|
+
const nearRelaxed = (x: number, y: number) => Math.abs(x - y) <= Math.max(x, y) * tolRelax;
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < pivots.length - 3; i++) {
|
|
186
|
+
const a = pivots[i],
|
|
187
|
+
b = pivots[i + 1],
|
|
188
|
+
c = pivots[i + 2];
|
|
189
|
+
if (!(a.kind === 'L' && b.kind === 'H' && c.kind === 'L')) continue;
|
|
190
|
+
if (b.idx - a.idx < minDistDB || c.idx - b.idx < minDistDB) continue;
|
|
191
|
+
|
|
192
|
+
const sizeReason = validateBottomSize(a, b, c);
|
|
193
|
+
if (sizeReason) {
|
|
194
|
+
const reason = sizeReason === 'peak_too_shallow' ? 'peak_too_shallow_relaxed' : sizeReason;
|
|
195
|
+
pcand({ type: 'double_bottom', accepted: false, reason, idxs: [a.idx, b.idx, c.idx] });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (!nearRelaxed(a.price, c.price)) {
|
|
199
|
+
pcand({
|
|
200
|
+
type: 'double_bottom',
|
|
201
|
+
accepted: false,
|
|
202
|
+
reason: 'valleys_not_equal_relaxed',
|
|
203
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
204
|
+
pts: [
|
|
205
|
+
{ role: 'valley1', idx: a.idx, price: a.price },
|
|
206
|
+
{ role: 'valley2', idx: c.idx, price: c.price },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const necklinePrice = b.price;
|
|
213
|
+
const breakoutIdx = findBreakoutIdx(candles, c.idx, necklinePrice, 'above');
|
|
214
|
+
if (breakoutIdx < 0) {
|
|
215
|
+
pcand({
|
|
216
|
+
type: 'double_bottom',
|
|
217
|
+
accepted: false,
|
|
218
|
+
reason: 'no_breakout_relaxed',
|
|
219
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
220
|
+
pts: [
|
|
221
|
+
{ role: 'valley1', idx: a.idx, price: a.price },
|
|
222
|
+
{ role: 'peak', idx: b.idx, price: b.price },
|
|
223
|
+
{ role: 'valley2', idx: c.idx, price: c.price },
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const start = candles[a.idx].isoTime,
|
|
230
|
+
end = candles[breakoutIdx].isoTime;
|
|
231
|
+
if (!start || !end) continue;
|
|
232
|
+
|
|
233
|
+
const neckline = [
|
|
234
|
+
{ x: a.idx, y: necklinePrice },
|
|
235
|
+
{ x: breakoutIdx, y: necklinePrice },
|
|
236
|
+
];
|
|
237
|
+
const tolMargin = marginFromRelDev(relDev(a.price, c.price), tolRelax);
|
|
238
|
+
const symmetry = clamp01(1 - relDev(a.price, c.price));
|
|
239
|
+
const per = periodScoreDays(start, end);
|
|
240
|
+
const base = (tolMargin + symmetry + per) / 3;
|
|
241
|
+
const confidence = finalizeConf(base * 0.85, 'double_bottom');
|
|
242
|
+
const diagram = generatePatternDiagram(
|
|
243
|
+
'double_bottom',
|
|
244
|
+
[
|
|
245
|
+
{ ...a, date: candles[a.idx]?.isoTime },
|
|
246
|
+
{ ...b, date: candles[b.idx]?.isoTime },
|
|
247
|
+
{ ...c, date: candles[c.idx]?.isoTime },
|
|
248
|
+
],
|
|
249
|
+
{ price: necklinePrice },
|
|
250
|
+
{ start, end },
|
|
251
|
+
);
|
|
252
|
+
const dbRelAvgValley = (a.price + c.price) / 2;
|
|
253
|
+
const dbRelTarget = Math.round(necklinePrice + (necklinePrice - dbRelAvgValley));
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
type: 'double_bottom',
|
|
257
|
+
confidence,
|
|
258
|
+
range: { start, end },
|
|
259
|
+
pivots: [a, b, c],
|
|
260
|
+
neckline,
|
|
261
|
+
trendlineLabel: 'ネックライン',
|
|
262
|
+
breakout: { idx: breakoutIdx, price: Number(candles[breakoutIdx]?.close ?? NaN) },
|
|
263
|
+
breakoutBarIndex: breakoutIdx,
|
|
264
|
+
breakoutTarget: dbRelTarget,
|
|
265
|
+
targetMethod: 'neckline_projection' as const,
|
|
266
|
+
structureDiagram: diagram,
|
|
267
|
+
_fallback: `relaxed_double_x${factor}`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Helper: 形成中ダブルトップ検索 ──
|
|
274
|
+
|
|
275
|
+
function tryFormingDoubleTop(ctx: DetectContext): PatternEntry | null {
|
|
276
|
+
const { candles, allPeaks, allValleys, want } = ctx;
|
|
277
|
+
if (!(want.size === 0 || want.has('double_top')) || allPeaks.length < 1 || allValleys.length < 1) return null;
|
|
278
|
+
|
|
279
|
+
const lastIdx = candles.length - 1;
|
|
280
|
+
const currentPrice = Number(candles[lastIdx]?.close ?? NaN);
|
|
281
|
+
const isoAt = (i: number) => candles[i]?.isoTime || '';
|
|
282
|
+
|
|
283
|
+
const lastConfirmedPeak = [...allPeaks].reverse().find((p) => p.idx < lastIdx - 2);
|
|
284
|
+
if (!lastConfirmedPeak) return null;
|
|
285
|
+
const valleyAfterPeak = allValleys.find((v) => v.idx > lastConfirmedPeak.idx && v.idx < lastIdx - 1);
|
|
286
|
+
if (!valleyAfterPeak || valleyAfterPeak.idx <= lastConfirmedPeak.idx) return null;
|
|
287
|
+
|
|
288
|
+
const leftPeak = lastConfirmedPeak;
|
|
289
|
+
const valley = valleyAfterPeak;
|
|
290
|
+
const leftPct = currentPrice / Math.max(1, leftPeak.price);
|
|
291
|
+
if (leftPct < 1 - FORMING_PEAK_TOLERANCE_PCT || leftPct > 1 + FORMING_PEAK_TOLERANCE_PCT) return null;
|
|
292
|
+
if (currentPrice <= valley.price) return null;
|
|
293
|
+
|
|
294
|
+
const ratio = (currentPrice - valley.price) / Math.max(EPSILON, leftPeak.price - valley.price);
|
|
295
|
+
const progress = Math.max(0, Math.min(1, ratio));
|
|
296
|
+
const completion = Math.min(1, FORMING_BASE_COMPLETION + progress * FORMING_COMPLETION_RANGE);
|
|
297
|
+
if (completion < MIN_FORMING_COMPLETION) return null;
|
|
298
|
+
|
|
299
|
+
const formationBars = Math.max(0, lastIdx - leftPeak.idx);
|
|
300
|
+
const daysPerBar = ctx.type === '1day' ? 1 : ctx.type === '1week' ? 7 : 1;
|
|
301
|
+
const patternDays = Math.round(formationBars * daysPerBar);
|
|
302
|
+
if (patternDays < MIN_PATTERN_DAYS || patternDays > MAX_FORMING_DAYS) return null;
|
|
303
|
+
|
|
304
|
+
const neckline = [
|
|
305
|
+
{ x: leftPeak.idx, y: valley.price },
|
|
306
|
+
{ x: lastIdx, y: valley.price },
|
|
307
|
+
];
|
|
308
|
+
const confBase = Math.min(1, Math.max(0, (1 - Math.abs(leftPct - 1)) * 0.6 + progress * 0.4));
|
|
309
|
+
const confidence = Math.round(confBase * 100) / 100;
|
|
310
|
+
const start = isoAt(leftPeak.idx);
|
|
311
|
+
const end = isoAt(lastIdx);
|
|
312
|
+
const formDtTarget = Math.round(valley.price - (leftPeak.price - valley.price));
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
type: 'double_top',
|
|
316
|
+
confidence,
|
|
317
|
+
range: { start, end },
|
|
318
|
+
status: 'forming',
|
|
319
|
+
pivots: [
|
|
320
|
+
{ idx: leftPeak.idx, price: leftPeak.price, kind: 'H' as const },
|
|
321
|
+
{ idx: valley.idx, price: valley.price, kind: 'L' as const },
|
|
322
|
+
],
|
|
323
|
+
neckline,
|
|
324
|
+
trendlineLabel: 'ネックライン',
|
|
325
|
+
breakoutTarget: formDtTarget,
|
|
326
|
+
targetMethod: 'neckline_projection' as const,
|
|
327
|
+
completionPct: Math.round(completion * 100),
|
|
328
|
+
_method: 'forming_double_top',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Helper: 形成中ダブルボトム検索 ──
|
|
333
|
+
|
|
334
|
+
function tryFormingDoubleBottom(ctx: DetectContext): PatternEntry | null {
|
|
335
|
+
const { candles, allPeaks, allValleys, tolerancePct, want } = ctx;
|
|
336
|
+
if (!(want.size === 0 || want.has('double_bottom')) || allValleys.length < 2) return null;
|
|
337
|
+
|
|
338
|
+
const lastIdx = candles.length - 1;
|
|
339
|
+
const currentPrice = Number(candles[lastIdx]?.close ?? NaN);
|
|
340
|
+
const isoAt = (i: number) => candles[i]?.isoTime || '';
|
|
341
|
+
const daysPerBar = ctx.type === '1day' ? 1 : ctx.type === '1week' ? 7 : 1;
|
|
342
|
+
|
|
343
|
+
const confirmedValleys = allValleys.filter((v) => v.idx < lastIdx - 2);
|
|
344
|
+
if (confirmedValleys.length < 2) return null;
|
|
345
|
+
|
|
346
|
+
for (let j = confirmedValleys.length - 1; j >= 1; j--) {
|
|
347
|
+
const rightValley = confirmedValleys[j];
|
|
348
|
+
const leftValley = confirmedValleys[j - 1];
|
|
349
|
+
if (rightValley.idx - leftValley.idx < MIN_PIVOT_DISTANCE_BARS) continue;
|
|
350
|
+
|
|
351
|
+
const peaksBetween = allPeaks.filter((p) => p.idx > leftValley.idx && p.idx < rightValley.idx);
|
|
352
|
+
if (!peaksBetween.length) continue;
|
|
353
|
+
const midPeak = peaksBetween.reduce((best, p) => (p.price > best.price ? p : best), peaksBetween[0]);
|
|
354
|
+
|
|
355
|
+
const leftDepth = (midPeak.price - leftValley.price) / Math.max(EPSILON, midPeak.price);
|
|
356
|
+
const rightDepth = (midPeak.price - rightValley.price) / Math.max(EPSILON, midPeak.price);
|
|
357
|
+
if (!(leftDepth >= MIN_PATTERN_HEIGHT_PCT && rightDepth >= MIN_PATTERN_HEIGHT_PCT)) continue;
|
|
358
|
+
|
|
359
|
+
const valleyDiff =
|
|
360
|
+
Math.abs(leftValley.price - rightValley.price) / Math.max(1, Math.max(leftValley.price, rightValley.price));
|
|
361
|
+
if (valleyDiff > tolerancePct * FORMING_TOLERANCE_MULTIPLIER) continue;
|
|
362
|
+
if (currentPrice < rightValley.price * (1 - FORMING_VALLEY_INVALID_PCT)) continue;
|
|
363
|
+
|
|
364
|
+
const upRatio = (currentPrice - rightValley.price) / Math.max(EPSILON, midPeak.price - rightValley.price);
|
|
365
|
+
const progress = Math.max(0, Math.min(1, upRatio));
|
|
366
|
+
const completion = Math.min(1, 0.66 + 0.34 * progress);
|
|
367
|
+
if (completion < 0.4) continue;
|
|
368
|
+
|
|
369
|
+
const formationBars = Math.max(0, lastIdx - leftValley.idx);
|
|
370
|
+
const patternDays = Math.round(formationBars * daysPerBar);
|
|
371
|
+
if (patternDays < 14 || patternDays > MAX_FORMING_DAYS) continue;
|
|
372
|
+
|
|
373
|
+
const neckline = [
|
|
374
|
+
{ x: midPeak.idx, y: midPeak.price },
|
|
375
|
+
{ x: lastIdx, y: midPeak.price },
|
|
376
|
+
];
|
|
377
|
+
const confidence = Number(Math.min(1, 0.5 + 0.5 * progress).toFixed(2));
|
|
378
|
+
const start = isoAt(leftValley.idx);
|
|
379
|
+
const end = isoAt(lastIdx);
|
|
380
|
+
const formDbAvgValley = (leftValley.price + rightValley.price) / 2;
|
|
381
|
+
const formDbTarget = Math.round(midPeak.price + (midPeak.price - formDbAvgValley));
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
type: 'double_bottom',
|
|
385
|
+
confidence,
|
|
386
|
+
range: { start, end },
|
|
387
|
+
status: 'forming',
|
|
388
|
+
pivots: [
|
|
389
|
+
{ idx: leftValley.idx, price: leftValley.price, kind: 'L' as const },
|
|
390
|
+
{ idx: midPeak.idx, price: midPeak.price, kind: 'H' as const },
|
|
391
|
+
{ idx: rightValley.idx, price: rightValley.price, kind: 'L' as const },
|
|
392
|
+
],
|
|
393
|
+
neckline,
|
|
394
|
+
trendlineLabel: 'ネックライン',
|
|
395
|
+
breakoutTarget: formDbTarget,
|
|
396
|
+
targetMethod: 'neckline_projection' as const,
|
|
397
|
+
completionPct: Math.round(completion * 100),
|
|
398
|
+
_method: 'forming_double_bottom',
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Main ──
|
|
405
|
+
|
|
406
|
+
export function detectDoubles(ctx: DetectContext): DetectResult {
|
|
407
|
+
const { candles, pivots, tolerancePct, want, includeForming, near } = ctx;
|
|
408
|
+
const pcand: Pcand = (arg) => pushCand(ctx, arg);
|
|
409
|
+
const push = (arr: PatternEntry[], item: PatternEntry) => {
|
|
410
|
+
arr.push(item);
|
|
411
|
+
};
|
|
412
|
+
let patterns: PatternEntry[] = [];
|
|
413
|
+
|
|
414
|
+
let foundDoubleTop = false,
|
|
415
|
+
foundDoubleBottom = false;
|
|
416
|
+
if (want.size === 0 || want.has('double_top') || want.has('double_bottom')) {
|
|
417
|
+
const minDistDB = MIN_PIVOT_DISTANCE_BARS;
|
|
418
|
+
for (let i = 0; i < pivots.length - 3; i++) {
|
|
419
|
+
const a = pivots[i];
|
|
420
|
+
const b = pivots[i + 1];
|
|
421
|
+
const c = pivots[i + 2];
|
|
422
|
+
if (b.idx - a.idx < minDistDB || c.idx - b.idx < minDistDB) continue;
|
|
423
|
+
|
|
424
|
+
// ── double top: H-L-H ──
|
|
425
|
+
if (a.kind === 'H' && b.kind === 'L' && c.kind === 'H') {
|
|
426
|
+
const sizeReason = validateTopSize(a, b, c);
|
|
427
|
+
if (sizeReason) {
|
|
428
|
+
pcand({ type: 'double_top', accepted: false, reason: sizeReason, idxs: [a.idx, b.idx, c.idx] });
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (!near(a.price, c.price)) {
|
|
432
|
+
const diffPct = Math.abs(a.price - c.price) / Math.max(1, Math.max(a.price, c.price));
|
|
433
|
+
if (diffPct > tolerancePct) {
|
|
434
|
+
pcand({
|
|
435
|
+
type: 'double_top',
|
|
436
|
+
accepted: false,
|
|
437
|
+
reason: 'peaks_not_equal',
|
|
438
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
439
|
+
pts: [
|
|
440
|
+
{ role: 'peak1', idx: a.idx, price: a.price },
|
|
441
|
+
{ role: 'peak2', idx: c.idx, price: c.price },
|
|
442
|
+
],
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
// ネックライン下抜け(終値ベース1.5%バッファ)必須
|
|
448
|
+
const necklinePrice = b.price;
|
|
449
|
+
const breakoutIdx = findBreakoutIdx(candles, c.idx, necklinePrice, 'below');
|
|
450
|
+
if (breakoutIdx < 0) {
|
|
451
|
+
pcand({
|
|
452
|
+
type: 'double_top',
|
|
453
|
+
accepted: false,
|
|
454
|
+
reason: 'no_breakout',
|
|
455
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
456
|
+
pts: [
|
|
457
|
+
{ role: 'peak1', idx: a.idx, price: a.price },
|
|
458
|
+
{ role: 'valley', idx: b.idx, price: b.price },
|
|
459
|
+
{ role: 'peak2', idx: c.idx, price: c.price },
|
|
460
|
+
],
|
|
461
|
+
});
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const start = candles[a.idx].isoTime;
|
|
465
|
+
const end = candles[breakoutIdx].isoTime;
|
|
466
|
+
if (!start || !end) continue;
|
|
467
|
+
const neckline = [
|
|
468
|
+
{ x: a.idx, y: necklinePrice },
|
|
469
|
+
{ x: breakoutIdx, y: necklinePrice },
|
|
470
|
+
];
|
|
471
|
+
const tolMargin = marginFromRelDev(relDev(a.price, c.price), tolerancePct);
|
|
472
|
+
const symmetry = clamp01(1 - relDev(a.price, c.price));
|
|
473
|
+
const per = periodScoreDays(start, end);
|
|
474
|
+
const base = (tolMargin + symmetry + per) / 3;
|
|
475
|
+
const confidence = finalizeConf(base, 'double_top');
|
|
476
|
+
const diagram = generatePatternDiagram(
|
|
477
|
+
'double_top',
|
|
478
|
+
[
|
|
479
|
+
{ ...a, date: candles[a.idx]?.isoTime },
|
|
480
|
+
{ ...b, date: candles[b.idx]?.isoTime },
|
|
481
|
+
{ ...c, date: candles[c.idx]?.isoTime },
|
|
482
|
+
],
|
|
483
|
+
{ price: necklinePrice },
|
|
484
|
+
{ start, end },
|
|
485
|
+
);
|
|
486
|
+
const dtAvgPeak = (a.price + c.price) / 2;
|
|
487
|
+
const dtTarget = Math.round(necklinePrice - (dtAvgPeak - necklinePrice));
|
|
488
|
+
const dtBp = Number(candles[breakoutIdx]?.close ?? NaN);
|
|
489
|
+
let dtTargetPct: number | undefined;
|
|
490
|
+
const dtCurPrice = Number(candles[candles.length - 1]?.close);
|
|
491
|
+
if (Number.isFinite(dtCurPrice) && Number.isFinite(dtBp) && Math.abs(dtTarget - dtBp) > EPSILON) {
|
|
492
|
+
dtTargetPct = Math.round(((dtCurPrice - dtBp) / (dtTarget - dtBp)) * 100);
|
|
493
|
+
}
|
|
494
|
+
push(patterns, {
|
|
495
|
+
type: 'double_top',
|
|
496
|
+
confidence,
|
|
497
|
+
range: { start, end },
|
|
498
|
+
pivots: [a, b, c],
|
|
499
|
+
neckline,
|
|
500
|
+
trendlineLabel: 'ネックライン',
|
|
501
|
+
breakout: { idx: breakoutIdx, price: dtBp },
|
|
502
|
+
breakoutBarIndex: breakoutIdx,
|
|
503
|
+
breakoutTarget: dtTarget,
|
|
504
|
+
targetMethod: 'neckline_projection' as const,
|
|
505
|
+
...(dtTargetPct !== undefined ? { targetReachedPct: dtTargetPct } : {}),
|
|
506
|
+
structureDiagram: diagram,
|
|
507
|
+
});
|
|
508
|
+
foundDoubleTop = true;
|
|
509
|
+
pcand({
|
|
510
|
+
type: 'double_top',
|
|
511
|
+
accepted: true,
|
|
512
|
+
idxs: [a.idx, b.idx, c.idx, breakoutIdx],
|
|
513
|
+
pts: [
|
|
514
|
+
{ role: 'peak1', idx: a.idx, price: a.price },
|
|
515
|
+
{ role: 'valley', idx: b.idx, price: b.price },
|
|
516
|
+
{ role: 'peak2', idx: c.idx, price: c.price },
|
|
517
|
+
{ role: 'breakout', idx: breakoutIdx, price: dtBp },
|
|
518
|
+
],
|
|
519
|
+
});
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── double bottom: L-H-L ──
|
|
524
|
+
if (a.kind === 'L' && b.kind === 'H' && c.kind === 'L') {
|
|
525
|
+
const sizeReason = validateBottomSize(a, b, c);
|
|
526
|
+
if (sizeReason) {
|
|
527
|
+
pcand({ type: 'double_bottom', accepted: false, reason: sizeReason, idxs: [a.idx, b.idx, c.idx] });
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (!near(a.price, c.price)) {
|
|
531
|
+
const diffPct = Math.abs(a.price - c.price) / Math.max(1, Math.max(a.price, c.price));
|
|
532
|
+
if (diffPct > tolerancePct) {
|
|
533
|
+
pcand({
|
|
534
|
+
type: 'double_bottom',
|
|
535
|
+
accepted: false,
|
|
536
|
+
reason: 'valleys_not_equal',
|
|
537
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
538
|
+
pts: [
|
|
539
|
+
{ role: 'valley1', idx: a.idx, price: a.price },
|
|
540
|
+
{ role: 'valley2', idx: c.idx, price: c.price },
|
|
541
|
+
],
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
// ネックライン突破(終値ベース+1.5%バッファ)を c 以降で確認
|
|
547
|
+
const necklinePrice = b.price;
|
|
548
|
+
const breakoutIdx = findBreakoutIdx(candles, c.idx, necklinePrice, 'above');
|
|
549
|
+
if (breakoutIdx < 0) {
|
|
550
|
+
pcand({
|
|
551
|
+
type: 'double_bottom',
|
|
552
|
+
accepted: false,
|
|
553
|
+
reason: 'no_breakout',
|
|
554
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
555
|
+
pts: [
|
|
556
|
+
{ role: 'valley1', idx: a.idx, price: a.price },
|
|
557
|
+
{ role: 'peak', idx: b.idx, price: b.price },
|
|
558
|
+
{ role: 'valley2', idx: c.idx, price: c.price },
|
|
559
|
+
],
|
|
560
|
+
});
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const start = candles[a.idx].isoTime;
|
|
564
|
+
const end = candles[breakoutIdx].isoTime;
|
|
565
|
+
if (!start || !end) continue;
|
|
566
|
+
const neckline = [
|
|
567
|
+
{ x: a.idx, y: necklinePrice },
|
|
568
|
+
{ x: breakoutIdx, y: necklinePrice },
|
|
569
|
+
];
|
|
570
|
+
const tolMargin = marginFromRelDev(relDev(a.price, c.price), tolerancePct);
|
|
571
|
+
const symmetry = clamp01(1 - relDev(a.price, c.price));
|
|
572
|
+
const per = periodScoreDays(start, end);
|
|
573
|
+
const base = (tolMargin + symmetry + per) / 3;
|
|
574
|
+
const confidence = finalizeConf(base, 'double_bottom');
|
|
575
|
+
const diagram = generatePatternDiagram(
|
|
576
|
+
'double_bottom',
|
|
577
|
+
[
|
|
578
|
+
{ ...a, date: candles[a.idx]?.isoTime },
|
|
579
|
+
{ ...b, date: candles[b.idx]?.isoTime },
|
|
580
|
+
{ ...c, date: candles[c.idx]?.isoTime },
|
|
581
|
+
],
|
|
582
|
+
{ price: necklinePrice },
|
|
583
|
+
{ start, end },
|
|
584
|
+
);
|
|
585
|
+
const dbAvgValley = (a.price + c.price) / 2;
|
|
586
|
+
const dbTarget = Math.round(necklinePrice + (necklinePrice - dbAvgValley));
|
|
587
|
+
const dbBp = Number(candles[breakoutIdx]?.close ?? NaN);
|
|
588
|
+
let dbTargetPct: number | undefined;
|
|
589
|
+
const dbCurPrice = Number(candles[candles.length - 1]?.close);
|
|
590
|
+
if (Number.isFinite(dbCurPrice) && Number.isFinite(dbBp) && Math.abs(dbTarget - dbBp) > EPSILON) {
|
|
591
|
+
dbTargetPct = Math.round(((dbCurPrice - dbBp) / (dbTarget - dbBp)) * 100);
|
|
592
|
+
}
|
|
593
|
+
push(patterns, {
|
|
594
|
+
type: 'double_bottom',
|
|
595
|
+
confidence,
|
|
596
|
+
range: { start, end },
|
|
597
|
+
pivots: [a, b, c],
|
|
598
|
+
neckline,
|
|
599
|
+
trendlineLabel: 'ネックライン',
|
|
600
|
+
breakout: { idx: breakoutIdx, price: dbBp },
|
|
601
|
+
breakoutBarIndex: breakoutIdx,
|
|
602
|
+
breakoutTarget: dbTarget,
|
|
603
|
+
targetMethod: 'neckline_projection' as const,
|
|
604
|
+
...(dbTargetPct !== undefined ? { targetReachedPct: dbTargetPct } : {}),
|
|
605
|
+
structureDiagram: diagram,
|
|
606
|
+
});
|
|
607
|
+
foundDoubleBottom = true;
|
|
608
|
+
pcand({
|
|
609
|
+
type: 'double_bottom',
|
|
610
|
+
accepted: true,
|
|
611
|
+
idxs: [a.idx, b.idx, c.idx],
|
|
612
|
+
pts: [
|
|
613
|
+
{ role: 'valley1', idx: a.idx, price: a.price },
|
|
614
|
+
{ role: 'peak', idx: b.idx, price: b.price },
|
|
615
|
+
{ role: 'valley2', idx: c.idx, price: c.price },
|
|
616
|
+
],
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// relaxed fallback for double top/bottom: single-stage factor 1.3
|
|
621
|
+
for (const f of [RELAXED_TOLERANCE_FACTOR]) {
|
|
622
|
+
if (!foundDoubleTop && (want.size === 0 || want.has('double_top'))) {
|
|
623
|
+
const result = findRelaxedDoubleTop(pivots, candles, tolerancePct, f, minDistDB, pcand);
|
|
624
|
+
if (result) {
|
|
625
|
+
push(patterns, result);
|
|
626
|
+
foundDoubleTop = true;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (!foundDoubleBottom && (want.size === 0 || want.has('double_bottom'))) {
|
|
630
|
+
const result = findRelaxedDoubleBottom(pivots, candles, tolerancePct, f, minDistDB, pcand);
|
|
631
|
+
if (result) {
|
|
632
|
+
push(patterns, result);
|
|
633
|
+
foundDoubleBottom = true;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// --- 重複パターンの排除(patterns/helpers.ts へ抽出済み) ---
|
|
638
|
+
patterns = deduplicatePatterns(patterns);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// 2b) 形成中ダブルトップ/ボトム
|
|
642
|
+
if (includeForming && (want.size === 0 || want.has('double_top') || want.has('double_bottom'))) {
|
|
643
|
+
const formingTop = tryFormingDoubleTop(ctx);
|
|
644
|
+
if (formingTop) push(patterns, formingTop);
|
|
645
|
+
const formingBottom = tryFormingDoubleBottom(ctx);
|
|
646
|
+
if (formingBottom) push(patterns, formingBottom);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return { patterns, found: { double_top: foundDoubleTop, double_bottom: foundDoubleBottom } };
|
|
650
|
+
}
|