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 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
  | 症状 | 原因・対処 |
@@ -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",
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/tjackiet/bitbank-lab-mcp.git"
12
+ "url": "https://github.com/bitbankinc/bitbank-lab-mcp.git"
13
13
  },
14
- "homepage": "https://github.com/tjackiet/bitbank-lab-mcp",
14
+ "homepage": "https://github.com/bitbankinc/bitbank-lab-mcp",
15
15
  "bugs": {
16
- "url": "https://github.com/tjackiet/bitbank-lab-mcp/issues"
16
+ "url": "https://github.com/bitbankinc/bitbank-lab-mcp/issues"
17
17
  },
18
18
  "files": [
19
19
  "bin/",
@@ -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.string().optional().default('/mnt/user-data/outputs').describe('Output directory for chart files'),
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 { dayjs, toDisplayTime, toIsoWithTz } from '../lib/datetime.js';
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
- function mergeTxResults(results: unknown[]): { txs: Tx[]; totalCount: number; failedCount: number } {
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
- let failedCount = 0;
34
- for (const res of results) {
35
- const r = res as { ok?: boolean; data?: { normalized?: Tx[] } } | null;
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
- failedCount++;
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, failedCount: number): string | undefined {
64
- if (failedCount === 0) return undefined;
65
- return `⚠️ ${totalCount}件中${failedCount}件のAPI取得に失敗しました。データが不完全な可能性があります。`;
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
- const sinceDayjs = dayjs(sinceMs).tz('Asia/Tokyo');
73
- const nowDayjs = dayjs(nowMs).tz('Asia/Tokyo');
74
-
75
- const dates: string[] = [];
76
- let d = sinceDayjs.startOf('day');
77
- while (d.isBefore(nowDayjs) || d.isSame(nowDayjs, 'day')) {
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 { txs: mergedTxs, totalCount, failedCount } = mergeTxResults(results);
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, failedCount),
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
- const todayJst = dayjs().tz('Asia/Tokyo');
119
- const supplementFetches: Promise<unknown>[] = [
120
- getTransactions(pair, 1000, todayJst.subtract(1, 'day').format('YYYYMMDD')),
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 { txs: mergedTxs, totalCount, failedCount } = mergeTxResults(allResults);
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
- if (failedCount > 0 && failedCount >= totalCount / 2) {
133
- return {
134
- ok: false,
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, failedCount),
154
+ fetchWarning: partialFailureWarning(totalCount, failures),
143
155
  };
144
156
  }
145
157
 
@@ -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, today, toIsoTime, toIsoWithTz } from '../lib/datetime.js';
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
- const effectiveDate = date ?? todayYyyymmdd();
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
- // - 未来日: anchor が現在時刻より先 データはまだ存在しない
316
+ // - 未来日: 期間の「開始」が現在時刻より先データはまだ存在しない。
317
+ // 当日(進行中の tz 暦日)は終端が未来でも部分データ取得が正当なユースケースなので、
318
+ // 終端ではなく開始で判定する(終端判定だと当日を『未来』として誤拒否する)。
254
319
  // - サービス開始前: anchor が BITBANK_SERVICE_START_MS より前 → ヒューリスティックに無効と推定
255
320
  // 上流 404 / 空配列として返すより user 向け原因を明示する方が調査が早い。
256
321
  if (anchorEndMs != null) {
257
- if (anchorEndMs > Date.now()) {
258
- const isoLocal = toIsoWithTz(anchorEndMs, anchorTz) ?? String(anchorEndMs);
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, anchor=${isoLocal})`,
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
- const currentYear = dayjs().year();
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
- const windowStartMs = Math.min(localDayStartMs, lookbackStartMs);
336
- const windowEndMs = localDayEndMs;
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
- const windowEndMs = anchorEndMs as number;
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
- // 過半数失敗なら信頼性が低いため fail
409
- const totalChunks = merged.results.length;
410
- const failedCount = merged.failedKeys.length;
411
- if (failedCount > 0 && failedCount >= totalChunks / 2) {
412
- return fail(`ローソク足取得の過半数が失敗しました(${totalChunks}年中${failedCount}年失敗)`, 'upstream');
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
- if (failedCount > 0) {
415
- fetchWarning = `⚠️ ${totalChunks}年中${failedCount}年の取得に失敗しました(${merged.failedKeys.join(', ')}年)。データが不完全な可能性があります。`;
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
- // 過半数失敗なら信頼性が低いため fail
447
- const totalDays = merged.results.length;
448
- const failedCount = merged.failedKeys.length;
449
- if (failedCount > 0 && failedCount >= totalDays / 2) {
450
- return fail(`ローソク足取得の過半数が失敗しました(${totalDays}日中${failedCount}日失敗)`, 'upstream');
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
- if (failedCount > 0) {
453
- fetchWarning = `⚠️ ${totalDays}日中${failedCount}日の取得に失敗しました。データが不完全な可能性があります。`;
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
- // 単一リクエスト(YEARLY tz window 1 UTC 年のみ交差する場合はその年 key)
463
- const singleYearKey =
464
- yearlyTzWindowActive && multiYearUtcKeys.length === 1 ? multiYearUtcKeys[0] : dateCheck.value;
465
- const url = `${BITBANK_API_BASE}/${chk.pair}/candlestick/${type}/${singleYearKey}`;
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
- // 過去日 anchor は確定足なので注記を付けない(false)。1month の暦依存も含め、
702
- // 厳密判定は lib の isLatestBarProvisional に委ねる(自前実装しない)。
703
- const provisional = !dateProvided && isLatestBarProvisional(normalized.at(-1)?.timestamp, String(type));
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} は JST 基準の日付アーカイブ。
155
- // 当日分はアーカイブが未生成で 404 を返すことがあるため、当日 URL の失敗は
156
- // fatal 扱いせず /transactions (latest) からの補完にフォールバックする。
157
- const sinceDayjs = dayjs(sinceMs).tz('Asia/Tokyo');
158
- const nowDayjs = dayjs(nowMs).tz('Asia/Tokyo');
159
- const todayStr = nowDayjs.format('YYYYMMDD');
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
- // 当日 (JST) のアーカイブ欠如は許容: その分は latest から補う。
193
- // fatal 扱いするのは「過去日が要求されたのに全て失敗」または「当日のみ要求で latest も失敗」
194
- const nonTodayFailures = dateMerge.failures.filter((f) => f.label !== todayStr);
195
- const todayFailed = dateMerge.failures.some((f) => f.label === todayStr);
196
- const historicalRequested = dates.some((ds) => ds !== todayStr);
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
- // 過去日が無く (today のみ) かつ today + latest 両方失敗 → 取得手段なし
209
- if (!historicalRequested && todayFailed && latestMerge.txs.length === 0) {
210
- const allFailures = [...dateMerge.failures, ...latestMerge.failures];
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
- `日付ベース取得(当日 ${todayStr})と latest の両方が失敗しました(${allFailures.length}件: ${formatFailures(allFailures)})`,
211
+ `取得手段がありません: 時間窓が進行中の UTC 日 (${currentUtcKey}) 内のためアーカイブは未公開で、latest 取得も失敗しました(${formatFailures(latestMerge.failures)})`,
214
212
  'upstream',
215
213
  ),
216
214
  );
217
215
  }
218
216
 
219
- // 部分失敗は警告のみ(latest 失敗は直近数分の欠落、一部 date 失敗は該当日のカバレッジ不足)
217
+ // 部分失敗・カバレッジ制約は警告で明示(latest 失敗は直近数分の欠落、一部 date 失敗は該当日のカバレッジ不足)
220
218
  const warnMsgs: string[] = [];
221
- if (nonTodayFailures.length > 0) {
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
- `⚠️ 当日 (${todayStr}) アーカイブが未公開または取得失敗のため /transactions (latest) から補完しました`,
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
- // 当日 (JST) はアーカイブ未生成で 404 の可能性があるため latest にフォールバック。
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 isTodayJst = date === dayjs().tz('Asia/Tokyo').format('YYYYMMDD');
260
+ const archivePublished = isArchiveExpectedPublished(date);
262
261
  if (!txRes?.ok) {
263
- if (isTodayJst) {
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} (today JST) アーカイブ未公開かつ latest 取得も失敗: ${txRes?.summary || 'unknown'} / ${latestRes?.summary || 'unknown'}`,
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 = `⚠️ 当日 (${date}) のアーカイブは未公開のため /transactions (latest) から取得しました`;
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
- // bitbank latest エンドポイントは約60件のみ返却するため
299
- const todayJst = dayjs().tz('Asia/Tokyo');
300
- const supplementFetches: Promise<unknown>[] = [
301
- getTransactions(chk.pair, 1000, todayJst.subtract(1, 'day').format('YYYYMMDD')),
302
- ];
303
- if (lim.value > 500) {
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', ...supplementFetches.map((_, i) => `supplement-${i + 1}`)];
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
- // 過半数失敗なら fail
317
- if (failures.length > 0 && failures.length >= totalCount / 2) {
318
- return GetFlowMetricsOutputSchema.parse(
319
- fail(
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
- fetchWarning = `⚠️ ${totalCount}件中 ${failures.length}件のAPI取得に失敗しました: ${formatFailures(failures)}`;
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
- const wrapped = new Error(`${baseMsg} [url: ${url}]`);
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 = '/mnt/user-data/outputs';
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(outputDir);
231
+ ensureOutputDir(resolvedOutputDir);
220
232
  const filename = generateBacktestChartFilename(pair, timeframe, strategyConfig.type, 'png');
221
- const pngPath = join(outputDir, filename);
233
+ const pngPath = join(resolvedOutputDir, filename);
222
234
  await svgToPng(svg, pngPath, { density: 150 });
223
235
  output.chartPath = pngPath;
224
236
  } catch (pngError) {