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,817 @@
|
|
|
1
|
+
import { dayjs } from '../lib/datetime.js';
|
|
2
|
+
import { formatSummary } from '../lib/formatter.js';
|
|
3
|
+
import { fail, failFromError, failFromValidation } from '../lib/result.js';
|
|
4
|
+
import { createMeta, ensurePair } from '../lib/validate.js';
|
|
5
|
+
import { AnalyzeSupportResistanceInputSchema, AnalyzeSupportResistanceOutputSchema } from '../src/schemas.js';
|
|
6
|
+
import type { ToolDefinition } from '../src/tool-definition.js';
|
|
7
|
+
import getCandles from './get_candles.js';
|
|
8
|
+
|
|
9
|
+
export interface AnalyzeSupportResistanceOptions {
|
|
10
|
+
lookbackDays?: number;
|
|
11
|
+
topN?: number;
|
|
12
|
+
tolerance?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TouchEvent {
|
|
16
|
+
date: string;
|
|
17
|
+
price: number;
|
|
18
|
+
bounceStrength: number; // ヒゲの長さ%
|
|
19
|
+
type: 'support' | 'resistance';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SupportResistanceLevel {
|
|
23
|
+
price: number;
|
|
24
|
+
pctFromCurrent: number;
|
|
25
|
+
strength: number; // 1-3
|
|
26
|
+
label: string;
|
|
27
|
+
touchCount: number;
|
|
28
|
+
touches: TouchEvent[];
|
|
29
|
+
recentBreak?: {
|
|
30
|
+
date: string;
|
|
31
|
+
price: number;
|
|
32
|
+
breakPct: number;
|
|
33
|
+
};
|
|
34
|
+
type: 'support' | 'resistance'; // タイプ
|
|
35
|
+
formationType?: 'traditional' | 'new_formation' | 'role_reversal'; // 形成タイプ
|
|
36
|
+
volumeBoost?: boolean; // 出来高による補強
|
|
37
|
+
note?: string; // 補足説明
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type SrCandle = { isoTime?: string | null; open: number; high: number; low: number; close: number; volume?: number };
|
|
41
|
+
|
|
42
|
+
/** スイングポイント(ピボット)を検出: 左右 depth 本より高値/安値が突出した足 */
|
|
43
|
+
function detectSwingPoints(
|
|
44
|
+
candles: SrCandle[],
|
|
45
|
+
depth: number = 5,
|
|
46
|
+
): {
|
|
47
|
+
swingHighs: Array<{ index: number; date: string; price: number; bounceStrength: number }>;
|
|
48
|
+
swingLows: Array<{ index: number; date: string; price: number; bounceStrength: number }>;
|
|
49
|
+
} {
|
|
50
|
+
const swingHighs: Array<{ index: number; date: string; price: number; bounceStrength: number }> = [];
|
|
51
|
+
const swingLows: Array<{ index: number; date: string; price: number; bounceStrength: number }> = [];
|
|
52
|
+
|
|
53
|
+
for (let i = depth; i < candles.length - depth; i++) {
|
|
54
|
+
// スイングハイ: 左右 depth 本より高値が高い
|
|
55
|
+
let isSwingHigh = true;
|
|
56
|
+
for (let j = i - depth; j <= i + depth; j++) {
|
|
57
|
+
if (j === i) continue;
|
|
58
|
+
if (candles[j].high >= candles[i].high) {
|
|
59
|
+
isSwingHigh = false;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (isSwingHigh) {
|
|
64
|
+
swingHighs.push({
|
|
65
|
+
index: i,
|
|
66
|
+
date: (candles[i].isoTime ?? '').split('T')[0],
|
|
67
|
+
price: candles[i].high,
|
|
68
|
+
bounceStrength: ((candles[i].high - candles[i].close) / candles[i].high) * 100,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// スイングロー: 左右 depth 本より安値が低い
|
|
73
|
+
let isSwingLow = true;
|
|
74
|
+
for (let j = i - depth; j <= i + depth; j++) {
|
|
75
|
+
if (j === i) continue;
|
|
76
|
+
if (candles[j].low <= candles[i].low) {
|
|
77
|
+
isSwingLow = false;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (isSwingLow) {
|
|
82
|
+
swingLows.push({
|
|
83
|
+
index: i,
|
|
84
|
+
date: (candles[i].isoTime ?? '').split('T')[0],
|
|
85
|
+
price: candles[i].low,
|
|
86
|
+
bounceStrength: ((candles[i].close - candles[i].low) / candles[i].low) * 100,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { swingHighs, swingLows };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 近接するスイングポイントを %ベースでクラスタリング(凝集型) */
|
|
95
|
+
function clusterSwingPoints(
|
|
96
|
+
points: Array<{ date: string; price: number; bounceStrength: number }>,
|
|
97
|
+
tolerance: number,
|
|
98
|
+
): Array<{ level: number; points: Array<{ date: string; price: number; bounceStrength: number }> }> {
|
|
99
|
+
if (points.length === 0) return [];
|
|
100
|
+
|
|
101
|
+
const sorted = [...points].sort((a, b) => a.price - b.price);
|
|
102
|
+
const clusters: Array<{ prices: number[]; points: Array<{ date: string; price: number; bounceStrength: number }> }> =
|
|
103
|
+
[];
|
|
104
|
+
let current = { prices: [sorted[0].price], points: [sorted[0]] };
|
|
105
|
+
|
|
106
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
107
|
+
const avg = current.prices.reduce((a, b) => a + b, 0) / current.prices.length;
|
|
108
|
+
if (Math.abs(sorted[i].price - avg) / avg <= tolerance) {
|
|
109
|
+
current.prices.push(sorted[i].price);
|
|
110
|
+
current.points.push(sorted[i]);
|
|
111
|
+
} else {
|
|
112
|
+
clusters.push(current);
|
|
113
|
+
current = { prices: [sorted[i].price], points: [sorted[i]] };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
clusters.push(current);
|
|
117
|
+
|
|
118
|
+
return clusters.map((c) => ({
|
|
119
|
+
level: Math.round(c.prices.reduce((a, b) => a + b, 0) / c.prices.length),
|
|
120
|
+
points: c.points,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** スイングポイントベースで S/R レベルを検出し、各レベルのタッチ回数をカウント */
|
|
125
|
+
function findPriceLevels(
|
|
126
|
+
candles: SrCandle[],
|
|
127
|
+
tolerance: number,
|
|
128
|
+
depth: number = 5,
|
|
129
|
+
): { supports: Map<number, TouchEvent[]>; resistances: Map<number, TouchEvent[]> } {
|
|
130
|
+
if (candles.length < 2 * depth + 1) {
|
|
131
|
+
return { supports: new Map(), resistances: new Map() };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { swingHighs, swingLows } = detectSwingPoints(candles, depth);
|
|
135
|
+
|
|
136
|
+
const supportClusters = clusterSwingPoints(
|
|
137
|
+
swingLows.map((p) => ({ date: p.date, price: p.price, bounceStrength: p.bounceStrength })),
|
|
138
|
+
tolerance,
|
|
139
|
+
);
|
|
140
|
+
const resistanceClusters = clusterSwingPoints(
|
|
141
|
+
swingHighs.map((p) => ({ date: p.date, price: p.price, bounceStrength: p.bounceStrength })),
|
|
142
|
+
tolerance,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// 各サポートレベルに対してゾーン内のタッチを全ローソク足からカウント
|
|
146
|
+
const supports = new Map<number, TouchEvent[]>();
|
|
147
|
+
for (const cluster of supportClusters) {
|
|
148
|
+
const zoneMin = cluster.level * (1 - tolerance);
|
|
149
|
+
const zoneMax = cluster.level * (1 + tolerance);
|
|
150
|
+
const touches: TouchEvent[] = [];
|
|
151
|
+
const seenDates = new Set<string>();
|
|
152
|
+
|
|
153
|
+
for (const candle of candles) {
|
|
154
|
+
const date = (candle.isoTime ?? '').split('T')[0];
|
|
155
|
+
if (seenDates.has(date)) continue;
|
|
156
|
+
if (candle.low >= zoneMin && candle.low <= zoneMax && candle.close > candle.low) {
|
|
157
|
+
touches.push({
|
|
158
|
+
date,
|
|
159
|
+
price: candle.low,
|
|
160
|
+
bounceStrength: ((candle.close - candle.low) / candle.low) * 100,
|
|
161
|
+
type: 'support',
|
|
162
|
+
});
|
|
163
|
+
seenDates.add(date);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
supports.set(cluster.level, touches);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 各レジスタンスレベルに対してゾーン内のタッチを全ローソク足からカウント
|
|
170
|
+
const resistances = new Map<number, TouchEvent[]>();
|
|
171
|
+
for (const cluster of resistanceClusters) {
|
|
172
|
+
const zoneMin = cluster.level * (1 - tolerance);
|
|
173
|
+
const zoneMax = cluster.level * (1 + tolerance);
|
|
174
|
+
const touches: TouchEvent[] = [];
|
|
175
|
+
const seenDates = new Set<string>();
|
|
176
|
+
|
|
177
|
+
for (const candle of candles) {
|
|
178
|
+
const date = (candle.isoTime ?? '').split('T')[0];
|
|
179
|
+
if (seenDates.has(date)) continue;
|
|
180
|
+
if (candle.high >= zoneMin && candle.high <= zoneMax && candle.close < candle.high) {
|
|
181
|
+
touches.push({
|
|
182
|
+
date,
|
|
183
|
+
price: candle.high,
|
|
184
|
+
bounceStrength: ((candle.high - candle.close) / candle.high) * 100,
|
|
185
|
+
type: 'resistance',
|
|
186
|
+
});
|
|
187
|
+
seenDates.add(date);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
resistances.set(cluster.level, touches);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { supports, resistances };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function detectRecentBreak(
|
|
197
|
+
level: number,
|
|
198
|
+
type: 'support' | 'resistance',
|
|
199
|
+
candles: SrCandle[],
|
|
200
|
+
recentDays: number = 7,
|
|
201
|
+
): { date: string; price: number; breakPct: number } | undefined {
|
|
202
|
+
const recentCutoff = dayjs().subtract(recentDays, 'day');
|
|
203
|
+
const recentCandles = candles.filter((c) => dayjs(c.isoTime).isAfter(recentCutoff));
|
|
204
|
+
|
|
205
|
+
// 偽ブレイクアウト検出用の平均出来高
|
|
206
|
+
const avgVolume = candles.reduce((sum, c) => sum + (c.volume || 0), 0) / (candles.length || 1);
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < recentCandles.length; i++) {
|
|
209
|
+
const candle = recentCandles[i];
|
|
210
|
+
// 終値ベースで判定(ヒゲのみの突破はテストとして除外)
|
|
211
|
+
const isBreak = type === 'support' ? candle.close < level * 0.99 : candle.close > level * 1.01;
|
|
212
|
+
if (!isBreak) continue;
|
|
213
|
+
|
|
214
|
+
// 低出来高の突破 → 翌日の終値で確認(偽ブレイクアウト防止)
|
|
215
|
+
if (avgVolume > 0 && (candle.volume || 0) < avgVolume) {
|
|
216
|
+
const next = recentCandles[i + 1];
|
|
217
|
+
const nextConfirms = next && (type === 'support' ? next.close < level * 0.99 : next.close > level * 1.01);
|
|
218
|
+
if (!nextConfirms) continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const breakPct = ((candle.close - level) / level) * 100;
|
|
222
|
+
return {
|
|
223
|
+
date: (candle.isoTime ?? '').split('T')[0],
|
|
224
|
+
price: candle.close,
|
|
225
|
+
breakPct,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function detectNewSupport(
|
|
233
|
+
candles: SrCandle[],
|
|
234
|
+
recentDays: number = 10,
|
|
235
|
+
): Array<{ price: number; date: string; volumeBoost: boolean; note: string }> {
|
|
236
|
+
const recentCutoff = dayjs().subtract(recentDays, 'day');
|
|
237
|
+
const recentCandles = candles.filter((c) => dayjs(c.isoTime).isAfter(recentCutoff));
|
|
238
|
+
|
|
239
|
+
const newSupports: Array<{ price: number; date: string; volumeBoost: boolean; note: string }> = [];
|
|
240
|
+
|
|
241
|
+
// 平均出来高計算
|
|
242
|
+
const avgVolume = recentCandles.reduce((sum, c) => sum + (c.volume || 0), 0) / recentCandles.length;
|
|
243
|
+
|
|
244
|
+
for (let i = 1; i < recentCandles.length - 1; i++) {
|
|
245
|
+
const current = recentCandles[i];
|
|
246
|
+
const prev = recentCandles[i - 1];
|
|
247
|
+
const next = recentCandles[i + 1];
|
|
248
|
+
|
|
249
|
+
// 安値が2日以上連続で切り上がっているかチェック
|
|
250
|
+
if (current.low < prev.low && next.low > current.low) {
|
|
251
|
+
// その最安値を以降割っていないかチェック
|
|
252
|
+
const subsequentCandles = recentCandles.slice(i + 1);
|
|
253
|
+
const lowestSubsequent = Math.min(...subsequentCandles.map((c) => c.low));
|
|
254
|
+
|
|
255
|
+
if (lowestSubsequent >= current.low * 0.999) {
|
|
256
|
+
// 0.1%の許容誤差
|
|
257
|
+
const volumeBoost = (current.volume || 0) > avgVolume * 1.5;
|
|
258
|
+
|
|
259
|
+
// V字反発の検出
|
|
260
|
+
const prevDrop = ((current.close - prev.close) / prev.close) * 100;
|
|
261
|
+
const nextRise = ((next.close - current.close) / current.close) * 100;
|
|
262
|
+
let note = '新サポート形成(安値切り上げ)';
|
|
263
|
+
|
|
264
|
+
if (prevDrop < -3 && nextRise > 3) {
|
|
265
|
+
note = 'V字反発によるサポート形成';
|
|
266
|
+
} else if (volumeBoost) {
|
|
267
|
+
note = '大出来高での反発(新サポート)';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
newSupports.push({
|
|
271
|
+
price: current.low,
|
|
272
|
+
date: (current.isoTime ?? '').split('T')[0],
|
|
273
|
+
volumeBoost,
|
|
274
|
+
note,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return newSupports;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function detectRoleReversal(
|
|
284
|
+
brokenSupports: Map<number, { date: string; price: number }>,
|
|
285
|
+
brokenResistances: Map<number, { date: string; price: number }>,
|
|
286
|
+
_candles: SrCandle[],
|
|
287
|
+
currentPrice: number,
|
|
288
|
+
): { newResistances: Map<number, string>; newSupports: Map<number, string> } {
|
|
289
|
+
const newResistances = new Map<number, string>();
|
|
290
|
+
const newSupports = new Map<number, string>();
|
|
291
|
+
|
|
292
|
+
// 崩壊したサポート → レジスタンス転換
|
|
293
|
+
for (const [level, breakInfo] of brokenSupports.entries()) {
|
|
294
|
+
if (level > currentPrice) {
|
|
295
|
+
// 現在価格より上
|
|
296
|
+
newResistances.set(level, `元サポート(${breakInfo.date}に崩壊)→ レジスタンス転換`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 突破されたレジスタンス → サポート転換
|
|
301
|
+
for (const [level, breakInfo] of brokenResistances.entries()) {
|
|
302
|
+
if (level < currentPrice) {
|
|
303
|
+
// 現在価格より下
|
|
304
|
+
newSupports.set(level, `元レジスタンス(${breakInfo.date}に突破)→ サポート転換`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { newResistances, newSupports };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** タッチの直近性スコアを計算(半減期ベースの指数減衰) */
|
|
312
|
+
function computeRecencyScore(touches: TouchEvent[], referenceDate: string, halfLifeDays: number = 30): number {
|
|
313
|
+
const ref = dayjs(referenceDate);
|
|
314
|
+
return touches.reduce((score, t) => {
|
|
315
|
+
const daysAgo = Math.max(0, ref.diff(dayjs(t.date), 'day'));
|
|
316
|
+
return score + Math.exp((-Math.LN2 * daysAgo) / halfLifeDays);
|
|
317
|
+
}, 0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** ロールリバーサル後のプルバック確認 */
|
|
321
|
+
function hasPullbackConfirmation(
|
|
322
|
+
level: number,
|
|
323
|
+
type: 'support_to_resistance' | 'resistance_to_support',
|
|
324
|
+
breakDate: string,
|
|
325
|
+
candles: SrCandle[],
|
|
326
|
+
tolerance: number,
|
|
327
|
+
): boolean {
|
|
328
|
+
const breakIdx = candles.findIndex((c) => (c.isoTime ?? '').split('T')[0] >= breakDate);
|
|
329
|
+
if (breakIdx < 0) return false;
|
|
330
|
+
const afterBreak = candles.slice(breakIdx + 1);
|
|
331
|
+
|
|
332
|
+
if (type === 'support_to_resistance') {
|
|
333
|
+
// 旧サポート崩壊 → レジスタンス転換: 高値がレベル付近に達したが終値はレベル以下
|
|
334
|
+
return afterBreak.some((c) => c.high >= level * (1 - tolerance) && c.close < level);
|
|
335
|
+
} else {
|
|
336
|
+
// 旧レジスタンス突破 → サポート転換: 安値がレベル付近に達したが終値はレベル以上
|
|
337
|
+
return afterBreak.some((c) => c.low <= level * (1 + tolerance) && c.close > level);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function calculateStrength(opts: {
|
|
342
|
+
touchCount: number;
|
|
343
|
+
recencyScore: number;
|
|
344
|
+
avgBounceStrength: number;
|
|
345
|
+
hasRecentBreak: boolean;
|
|
346
|
+
volumeBoost: boolean;
|
|
347
|
+
formationType: 'traditional' | 'new_formation' | 'role_reversal';
|
|
348
|
+
pullbackConfirmed: boolean;
|
|
349
|
+
}): number {
|
|
350
|
+
const { touchCount, recencyScore, avgBounceStrength, hasRecentBreak, volumeBoost, formationType, pullbackConfirmed } =
|
|
351
|
+
opts;
|
|
352
|
+
let strength = 1;
|
|
353
|
+
|
|
354
|
+
if (formationType === 'new_formation') {
|
|
355
|
+
// 新形成は基本★★
|
|
356
|
+
strength = 2;
|
|
357
|
+
if (volumeBoost) strength = 3;
|
|
358
|
+
} else if (formationType === 'role_reversal') {
|
|
359
|
+
// ロールリバーサルは基本★、プルバック確認で★★
|
|
360
|
+
strength = pullbackConfirmed ? 2 : 1;
|
|
361
|
+
} else {
|
|
362
|
+
// 従来型:接触回数ベース
|
|
363
|
+
if (touchCount >= 5) strength = 3;
|
|
364
|
+
else if (touchCount >= 3) strength = 2;
|
|
365
|
+
else strength = 1;
|
|
366
|
+
|
|
367
|
+
// 直近性スコアで強化(半減期30日、スコア1.5 ≈ 直近2回相当)
|
|
368
|
+
if (recencyScore >= 1.5 && touchCount >= 3) strength = Math.min(3, strength + 1);
|
|
369
|
+
|
|
370
|
+
// 反発の大きさで強化(平均反発2%以上)
|
|
371
|
+
if (avgBounceStrength >= 2.0 && strength < 3) strength += 1;
|
|
372
|
+
|
|
373
|
+
// 出来高補強
|
|
374
|
+
if (volumeBoost && strength < 3) strength += 1;
|
|
375
|
+
|
|
376
|
+
// 直近崩壊で減格
|
|
377
|
+
if (hasRecentBreak && strength > 1) strength -= 1;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return Math.max(1, Math.min(3, strength));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export default async function analyzeSupportResistance(
|
|
384
|
+
pair: string = 'btc_jpy',
|
|
385
|
+
{ lookbackDays = 90, topN = 3, tolerance = 0.015 }: AnalyzeSupportResistanceOptions = {},
|
|
386
|
+
) {
|
|
387
|
+
const chk = ensurePair(pair);
|
|
388
|
+
if (!chk.ok) return failFromValidation(chk, AnalyzeSupportResistanceOutputSchema);
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
// ローソク足データ取得
|
|
392
|
+
const candlesRes = await getCandles(chk.pair, '1day', undefined, lookbackDays + 10);
|
|
393
|
+
if (!candlesRes.ok) {
|
|
394
|
+
return AnalyzeSupportResistanceOutputSchema.parse(
|
|
395
|
+
fail(candlesRes.summary || 'candles failed', candlesRes.meta.errorType || 'internal'),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const candles = candlesRes.data.normalized || [];
|
|
400
|
+
if (candles.length === 0) {
|
|
401
|
+
return AnalyzeSupportResistanceOutputSchema.parse(fail('No candle data available', 'data'));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// lookbackDays 範囲のローソク足のみを分析対象にする(バッファは除外)
|
|
405
|
+
const analysisCandles = candles.length > lookbackDays ? candles.slice(-lookbackDays) : candles;
|
|
406
|
+
|
|
407
|
+
const currentCandle = analysisCandles[analysisCandles.length - 1];
|
|
408
|
+
const currentPrice = currentCandle.close;
|
|
409
|
+
|
|
410
|
+
// 価格レベル検出(lookbackDays 範囲のみ)
|
|
411
|
+
const { supports, resistances } = findPriceLevels(analysisCandles, tolerance);
|
|
412
|
+
|
|
413
|
+
// 新サポート形成の検出
|
|
414
|
+
const newSupports = detectNewSupport(analysisCandles, 10);
|
|
415
|
+
|
|
416
|
+
// 崩壊・突破を記録
|
|
417
|
+
const brokenSupports = new Map<number, { date: string; price: number }>();
|
|
418
|
+
const brokenResistances = new Map<number, { date: string; price: number }>();
|
|
419
|
+
|
|
420
|
+
for (const [level] of supports.entries()) {
|
|
421
|
+
const recentBreak = detectRecentBreak(level, 'support', analysisCandles, 30);
|
|
422
|
+
if (recentBreak) {
|
|
423
|
+
brokenSupports.set(level, { date: recentBreak.date, price: recentBreak.price });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (const [level] of resistances.entries()) {
|
|
428
|
+
const recentBreak = detectRecentBreak(level, 'resistance', analysisCandles, 30);
|
|
429
|
+
if (recentBreak) {
|
|
430
|
+
brokenResistances.set(level, { date: recentBreak.date, price: recentBreak.price });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ロールリバーサル検出
|
|
435
|
+
const { newResistances, newSupports: roleReversalSupports } = detectRoleReversal(
|
|
436
|
+
brokenSupports,
|
|
437
|
+
brokenResistances,
|
|
438
|
+
analysisCandles,
|
|
439
|
+
currentPrice,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
// 平均出来高計算
|
|
443
|
+
const avgVolume =
|
|
444
|
+
analysisCandles.reduce((sum: number, c: SrCandle) => sum + (c.volume || 0), 0) / analysisCandles.length;
|
|
445
|
+
|
|
446
|
+
// サポートレベルを評価
|
|
447
|
+
const supportLevels: SupportResistanceLevel[] = [];
|
|
448
|
+
|
|
449
|
+
// A. 従来型サポート(崩壊していないもの)
|
|
450
|
+
for (const [level, touches] of supports.entries()) {
|
|
451
|
+
const pctFromCurrent = ((level - currentPrice) / currentPrice) * 100;
|
|
452
|
+
|
|
453
|
+
if (pctFromCurrent >= 0) continue;
|
|
454
|
+
if (Math.abs(pctFromCurrent) > 20) continue;
|
|
455
|
+
if (touches.length < 2) continue;
|
|
456
|
+
|
|
457
|
+
const recencyScore = computeRecencyScore(touches, currentCandle.isoTime ?? '');
|
|
458
|
+
const avgBounce = touches.reduce((sum, t) => sum + t.bounceStrength, 0) / (touches.length || 1);
|
|
459
|
+
|
|
460
|
+
const recentBreak = detectRecentBreak(level, 'support', analysisCandles, 7);
|
|
461
|
+
if (recentBreak) continue; // 直近7日で崩壊したものは除外
|
|
462
|
+
|
|
463
|
+
const volumeBoost = touches.some((t) => {
|
|
464
|
+
const c = analysisCandles.find((c: SrCandle) => (c.isoTime ?? '').split('T')[0] === t.date);
|
|
465
|
+
return c && (c.volume || 0) > avgVolume * 1.5;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const strength = calculateStrength({
|
|
469
|
+
touchCount: touches.length,
|
|
470
|
+
recencyScore,
|
|
471
|
+
avgBounceStrength: avgBounce,
|
|
472
|
+
hasRecentBreak: false,
|
|
473
|
+
volumeBoost,
|
|
474
|
+
formationType: 'traditional',
|
|
475
|
+
pullbackConfirmed: false,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
supportLevels.push({
|
|
479
|
+
price: level,
|
|
480
|
+
pctFromCurrent,
|
|
481
|
+
strength,
|
|
482
|
+
label: '',
|
|
483
|
+
touchCount: touches.length,
|
|
484
|
+
touches,
|
|
485
|
+
type: 'support',
|
|
486
|
+
formationType: 'traditional',
|
|
487
|
+
volumeBoost,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// B. 新形成サポート
|
|
492
|
+
for (const newSup of newSupports) {
|
|
493
|
+
const pctFromCurrent = ((newSup.price - currentPrice) / currentPrice) * 100;
|
|
494
|
+
if (pctFromCurrent >= 0 || Math.abs(pctFromCurrent) > 20) continue;
|
|
495
|
+
|
|
496
|
+
const isDuplicate = supportLevels.some((s) => Math.abs(s.price - newSup.price) < newSup.price * tolerance);
|
|
497
|
+
if (isDuplicate) continue;
|
|
498
|
+
|
|
499
|
+
const strength = calculateStrength({
|
|
500
|
+
touchCount: 0,
|
|
501
|
+
recencyScore: 0,
|
|
502
|
+
avgBounceStrength: 0,
|
|
503
|
+
hasRecentBreak: false,
|
|
504
|
+
volumeBoost: newSup.volumeBoost,
|
|
505
|
+
formationType: 'new_formation',
|
|
506
|
+
pullbackConfirmed: false,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
supportLevels.push({
|
|
510
|
+
price: newSup.price,
|
|
511
|
+
pctFromCurrent,
|
|
512
|
+
strength,
|
|
513
|
+
label: '',
|
|
514
|
+
touchCount: 1,
|
|
515
|
+
touches: [{ date: newSup.date, price: newSup.price, bounceStrength: 0, type: 'support' }],
|
|
516
|
+
type: 'support',
|
|
517
|
+
formationType: 'new_formation',
|
|
518
|
+
volumeBoost: newSup.volumeBoost,
|
|
519
|
+
note: newSup.note,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// C. ロールリバーサル(元レジスタンス → サポート転換)
|
|
524
|
+
for (const [level, note] of roleReversalSupports.entries()) {
|
|
525
|
+
const pctFromCurrent = ((level - currentPrice) / currentPrice) * 100;
|
|
526
|
+
if (pctFromCurrent >= 0 || Math.abs(pctFromCurrent) > 20) continue;
|
|
527
|
+
|
|
528
|
+
const isDuplicate = supportLevels.some((s) => Math.abs(s.price - level) < level * tolerance);
|
|
529
|
+
if (isDuplicate) continue;
|
|
530
|
+
|
|
531
|
+
const breakInfo = brokenResistances.get(level);
|
|
532
|
+
const pullbackConfirmed = breakInfo
|
|
533
|
+
? hasPullbackConfirmation(level, 'resistance_to_support', breakInfo.date, analysisCandles, tolerance)
|
|
534
|
+
: false;
|
|
535
|
+
|
|
536
|
+
const strength = calculateStrength({
|
|
537
|
+
touchCount: 0,
|
|
538
|
+
recencyScore: 0,
|
|
539
|
+
avgBounceStrength: 0,
|
|
540
|
+
hasRecentBreak: false,
|
|
541
|
+
volumeBoost: false,
|
|
542
|
+
formationType: 'role_reversal',
|
|
543
|
+
pullbackConfirmed,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
supportLevels.push({
|
|
547
|
+
price: level,
|
|
548
|
+
pctFromCurrent,
|
|
549
|
+
strength,
|
|
550
|
+
label: '',
|
|
551
|
+
touchCount: 1,
|
|
552
|
+
touches: [],
|
|
553
|
+
type: 'support',
|
|
554
|
+
formationType: 'role_reversal',
|
|
555
|
+
note,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// レジスタンスレベルを評価
|
|
560
|
+
const resistanceLevels: SupportResistanceLevel[] = [];
|
|
561
|
+
|
|
562
|
+
// A. 従来型レジスタンス(突破されていないもの)
|
|
563
|
+
for (const [level, touches] of resistances.entries()) {
|
|
564
|
+
const pctFromCurrent = ((level - currentPrice) / currentPrice) * 100;
|
|
565
|
+
|
|
566
|
+
if (pctFromCurrent <= 0) continue;
|
|
567
|
+
if (Math.abs(pctFromCurrent) > 20) continue;
|
|
568
|
+
if (touches.length < 2) continue;
|
|
569
|
+
|
|
570
|
+
const recencyScore = computeRecencyScore(touches, currentCandle.isoTime ?? '');
|
|
571
|
+
const avgBounce = touches.reduce((sum, t) => sum + t.bounceStrength, 0) / (touches.length || 1);
|
|
572
|
+
|
|
573
|
+
const recentBreak = detectRecentBreak(level, 'resistance', analysisCandles, 7);
|
|
574
|
+
if (recentBreak) continue; // 直近7日で突破されたものは除外
|
|
575
|
+
|
|
576
|
+
const volumeBoost = touches.some((t) => {
|
|
577
|
+
const c = analysisCandles.find((c: SrCandle) => (c.isoTime ?? '').split('T')[0] === t.date);
|
|
578
|
+
return c && (c.volume || 0) > avgVolume * 1.5;
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const strength = calculateStrength({
|
|
582
|
+
touchCount: touches.length,
|
|
583
|
+
recencyScore,
|
|
584
|
+
avgBounceStrength: avgBounce,
|
|
585
|
+
hasRecentBreak: false,
|
|
586
|
+
volumeBoost,
|
|
587
|
+
formationType: 'traditional',
|
|
588
|
+
pullbackConfirmed: false,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
resistanceLevels.push({
|
|
592
|
+
price: level,
|
|
593
|
+
pctFromCurrent,
|
|
594
|
+
strength,
|
|
595
|
+
label: '',
|
|
596
|
+
touchCount: touches.length,
|
|
597
|
+
touches,
|
|
598
|
+
type: 'resistance',
|
|
599
|
+
formationType: 'traditional',
|
|
600
|
+
volumeBoost,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// B. ロールリバーサル(元サポート → レジスタンス転換)
|
|
605
|
+
for (const [level, note] of newResistances.entries()) {
|
|
606
|
+
const pctFromCurrent = ((level - currentPrice) / currentPrice) * 100;
|
|
607
|
+
if (pctFromCurrent <= 0 || Math.abs(pctFromCurrent) > 20) continue;
|
|
608
|
+
|
|
609
|
+
const isDuplicate = resistanceLevels.some((r) => Math.abs(r.price - level) < level * tolerance);
|
|
610
|
+
if (isDuplicate) continue;
|
|
611
|
+
|
|
612
|
+
const breakInfo = brokenSupports.get(level);
|
|
613
|
+
const pullbackConfirmed = breakInfo
|
|
614
|
+
? hasPullbackConfirmation(level, 'support_to_resistance', breakInfo.date, analysisCandles, tolerance)
|
|
615
|
+
: false;
|
|
616
|
+
|
|
617
|
+
const strength = calculateStrength({
|
|
618
|
+
touchCount: 0,
|
|
619
|
+
recencyScore: 0,
|
|
620
|
+
avgBounceStrength: 0,
|
|
621
|
+
hasRecentBreak: false,
|
|
622
|
+
volumeBoost: false,
|
|
623
|
+
formationType: 'role_reversal',
|
|
624
|
+
pullbackConfirmed,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
resistanceLevels.push({
|
|
628
|
+
price: level,
|
|
629
|
+
pctFromCurrent,
|
|
630
|
+
strength,
|
|
631
|
+
label: '',
|
|
632
|
+
touchCount: 1,
|
|
633
|
+
touches: [],
|
|
634
|
+
type: 'resistance',
|
|
635
|
+
formationType: 'role_reversal',
|
|
636
|
+
note,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ソート(現在価格に近い順、同じ距離なら強度順)
|
|
641
|
+
supportLevels.sort((a, b) => {
|
|
642
|
+
const distA = Math.abs(a.pctFromCurrent);
|
|
643
|
+
const distB = Math.abs(b.pctFromCurrent);
|
|
644
|
+
if (Math.abs(distA - distB) < 0.5) {
|
|
645
|
+
return b.strength - a.strength;
|
|
646
|
+
}
|
|
647
|
+
return distA - distB;
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
resistanceLevels.sort((a, b) => {
|
|
651
|
+
const distA = Math.abs(a.pctFromCurrent);
|
|
652
|
+
const distB = Math.abs(b.pctFromCurrent);
|
|
653
|
+
if (Math.abs(distA - distB) < 0.5) {
|
|
654
|
+
return b.strength - a.strength;
|
|
655
|
+
}
|
|
656
|
+
return distA - distB;
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// 有効なレベルのみ出力(topN個まで、無理に埋めない)
|
|
660
|
+
const topSupports = supportLevels.slice(0, Math.min(supportLevels.length, topN));
|
|
661
|
+
const topResistances = resistanceLevels.slice(0, Math.min(resistanceLevels.length, topN));
|
|
662
|
+
|
|
663
|
+
// ラベルを付与(タイプに関わらず統一表記)
|
|
664
|
+
topSupports.forEach((level) => {
|
|
665
|
+
level.label = `サポート`;
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
topResistances.forEach((level) => {
|
|
669
|
+
level.label = `レジスタンス`;
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// content生成(LLMが読みやすいフォーマット)
|
|
673
|
+
const formatLevel = (level: SupportResistanceLevel, type: 'support' | 'resistance') => {
|
|
674
|
+
// 3段階表記:★☆☆ / ★★☆ / ★★★
|
|
675
|
+
const stars = '★'.repeat(level.strength) + '☆'.repeat(3 - level.strength);
|
|
676
|
+
let text = `${level.label}: ${level.price.toLocaleString('ja-JP')}円(${level.pctFromCurrent > 0 ? '+' : ''}${level.pctFromCurrent.toFixed(1)}%)強度:${stars}\n`;
|
|
677
|
+
|
|
678
|
+
// 形成タイプに応じた平易な説明
|
|
679
|
+
if (level.formationType === 'new_formation') {
|
|
680
|
+
// 新形成サポート
|
|
681
|
+
text += ` - 背景: ${level.note || '直近で底を打ち、安値を切り上げ中'}\n`;
|
|
682
|
+
if (level.volumeBoost) {
|
|
683
|
+
text += ` - 特徴: 大出来高での反発(平均の1.5倍以上)\n`;
|
|
684
|
+
}
|
|
685
|
+
text += ` - 意義: 直近の最安値、形成されたばかりの下支え\n`;
|
|
686
|
+
} else if (level.formationType === 'role_reversal') {
|
|
687
|
+
// ロールリバーサル
|
|
688
|
+
if (type === 'support') {
|
|
689
|
+
text += ` - 背景: ${level.note || '以前に上抜けした価格帯。現在は「上抜け後の下支え」として機能する可能性'}\n`;
|
|
690
|
+
if (level.strength >= 2) {
|
|
691
|
+
text += ` - 確認: プルバック後に価格がレベル上で維持(信頼性向上)\n`;
|
|
692
|
+
} else {
|
|
693
|
+
text += ` - 注意: プルバック未確認、再割れリスクあり\n`;
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
text += ` - 背景: ${level.note || '以前に崩壊した価格帯。現在は「戻り売りポイント」として機能する可能性'}\n`;
|
|
697
|
+
if (level.strength >= 2) {
|
|
698
|
+
text += ` - 確認: プルバック後に価格がレベル下で維持(信頼性向上)\n`;
|
|
699
|
+
} else {
|
|
700
|
+
text += ` - 注意: プルバック未確認、再突破される可能性あり\n`;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
// 従来型
|
|
705
|
+
text += ` - 実績: ${level.touchCount}回の反応`;
|
|
706
|
+
if (level.touches.length > 0) {
|
|
707
|
+
const dates = level.touches
|
|
708
|
+
.slice(-3)
|
|
709
|
+
.map((t) => t.date)
|
|
710
|
+
.join(', ');
|
|
711
|
+
text += `(最近: ${dates})`;
|
|
712
|
+
}
|
|
713
|
+
text += `\n`;
|
|
714
|
+
if (level.volumeBoost) {
|
|
715
|
+
text += ` - 特徴: 大出来高での反応あり(強度補強済み)\n`;
|
|
716
|
+
}
|
|
717
|
+
text += ` - 意義: 過去の実績から信頼性高い\n`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (level.recentBreak) {
|
|
721
|
+
text += ` - ⚠️ 直近の崩壊: ${level.recentBreak.date}に${Math.abs(level.recentBreak.breakPct).toFixed(1)}%${type === 'support' ? '下抜け' : '上抜け'}(${type === 'support' ? '最安' : '最高'}${level.recentBreak.price.toLocaleString('ja-JP')}円)\n`;
|
|
722
|
+
text += ` - 評価: 崩壊実績により信頼性低下、${type === 'support' ? '再割れ' : '再突破'}リスク高\n`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return text;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const displayPair = chk.pair.replace('_', '/').toUpperCase();
|
|
729
|
+
let contentText = `${displayPair} サポート・レジスタンス分析(過去${lookbackDays}日)\n`;
|
|
730
|
+
contentText += `現在価格: ${currentPrice.toLocaleString('ja-JP')}円\n`;
|
|
731
|
+
contentText += `分析日時: ${(currentCandle.isoTime ?? '').split('T')[0]}\n\n`;
|
|
732
|
+
|
|
733
|
+
contentText += `【サポートライン】\n`;
|
|
734
|
+
if (topSupports.length === 0) {
|
|
735
|
+
contentText += ` 明確なサポートラインは検出されませんでした\n`;
|
|
736
|
+
} else {
|
|
737
|
+
topSupports.forEach((level) => {
|
|
738
|
+
contentText += `${formatLevel(level, 'support')}\n`;
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
contentText += `\n【レジスタンスライン】\n`;
|
|
743
|
+
if (topResistances.length === 0) {
|
|
744
|
+
contentText += ` 明確なレジスタンスラインは検出されませんでした\n`;
|
|
745
|
+
} else {
|
|
746
|
+
topResistances.forEach((level) => {
|
|
747
|
+
contentText += `${formatLevel(level, 'resistance')}\n`;
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
contentText += `\n【判定ロジック】\n`;
|
|
752
|
+
contentText += `A. 従来型: ピボット検出(左右5本)→ ${(tolerance * 100).toFixed(1)}%クラスタリング → タッチ2回以上、直近7日で崩壊なし\n`;
|
|
753
|
+
contentText += `B. 新形成: 安値2日以上切り上げ + 以降割れなし(出来高1.5倍以上で強度+1)\n`;
|
|
754
|
+
contentText += `C. 転換型: 崩壊したサポート→レジスタンス転換、突破したレジスタンス→サポート転換\n`;
|
|
755
|
+
contentText += `- 崩壊判定: 終値ベース + 出来高確認(低出来高の突破は翌日確認を要求)\n`;
|
|
756
|
+
contentText += `- 強度判定: 接触回数 × 直近性スコア(半減期30日)× 反発幅(2%以上で+1)× 出来高を総合評価\n`;
|
|
757
|
+
contentText += `- 転換型の強化: プルバック確認時に強度★→★★\n`;
|
|
758
|
+
|
|
759
|
+
const summary = formatSummary({
|
|
760
|
+
pair: chk.pair,
|
|
761
|
+
latest: currentPrice,
|
|
762
|
+
extra: `supports=${topSupports.length} resistances=${topResistances.length}`,
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const data = {
|
|
766
|
+
currentPrice,
|
|
767
|
+
analysisDate: currentCandle.isoTime,
|
|
768
|
+
lookbackDays,
|
|
769
|
+
supports: topSupports,
|
|
770
|
+
resistances: topResistances,
|
|
771
|
+
detectionCriteria: {
|
|
772
|
+
swingDepth: 5,
|
|
773
|
+
recentBreakWindow: 7,
|
|
774
|
+
tolerance,
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const meta = createMeta(chk.pair, {
|
|
779
|
+
lookbackDays,
|
|
780
|
+
topN,
|
|
781
|
+
supportCount: topSupports.length,
|
|
782
|
+
resistanceCount: topResistances.length,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
return AnalyzeSupportResistanceOutputSchema.parse({
|
|
786
|
+
ok: true,
|
|
787
|
+
summary,
|
|
788
|
+
content: [{ type: 'text', text: contentText }],
|
|
789
|
+
data,
|
|
790
|
+
meta,
|
|
791
|
+
});
|
|
792
|
+
} catch (err: unknown) {
|
|
793
|
+
return failFromError(err, {
|
|
794
|
+
schema: AnalyzeSupportResistanceOutputSchema,
|
|
795
|
+
defaultMessage: 'サポート・レジスタンス分析中にエラーが発生しました',
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ── MCP ツール定義(tool-registry から自動収集) ──
|
|
801
|
+
export const toolDef: ToolDefinition = {
|
|
802
|
+
name: 'analyze_support_resistance',
|
|
803
|
+
description:
|
|
804
|
+
'[Support / Resistance / Key Levels] サポート・レジスタンス(support / resistance / key levels / price levels)を自動検出。反発/反落ポイントの接触回数・強度・崩壊実績を分析。',
|
|
805
|
+
inputSchema: AnalyzeSupportResistanceInputSchema,
|
|
806
|
+
handler: async ({
|
|
807
|
+
pair,
|
|
808
|
+
lookbackDays,
|
|
809
|
+
topN,
|
|
810
|
+
tolerance,
|
|
811
|
+
}: {
|
|
812
|
+
pair?: string;
|
|
813
|
+
lookbackDays?: number;
|
|
814
|
+
topN?: number;
|
|
815
|
+
tolerance?: number;
|
|
816
|
+
}) => analyzeSupportResistance(pair, { lookbackDays, topN, tolerance }),
|
|
817
|
+
};
|