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,799 @@
1
+ // tools/render_chart_svg.ts
2
+ /**
3
+ * Render chart as SVG or save to file depending on options.
4
+ *
5
+ * ## ボリンジャーバンド (BB)
6
+ * - default : ±2σ のみ描画(軽量版)
7
+ * - extended: ±1σ, ±2σ, ±3σ を描画(完全版)
8
+ * - CLI: `--bb-mode=default|extended`、`--no-bb` で無効化
9
+ * - 後方互換: `--bb-mode=light` → default、`--bb-mode=full` → extended
10
+ *
11
+ * ## 一目均衡表 (Ichimoku)
12
+ * - default : 転換線・基準線・雲(先行スパン A/B)
13
+ * - extended: 上記+遅行スパン
14
+ * - CLI: `--with-ichimoku --ichimoku-mode=default|extended`
15
+ * - 指定なしの場合はオフ
16
+ *
17
+ * ## SMA
18
+ * - デフォルトでは描画しない
19
+ * - CLI: `--sma=5,20,50` のように明示指定した場合のみ描画
20
+ * - 利用可能な期間: 5, 20, 25, 50, 75, 200
21
+ *
22
+ * @returns Result<
23
+ * { svg?: string | null; filePath?: string | null; legend?: Record<string,string> },
24
+ * { pair: string; type: string; limit?: number; indicators?: string[]; bbMode: 'default'|'extended'; range?: {start:string; end:string}; sizeBytes?: number; layerCount?: number; truncated?: boolean; }>
25
+ */
26
+ import { dayjs } from '../lib/datetime.js';
27
+ import { formatPair } from '../lib/formatter.js';
28
+ import { ICHIMOKU_MIN_BARS_FOR_CLOUD, ICHIMOKU_SHIFT } from '../lib/indicator-config.js';
29
+ import { fail, ok } from '../lib/result.js';
30
+ import type { CandleType, ChartPayload, Pair, RenderChartSvgOptions, Result } from '../src/schemas.js';
31
+ import analyzeIndicators from './analyze_indicators.js';
32
+
33
+ // ── Chart Dimensions ──
34
+ const CHART_WIDTH = 860;
35
+ const CHART_HEIGHT = 420;
36
+ const PADDING_TIGHT = { top: 36, right: 12, bottom: 32 } as const;
37
+ const PADDING_NORMAL = { top: 48, right: 16, bottom: 40 } as const;
38
+ const Y_HEADROOM_PCT = 0.02;
39
+ const LABEL_CHAR_WIDTH_PX = 8;
40
+ const LABEL_EXTRA_PADDING = 16;
41
+ const HEAVY_CHART_THRESHOLD = 500;
42
+
43
+ import { createCloudPaths } from './chart/ichimoku-cloud.js';
44
+ import { renderDepthChart } from './chart/render-depth.js';
45
+ import { renderSubPanels } from './chart/render-sub-panels.js';
46
+ import {
47
+ bbColors,
48
+ emaColors,
49
+ formatYLabel,
50
+ niceTicks,
51
+ type Pt,
52
+ sanitizeSvg,
53
+ simplifyPts,
54
+ smaColors,
55
+ } from './chart/svg-utils.js';
56
+
57
+ /** Internal options extending the public schema with undocumented/debug properties */
58
+ type RenderChartSvgInternalOpts = RenderChartSvgOptions & {
59
+ debug?: boolean;
60
+ forceLayers?: boolean;
61
+ noAutoLighten?: boolean;
62
+ };
63
+
64
+ type RenderData = { svg?: string; filePath?: string; legend?: Record<string, string> };
65
+ type RenderMeta = {
66
+ pair: Pair;
67
+ type: CandleType | string;
68
+ limit?: number;
69
+ indicators?: string[];
70
+ bbMode: 'default' | 'extended';
71
+ range?: { start: string; end: string };
72
+ sizeBytes?: number;
73
+ layerCount?: number;
74
+ truncated?: boolean;
75
+ fallback?: string;
76
+ warnings?: string[];
77
+ debug?: Record<string, unknown>;
78
+ };
79
+
80
+ export default async function renderChartSvg(
81
+ args: RenderChartSvgInternalOpts = {},
82
+ ): Promise<Result<RenderData, RenderMeta>> {
83
+ // --- パラメータの解決 ---
84
+ // indicators 配列 + legacy with* を統合して内部フラグに変換
85
+ const indSet = new Set<string>(args.indicators ?? []);
86
+
87
+ // legacy with* → indSet にマージ(後方互換)
88
+ if (args.withIchimoku) indSet.add(args.ichimoku?.mode === 'extended' ? 'ICHIMOKU_EXTENDED' : 'ICHIMOKU');
89
+ if (args.withBB) {
90
+ const rawBb = String(args.bbMode ?? '').toLowerCase();
91
+ indSet.add(rawBb === 'extended' || rawBb === 'full' ? 'BB_EXTENDED' : 'BB');
92
+ }
93
+ for (const p of args.withSMA ?? []) indSet.add(`SMA_${p}`);
94
+ for (const p of args.withEMA ?? []) indSet.add(`EMA_${p}`);
95
+
96
+ // 内部フラグの導出(一箇所で集約)
97
+ let withIchimoku = indSet.has('ICHIMOKU') || indSet.has('ICHIMOKU_EXTENDED');
98
+ const ichimokuMode: 'default' | 'extended' = indSet.has('ICHIMOKU_EXTENDED') ? 'extended' : 'default';
99
+ const drawChikou = ichimokuMode === 'extended' || args.ichimoku?.withChikou === true;
100
+ let withBB = indSet.has('BB') || indSet.has('BB_EXTENDED');
101
+ const bbMode: 'default' | 'extended' = indSet.has('BB_EXTENDED') ? 'extended' : 'default';
102
+ let withSMA = [...indSet].filter((k) => k.startsWith('SMA_')).map((k) => Number(k.split('_')[1]));
103
+ let withEMA = [...indSet].filter((k) => k.startsWith('EMA_')).map((k) => Number(k.split('_')[1]));
104
+
105
+ // 排他制御: 一目均衡表使用時は SMA/BB をオフ
106
+ if (withIchimoku) {
107
+ withSMA = [];
108
+ withEMA = [];
109
+ withBB = false;
110
+ }
111
+
112
+ const style = (args.style === 'line' ? 'line' : 'candles') as 'candles' | 'line';
113
+ const isDepth = args.style === 'depth';
114
+ const svgPrecision = Math.max(0, Math.min(3, Number(args.svgPrecision ?? 1)));
115
+ const effectivePrecision = Math.max(1, svgPrecision);
116
+ const svgMinify = args.svgMinify !== false;
117
+ const simplifyTolerance = Math.max(0, Number(args.simplifyTolerance ?? 0.5));
118
+ const viewBoxTight = args.viewBoxTight !== false;
119
+
120
+ const {
121
+ pair = 'btc_jpy',
122
+ // normalize to CandleType-like values (historically 'day' was used)
123
+ type = '1day',
124
+ limit = 60,
125
+ withLegend = true,
126
+ overlays,
127
+ tz = 'Asia/Tokyo',
128
+ } = args;
129
+ const debugEnabled = Boolean(args.debug);
130
+ const debugInfo: Record<string, unknown[]> = debugEnabled ? { notes: [] } : {};
131
+ const forceLayers = args.forceLayers === true || args.noAutoLighten === true;
132
+
133
+ // Sub-panel configuration
134
+ const subPanelTypes: Array<'macd' | 'rsi' | 'volume'> = ((args.subPanels || []) as string[]).filter(
135
+ (t): t is 'macd' | 'rsi' | 'volume' => ['macd', 'rsi', 'volume'].includes(t),
136
+ );
137
+ const SUB_PANEL_HEIGHT = 120;
138
+ const SUB_PANEL_GAP = 24;
139
+
140
+ // === Depth チャート(独立描画) ===
141
+ if (isDepth) {
142
+ return renderDepthChart(pair, args, effectivePrecision);
143
+ }
144
+
145
+ // --- 事前見積もりヒューリスティクス(重そうなら candles-only にフォールバック) ---
146
+ // 一目均衡表は雲+転換線+基準線+先行スパン2本で実質5レイヤー
147
+ const estimatedLayers =
148
+ (withIchimoku ? 5 : 0) + (withBB ? (bbMode === 'extended' ? 3 : 1) : 0) + withSMA.length + withEMA.length + 1; // +1 for base series
149
+ const summaryNotes: string[] = [];
150
+ if (!forceLayers && limit * estimatedLayers > HEAVY_CHART_THRESHOLD) {
151
+ if (withBB || withSMA.length > 0 || withEMA.length > 0 || withIchimoku) {
152
+ withBB = false;
153
+ withSMA = [];
154
+ withEMA = [];
155
+ withIchimoku = false;
156
+ summaryNotes.push('heavy chart detected → fallback to candles-only to avoid oversized SVG');
157
+ }
158
+ }
159
+
160
+ // ★ データ取得はバッファ計算をgetIndicatorsに任せる
161
+ // 一目均衡表の雲を適切に表示するには limit >= 60 が必要(先行スパンB: 52期間 + シフト: 26日)
162
+ const ichimokuMinLimit = ICHIMOKU_MIN_BARS_FOR_CLOUD;
163
+ const warnings: string[] = [];
164
+
165
+ // 一目均衡表使用時に limit が小さすぎる場合は自動調整
166
+ let effectiveLimit = limit;
167
+ if (withIchimoku && limit < ichimokuMinLimit) {
168
+ effectiveLimit = ichimokuMinLimit;
169
+ summaryNotes.push(`一目均衡表の雲表示のため limit を ${limit} → ${effectiveLimit} に自動調整`);
170
+ }
171
+
172
+ const internalLimit = withIchimoku ? effectiveLimit + ICHIMOKU_SHIFT : effectiveLimit;
173
+ const res = await analyzeIndicators(pair, type, internalLimit);
174
+ if (!res?.ok) {
175
+ return fail(
176
+ res?.summary?.replace?.(/^Error: /, '') || 'failed to fetch indicators',
177
+ res?.meta?.errorType || 'internal',
178
+ );
179
+ }
180
+
181
+ const chartData = res.data?.chart as ChartPayload;
182
+ const items = chartData?.candles || [];
183
+ const indicators = chartData?.indicators;
184
+ /** Safe dynamic indicator access by key (for EMA_${p}, BB${n}_upper, etc.) */
185
+ const indicatorSeries = (key: string): Array<number | null> | undefined =>
186
+ (indicators as Record<string, unknown>)[key] as Array<number | null> | undefined;
187
+ const pastBuffer = chartData.meta?.pastBuffer ?? 0;
188
+ const forwardShiftMeta = chartData.meta?.shift ?? 0;
189
+ // 一目を描画しない場合は forwardShift を 0 にする(間隔が詰まるのを防ぐ)
190
+ const forwardShift = withIchimoku ? forwardShiftMeta : 0;
191
+ const displayItems = items.slice(pastBuffer);
192
+
193
+ if (!items?.length) {
194
+ return fail('No candle data available to render SVG chart.', 'user');
195
+ }
196
+
197
+ // 一目均衡表の雲(spanA/spanB)が十分なデータを持っているかチェック
198
+ if (withIchimoku) {
199
+ const spanA = indicators?.ICHI_spanA as Array<number | null> | undefined;
200
+ const spanB = indicators?.ICHI_spanB as Array<number | null> | undefined;
201
+ const spanAValidCount = spanA?.filter((v) => v !== null)?.length ?? 0;
202
+ const spanBValidCount = spanB?.filter((v) => v !== null)?.length ?? 0;
203
+
204
+ if (spanBValidCount === 0) {
205
+ warnings.push('先行スパンBのデータが不足しています。雲が描画されません。');
206
+ } else if (spanAValidCount < effectiveLimit || spanBValidCount < effectiveLimit) {
207
+ const cloudCoverage = Math.min(spanAValidCount, spanBValidCount);
208
+ const coveragePct = Math.round((cloudCoverage / effectiveLimit) * 100);
209
+ if (coveragePct < 80) {
210
+ warnings.push(`雲のカバー率: ${coveragePct}%(${cloudCoverage}/${effectiveLimit}本)。`);
211
+ }
212
+ }
213
+ }
214
+
215
+ // Y軸ラベルの省略表示フォーマッタ(imported formatYLabel を pair に束縛)
216
+ const isJpyPair = pair.toLowerCase().includes('jpy');
217
+ const fmtYLabel = (val: number) => formatYLabel(val, isJpyPair);
218
+
219
+ const xs = displayItems.map((_, i) => i);
220
+ const highs = displayItems.map((d) => d.high as number);
221
+ const lows = displayItems.map((d) => d.low as number);
222
+ const _xMin = 0;
223
+ const _xMax = xs.length - 1;
224
+ // forwardShift は上部で meta.shift から取得済み
225
+
226
+ // Y軸の範囲を、表示されるすべての要素から計算
227
+ // 描画対象のインジケーター系列キーを一箇所で列挙
228
+ const yRangeKeys: string[] = [];
229
+ if (withIchimoku) yRangeKeys.push('ICHI_tenkan', 'ICHI_kijun', 'ICHI_spanA', 'ICHI_spanB');
230
+ if (withBB) {
231
+ if (bbMode === 'extended') {
232
+ yRangeKeys.push('BB1_upper', 'BB1_lower', 'BB2_upper', 'BB2_lower', 'BB3_upper', 'BB3_lower');
233
+ } else {
234
+ yRangeKeys.push('BB_upper', 'BB_lower');
235
+ }
236
+ }
237
+ for (const p of withSMA) yRangeKeys.push(`SMA_${p}`);
238
+ for (const p of withEMA) yRangeKeys.push(`EMA_${p}`);
239
+
240
+ const allYValues: number[] = [...highs, ...lows];
241
+ for (const key of yRangeKeys) {
242
+ const series = indicatorSeries(key)?.slice(pastBuffer) ?? [];
243
+ for (const v of series) {
244
+ if (v !== null) allYValues.push(v);
245
+ }
246
+ }
247
+
248
+ const dataYMin = Math.min(...allYValues);
249
+ const dataYMax = Math.max(...allYValues);
250
+ const yPad = Math.min(0.2, Math.max(0, Number(args.yPaddingPct ?? 0.06)));
251
+ const yRange = dataYMax - dataYMin;
252
+ // クリップ回避用の安全ヘッドルーム(レンジの2%)
253
+ const autoHeadroom = yRange * Y_HEADROOM_PCT;
254
+ // データレンジに対する相対パディング(値幅に比例してタイトに描画)
255
+ const yAxisMinWithBuffer = yRange > 0 ? dataYMin - yRange * yPad : dataYMin * (1 - yPad);
256
+ const yAxisMaxTarget = yRange > 0 ? dataYMax + yRange * yPad + autoHeadroom : dataYMax * (1 + yPad) + autoHeadroom;
257
+ const yTicks = niceTicks(yAxisMinWithBuffer, yAxisMaxTarget, 6);
258
+ const yMin = yTicks[0];
259
+ const yMax = yTicks.at(-1) as number;
260
+
261
+ // Y軸ラベルの最大幅に基づいてpadding.leftを動的に調整
262
+ const maxLabelWidth = Math.max(...yTicks.map((v) => fmtYLabel(v).length));
263
+ const dynamicPaddingLeft = maxLabelWidth * LABEL_CHAR_WIDTH_PX + LABEL_EXTRA_PADDING;
264
+
265
+ // スケール計算
266
+ const w = CHART_WIDTH;
267
+ const h = CHART_HEIGHT;
268
+ // 上部に余白を多めに確保(凡例が詰まらないように)
269
+ const padding = viewBoxTight
270
+ ? { top: PADDING_TIGHT.top, right: PADDING_TIGHT.right, bottom: PADDING_TIGHT.bottom, left: dynamicPaddingLeft }
271
+ : { top: PADDING_NORMAL.top, right: PADDING_NORMAL.right, bottom: PADDING_NORMAL.bottom, left: dynamicPaddingLeft };
272
+ const plotW = w - padding.left - padding.right;
273
+ const plotH = h - padding.top - padding.bottom;
274
+
275
+ // Dynamic total height: price panel + sub-panels
276
+ const subPanelsTotalH =
277
+ subPanelTypes.length > 0 ? subPanelTypes.length * SUB_PANEL_HEIGHT + subPanelTypes.length * SUB_PANEL_GAP : 0;
278
+ const totalH = h + subPanelsTotalH;
279
+ const xAxisBottom = totalH - padding.bottom;
280
+
281
+ // X座標計算: 描画ウィンドウ内での相対位置を計算
282
+ // Xはバー中心を(i+0.5)に置き、左右に半スロットの余白を確保して端の切れを防ぐ
283
+ const totalSlots = Math.max(1, xs.length + forwardShift);
284
+ const x = (i: number) => Number((padding.left + ((i + 0.5) * plotW) / totalSlots).toFixed(effectivePrecision));
285
+ const y = (v: number) =>
286
+ Number((h - padding.bottom - ((v - yMin) * plotH) / Math.max(1, yMax - yMin)).toFixed(effectivePrecision));
287
+
288
+ // --- 凡例メタデータと描画レイヤーの準備 ---
289
+ const legendMeta: Record<string, string> = {};
290
+ let legendLayers = '';
291
+
292
+ // 自動調整: 未指定時は本数に応じて隙間が過剰/不足にならないよう最適化
293
+ let barWidthRatio = Number(args.barWidthRatio);
294
+ if (!Number.isFinite(barWidthRatio)) {
295
+ const n = xs.length;
296
+ if (n <= 30)
297
+ barWidthRatio = 0.55; // 少本数 → やや細め(間延び防止)
298
+ else if (n <= 45) barWidthRatio = 0.6;
299
+ else if (n <= 60) barWidthRatio = 0.65;
300
+ else barWidthRatio = 0.7; // 多本数 → やや太め(隙間詰め)
301
+ }
302
+ barWidthRatio = Math.min(0.9, Math.max(0.1, barWidthRatio));
303
+ const barW = Math.max(2, (plotW / Math.max(1, xs.length)) * barWidthRatio);
304
+
305
+ // ローソク(棒+ヒゲ) or 折れ線
306
+ let sticks = '';
307
+ let bodies = '';
308
+ let priceLine = '';
309
+ let wantPriceLine = false;
310
+ if (style === 'candles') {
311
+ sticks = displayItems
312
+ .map((d, i: number) => {
313
+ const cx = x(i);
314
+ return `<line x1="${cx}" y1="${y(d.high)}" x2="${cx}" y2="${y(d.low)}" class="w"/>`;
315
+ })
316
+ .join('');
317
+ bodies = displayItems
318
+ .map((d, i: number) => {
319
+ const cx = x(i) - barW / 2;
320
+ const o = y(d.open);
321
+ const c = y(d.close);
322
+ const top = Math.min(o, c);
323
+ const bot = Math.max(o, c);
324
+ const up = d.close >= d.open;
325
+ return `<rect x="${Number(cx.toFixed(effectivePrecision))}" y="${Number(top.toFixed(effectivePrecision))}" width="${Number(barW.toFixed(effectivePrecision))}" height="${Number(Math.max(1, bot - top).toFixed(effectivePrecision))}" class="${up ? 'u' : 'd'}"/>`;
326
+ })
327
+ .join('');
328
+ } else if (style === 'line') {
329
+ // style === 'line' → 終値の折れ線(描画はヘルパー定義後に実施)
330
+ wantPriceLine = true;
331
+ } else if (style === 'depth') {
332
+ // depth は価格系列の描画を行わず、後段の overlays/axes のみ使用
333
+ }
334
+
335
+ // --- インジケータ描画 ---
336
+
337
+ // 汎用的なライン描画関数
338
+ const round = (v: number) => Number(v.toFixed(svgPrecision));
339
+
340
+ const createLinePath = (
341
+ data: Array<number | null> | undefined,
342
+ color: string,
343
+ options: { dash?: string; width?: string; offset?: number; simplify?: boolean } = {},
344
+ ) => {
345
+ if (!data || data.length === 0) return '';
346
+ let raw: Pt[] = [];
347
+ const offset = options.offset || 0; // 先行(+26) / 遅行(-26)
348
+ let skipped = 0;
349
+ data.forEach((val, i) => {
350
+ if (val !== null && typeof val === 'number') {
351
+ const posIndex = i - pastBuffer + offset;
352
+ // 極端に描画領域外になる点はスキップしてパス破綻を防ぐ
353
+ if (posIndex < -1 || posIndex > xs.length + forwardShift + 1) {
354
+ skipped++;
355
+ return;
356
+ }
357
+ raw.push({ x: x(posIndex), y: y(val) });
358
+ }
359
+ });
360
+ if (raw.length === 0) return '';
361
+ // RDP風の単純化
362
+ if (options.simplify !== false) {
363
+ raw = simplifyPts(raw, simplifyTolerance);
364
+ }
365
+ const points = raw.map((p) => `${round(p.x)},${round(p.y)}`);
366
+ const d = `M ${points.join(' L ')}`;
367
+ const dash = options.dash ? `stroke-dasharray="${options.dash}"` : '';
368
+ const width = options.width || '2';
369
+ if (debugEnabled) {
370
+ if (!debugInfo.paths) debugInfo.paths = [];
371
+ debugInfo.paths.push({ color, count: raw.length, skipped });
372
+ }
373
+ return `<path d="${d}" fill="none" stroke="${color}" stroke-width="${width}" ${dash}/>`;
374
+ };
375
+
376
+ // 折れ線(終値)を必要に応じて描画
377
+ if (wantPriceLine) {
378
+ const closesFull: Array<number | null> = items.map((d) => (typeof d.close === 'number' ? d.close : null));
379
+ priceLine = createLinePath(closesFull, '#60a5fa', { width: '1.5', simplify: true, offset: 0 });
380
+ }
381
+
382
+ // --- 型安全なインジケータ参照ヘルパー ---
383
+ const bbSeries = {
384
+ getUpper: (mode: 'default' | 'extended') => (mode === 'extended' ? indicators?.BB2_upper : indicators?.BB_upper),
385
+ getMiddle: (mode: 'default' | 'extended') => (mode === 'extended' ? indicators?.BB2_middle : indicators?.BB_middle),
386
+ getLower: (mode: 'default' | 'extended') => (mode === 'extended' ? indicators?.BB2_lower : indicators?.BB_lower),
387
+ getBand: (sigma: 1 | 2 | 3) => {
388
+ switch (sigma) {
389
+ case 1:
390
+ return { upper: indicators?.BB1_upper, middle: indicators?.BB1_middle, lower: indicators?.BB1_lower };
391
+ case 2:
392
+ return { upper: indicators?.BB2_upper, middle: indicators?.BB2_middle, lower: indicators?.BB2_lower };
393
+ case 3:
394
+ return { upper: indicators?.BB3_upper, middle: indicators?.BB3_middle, lower: indicators?.BB3_lower };
395
+ default:
396
+ return { upper: undefined, middle: undefined, lower: undefined };
397
+ }
398
+ },
399
+ } as const;
400
+
401
+ const ichiSeries = {
402
+ tenkan: indicators?.ICHI_tenkan as Array<number | null> | undefined,
403
+ kijun: indicators?.ICHI_kijun as Array<number | null> | undefined,
404
+ spanA: indicators?.ICHI_spanA as Array<number | null> | undefined,
405
+ spanB: indicators?.ICHI_spanB as Array<number | null> | undefined,
406
+ chikou: indicators?.ICHI_chikou as Array<number | null> | undefined,
407
+ } as const;
408
+
409
+ // SMAレイヤー
410
+ let smaLayers = '';
411
+ for (const p of withSMA) {
412
+ const series = indicatorSeries(`SMA_${p}`);
413
+ if (series && series.length > 0) {
414
+ smaLayers += createLinePath(series, smaColors[p] || '#e5e7eb', { simplify: false });
415
+ }
416
+ }
417
+
418
+ // EMAレイヤー(SMAと区別するため暖色系・破線)
419
+ let emaLayers = '';
420
+ for (const p of withEMA) {
421
+ const series = indicatorSeries(`EMA_${p}`);
422
+ if (series && series.length > 0) {
423
+ emaLayers += createLinePath(series, emaColors[p] || '#ff9f1c', { simplify: false, dash: '6,3' });
424
+ }
425
+ }
426
+
427
+ // ボリンジャーバンド
428
+ let bbLayers = '';
429
+ if (withBB) {
430
+ const createBBPoints = (data?: Array<number | null>): Pt[] => {
431
+ const points: Pt[] = [];
432
+ let skipped = 0;
433
+ data?.forEach?.((val, i) => {
434
+ if (val !== null && val !== undefined) {
435
+ const posIndex = i - pastBuffer;
436
+ if (posIndex < -1 || posIndex > xs.length + forwardShift + 1) {
437
+ skipped++;
438
+ return;
439
+ }
440
+ points.push({ x: x(posIndex), y: y(val) });
441
+ }
442
+ });
443
+ if (debugEnabled) {
444
+ if (!debugInfo.bb) debugInfo.bb = [];
445
+ debugInfo.bb.push({ count: points.length, skipped });
446
+ }
447
+ return points;
448
+ };
449
+ const createPathFromPoints = (points?: Pt[]): string => {
450
+ if (!points || points.length === 0) return '';
451
+ return `M ${points.map((p) => `${round(p.x)},${round(p.y)}`).join(' L ')}`;
452
+ };
453
+
454
+ const makeBand = (upperSeries?: Array<number | null>, lowerSeries?: Array<number | null>) => {
455
+ const upperPoints = createBBPoints(upperSeries);
456
+ const lowerPoints = createBBPoints(lowerSeries);
457
+ const upperPath = createPathFromPoints(upperPoints);
458
+ const lowerPath = createPathFromPoints(lowerPoints);
459
+ let bandPath = '';
460
+ if (upperPoints.length > 0 && lowerPoints.length > 0) {
461
+ const lowerPointsReversed = [...lowerPoints].reverse();
462
+ const allPoints = [...upperPoints, ...lowerPointsReversed];
463
+ bandPath = `${createPathFromPoints(allPoints)} Z`;
464
+ }
465
+ return { upperPath, lowerPath, bandPath };
466
+ };
467
+
468
+ if (bbMode === 'extended') {
469
+ // ±2σのバンド塗り
470
+ const band2 = makeBand(bbSeries.getBand(2).upper, bbSeries.getBand(2).lower);
471
+ bbLayers += `
472
+ <path d="${band2.bandPath}" fill="${bbColors.bandFill2}" stroke="none" />
473
+ `;
474
+ // ±1σ ライン(グレー)
475
+ const p1u = createPathFromPoints(createBBPoints(bbSeries.getBand(1).upper));
476
+ const p1l = createPathFromPoints(createBBPoints(bbSeries.getBand(1).lower));
477
+ bbLayers += `
478
+ <path d="${p1u}" fill="none" stroke="${bbColors.line1}" stroke-width="1"/>
479
+ <path d="${p1l}" fill="none" stroke="${bbColors.line1}" stroke-width="1"/>
480
+ `;
481
+ // ±2σ ライン(青) + 中央線(灰の破線)
482
+ const p2u = createPathFromPoints(createBBPoints(bbSeries.getBand(2).upper));
483
+ const p2m = createPathFromPoints(createBBPoints(bbSeries.getBand(2).middle));
484
+ const p2l = createPathFromPoints(createBBPoints(bbSeries.getBand(2).lower));
485
+ bbLayers += `
486
+ <path d="${p2u}" fill="none" stroke="${bbColors.line2}" stroke-width="1"/>
487
+ <path d="${p2l}" fill="none" stroke="${bbColors.line2}" stroke-width="1"/>
488
+ <path d="${p2m}" fill="none" stroke="${bbColors.middle}" stroke-width="1" stroke-dasharray="4 4"/>
489
+ `;
490
+ // ±3σ ライン(オレンジ)
491
+ const p3u = createPathFromPoints(createBBPoints(bbSeries.getBand(3).upper));
492
+ const p3l = createPathFromPoints(createBBPoints(bbSeries.getBand(3).lower));
493
+ bbLayers += `
494
+ <path d="${p3u}" fill="none" stroke="${bbColors.line3}" stroke-width="1"/>
495
+ <path d="${p3l}" fill="none" stroke="${bbColors.line3}" stroke-width="1"/>
496
+ `;
497
+ } else {
498
+ // light: 互換キー(±2σ)のみを使って従来描画
499
+ const band2 = makeBand(bbSeries.getUpper('default'), bbSeries.getLower('default'));
500
+ const mid2 = createPathFromPoints(createBBPoints(bbSeries.getMiddle('default')));
501
+ bbLayers = `
502
+ <path d="${band2.bandPath}" fill="${bbColors.bandFill2}" stroke="none" />
503
+ <path d="${band2.upperPath}" fill="none" stroke="${bbColors.line2}" stroke-width="1"/>
504
+ <path d="${band2.lowerPath}" fill="none" stroke="${bbColors.line2}" stroke-width="1"/>
505
+ <path d="${mid2}" fill="none" stroke="${bbColors.middle}" stroke-width="1" stroke-dasharray="4 4"/>
506
+ `;
507
+ }
508
+ }
509
+
510
+ // 一目均衡表
511
+ let ichimokuLayers = '';
512
+ if (withIchimoku && ichiSeries.tenkan) {
513
+ const tenkanPath = createLinePath(ichiSeries.tenkan, '#00a3ff', { width: '1', offset: 0 });
514
+ const kijunPath = createLinePath(ichiSeries.kijun, '#ff4d4d', { width: '1', offset: 0 });
515
+ const chikouPath = drawChikou
516
+ ? createLinePath(ichiSeries.chikou, '#16a34a', { width: '1', dash: '2 2', offset: -ICHIMOKU_SHIFT })
517
+ : '';
518
+ const spanAPath = createLinePath(ichiSeries.spanA, '#16a34a', { width: '1', offset: ICHIMOKU_SHIFT });
519
+ const spanBPath = createLinePath(ichiSeries.spanB, '#ef4444', { width: '1', offset: ICHIMOKU_SHIFT });
520
+
521
+ // 雲の描画(交点で色切替)— 抽出済みモジュールに委譲
522
+ const { greenCloudPath, redCloudPath } = createCloudPaths(ichiSeries.spanA, ichiSeries.spanB, ICHIMOKU_SHIFT, {
523
+ x,
524
+ y,
525
+ pastBuffer,
526
+ displayLength: xs.length,
527
+ forwardShift,
528
+ });
529
+
530
+ ichimokuLayers = `
531
+ <path d="${greenCloudPath}" fill="rgba(16, 163, 74, 0.16)" stroke="none" />
532
+ <path d="${redCloudPath}" fill="rgba(239, 68, 68, 0.24)" stroke="none" />
533
+ ${tenkanPath}
534
+ ${kijunPath}
535
+ ${chikouPath}
536
+ ${spanAPath}
537
+ ${spanBPath}
538
+ `;
539
+ }
540
+
541
+ // --- インジケータメタデータ+凡例(統一構築) ---
542
+ const legendItems: Array<{ key: string; text: string; color: string }> = [];
543
+ for (const p of withSMA) {
544
+ const c = smaColors[p] || '#e5e7eb';
545
+ legendItems.push({ key: `SMA_${p}`, text: `SMA ${p}`, color: c });
546
+ }
547
+ for (const p of withEMA) {
548
+ const c = emaColors[p] || '#ff9f1c';
549
+ legendItems.push({ key: `EMA_${p}`, text: `EMA ${p}`, color: c });
550
+ }
551
+ if (withBB) {
552
+ if (bbMode === 'extended') {
553
+ legendItems.push({ key: 'BB1', text: 'BB ±1σ', color: bbColors.line1 });
554
+ legendItems.push({ key: 'BB2', text: 'BB ±2σ', color: bbColors.line2 });
555
+ legendItems.push({ key: 'BB3', text: 'BB ±3σ', color: bbColors.line3 });
556
+ } else {
557
+ legendItems.push({ key: 'BB', text: 'BB ±2σ', color: bbColors.line2 });
558
+ }
559
+ }
560
+ if (withIchimoku) {
561
+ legendItems.push({ key: 'Ichimoku', text: '転換線', color: '#00a3ff' });
562
+ legendItems.push({ key: 'Ichimoku_kijun', text: '基準線', color: '#ff4d4d' });
563
+ }
564
+ // メタデータ(withLegend に依存しない)
565
+ for (const item of legendItems) legendMeta[item.key] = `${item.text} (${item.color})`;
566
+
567
+ // 凡例SVG
568
+ if (withLegend) {
569
+ const yOffset = Math.max(14, padding.top - 18);
570
+ legendLayers =
571
+ `<g font-size="12" fill="#e5e7eb">` +
572
+ legendItems
573
+ .map((item, i) => {
574
+ const xPos = padding.left + i * 130;
575
+ return `<g transform="translate(${xPos}, ${yOffset})">
576
+ <rect y="-10" width="12" height="12" fill="${item.color}"></rect>
577
+ <text x="16" y="0">${item.text}</text>
578
+ </g>`;
579
+ })
580
+ .join('') +
581
+ `</g>`;
582
+ }
583
+
584
+ // Y軸 (価格)
585
+ const yAxis = `
586
+ <line x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${h - padding.bottom}" stroke="#4b5563" stroke-width="1"/>
587
+ <g font-size="12" fill="#9ca3af">
588
+ ${yTicks
589
+ .map((val) => {
590
+ const yPos = y(val);
591
+ return `<text x="${padding.left - 8}" y="${yPos}" text-anchor="end" dominant-baseline="middle">${fmtYLabel(val)}</text>`;
592
+ })
593
+ .join('')}
594
+ </g>
595
+ `;
596
+
597
+ // X軸 (日付) - timeframe に応じたフォーマットで表示
598
+ // タイムゾーンを適用してdayjsインスタンスを生成
599
+ const toDayjs = (d: { isoTime?: string | null; time?: string | number | null; timestamp?: string | number | null }) =>
600
+ dayjs(d?.isoTime || d?.time || d?.timestamp).tz(tz);
601
+ // 全データが同一日に収まるかを判定
602
+ const firstDate = toDayjs(displayItems[0]);
603
+ const lastDate = toDayjs(displayItems[displayItems.length - 1]);
604
+ const isSameDay =
605
+ firstDate.isValid() && lastDate.isValid() && firstDate.format('YYYYMMDD') === lastDate.format('YYYYMMDD');
606
+
607
+ // timeframe に基づくフォーマット決定
608
+ const formatXLabel = (d: ReturnType<typeof dayjs>): string => {
609
+ const tf = String(type).toLowerCase();
610
+ if (['1min', '5min', '15min', '30min'].includes(tf)) {
611
+ return d.format('H:mm');
612
+ }
613
+ if (['1hour', '4hour'].includes(tf)) {
614
+ return isSameDay ? d.format('H:mm') : d.format('M/D H:mm');
615
+ }
616
+ if (['8hour', '12hour', '1day'].includes(tf)) {
617
+ return `${d.month() + 1}/${d.date()}`;
618
+ }
619
+ // 1week, 1month
620
+ return d.format('YYYY/M/D');
621
+ };
622
+
623
+ const xAxis = `
624
+ <line x1="${padding.left}" y1="${h - padding.bottom}" x2="${w - padding.right}" y2="${h - padding.bottom}" stroke="#4b5563" stroke-width="1"/>
625
+ ${subPanelTypes.length > 0 ? `<line x1="${padding.left}" y1="${xAxisBottom}" x2="${w - padding.right}" y2="${xAxisBottom}" stroke="#4b5563" stroke-width="1"/>` : ''}
626
+ <g font-size="12" fill="#9ca3af">
627
+ ${displayItems
628
+ .map((d, i: number) => {
629
+ const step = Math.max(1, Math.floor(displayItems.length / 5));
630
+ if (i % step !== 0) return '';
631
+ const xPos = x(i);
632
+ const date = toDayjs(d);
633
+ if (!date.isValid()) return '';
634
+ const label = formatXLabel(date);
635
+ return `<text x="${xPos}" y="${xAxisBottom + 16}" text-anchor="middle" fill="#9ca3af" font-size="10">${label}</text>`;
636
+ })
637
+ .join('')}
638
+ </g>
639
+ `;
640
+
641
+ // --- Sub-panel rendering ---
642
+ const { svg: subPanelSvg } = renderSubPanels(subPanelTypes, {
643
+ x,
644
+ padding,
645
+ plotW,
646
+ w,
647
+ barW,
648
+ effectivePrecision,
649
+ pastBuffer,
650
+ displayItems: displayItems as Array<{ open: number; close: number; volume?: number; [k: string]: unknown }>,
651
+ indicators: indicators as Record<string, unknown> | undefined,
652
+ });
653
+
654
+ // --- 2種類のSVGを構築 ---
655
+ const createSvgString = (layers: { ichimoku: string; bb: string; sma: string; ema: string }) => `
656
+ <svg width="${w}" height="${totalH}" viewBox="0 0 ${w} ${totalH}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="background-color: #1f2937; color: #e5e7eb; font-family: sans-serif; max-width: 100%; height: auto;">
657
+ <title>${formatPair(pair)} ${type} chart</title>
658
+ <style>.u{fill:#16a34a}.d{fill:#ef4444}.w{stroke:#9ca3af;stroke-width:${Math.max(1, Math.min(2.5, barW * 0.15)).toFixed(1)}}</style>
659
+ <defs>
660
+ <clipPath id="plotArea">
661
+ <rect x="${padding.left}" y="${padding.top}" width="${plotW}" height="${plotH}"/>
662
+ </clipPath>
663
+ </defs>
664
+ <g class="axes">
665
+ ${yAxis}
666
+ ${xAxis}
667
+ </g>
668
+ <g class="plot-area" clip-path="url(#plotArea)">
669
+ ${layers.ichimoku}
670
+ ${layers.bb}
671
+ ${sticks}
672
+ ${bodies}
673
+ ${priceLine}
674
+ ${layers.sma}
675
+ ${layers.ema}
676
+ ${(() => {
677
+ if (!overlays?.ranges) return '';
678
+ const mkRect = (startIso: string, endIso: string, color?: string, label?: string) => {
679
+ const findIndexByIso = (iso: string) => displayItems.findIndex((d) => d.isoTime === iso);
680
+ const i0 = findIndexByIso(startIso);
681
+ const i1 = findIndexByIso(endIso);
682
+ if (i0 < 0 || i1 < 0) return '';
683
+ const left = Math.min(x(i0), x(i1));
684
+ const right = Math.max(x(i0), x(i1));
685
+ const width = Math.max(0, right - left);
686
+ const fill = color || 'rgba(180,180,40,0.18)';
687
+ const rect = `<rect x="${left}" y="${padding.top}" width="${width}" height="${plotH}" fill="${fill}" />`;
688
+ const text = label
689
+ ? `<text x="${left + 4}" y="${padding.top + 12}" fill="#e5e7eb" font-size="10">${label}</text>`
690
+ : '';
691
+ return rect + text;
692
+ };
693
+ return overlays.ranges
694
+ .map((r: { start: string; end: string; color?: string; label?: string }) =>
695
+ mkRect(r.start, r.end, r.color, r.label),
696
+ )
697
+ .join('');
698
+ })()}
699
+ ${(() => {
700
+ if (!overlays?.annotations) return '';
701
+ // ピン&テキストを上部に配置し、重なりを軽減するため縦位置を交互にずらす
702
+ let slot = 0;
703
+ const mkPin = (iso: string, text: string) => {
704
+ const findIndexByIso = (s: string) => displayItems.findIndex((d) => d.isoTime === s);
705
+ const i = findIndexByIso(iso);
706
+ if (i < 0) return '';
707
+ const cx = x(i);
708
+ const y0 = padding.top + 6 + (slot++ % 2) * 12; // 交互にオフセット
709
+ const stemY1 = y0 + 10;
710
+ const circle = `<circle cx="${cx}" cy="${y0}" r="3" fill="#e5e7eb" />`;
711
+ const stem = `<line x1="${cx}" y1="${y0 + 3}" x2="${cx}" y2="${Math.min(padding.top + plotH - 6, stemY1)}" stroke="#9ca3af" stroke-width="1" stroke-dasharray="2 2" />`;
712
+ const label = `<text x="${cx + 6}" y="${y0 + 4}" fill="#e5e7eb" font-size="10">${text}</text>`;
713
+ return circle + stem + label;
714
+ };
715
+ return overlays.annotations.map((a: { isoTime: string; text: string }) => mkPin(a.isoTime, a.text)).join('');
716
+ })()}
717
+ ${(() => {
718
+ if (!overlays?.depth_zones) return '';
719
+ const mkBand = (low: number, high: number, color?: string, label?: string) => {
720
+ const y1 = y(high);
721
+ const y2 = y(low);
722
+ const rect = `<rect x="${padding.left}" y="${Math.min(y1, y2)}" width="${plotW}" height="${Math.abs(y2 - y1)}" fill="${color || 'rgba(34,197,94,0.08)'}" />`;
723
+ const text = label
724
+ ? `<text x="${padding.left + 4}" y="${Math.min(y1, y2) + 12}" fill="#e5e7eb" font-size="10">${label}</text>`
725
+ : '';
726
+ return rect + text;
727
+ };
728
+ return overlays.depth_zones
729
+ .map((z: { low: number; high: number; color?: string; label?: string }) =>
730
+ mkBand(z.low, z.high, z.color, z.label),
731
+ )
732
+ .join('');
733
+ })()}
734
+ </g>
735
+ <g class="legend">
736
+ ${legendLayers}
737
+ </g>
738
+ <g class="sub-panels">
739
+ ${subPanelSvg}
740
+ </g>
741
+ </svg>
742
+ `;
743
+
744
+ let fullSvg = createSvgString({ ichimoku: ichimokuLayers, bb: bbLayers, sma: smaLayers, ema: emaLayers });
745
+ let lightSvg = createSvgString({
746
+ ichimoku: withIchimoku ? ichimokuLayers : '',
747
+ bb: bbLayers,
748
+ sma: smaLayers,
749
+ ema: emaLayers,
750
+ });
751
+ if (svgMinify) {
752
+ const minify = (s: string) => s.replace(/\s{2,}/g, ' ').replace(/>\s+</g, '><');
753
+ fullSvg = minify(fullSvg);
754
+ lightSvg = minify(lightSvg);
755
+ }
756
+
757
+ // --- 返却: 常に生 SVG をインライン返却 ---
758
+ const finalSvg = sanitizeSvg(withIchimoku ? lightSvg : fullSvg);
759
+ const sizeBytes = Buffer.byteLength(finalSvg, 'utf8');
760
+ const layerCount = estimatedLayers;
761
+
762
+ // Human-friendly identifiers
763
+ const title = `${formatPair(pair)} ${type} chart`;
764
+ const rangeStart = displayItems[0]?.isoTime || '';
765
+ const rangeEnd = displayItems.at(-1)?.isoTime || '';
766
+ const identifier =
767
+ `${String(pair)}-${String(type)}-${String(rangeStart).slice(0, 10)}-${String(rangeEnd).slice(0, 10)}`.replace(
768
+ /[^a-z0-9_-]+/gi,
769
+ '-',
770
+ );
771
+
772
+ const metaBase: RenderMeta & { identifier?: string; title?: string } = {
773
+ pair: pair as Pair,
774
+ type,
775
+ limit: effectiveLimit,
776
+ indicators: Object.keys(legendMeta),
777
+ bbMode,
778
+ range: { start: rangeStart, end: rangeEnd },
779
+ sizeBytes,
780
+ layerCount,
781
+ ...(identifier ? { identifier } : {}),
782
+ ...(title ? { title } : {}),
783
+ ...(warnings.length > 0 ? { warnings } : {}),
784
+ };
785
+ if (debugEnabled) {
786
+ metaBase.debug = {
787
+ x: { count: xs.length, totalSlots, padding, plotW },
788
+ y: { yMin, yMax, ticks: yTicks },
789
+ data: { withBB, withSMA, withEMA, withIchimoku, forwardShift, pastBuffer },
790
+ ...debugInfo,
791
+ };
792
+ }
793
+
794
+ const summary = summaryNotes.length
795
+ ? `${formatPair(pair)} ${type} chart rendered (${summaryNotes.join('; ')})`
796
+ : `${formatPair(pair)} ${type} chart rendered`;
797
+
798
+ return ok<RenderData, RenderMeta>(summary, { svg: finalSvg, legend: legendMeta }, metaBase);
799
+ }