bitbank-lab-mcp 0.1.3 → 0.2.1
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/README.md +2 -0
- package/lib/candle-fetch.ts +23 -0
- package/lib/tx-archive.ts +58 -0
- package/lib/validate.ts +70 -0
- package/package.json +4 -4
- package/src/schema/backtest.ts +9 -1
- package/tools/analyze_volume_profile.ts +51 -39
- package/tools/get_candles.ts +177 -42
- package/tools/get_flow_metrics.ts +58 -58
- package/tools/get_transactions.ts +13 -2
- package/tools/trading_process/lib/svg_to_png.ts +11 -2
- package/tools/trading_process/run_backtest.ts +17 -5
package/README.md
CHANGED
|
@@ -420,6 +420,8 @@ npx @modelcontextprotocol/inspector -- npx -y bitbank-lab-mcp
|
|
|
420
420
|
```
|
|
421
421
|
ソースコードから動かす場合は `npx @modelcontextprotocol/inspector -- tsx src/server.ts`([開発者向け](#開発者向けソースから起動) を参照)。
|
|
422
422
|
|
|
423
|
+
**Q. 当日の約定(フロー)データが少ない・古い時間帯が取れない** bitbank 側の仕様です。約定履歴の日付アーカイブ(`/transactions/{YYYYMMDD}`)は **UTC 暦日単位**で、当該 UTC 日の完了後(日本時間 9:00 以降、さらに公開遅延あり)にのみ公開されます。進行中の UTC 日の約定は直近約 60 件(latest)しか取得できないため、`get_flow_metrics` 等では当日区間のカバレッジが限定的になります(結果に warning として明示されます)。ローソク足(`get_candles`)は進行中の UTC 日でもリアルタイムで取得できます。
|
|
424
|
+
|
|
423
425
|
## トラブルシューティング
|
|
424
426
|
|
|
425
427
|
| 症状 | 原因・対処 |
|
package/lib/candle-fetch.ts
CHANGED
|
@@ -160,6 +160,29 @@ export async function mergeChunks(
|
|
|
160
160
|
return { rows, results, lastRateLimit, failedKeys };
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* 失敗した chunk を「key(エラー内容)」形式で列挙する(診断用メッセージ向け)。
|
|
165
|
+
* 例: "20260708(HTTP 404 Not Found), 20260707(bitbank API error (code: 10000))"
|
|
166
|
+
*
|
|
167
|
+
* 「N日中M日失敗」だけではどの日付が何の理由で落ちたか判別できず調査不能になるため、
|
|
168
|
+
* 過半数失敗 fail / 部分失敗 warning のメッセージには必ずこれを含めること。
|
|
169
|
+
*/
|
|
170
|
+
export function describeFailedChunks(
|
|
171
|
+
keys: string[],
|
|
172
|
+
results: FetchChunkResult[],
|
|
173
|
+
onlyKeys?: ReadonlySet<string>,
|
|
174
|
+
): string {
|
|
175
|
+
const parts: string[] = [];
|
|
176
|
+
for (let i = 0; i < keys.length; i++) {
|
|
177
|
+
const err = results[i]?.error;
|
|
178
|
+
if (err == null) continue;
|
|
179
|
+
if (onlyKeys && !onlyKeys.has(keys[i])) continue;
|
|
180
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
181
|
+
parts.push(`${keys[i]}(${msg})`);
|
|
182
|
+
}
|
|
183
|
+
return parts.join(', ');
|
|
184
|
+
}
|
|
185
|
+
|
|
163
186
|
function isOhlcAllZero(row: OhlcvRow): boolean {
|
|
164
187
|
return Number(row[0]) === 0 && Number(row[1]) === 0 && Number(row[2]) === 0 && Number(row[3]) === 0;
|
|
165
188
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /transactions/{YYYYMMDD} 日付アーカイブの日付キーユーティリティ。
|
|
3
|
+
*
|
|
4
|
+
* bitbank Public API の日付アーカイブは **UTC 暦日** でグルーピングされ、
|
|
5
|
+
* 当該 UTC 日が完了するまで 404 を返す(進行中の UTC 日のデータは
|
|
6
|
+
* /transactions (latest, 直近約60件) でのみ取得可能)。
|
|
7
|
+
* 実測ログ: docs/internal/bitbank-tx-archive-tz.md
|
|
8
|
+
*
|
|
9
|
+
* JST 基準で「今日 / 昨日」を組むと、JST 早朝(00:00〜09:00 = UTC 日付更新前)には
|
|
10
|
+
* 進行中の UTC 日を要求してしまい必ず 404 になる。日付キーは必ず本モジュールの
|
|
11
|
+
* 「完了済み UTC 暦日」ベースで導出すること。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { dayjs } from './datetime.js';
|
|
15
|
+
|
|
16
|
+
/** nowMs が属する UTC 暦日キー (YYYYMMDD)。この日のアーカイブは未公開(404)。 */
|
|
17
|
+
export function currentUtcDayKey(nowMs: number = Date.now()): string {
|
|
18
|
+
return dayjs.utc(nowMs).format('YYYYMMDD');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* dateKey のアーカイブが公開済みと期待できるか(= その UTC 暦日が完了しているか)。
|
|
23
|
+
* 進行中・未来の UTC 日は false。形式不正(YYYYMMDD 以外)も false。
|
|
24
|
+
*/
|
|
25
|
+
export function isArchiveExpectedPublished(dateKey: string, nowMs: number = Date.now()): boolean {
|
|
26
|
+
return /^\d{8}$/.test(dateKey) && dateKey < currentUtcDayKey(nowMs);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 直近 count 個の完了済み UTC 暦日キーを新しい順で返す。
|
|
31
|
+
* 進行中の UTC 日(= currentUtcDayKey)は含めない。
|
|
32
|
+
*/
|
|
33
|
+
export function recentCompletedUtcDayKeys(count: number, nowMs: number = Date.now()): string[] {
|
|
34
|
+
const utcNow = dayjs.utc(nowMs);
|
|
35
|
+
return Array.from({ length: Math.max(0, Math.floor(count)) }, (_, i) =>
|
|
36
|
+
utcNow.subtract(i + 1, 'day').format('YYYYMMDD'),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* [sinceMs, nowMs] と交差する完了済み UTC 暦日キーを昇順で返す。
|
|
42
|
+
* 進行中の UTC 日は除外する(アーカイブ未公開のため要求しても 404)。
|
|
43
|
+
* その区間のデータが必要な場合は /transactions (latest) で補完し、
|
|
44
|
+
* カバレッジ不足を warning で明示すること。
|
|
45
|
+
*/
|
|
46
|
+
export function completedUtcDayKeysInRange(sinceMs: number, nowMs: number = Date.now()): string[] {
|
|
47
|
+
if (!Number.isFinite(sinceMs) || !Number.isFinite(nowMs) || sinceMs > nowMs) return [];
|
|
48
|
+
const current = currentUtcDayKey(nowMs);
|
|
49
|
+
const keys: string[] = [];
|
|
50
|
+
let d = dayjs.utc(sinceMs).startOf('day');
|
|
51
|
+
const end = dayjs.utc(nowMs).startOf('day');
|
|
52
|
+
while (d.valueOf() <= end.valueOf()) {
|
|
53
|
+
const key = d.format('YYYYMMDD');
|
|
54
|
+
if (key < current) keys.push(key);
|
|
55
|
+
d = d.add(1, 'day');
|
|
56
|
+
}
|
|
57
|
+
return keys;
|
|
58
|
+
}
|
package/lib/validate.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, delimiter, dirname, join, resolve, sep } from 'node:path';
|
|
1
3
|
import type { Pair } from '../src/schemas.js';
|
|
2
4
|
import { nowIso } from './datetime.js';
|
|
3
5
|
|
|
@@ -148,6 +150,74 @@ export function validateDate(
|
|
|
148
150
|
return { ok: true, value: date };
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/** チャートファイルの既定出力ディレクトリ(Claude.ai 環境の出力先) */
|
|
154
|
+
export const DEFAULT_CHART_OUTPUT_DIR = '/mnt/user-data/outputs';
|
|
155
|
+
|
|
156
|
+
/** チャート出力の許可 root を運用側が追加する環境変数(path.delimiter 区切り) */
|
|
157
|
+
export const OUTPUT_DIR_ALLOWLIST_ENV = 'BACKTEST_OUTPUT_DIR_ALLOWLIST';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* チャート出力先として許可される root ディレクトリ一覧。
|
|
161
|
+
* 既定: DEFAULT_CHART_OUTPUT_DIR とサーバー作業ディレクトリ配下
|
|
162
|
+
* (相対パス指定・Cursor 等でワークスペース直下に書き出すユースケース用)。
|
|
163
|
+
* それ以外は運用側が環境変数で明示的に許可する(LLM 入力からは追加できない)。
|
|
164
|
+
*/
|
|
165
|
+
export function allowedOutputRoots(): string[] {
|
|
166
|
+
const extra = (process.env[OUTPUT_DIR_ALLOWLIST_ENV] ?? '')
|
|
167
|
+
.split(delimiter)
|
|
168
|
+
.map((p) => p.trim())
|
|
169
|
+
.filter((p) => p.length > 0)
|
|
170
|
+
.map((p) => resolve(p));
|
|
171
|
+
return [resolve(DEFAULT_CHART_OUTPUT_DIR), process.cwd(), ...extra];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* パスを実パスに正規化する。実在する最深の祖先を realpathSync で解決し、
|
|
176
|
+
* 未作成の末尾セグメントは字句のまま結合する(mkdir 前の検証で使うため)。
|
|
177
|
+
* 許可 root 配下に置かれた symlink を経由した外部への書き込みを防ぐ。
|
|
178
|
+
*/
|
|
179
|
+
function canonicalizePath(p: string): string {
|
|
180
|
+
let current = resolve(p);
|
|
181
|
+
const pending: string[] = [];
|
|
182
|
+
for (;;) {
|
|
183
|
+
try {
|
|
184
|
+
return join(realpathSync(current), ...pending);
|
|
185
|
+
} catch {
|
|
186
|
+
const parent = dirname(current);
|
|
187
|
+
if (parent === current) return join(current, ...pending);
|
|
188
|
+
pending.unshift(basename(current));
|
|
189
|
+
current = parent;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* outputDir が許可 root 配下かを検証する。
|
|
196
|
+
* LLM 入力(プロンプトインジェクション含む)経由でプロセス権限内の任意パスへ
|
|
197
|
+
* ディレクトリ作成・ファイル書き込みされるのを防ぐ。`..` を含むパスも
|
|
198
|
+
* symlink も実パスに解決してから判定するため、トラバーサル・symlink では
|
|
199
|
+
* 迂回できない(検証と書き込みの間に symlink を差し替える TOCTOU は、
|
|
200
|
+
* それができる時点でローカルアクセスを持つため脅威モデル外)。
|
|
201
|
+
*/
|
|
202
|
+
export function ensureAllowedOutputDir(
|
|
203
|
+
dir: string,
|
|
204
|
+
): { ok: true; dir: string } | { ok: false; error: { type: 'user' | 'internal'; message: string } } {
|
|
205
|
+
const canonical = canonicalizePath(dir);
|
|
206
|
+
const roots = allowedOutputRoots();
|
|
207
|
+
const canonicalRoots = roots.map(canonicalizePath);
|
|
208
|
+
const allowed = canonicalRoots.some((root) => canonical === root || canonical.startsWith(root + sep));
|
|
209
|
+
if (!allowed) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: {
|
|
213
|
+
type: 'user',
|
|
214
|
+
message: `outputDir '${dir}' は許可されていません。許可 root: ${roots.join(', ')} 配下のみ(追加は環境変数 ${OUTPUT_DIR_ALLOWLIST_ENV} で指定)`,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { ok: true, dir: canonical };
|
|
219
|
+
}
|
|
220
|
+
|
|
151
221
|
export function createMeta(
|
|
152
222
|
pair: Pair,
|
|
153
223
|
additional: Record<string, unknown> = {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bitbank-lab-mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Experimental MCP server for bitbank — botters lab community edition. Market data, technical analysis, charting, and Private API trading tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "https://github.com/
|
|
12
|
+
"url": "https://github.com/bitbankinc/bitbank-lab-mcp.git"
|
|
13
13
|
},
|
|
14
|
-
"homepage": "https://github.com/
|
|
14
|
+
"homepage": "https://github.com/bitbankinc/bitbank-lab-mcp",
|
|
15
15
|
"bugs": {
|
|
16
|
-
"url": "https://github.com/
|
|
16
|
+
"url": "https://github.com/bitbankinc/bitbank-lab-mcp/issues"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"bin/",
|
package/src/schema/backtest.ts
CHANGED
|
@@ -60,7 +60,15 @@ export const RunBacktestInputSchema = z
|
|
|
60
60
|
'One-way fee in basis points. When omitted, resolved dynamically from the current /spot/pairs taker rate (falls back to nominal 12 bp if unavailable). Explicit values are always respected.',
|
|
61
61
|
),
|
|
62
62
|
execution: z.literal('t+1_open').optional().default('t+1_open').describe('Execution timing (fixed: t+1_open)'),
|
|
63
|
-
outputDir: z
|
|
63
|
+
outputDir: z
|
|
64
|
+
.string()
|
|
65
|
+
.optional()
|
|
66
|
+
.default('/mnt/user-data/outputs')
|
|
67
|
+
.describe(
|
|
68
|
+
'Output directory for chart files. Must be under an allowed root: ' +
|
|
69
|
+
'/mnt/user-data/outputs, the server working directory, or a directory listed in ' +
|
|
70
|
+
'the BACKTEST_OUTPUT_DIR_ALLOWLIST environment variable.',
|
|
71
|
+
),
|
|
64
72
|
savePng: z
|
|
65
73
|
.boolean()
|
|
66
74
|
.optional()
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { z } from 'zod';
|
|
13
|
-
import {
|
|
13
|
+
import { toDisplayTime, toIsoWithTz } from '../lib/datetime.js';
|
|
14
14
|
import { formatPair, formatPercent, formatPrice } from '../lib/formatter.js';
|
|
15
15
|
import { fail, failFromError, failFromValidation, ok } from '../lib/result.js';
|
|
16
|
+
import { completedUtcDayKeysInRange, recentCompletedUtcDayKeys } from '../lib/tx-archive.js';
|
|
16
17
|
import { createMeta, ensurePair } from '../lib/validate.js';
|
|
17
18
|
import {
|
|
18
19
|
type AnalyzeVolumeProfileDataSchemaOut,
|
|
@@ -27,12 +28,22 @@ type Tx = { price: number; amount: number; side: 'buy' | 'sell'; timestampMs: nu
|
|
|
27
28
|
|
|
28
29
|
// ── Transaction fetch helpers (shared pattern with get_flow_metrics) ──
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
type TxFetchFailure = { label: string; errorType: string; message: string };
|
|
32
|
+
|
|
33
|
+
function mergeTxResults(
|
|
34
|
+
results: unknown[],
|
|
35
|
+
labels?: string[],
|
|
36
|
+
): { txs: Tx[]; totalCount: number; failedCount: number; failures: TxFetchFailure[] } {
|
|
31
37
|
const seen = new Set<string>();
|
|
32
38
|
const merged: Tx[] = [];
|
|
33
|
-
|
|
34
|
-
for (
|
|
35
|
-
const r =
|
|
39
|
+
const failures: TxFetchFailure[] = [];
|
|
40
|
+
for (let i = 0; i < results.length; i++) {
|
|
41
|
+
const r = results[i] as {
|
|
42
|
+
ok?: boolean;
|
|
43
|
+
data?: { normalized?: Tx[] };
|
|
44
|
+
summary?: string;
|
|
45
|
+
meta?: { errorType?: string };
|
|
46
|
+
} | null;
|
|
36
47
|
if (r?.ok && Array.isArray(r.data?.normalized)) {
|
|
37
48
|
for (const tx of r.data.normalized as Tx[]) {
|
|
38
49
|
const key = `${tx.timestampMs}:${tx.price}:${tx.amount}:${tx.side}`;
|
|
@@ -42,10 +53,19 @@ function mergeTxResults(results: unknown[]): { txs: Tx[]; totalCount: number; fa
|
|
|
42
53
|
}
|
|
43
54
|
}
|
|
44
55
|
} else {
|
|
45
|
-
|
|
56
|
+
failures.push({
|
|
57
|
+
label: labels?.[i] ?? `#${i}`,
|
|
58
|
+
errorType: r?.meta?.errorType ?? 'unknown',
|
|
59
|
+
message: r?.summary ?? 'unknown error',
|
|
60
|
+
});
|
|
46
61
|
}
|
|
47
62
|
}
|
|
48
|
-
return { txs: merged, totalCount: results.length, failedCount };
|
|
63
|
+
return { txs: merged, totalCount: results.length, failedCount: failures.length, failures };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 失敗詳細を "label(errorType: message)" 形式で列挙する(get_flow_metrics と同形式)。 */
|
|
67
|
+
function formatFailures(failures: TxFetchFailure[]): string {
|
|
68
|
+
return failures.map((f) => `${f.label}(${f.errorType}: ${f.message})`).join(', ');
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
type FetchResult = { ok: true; txs: Tx[]; fetchWarning?: string } | { ok: false; errorType: string; summary: string };
|
|
@@ -60,29 +80,28 @@ function extractUpstreamError(results: unknown[]): { errorType: string; summary:
|
|
|
60
80
|
return null;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
function partialFailureWarning(totalCount: number,
|
|
64
|
-
if (
|
|
65
|
-
|
|
83
|
+
function partialFailureWarning(totalCount: number, failures: TxFetchFailure[]): string | undefined {
|
|
84
|
+
if (failures.length === 0) return undefined;
|
|
85
|
+
// どの日付/エンドポイントが何の理由で失敗したかを必ず含める(件数だけでは診断不能)。
|
|
86
|
+
return `⚠️ ${totalCount}件中${failures.length}件のAPI取得に失敗しました(${formatFailures(failures)})。データが不完全な可能性があります。`;
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
async function fetchTransactions(pair: string, hours?: number, limit?: number): Promise<FetchResult> {
|
|
69
90
|
if (hours != null && hours > 0) {
|
|
70
91
|
const nowMs = Date.now();
|
|
71
92
|
const sinceMs = nowMs - hours * 3600_000;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
dates.push(d.format('YYYYMMDD'));
|
|
79
|
-
d = d.add(1, 'day');
|
|
80
|
-
}
|
|
93
|
+
|
|
94
|
+
// /transactions/{YYYYMMDD} は UTC 暦日アーカイブで、当該 UTC 日が完了するまで 404
|
|
95
|
+
// (実測: docs/internal/bitbank-tx-archive-tz.md)。進行中の UTC 日は列挙から除外し、
|
|
96
|
+
// その区間は /transactions (latest) で補完する。JST 暦日で列挙すると JST 早朝に
|
|
97
|
+
// 進行中の UTC 日を要求して必ず失敗する。
|
|
98
|
+
const dates = completedUtcDayKeysInRange(sinceMs, nowMs);
|
|
81
99
|
|
|
82
100
|
const fetches: Promise<unknown>[] = dates.map((ds) => getTransactions(pair, 1000, ds));
|
|
83
101
|
fetches.push(getTransactions(pair, 1000));
|
|
84
102
|
const results = await Promise.all(fetches);
|
|
85
|
-
const
|
|
103
|
+
const labels = [...dates, 'latest'];
|
|
104
|
+
const { txs: mergedTxs, totalCount, failedCount, failures } = mergeTxResults(results, labels);
|
|
86
105
|
if (mergedTxs.length === 0) {
|
|
87
106
|
const upstreamErr = extractUpstreamError(results);
|
|
88
107
|
if (upstreamErr) return { ok: false, ...upstreamErr };
|
|
@@ -91,7 +110,7 @@ async function fetchTransactions(pair: string, hours?: number, limit?: number):
|
|
|
91
110
|
return {
|
|
92
111
|
ok: false,
|
|
93
112
|
errorType: 'upstream',
|
|
94
|
-
summary: `API取得の過半数が失敗しました(${totalCount}件中${failedCount}
|
|
113
|
+
summary: `API取得の過半数が失敗しました(${totalCount}件中${failedCount}件失敗: ${formatFailures(failures)})`,
|
|
95
114
|
};
|
|
96
115
|
}
|
|
97
116
|
return {
|
|
@@ -99,7 +118,7 @@ async function fetchTransactions(pair: string, hours?: number, limit?: number):
|
|
|
99
118
|
txs: mergedTxs
|
|
100
119
|
.filter((t) => t.timestampMs >= sinceMs && t.timestampMs <= nowMs)
|
|
101
120
|
.sort((a, b) => a.timestampMs - b.timestampMs),
|
|
102
|
-
fetchWarning: partialFailureWarning(totalCount,
|
|
121
|
+
fetchWarning: partialFailureWarning(totalCount, failures),
|
|
103
122
|
};
|
|
104
123
|
}
|
|
105
124
|
|
|
@@ -115,31 +134,24 @@ async function fetchTransactions(pair: string, hours?: number, limit?: number):
|
|
|
115
134
|
if (latestTxs.length >= lim) return { ok: true, txs: latestTxs.slice(-lim) };
|
|
116
135
|
|
|
117
136
|
// Supplement with previous days
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (lim > 500) {
|
|
123
|
-
supplementFetches.push(getTransactions(pair, 1000, todayJst.subtract(2, 'day').format('YYYYMMDD')));
|
|
124
|
-
}
|
|
125
|
-
const supplementResults = await Promise.all(supplementFetches);
|
|
137
|
+
// /transactions/{YYYYMMDD} は UTC 暦日アーカイブ・当該 UTC 日完了後に公開のため、
|
|
138
|
+
// 完了済み UTC 日から補完する(JST 基準で組むと JST 早朝に進行中の UTC 日を要求して 404)。
|
|
139
|
+
const supplementDates = recentCompletedUtcDayKeys(lim > 500 ? 2 : 1);
|
|
140
|
+
const supplementResults = await Promise.all(supplementDates.map((ds) => getTransactions(pair, 1000, ds)));
|
|
126
141
|
const allResults = [latestRes, ...supplementResults];
|
|
127
|
-
const
|
|
142
|
+
const labels = ['latest', ...supplementDates];
|
|
143
|
+
const { txs: mergedTxs, totalCount, failures } = mergeTxResults(allResults, labels);
|
|
128
144
|
if (mergedTxs.length === 0) {
|
|
129
145
|
const upstreamErr = extractUpstreamError(allResults);
|
|
130
146
|
if (upstreamErr) return { ok: false, ...upstreamErr };
|
|
131
147
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
errorType: 'upstream',
|
|
136
|
-
summary: `API取得の過半数が失敗しました(${totalCount}件中${failedCount}件失敗)`,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
148
|
+
// 補完は best-effort: 何かしら取得できていれば fail せず、部分失敗は警告で明示する
|
|
149
|
+
// (latest 成功 + 補完失敗を「過半数失敗」として全体 fail すると、正当に取得できた
|
|
150
|
+
// 直近データまで捨ててしまう。補完アーカイブは公開遅延等で 404 になり得る)。
|
|
139
151
|
return {
|
|
140
152
|
ok: true,
|
|
141
153
|
txs: mergedTxs.sort((a, b) => a.timestampMs - b.timestampMs).slice(-lim),
|
|
142
|
-
fetchWarning: partialFailureWarning(totalCount,
|
|
154
|
+
fetchWarning: partialFailureWarning(totalCount, failures),
|
|
143
155
|
};
|
|
144
156
|
}
|
|
145
157
|
|
package/tools/get_candles.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
dedupeByTimestamp,
|
|
3
|
+
describeFailedChunks,
|
|
3
4
|
type FetchChunkResult,
|
|
4
5
|
fetchCandleChunk,
|
|
5
6
|
mergeChunks,
|
|
6
7
|
type OhlcvRow,
|
|
7
8
|
UpstreamApiError,
|
|
8
9
|
} from '../lib/candle-fetch.js';
|
|
9
|
-
import { dayjs, formatDateInTz,
|
|
10
|
+
import { dayjs, formatDateInTz, toIsoTime, toIsoWithTz } from '../lib/datetime.js';
|
|
10
11
|
import { getErrorMessage } from '../lib/error.js';
|
|
11
12
|
import { formatSummary } from '../lib/formatter.js';
|
|
12
13
|
import { BITBANK_API_BASE, DEFAULT_RETRIES, fetchJsonWithRateLimit, type RateLimitInfo } from '../lib/http.js';
|
|
@@ -99,10 +100,6 @@ const CANDLE_LIMIT = {
|
|
|
99
100
|
multiDay: 10_000,
|
|
100
101
|
} as const;
|
|
101
102
|
|
|
102
|
-
function todayYyyymmdd(): string {
|
|
103
|
-
return today('YYYYMMDD');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
103
|
/**
|
|
107
104
|
* tz 引数を正規化する。空文字・undefined・不正値は Asia/Tokyo にフォールバック。
|
|
108
105
|
*
|
|
@@ -149,6 +146,33 @@ function computeAnchorEndMs(rawDate: string, type: string, tz: string = 'Asia/To
|
|
|
149
146
|
return null;
|
|
150
147
|
}
|
|
151
148
|
|
|
149
|
+
/**
|
|
150
|
+
* date 指定時の対象期間の「開始」timestamp (ms since epoch) を返す(未来日の早期判定用)。
|
|
151
|
+
*
|
|
152
|
+
* computeAnchorEndMs と対になる関数で、tz 暦日/暦年の先頭 00:00:00.000 (in tz) を返す:
|
|
153
|
+
* - YYYYMMDD: その日の先頭
|
|
154
|
+
* - YYYY: その年の 1/1 先頭(YEARLY_TYPES でのみ用いる)
|
|
155
|
+
*
|
|
156
|
+
* 未来日判定は「終端 > now」ではなく「開始 > now」で行う。終端で判定すると
|
|
157
|
+
* 当日(進行中の tz 暦日)が『未来』扱いになり、当日途中の部分データ取得という
|
|
158
|
+
* 正当なユースケースを拒否してしまう。
|
|
159
|
+
*/
|
|
160
|
+
function computeAnchorStartMs(rawDate: string, type: string, tz: string = 'Asia/Tokyo'): number | null {
|
|
161
|
+
const safeTz = normalizeAnchorTz(tz);
|
|
162
|
+
if (/^\d{8}$/.test(rawDate)) {
|
|
163
|
+
const year = rawDate.slice(0, 4);
|
|
164
|
+
const month = rawDate.slice(4, 6);
|
|
165
|
+
const day = rawDate.slice(6, 8);
|
|
166
|
+
const d = dayjs.tz(`${year}-${month}-${day}`, safeTz);
|
|
167
|
+
return d.isValid() ? d.startOf('day').valueOf() : null;
|
|
168
|
+
}
|
|
169
|
+
if (YEARLY_TYPES.has(type) && /^\d{4}$/.test(rawDate)) {
|
|
170
|
+
const d = dayjs.tz(`${rawDate}-01-01`, safeTz);
|
|
171
|
+
return d.isValid() ? d.startOf('year').valueOf() : null;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
152
176
|
/**
|
|
153
177
|
* [windowStartMs, windowEndMs] と交差する UTC 暦年 (YYYY) を昇順で返す。
|
|
154
178
|
* YEARLY_TYPES の fetch key 導出に使う(bitbank API は UTC 年 chunk)。
|
|
@@ -213,6 +237,43 @@ function classifyAllChunksFailure(results: FetchChunkResult[]): FailResult | nul
|
|
|
213
237
|
return null;
|
|
214
238
|
}
|
|
215
239
|
|
|
240
|
+
/**
|
|
241
|
+
* 上流の「データ未生成(まだ無い)」応答か。
|
|
242
|
+
* bitbank /candlestick は未来・未開始の期間に HTTP 404 または success:0 (code: 10000) を返す
|
|
243
|
+
* (docs/internal/bitbank-candle-tz.md 実測)。5xx・ネットワークエラーは含めない。
|
|
244
|
+
*/
|
|
245
|
+
function isNotYetAvailableError(err: unknown): boolean {
|
|
246
|
+
if (err instanceof UpstreamApiError) return true;
|
|
247
|
+
const msg = err instanceof Error ? err.message : String(err ?? '');
|
|
248
|
+
return /\b404\b/.test(msg);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 失敗 chunk を「実失敗 (hard)」と「進行中 UTC 期間のデータ未生成 (expectedGap)」に分ける。
|
|
253
|
+
*
|
|
254
|
+
* expectedGap = key が進行中の UTC 期間以降(辞書順比較。YYYYMMDD 日 key / YYYY 年 key 両対応)
|
|
255
|
+
* かつエラーが 404 / success:0 のもの。UTC 期間の開始直後はその期間の chunk がまだ生成されて
|
|
256
|
+
* いないことがあり、これは上流の正常応答(データが無いだけ)なので、過半数失敗の分母分子から
|
|
257
|
+
* 除外して ℹ️ 注記に落とす。過去期間の失敗やネットワーク/5xx は実失敗として扱う。
|
|
258
|
+
*/
|
|
259
|
+
function partitionFailedChunks(
|
|
260
|
+
keys: string[],
|
|
261
|
+
results: FetchChunkResult[],
|
|
262
|
+
nowUtcDayKey: string,
|
|
263
|
+
): { hardFailedKeys: string[]; expectedGapKeys: string[] } {
|
|
264
|
+
const hardFailedKeys: string[] = [];
|
|
265
|
+
const expectedGapKeys: string[] = [];
|
|
266
|
+
for (let i = 0; i < keys.length; i++) {
|
|
267
|
+
const err = results[i]?.error;
|
|
268
|
+
if (err == null) continue;
|
|
269
|
+
const key = keys[i];
|
|
270
|
+
const isCurrentOrFuturePeriod = key >= nowUtcDayKey.slice(0, key.length);
|
|
271
|
+
if (isCurrentOrFuturePeriod && isNotYetAvailableError(err)) expectedGapKeys.push(key);
|
|
272
|
+
else hardFailedKeys.push(key);
|
|
273
|
+
}
|
|
274
|
+
return { hardFailedKeys, expectedGapKeys };
|
|
275
|
+
}
|
|
276
|
+
|
|
216
277
|
export default async function getCandles(
|
|
217
278
|
pair: string,
|
|
218
279
|
type: CandleType | string = '1day',
|
|
@@ -227,8 +288,14 @@ export default async function getCandles(
|
|
|
227
288
|
return fail(`type は ${[...TYPES].join(', ')} から選択してください(指定値: ${String(type)})`, 'user');
|
|
228
289
|
}
|
|
229
290
|
|
|
291
|
+
// anchor / fetch 範囲計算で参照する tz。空文字・不正値は Asia/Tokyo にフォールバックする
|
|
292
|
+
// (PR-2 の displayTz と同じフォールバック規則)。
|
|
293
|
+
const anchorTz = normalizeAnchorTz(tz);
|
|
294
|
+
|
|
230
295
|
const dateProvided = date != null;
|
|
231
|
-
|
|
296
|
+
// date 省略時の基準日は anchorTz の暦日で解釈する。サーバーのローカル tz で組むと、
|
|
297
|
+
// tz とローカルの暦日がずれる時間帯(JST 早朝の UTC サーバー等)に基準日が 1 日ずれる。
|
|
298
|
+
const effectiveDate = date ?? dayjs().tz(anchorTz).format('YYYYMMDD');
|
|
232
299
|
const dateCheck = validateDate(effectiveDate, String(type));
|
|
233
300
|
if (!dateCheck.ok) return failFromValidation(dateCheck);
|
|
234
301
|
|
|
@@ -238,10 +305,6 @@ export default async function getCandles(
|
|
|
238
305
|
const barsPerYear = BARS_PER_YEAR[type] || 365;
|
|
239
306
|
const barsPerDay = BARS_PER_DAY[type] || 24;
|
|
240
307
|
|
|
241
|
-
// anchor / fetch 範囲計算で参照する tz。空文字・不正値は Asia/Tokyo にフォールバックする
|
|
242
|
-
// (PR-2 の displayTz と同じフォールバック規則)。
|
|
243
|
-
const anchorTz = normalizeAnchorTz(tz);
|
|
244
|
-
|
|
245
308
|
// date 指定時に「これ以下の足だけ返す」アンカー timestamp を計算する。
|
|
246
309
|
// 単一 fetch(YEARLY_TYPES)では API が年単位で返すため slice(-limit) のみだと
|
|
247
310
|
// 「指定日以前 limit 件」ではなく「年末側 limit 件」を返してしまう問題への対処。
|
|
@@ -250,14 +313,17 @@ export default async function getCandles(
|
|
|
250
313
|
const anchorActive = anchorEndMs != null;
|
|
251
314
|
|
|
252
315
|
// anchor 計算後の早期 fail(fetch 前に明らかに無効な範囲を弾く):
|
|
253
|
-
// - 未来日:
|
|
316
|
+
// - 未来日: 期間の「開始」が現在時刻より先 → データはまだ存在しない。
|
|
317
|
+
// 当日(進行中の tz 暦日)は終端が未来でも部分データ取得が正当なユースケースなので、
|
|
318
|
+
// 終端ではなく開始で判定する(終端判定だと当日を『未来』として誤拒否する)。
|
|
254
319
|
// - サービス開始前: anchor が BITBANK_SERVICE_START_MS より前 → ヒューリスティックに無効と推定
|
|
255
320
|
// 上流 404 / 空配列として返すより user 向け原因を明示する方が調査が早い。
|
|
256
321
|
if (anchorEndMs != null) {
|
|
257
|
-
|
|
258
|
-
|
|
322
|
+
const anchorStartMs = computeAnchorStartMs(effectiveDate, String(type), anchorTz);
|
|
323
|
+
if (anchorStartMs != null && anchorStartMs > Date.now()) {
|
|
324
|
+
const isoLocal = toIsoWithTz(anchorStartMs, anchorTz) ?? String(anchorStartMs);
|
|
259
325
|
return fail(
|
|
260
|
-
`No candle data available for date=${effectiveDate} (date is in the future,
|
|
326
|
+
`No candle data available for date=${effectiveDate} (date is in the future, starts at ${isoLocal} ${anchorTz})`,
|
|
261
327
|
'user',
|
|
262
328
|
);
|
|
263
329
|
}
|
|
@@ -268,9 +334,11 @@ export default async function getCandles(
|
|
|
268
334
|
|
|
269
335
|
// 起点年(multi-year のみ参照):
|
|
270
336
|
// - date 指定時 → その年(過去年は丸ごと利用可能)
|
|
271
|
-
// - date 未指定 →
|
|
337
|
+
// - date 未指定 → 現在の UTC 年(部分年であり経過日数を考慮)
|
|
272
338
|
// 公式 API は YYYY 指定で 1 年分の candlestick を返す(4hour 以上の YEARLY_TYPES のみ)。
|
|
273
|
-
|
|
339
|
+
// 年は UTC で数える(fetch key は UTC 年 chunk。ローカル/anchorTz 年で数えると、
|
|
340
|
+
// JST 元日早朝など UTC ではまだ前年の時間帯に存在しない未来年 chunk を要求して 404 になる)。
|
|
341
|
+
const currentYear = dayjs.utc().year();
|
|
274
342
|
const anchorYear = dateProvided && isYearlyType ? Number(dateCheck.value) : currentYear;
|
|
275
343
|
const isCurrentYearAnchor = anchorYear === currentYear;
|
|
276
344
|
|
|
@@ -278,7 +346,7 @@ export default async function getCandles(
|
|
|
278
346
|
// floor(elapsedMs / intervalMs) + 1 は「確定済み本数 + 現在形成中の足」。
|
|
279
347
|
// 日数ベース(floor(dayOfYear * barsPerYear/365))だと 4hour/8hour/12hour で
|
|
280
348
|
// 今日 1 日分の足がすべて確定済と過大評価され、年初の小 limit で前年取得が漏れる。
|
|
281
|
-
const now = dayjs();
|
|
349
|
+
const now = dayjs.utc();
|
|
282
350
|
const startOfYear = now.startOf('year');
|
|
283
351
|
const intervalMs = INTERVAL_MS[String(type)] ?? 86_400_000;
|
|
284
352
|
const elapsedThisYearMs = Math.max(0, now.valueOf() - startOfYear.valueOf());
|
|
@@ -332,8 +400,20 @@ export default async function getCandles(
|
|
|
332
400
|
const localDayEndMs = dayjs.tz(`${y}-${m}-${d}`, anchorTz).endOf('day').valueOf();
|
|
333
401
|
const intervalMsForDaily = INTERVAL_MS[String(type)] ?? 3_600_000;
|
|
334
402
|
const lookbackStartMs = localDayEndMs - (limit - 1) * intervalMsForDaily;
|
|
335
|
-
|
|
336
|
-
|
|
403
|
+
// date 省略(realtime)は「現在時刻から遡って limit 本」も window に含める。
|
|
404
|
+
// tz 暦日終端起点の lookback だけだと、tz 暦日 = UTC 暦日となる tz(UTC 等)で window が
|
|
405
|
+
// 当日 1 chunk に潰れ、UTC 日開始直後はデータ未生成(success:0)で 0 本、日中も当日分
|
|
406
|
+
// しか返せず limit 本を満たせない。realtime の意味論は「最新 limit 本」なので now 起点が正。
|
|
407
|
+
const realtimeLookbackStartMs = !dateProvided
|
|
408
|
+
? Date.now() - (limit - 1) * intervalMsForDaily
|
|
409
|
+
: Number.POSITIVE_INFINITY;
|
|
410
|
+
const windowStartMs = Math.min(localDayStartMs, lookbackStartMs, realtimeLookbackStartMs);
|
|
411
|
+
// fetch key の列挙は現在時刻でクランプする。date 省略・当日指定では window 終端が
|
|
412
|
+
// tz 暦日の終端(未来)まで伸びるが、未来の UTC 日 chunk は上流に存在せず 404 が確定
|
|
413
|
+
// している(docs/internal/bitbank-candle-tz.md)。クランプしないと JST 早朝
|
|
414
|
+
// (UTC 日付更新前)に「まだ始まっていない UTC 日」を要求し、その 404 が
|
|
415
|
+
// 過半数失敗と誤判定されて取得済みデータごと捨てられる。
|
|
416
|
+
const windowEndMs = Math.min(localDayEndMs, Date.now());
|
|
337
417
|
|
|
338
418
|
let cursor = dayjs.utc(windowStartMs).startOf('day');
|
|
339
419
|
const endCursor = dayjs.utc(windowEndMs).startOf('day');
|
|
@@ -364,7 +444,8 @@ export default async function getCandles(
|
|
|
364
444
|
}
|
|
365
445
|
const lookbackStartMs = (anchorEndMs as number) - (limit - 1) * intervalMsYearly;
|
|
366
446
|
const windowStartMs = Math.min(periodStartMs, lookbackStartMs);
|
|
367
|
-
|
|
447
|
+
// 未来の UTC 年 chunk は存在しない(404 確定)ため現在時刻でクランプ(multi-day と同旨)。
|
|
448
|
+
const windowEndMs = Math.min(anchorEndMs as number, Date.now());
|
|
368
449
|
multiYearUtcKeys.push(...enumerateUtcYearsIntersectingWindow(windowStartMs, windowEndMs));
|
|
369
450
|
}
|
|
370
451
|
// date 未指定時は従来どおり anchorYear から過去方向に yearsNeeded 分だけ fetch。
|
|
@@ -405,15 +486,29 @@ export default async function getCandles(
|
|
|
405
486
|
if (classified) return classified;
|
|
406
487
|
}
|
|
407
488
|
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
489
|
+
// 失敗を「実失敗」と「進行中 UTC 年のデータ未生成(許容)」に分け、実失敗のみで過半数判定する。
|
|
490
|
+
// 失敗メッセージには key と原因を必ず含める(「N年中M年失敗」だけでは診断不能)。
|
|
491
|
+
const nowUtcDayKey = dayjs.utc().format('YYYYMMDD');
|
|
492
|
+
const { hardFailedKeys, expectedGapKeys } = partitionFailedChunks(yearKeys, merged.results, nowUtcDayKey);
|
|
493
|
+
const totalChunks = merged.results.length - expectedGapKeys.length;
|
|
494
|
+
if (hardFailedKeys.length > 0 && hardFailedKeys.length >= totalChunks / 2) {
|
|
495
|
+
const detail = describeFailedChunks(yearKeys, merged.results, new Set(hardFailedKeys));
|
|
496
|
+
return fail(
|
|
497
|
+
`ローソク足取得の過半数が失敗しました(${chk.pair}/${type}, ${totalChunks}年中${hardFailedKeys.length}年失敗: ${detail})`,
|
|
498
|
+
'upstream',
|
|
499
|
+
);
|
|
413
500
|
}
|
|
414
|
-
|
|
415
|
-
|
|
501
|
+
const warnLines: string[] = [];
|
|
502
|
+
if (hardFailedKeys.length > 0) {
|
|
503
|
+
const detail = describeFailedChunks(yearKeys, merged.results, new Set(hardFailedKeys));
|
|
504
|
+
warnLines.push(
|
|
505
|
+
`⚠️ ${totalChunks}年中${hardFailedKeys.length}年の取得に失敗しました(${detail})。データが不完全な可能性があります。`,
|
|
506
|
+
);
|
|
416
507
|
}
|
|
508
|
+
if (expectedGapKeys.length > 0) {
|
|
509
|
+
warnLines.push(`ℹ️ UTC 暦年 ${expectedGapKeys.join(', ')} は開始直後でデータ未生成のためスキップしました。`);
|
|
510
|
+
}
|
|
511
|
+
if (warnLines.length > 0) fetchWarning = warnLines.join('\n');
|
|
417
512
|
|
|
418
513
|
ohlcvs = merged.rows;
|
|
419
514
|
const years = yearKeys.map(Number);
|
|
@@ -443,15 +538,34 @@ export default async function getCandles(
|
|
|
443
538
|
if (classified) return classified;
|
|
444
539
|
}
|
|
445
540
|
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
541
|
+
// 失敗を「実失敗」と「進行中 UTC 日のデータ未生成(許容)」に分け、実失敗のみで過半数判定する。
|
|
542
|
+
// 進行中の UTC 日は通常 200 + 部分データを返すが、UTC 日開始直後は chunk 未生成で
|
|
543
|
+
// 404 になることがある。これを過半数失敗に数えると、取得済みの正当なデータごと捨てて
|
|
544
|
+
// しまう(JST 早朝の当日取得が全滅する障害の原因)。
|
|
545
|
+
// 失敗メッセージには key と原因を必ず含める(「N日中M日失敗」だけでは診断不能)。
|
|
546
|
+
const nowUtcDayKey = dayjs.utc().format('YYYYMMDD');
|
|
547
|
+
const { hardFailedKeys, expectedGapKeys } = partitionFailedChunks(multiDayUtcKeys, merged.results, nowUtcDayKey);
|
|
548
|
+
const totalDays = merged.results.length - expectedGapKeys.length;
|
|
549
|
+
if (hardFailedKeys.length > 0 && hardFailedKeys.length >= totalDays / 2) {
|
|
550
|
+
const detail = describeFailedChunks(multiDayUtcKeys, merged.results, new Set(hardFailedKeys));
|
|
551
|
+
return fail(
|
|
552
|
+
`ローソク足取得の過半数が失敗しました(${chk.pair}/${type}, ${totalDays}日中${hardFailedKeys.length}日失敗: ${detail})`,
|
|
553
|
+
'upstream',
|
|
554
|
+
);
|
|
451
555
|
}
|
|
452
|
-
|
|
453
|
-
|
|
556
|
+
const warnLines: string[] = [];
|
|
557
|
+
if (hardFailedKeys.length > 0) {
|
|
558
|
+
const detail = describeFailedChunks(multiDayUtcKeys, merged.results, new Set(hardFailedKeys));
|
|
559
|
+
warnLines.push(
|
|
560
|
+
`⚠️ ${totalDays}日中${hardFailedKeys.length}日の取得に失敗しました(${detail})。データが不完全な可能性があります。`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (expectedGapKeys.length > 0) {
|
|
564
|
+
warnLines.push(
|
|
565
|
+
`ℹ️ UTC 暦日 ${expectedGapKeys.join(', ')} は開始直後でデータ未生成のためスキップしました(当日分は形成され次第、以降の取得で揃います)。`,
|
|
566
|
+
);
|
|
454
567
|
}
|
|
568
|
+
if (warnLines.length > 0) fetchWarning = warnLines.join('\n');
|
|
455
569
|
|
|
456
570
|
ohlcvs = merged.rows;
|
|
457
571
|
json = {
|
|
@@ -459,10 +573,18 @@ export default async function getCandles(
|
|
|
459
573
|
_multiDay: { daysRequested: multiDayUtcKeys.length, totalFetched: ohlcvs.length },
|
|
460
574
|
};
|
|
461
575
|
} else {
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
576
|
+
// 単一リクエスト。fetch key は UTC 基準で導出済みの key set を優先する:
|
|
577
|
+
// - YEARLY tz window で 1 UTC 年のみ交差 → その年 key
|
|
578
|
+
// - DAILY で UTC 暦日 key が 1 日のみ(クランプ後の当日取得など) → その日 key。
|
|
579
|
+
// dateCheck.value(tz 暦日)をそのまま使うと、JST 早朝の当日取得で
|
|
580
|
+
// 「UTC ではまだ始まっていない日」を要求して 404 になる。
|
|
581
|
+
const singleKey =
|
|
582
|
+
yearlyTzWindowActive && multiYearUtcKeys.length === 1
|
|
583
|
+
? multiYearUtcKeys[0]
|
|
584
|
+
: isDailyType && multiDayUtcKeys.length >= 1
|
|
585
|
+
? multiDayUtcKeys[multiDayUtcKeys.length - 1]
|
|
586
|
+
: dateCheck.value;
|
|
587
|
+
const url = `${BITBANK_API_BASE}/${chk.pair}/candlestick/${type}/${singleKey}`;
|
|
466
588
|
const fetchResult = await fetchJsonWithRateLimit(url, {
|
|
467
589
|
timeoutMs: CANDLE_FETCH.singleTimeoutMs,
|
|
468
590
|
retries: DEFAULT_RETRIES,
|
|
@@ -477,6 +599,18 @@ export default async function getCandles(
|
|
|
477
599
|
// 公式 API は { success: 0|1, data: ... } 形式で、エラー時は success:0 を返す。
|
|
478
600
|
// optional chaining のフォールバックに任せると空配列として握りつぶされ「データなし」(user) として返してしまう。
|
|
479
601
|
if (jsonObj?.success !== 1) {
|
|
602
|
+
// 進行中の UTC 期間の success:0 は「データ未生成(UTC 期間の開始直後)」で上流障害ではない。
|
|
603
|
+
// multi-fetch 経路の expected-gap(partitionFailedChunks)と同じ扱いで user 向けに明示する
|
|
604
|
+
// (例: tz=UTC × 当日指定が UTC 日開始直後に単一 key へ潰れるケース)。
|
|
605
|
+
if (isDailyType && String(singleKey) >= dayjs.utc().format('YYYYMMDD')) {
|
|
606
|
+
return parseAsResult<GetCandlesData, GetCandlesMeta>(
|
|
607
|
+
GetCandlesOutputSchema,
|
|
608
|
+
fail(
|
|
609
|
+
`No candle data available yet for ${chk.pair} / ${type} / ${singleKey} (UTC 暦日の開始直後でデータ未生成です。しばらく待って再試行するか、前日以前の date を指定してください)`,
|
|
610
|
+
'user',
|
|
611
|
+
),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
480
614
|
const code = jsonObj?.data?.code;
|
|
481
615
|
const codeStr = code != null ? `(code: ${code})` : '';
|
|
482
616
|
return parseAsResult<GetCandlesData, GetCandlesMeta>(
|
|
@@ -697,10 +831,11 @@ export default async function getCandles(
|
|
|
697
831
|
priceRange,
|
|
698
832
|
});
|
|
699
833
|
|
|
700
|
-
// 最新足が形成中(未確定)か。realtime 取得(date
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
const
|
|
834
|
+
// 最新足が形成中(未確定)か。realtime 取得(date 未指定)と、anchor 期間が現在を含む
|
|
835
|
+
// 当日/当年指定で判定する。過去 anchor は確定足なので注記を付けない(false)。
|
|
836
|
+
// 1month の暦依存も含め、厳密判定は lib の isLatestBarProvisional に委ねる(自前実装しない)。
|
|
837
|
+
const anchorIncludesNow = anchorEndMs == null || anchorEndMs > Date.now();
|
|
838
|
+
const provisional = anchorIncludesNow && isLatestBarProvisional(normalized.at(-1)?.timestamp, String(type));
|
|
704
839
|
|
|
705
840
|
// テキスト summary に全ローソク足データを含める
|
|
706
841
|
// (MCP クライアントが structuredContent.data を読めない場合に対応)
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { dayjs, toDisplayTime, toIsoTime, toIsoWithTz } from '../lib/datetime.js';
|
|
2
2
|
import { formatSummary } from '../lib/formatter.js';
|
|
3
3
|
import { fail, failFromError, failFromValidation, ok } from '../lib/result.js';
|
|
4
|
+
import {
|
|
5
|
+
completedUtcDayKeysInRange,
|
|
6
|
+
currentUtcDayKey,
|
|
7
|
+
isArchiveExpectedPublished,
|
|
8
|
+
recentCompletedUtcDayKeys,
|
|
9
|
+
} from '../lib/tx-archive.js';
|
|
4
10
|
import { createMeta, ensurePair, validateLimit } from '../lib/validate.js';
|
|
5
11
|
import { GetFlowMetricsInputSchema, GetFlowMetricsOutputSchema } from '../src/schemas.js';
|
|
6
12
|
import type { ToolDefinition } from '../src/tool-definition.js';
|
|
@@ -151,20 +157,13 @@ export default async function getFlowMetrics(
|
|
|
151
157
|
const nowMs = Date.now();
|
|
152
158
|
const sinceMs = nowMs - hours * 3600_000;
|
|
153
159
|
|
|
154
|
-
// bitbank の /transactions/{YYYYMMDD} は
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
// 必要な日付を YYYYMMDD (JST) 形式で列挙(古い順)
|
|
162
|
-
const dates: string[] = [];
|
|
163
|
-
let d = sinceDayjs.startOf('day');
|
|
164
|
-
while (d.isBefore(nowDayjs) || d.isSame(nowDayjs, 'day')) {
|
|
165
|
-
dates.push(d.format('YYYYMMDD'));
|
|
166
|
-
d = d.add(1, 'day');
|
|
167
|
-
}
|
|
160
|
+
// bitbank の /transactions/{YYYYMMDD} は UTC 暦日アーカイブで、当該 UTC 日が完了する
|
|
161
|
+
// まで 404 を返す(実測: docs/internal/bitbank-tx-archive-tz.md)。進行中の UTC 日は
|
|
162
|
+
// 要求しても必ず 404 なので列挙から除外し、その区間は /transactions (latest) で補完する。
|
|
163
|
+
// (旧実装は JST 暦日で列挙しており、JST 早朝=UTC 日付更新前に進行中の UTC 日を
|
|
164
|
+
// 要求して 404 → 全滅していた。)
|
|
165
|
+
const currentUtcKey = currentUtcDayKey(nowMs);
|
|
166
|
+
const dates = completedUtcDayKeysInRange(sinceMs, nowMs);
|
|
168
167
|
|
|
169
168
|
// 日付ベース取得(authoritative: 時間範囲をカバー)と latest(supplement: 直近数分の補完)を区別。
|
|
170
169
|
// 当日分は日付指定だと直近数分が欠ける場合があるため latest も併用する。
|
|
@@ -189,12 +188,11 @@ export default async function getFlowMetrics(
|
|
|
189
188
|
const dateMerge = mergeTxResults(dateResults, dates);
|
|
190
189
|
const latestMerge = mergeTxResults([latestResult], ['latest']);
|
|
191
190
|
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
const historicalAllFailed = historicalRequested && dateMerge.txs.length === 0 && nonTodayFailures.length > 0;
|
|
191
|
+
// 完了済み UTC 日アーカイブ(authoritative)が全滅した場合は fail。
|
|
192
|
+
// 進行中の UTC 日は fetch 対象外(アーカイブ未公開)なので、この失敗は実失敗のみ。
|
|
193
|
+
// 「全滅」は失敗件数 == 要求件数で厳密に判定する(txs.length===0 をプロキシにすると、
|
|
194
|
+
// 約定 0 件の日 + 一部失敗の組合せを全滅と誤分類する)。
|
|
195
|
+
const historicalAllFailed = dates.length > 0 && dateMerge.failures.length === dates.length;
|
|
198
196
|
|
|
199
197
|
if (historicalAllFailed) {
|
|
200
198
|
return GetFlowMetricsOutputSchema.parse(
|
|
@@ -205,29 +203,28 @@ export default async function getFlowMetrics(
|
|
|
205
203
|
);
|
|
206
204
|
}
|
|
207
205
|
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
206
|
+
// 時間窓が進行中の UTC 日内に収まる(アーカイブ要求なし)場合、latest が唯一のソース。
|
|
207
|
+
// その latest も失敗したら取得手段なし。
|
|
208
|
+
if (dates.length === 0 && latestMerge.txs.length === 0 && latestMerge.failures.length > 0) {
|
|
211
209
|
return GetFlowMetricsOutputSchema.parse(
|
|
212
210
|
fail(
|
|
213
|
-
|
|
211
|
+
`取得手段がありません: 時間窓が進行中の UTC 日 (${currentUtcKey}) 内のためアーカイブは未公開で、latest 取得も失敗しました(${formatFailures(latestMerge.failures)})`,
|
|
214
212
|
'upstream',
|
|
215
213
|
),
|
|
216
214
|
);
|
|
217
215
|
}
|
|
218
216
|
|
|
219
|
-
//
|
|
217
|
+
// 部分失敗・カバレッジ制約は警告で明示(latest 失敗は直近数分の欠落、一部 date 失敗は該当日のカバレッジ不足)
|
|
220
218
|
const warnMsgs: string[] = [];
|
|
221
|
-
if (
|
|
222
|
-
warnMsgs.push(
|
|
223
|
-
`⚠️ 日付ベース取得で ${dateMerge.totalCount}件中 ${nonTodayFailures.length}件失敗: ${formatFailures(nonTodayFailures)}`,
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
if (todayFailed) {
|
|
219
|
+
if (dateMerge.failures.length > 0) {
|
|
227
220
|
warnMsgs.push(
|
|
228
|
-
`⚠️
|
|
221
|
+
`⚠️ 日付ベース取得で ${dateMerge.totalCount}件中 ${dateMerge.failures.length}件失敗: ${formatFailures(dateMerge.failures)}`,
|
|
229
222
|
);
|
|
230
223
|
}
|
|
224
|
+
// 進行中の UTC 日の区間はアーカイブが存在しないため、常に latest(直近約60件)のみでの補完になる
|
|
225
|
+
warnMsgs.push(
|
|
226
|
+
`ℹ️ 進行中の UTC 日 (${currentUtcKey}) のアーカイブは未公開のため、この区間は /transactions (latest, 直近約60件) で補完しています`,
|
|
227
|
+
);
|
|
231
228
|
if (latestMerge.failures.length > 0) {
|
|
232
229
|
warnMsgs.push(
|
|
233
230
|
`⚠️ 最新約定の補完取得に失敗 (${formatFailures(latestMerge.failures)}) — 直近数分のデータが欠落している可能性があります`,
|
|
@@ -256,21 +253,23 @@ export default async function getFlowMetrics(
|
|
|
256
253
|
|
|
257
254
|
if (date) {
|
|
258
255
|
// 明示的な日付指定がある場合はそのまま取得。
|
|
259
|
-
//
|
|
256
|
+
// /transactions/{YYYYMMDD} は UTC 暦日アーカイブで、当該 UTC 日が完了するまで未公開
|
|
257
|
+
// (404)。進行中・未来の UTC 日(JST の「今日」に加え、JST 早朝は「昨日」も該当)を
|
|
258
|
+
// 指定された場合は latest にフォールバックする。
|
|
260
259
|
const txRes = await getTransactions(chk.pair, Math.min(lim.value, 1000), date);
|
|
261
|
-
const
|
|
260
|
+
const archivePublished = isArchiveExpectedPublished(date);
|
|
262
261
|
if (!txRes?.ok) {
|
|
263
|
-
if (
|
|
262
|
+
if (!archivePublished) {
|
|
264
263
|
const latestRes = await getTransactions(chk.pair, Math.min(lim.value, 1000));
|
|
265
264
|
if (!latestRes?.ok) {
|
|
266
265
|
return GetFlowMetricsOutputSchema.parse(
|
|
267
266
|
fail(
|
|
268
|
-
`date=${date}
|
|
267
|
+
`date=${date} のアーカイブは未公開(UTC 暦日完了後に公開)で、latest 取得も失敗: ${txRes?.summary || 'unknown'} / ${latestRes?.summary || 'unknown'}`,
|
|
269
268
|
latestRes?.meta?.errorType || 'upstream',
|
|
270
269
|
),
|
|
271
270
|
);
|
|
272
271
|
}
|
|
273
|
-
fetchWarning = `⚠️
|
|
272
|
+
fetchWarning = `⚠️ date=${date} のアーカイブは未公開(/transactions/{YYYYMMDD} は UTC 暦日の完了後に公開)のため /transactions (latest) から取得しました`;
|
|
274
273
|
// 加工契約: 全ての取得パスで昇順 sort を保証する。
|
|
275
274
|
// 上流 getTransactions も内部 sort 済みだが、契約の単一ソースをこちらに置く。
|
|
276
275
|
txs = (latestRes.data.normalized as Tx[]).slice().sort((a, b) => a.timestampMs - b.timestampMs);
|
|
@@ -294,18 +293,15 @@ export default async function getFlowMetrics(
|
|
|
294
293
|
// 加工契約: 全ての取得パスで昇順 sort を保証する。
|
|
295
294
|
txs = latestTxs.slice().sort((a, b) => a.timestampMs - b.timestampMs);
|
|
296
295
|
} else {
|
|
297
|
-
// latest
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
supplementFetches.push(getTransactions(chk.pair, 1000, todayJst.subtract(2, 'day').format('YYYYMMDD')));
|
|
305
|
-
}
|
|
306
|
-
const supplementResults = await Promise.all(supplementFetches);
|
|
296
|
+
// latest の返却数が不足(bitbank の latest エンドポイントは約60件のみ返却)
|
|
297
|
+
// → 完了済み UTC 日アーカイブで補完する。
|
|
298
|
+
// /transactions/{YYYYMMDD} は UTC 暦日アーカイブ・当該 UTC 日完了後に公開のため、
|
|
299
|
+
// JST 基準の「昨日」で組むと JST 早朝(UTC 日付更新前)は進行中の UTC 日を
|
|
300
|
+
// 要求して必ず 404 になる(当該障害の原因)。
|
|
301
|
+
const supplementDates = recentCompletedUtcDayKeys(lim.value > 500 ? 2 : 1);
|
|
302
|
+
const supplementResults = await Promise.all(supplementDates.map((ds) => getTransactions(chk.pair, 1000, ds)));
|
|
307
303
|
const allResults = [latestRes, ...supplementResults];
|
|
308
|
-
const labels = ['latest', ...
|
|
304
|
+
const labels = ['latest', ...supplementDates];
|
|
309
305
|
const { txs: merged, totalCount, failures } = mergeTxResults(allResults, labels);
|
|
310
306
|
// 全て失敗した場合は network エラーとして返す
|
|
311
307
|
if (merged.length === 0 && failures.length > 0) {
|
|
@@ -313,19 +309,22 @@ export default async function getFlowMetrics(
|
|
|
313
309
|
fail(`upstream fetch all failed (${formatFailures(failures)})`, 'network'),
|
|
314
310
|
);
|
|
315
311
|
}
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
`API取得の過半数が失敗しました(${totalCount}件中${failures.length}件失敗: ${formatFailures(failures)})`,
|
|
321
|
-
'upstream',
|
|
322
|
-
),
|
|
323
|
-
);
|
|
324
|
-
}
|
|
312
|
+
// 補完は best-effort: 何かしら取得できていれば fail せず、失敗と件数不足を警告で明示する。
|
|
313
|
+
// (latest 成功 + 補完失敗を「過半数失敗」として全体 fail すると、正当に取得できた
|
|
314
|
+
// 直近データまで捨ててしまう。補完アーカイブは公開遅延等で 404 になり得る。)
|
|
315
|
+
const warnMsgs: string[] = [];
|
|
325
316
|
if (failures.length > 0) {
|
|
326
|
-
|
|
317
|
+
warnMsgs.push(
|
|
318
|
+
`⚠️ ${totalCount}件中 ${failures.length}件のAPI取得に失敗しました: ${formatFailures(failures)}`,
|
|
319
|
+
);
|
|
327
320
|
}
|
|
328
321
|
txs = merged.sort((a, b) => a.timestampMs - b.timestampMs).slice(-lim.value);
|
|
322
|
+
if (txs.length < lim.value) {
|
|
323
|
+
warnMsgs.push(
|
|
324
|
+
`ℹ️ 要求 ${lim.value}件に対し取得できたのは ${txs.length}件です(進行中の UTC 日のアーカイブは未公開のため取得不可)`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (warnMsgs.length > 0) fetchWarning = warnMsgs.join('\n');
|
|
329
328
|
}
|
|
330
329
|
}
|
|
331
330
|
}
|
|
@@ -546,6 +545,7 @@ export const toolDef: ToolDefinition = {
|
|
|
546
545
|
name: 'get_flow_metrics',
|
|
547
546
|
description:
|
|
548
547
|
`[Flow / CVD / Buy-Sell Pressure] 資金フロー分析(flow / CVD / aggressor ratio / buy-sell pressure)。約定データからCVD・アグレッサー比・スパイクを検出。hours(推奨)で時間範囲指定、または limit で件数指定。` +
|
|
548
|
+
`\n\nデータソース制約(bitbank 側仕様): 約定アーカイブ /transactions/{YYYYMMDD} は UTC 暦日単位で、当該 UTC 日の完了後にのみ公開される。進行中の UTC 日(JST 09:00 で切り替わる)の約定は /transactions (latest, 直近約60件) でしか取得できないため、当日区間のカバレッジは限定的(warning で明示される)。` +
|
|
549
549
|
`\n\n加工契約:` +
|
|
550
550
|
`\n- 内部で使用する約定列は、取得パスに関わらず timestampMs 昇順にソート済み。` +
|
|
551
551
|
`\n- latest と date ベースをマージする場合、重複除去キーは \`timestampMs:price:amount:side\`(transaction_id は使用しない: 同一約定でも上流エンドポイント間で ID が一致しないケースがあるため)。`,
|
|
@@ -4,6 +4,7 @@ import { dayjs, toIsoMs } from '../lib/datetime.js';
|
|
|
4
4
|
import { formatPair, formatPrice } from '../lib/formatter.js';
|
|
5
5
|
import { BITBANK_API_BASE, DEFAULT_RETRIES, fetchJsonWithRateLimit } from '../lib/http.js';
|
|
6
6
|
import { fail, failFromError, failFromValidation, ok } from '../lib/result.js';
|
|
7
|
+
import { isArchiveExpectedPublished } from '../lib/tx-archive.js';
|
|
7
8
|
import { createMeta, ensurePair, validateLimit } from '../lib/validate.js';
|
|
8
9
|
import {
|
|
9
10
|
type GetTransactionsDataSchemaOut,
|
|
@@ -193,7 +194,16 @@ export default async function getTransactions(pair: string = 'btc_jpy', limit: n
|
|
|
193
194
|
});
|
|
194
195
|
}
|
|
195
196
|
const baseMsg = e instanceof Error && e.message ? e.message : typeof e === 'string' ? e : 'ネットワークエラー';
|
|
196
|
-
|
|
197
|
+
// 進行中・未来の UTC 日のアーカイブ 404 は bitbank 側の仕様(UTC 暦日完了後に公開)。
|
|
198
|
+
// 「なぜ 404 か」を呼び出し側で診断できるようヒントを付与する。
|
|
199
|
+
// URL 生成(上記)と同じ形式判定に揃える: YYYYMMDD 形式でない date は latest エンドポイントに
|
|
200
|
+
// フォールバックしており、その 404 にアーカイブ未公開ヒントを付けると誤誘導になる。
|
|
201
|
+
const isDateArchiveRequest = date != null && /^\d{8}$/.test(String(date));
|
|
202
|
+
const archiveHint =
|
|
203
|
+
isDateArchiveRequest && /404/.test(baseMsg) && !isArchiveExpectedPublished(String(date))
|
|
204
|
+
? `(/transactions/{YYYYMMDD} は UTC 暦日アーカイブで、date=${date} は UTC ではまだ完了していないため未公開です。直近の約定は date 省略の latest を使用してください)`
|
|
205
|
+
: '';
|
|
206
|
+
const wrapped = new Error(`${baseMsg} [url: ${url}]${archiveHint}`);
|
|
197
207
|
return failFromError(wrapped, {
|
|
198
208
|
schema: GetTransactionsOutputSchema,
|
|
199
209
|
timeoutMs: 4000,
|
|
@@ -207,7 +217,8 @@ export default async function getTransactions(pair: string = 'btc_jpy', limit: n
|
|
|
207
217
|
export const toolDef: ToolDefinition = {
|
|
208
218
|
name: 'get_transactions',
|
|
209
219
|
description:
|
|
210
|
-
'[Transactions / Trades] 市場の約定履歴(transactions / recent trades)を取得。直近60件 or 日付指定。金額・価格でフィルタ可能。'
|
|
220
|
+
'[Transactions / Trades] 市場の約定履歴(transactions / recent trades)を取得。直近60件 or 日付指定。金額・価格でフィルタ可能。' +
|
|
221
|
+
'\n\n制約(bitbank 側仕様): date 指定(YYYYMMDD)は UTC 暦日アーカイブで、当該 UTC 日の完了後(JST 09:00 以降)にのみ公開される。進行中の UTC 日を指定すると 404。当日分の約定は date 省略(latest, 直近約60件)でのみ取得可能。',
|
|
211
222
|
inputSchema: GetTransactionsInputSchema,
|
|
212
223
|
handler: async ({
|
|
213
224
|
pair,
|
|
@@ -49,6 +49,15 @@ export async function svgToPng(
|
|
|
49
49
|
return outputPath;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* ファイル名に埋め込む値からパス区切り・ドット等を除去する。
|
|
54
|
+
* pair は上流(ensurePair)で検証済みだが、ファイル名の安全性を
|
|
55
|
+
* 上流バリデーションに依存させないための防御的サニタイズ。
|
|
56
|
+
*/
|
|
57
|
+
function sanitizeFilenamePart(part: string): string {
|
|
58
|
+
return part.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
/**
|
|
53
62
|
* Generate a unique filename for backtest chart
|
|
54
63
|
*/
|
|
@@ -59,6 +68,6 @@ export function generateBacktestChartFilename(
|
|
|
59
68
|
format: 'png' | 'svg' = 'png',
|
|
60
69
|
): string {
|
|
61
70
|
const timestamp = dayjs().format('YYYY-MM-DDTHH-mm-ss');
|
|
62
|
-
const safePair = pair.replace('_', '');
|
|
63
|
-
return `backtest_${safePair}_${timeframe}_${strategy}_${timestamp}.${format}`;
|
|
71
|
+
const safePair = sanitizeFilenamePart(pair.replace('_', ''));
|
|
72
|
+
return `backtest_${safePair}_${sanitizeFilenamePart(timeframe)}_${sanitizeFilenamePart(strategy)}_${timestamp}.${format}`;
|
|
64
73
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { dayjs, formatDateInTz } from '../../lib/datetime.js';
|
|
10
|
+
import { DEFAULT_CHART_OUTPUT_DIR, ensureAllowedOutputDir } from '../../lib/validate.js';
|
|
10
11
|
import { prependWarnings } from '../../lib/warning-propagation.js';
|
|
11
12
|
import { type BacktestEngineInput, type BacktestEngineResult, runBacktestEngine } from './lib/backtest_engine.js';
|
|
12
13
|
import { fetchCandlesForBacktest } from './lib/fetch_candles.js';
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
import type { BacktestRange, Period, Timeframe } from './types.js';
|
|
22
23
|
|
|
23
24
|
// Claude.ai のデフォルト出力ディレクトリ
|
|
24
|
-
const DEFAULT_OUTPUT_DIR =
|
|
25
|
+
const DEFAULT_OUTPUT_DIR = DEFAULT_CHART_OUTPUT_DIR;
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* 書き込み可能なディレクトリを確保
|
|
@@ -48,7 +49,7 @@ export interface RunBacktestInput {
|
|
|
48
49
|
strategy: StrategyConfig;
|
|
49
50
|
fee_bp?: number;
|
|
50
51
|
execution?: 't+1_open';
|
|
51
|
-
/** 出力ディレクトリ(デフォルト: /mnt/user-data/outputs
|
|
52
|
+
/** 出力ディレクトリ(デフォルト: /mnt/user-data/outputs/)。許可 root 配下のみ(lib/validate.ts の ensureAllowedOutputDir) */
|
|
52
53
|
outputDir?: string;
|
|
53
54
|
/** PNG ファイルを生成する(デフォルト: false、ファイルシステム非共有のため) */
|
|
54
55
|
savePng?: boolean;
|
|
@@ -118,6 +119,17 @@ export default async function runBacktest(input: RunBacktestInput): Promise<RunB
|
|
|
118
119
|
}
|
|
119
120
|
const params = validation.normalizedParams;
|
|
120
121
|
|
|
122
|
+
// 出力ディレクトリをバリデーション(savePng 時のみ・fetch 前に弾く)。
|
|
123
|
+
// LLM 指定の任意パスへの書き込みを許可 root 配下に制限する。
|
|
124
|
+
let resolvedOutputDir: string | undefined;
|
|
125
|
+
if (savePng) {
|
|
126
|
+
const dirCheck = ensureAllowedOutputDir(outputDir);
|
|
127
|
+
if (!dirCheck.ok) {
|
|
128
|
+
return { ok: false, error: dirCheck.error.message };
|
|
129
|
+
}
|
|
130
|
+
resolvedOutputDir = dirCheck.dir;
|
|
131
|
+
}
|
|
132
|
+
|
|
121
133
|
// 必要なバー数を params から動的に計算(ユーザーが long / slow / signal 等を
|
|
122
134
|
// デフォルトより大きく指定した場合でもウォームアップを十分確保するため)
|
|
123
135
|
const requiredBars = strategy.computeRequiredBars(params);
|
|
@@ -214,11 +226,11 @@ export default async function runBacktest(input: RunBacktestInput): Promise<RunB
|
|
|
214
226
|
};
|
|
215
227
|
|
|
216
228
|
// PNG ファイル保存(savePng: true の場合のみ)
|
|
217
|
-
if (savePng && svg) {
|
|
229
|
+
if (savePng && svg && resolvedOutputDir) {
|
|
218
230
|
try {
|
|
219
|
-
ensureOutputDir(
|
|
231
|
+
ensureOutputDir(resolvedOutputDir);
|
|
220
232
|
const filename = generateBacktestChartFilename(pair, timeframe, strategyConfig.type, 'png');
|
|
221
|
-
const pngPath = join(
|
|
233
|
+
const pngPath = join(resolvedOutputDir, filename);
|
|
222
234
|
await svgToPng(svg, pngPath, { density: 150 });
|
|
223
235
|
output.chartPath = pngPath;
|
|
224
236
|
} catch (pngError) {
|