bcs-mcp 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +141 -0
- package/dist/catalog.js +63 -0
- package/dist/catalog.js.map +1 -0
- package/dist/client.js +179 -0
- package/dist/client.js.map +1 -0
- package/dist/endpoints.js +18 -0
- package/dist/endpoints.js.map +1 -0
- package/dist/helpers.js +144 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/instruments-cache.js +119 -0
- package/dist/instruments-cache.js.map +1 -0
- package/dist/output.js +139 -0
- package/dist/output.js.map +1 -0
- package/dist/prompts.js +209 -0
- package/dist/prompts.js.map +1 -0
- package/dist/resources.js +31 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/info.js +29 -0
- package/dist/tools/info.js.map +1 -0
- package/dist/tools/read.js +884 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/trading.js +196 -0
- package/dist/tools/trading.js.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { API } from "../endpoints.js";
|
|
3
|
+
import { BcsApiError, bcsFetch } from "../client.js";
|
|
4
|
+
import { fetchInstruments, pickPrimaryCard, resolveClassCode, resolveClassCodes } from "../instruments-cache.js";
|
|
5
|
+
import { fail, toMsk, parseDate, daysFromNow, notifyProgress, computeChunks, mapPool, pickList, omit, } from "../helpers.js";
|
|
6
|
+
import { outputParams, deliver, createSafeWriter } from "../output.js";
|
|
7
|
+
import { ok } from "../helpers.js";
|
|
8
|
+
const RO = {
|
|
9
|
+
readOnlyHint: true,
|
|
10
|
+
destructiveHint: false,
|
|
11
|
+
idempotentHint: true,
|
|
12
|
+
openWorldHint: true,
|
|
13
|
+
};
|
|
14
|
+
/** API hard limit: at most 1000 bars per candles request; chunks stay below it */
|
|
15
|
+
const CHUNK_BARS = 900;
|
|
16
|
+
const MAX_INLINE_BARS = 1000;
|
|
17
|
+
/** Inline candles walk backwards through at most this many chunks (bounds rate-limit usage) */
|
|
18
|
+
const MAX_INLINE_CHUNKS = 8;
|
|
19
|
+
const MAX_CATALOG_PAGES = 200; // backstop for by-type full dumps (200 × 100 instruments)
|
|
20
|
+
const MAX_SEARCH_PAGES = 500; // backstop for orders/trades full dumps (500 × 100 records)
|
|
21
|
+
const ENRICH_CONCURRENCY = 4;
|
|
22
|
+
/**
|
|
23
|
+
* /last-trades caps: one request's period ≤ 1 HOUR (400 otherwise) AND the response
|
|
24
|
+
* ≤ ~4 MB. Both modes therefore fetch hour windows (inline walks backwards from `to`
|
|
25
|
+
* until `limit` trades are collected); a window is bisected when the 4 MB cap trips.
|
|
26
|
+
*/
|
|
27
|
+
const TRADE_WINDOW_MS = 3_600_000;
|
|
28
|
+
const MIN_TRADE_WINDOW_MS = 5 * 60_000;
|
|
29
|
+
/** /last-trades serves ONLY the current UTC day — window starts must be clamped to 00:00Z */
|
|
30
|
+
const startOfUtcDay = (d) => new Date(Math.floor(d.getTime() / 86_400_000) * 86_400_000);
|
|
31
|
+
const TIMEFRAME_MS = {
|
|
32
|
+
M1: 60_000,
|
|
33
|
+
M5: 300_000,
|
|
34
|
+
M15: 900_000,
|
|
35
|
+
M30: 1_800_000,
|
|
36
|
+
H1: 3_600_000,
|
|
37
|
+
H4: 14_400_000,
|
|
38
|
+
D: 86_400_000,
|
|
39
|
+
W: 604_800_000,
|
|
40
|
+
MN: 2_592_000_000,
|
|
41
|
+
};
|
|
42
|
+
const SIDE_LABELS = { "1": "buy", "2": "sell" };
|
|
43
|
+
// Trading endpoints use 1/2 only; orders/search extends the same scale (docs enum)
|
|
44
|
+
const ORDER_TYPE_LABELS = {
|
|
45
|
+
"1": "market",
|
|
46
|
+
"2": "limit",
|
|
47
|
+
"3": "iceberg",
|
|
48
|
+
"4": "stop-limit",
|
|
49
|
+
"5": "take-profit (limit)",
|
|
50
|
+
"6": "stop-loss",
|
|
51
|
+
"7": "take-profit & stop-loss",
|
|
52
|
+
"10": "limit 30 days",
|
|
53
|
+
"11": "take-profit",
|
|
54
|
+
"12": "trailing-stop",
|
|
55
|
+
};
|
|
56
|
+
// FIX OrdStatus (the STATUS endpoint relays FIX execution reports: messageType 8, executionType, …)
|
|
57
|
+
const ORDER_STATUS_LABELS = {
|
|
58
|
+
"0": "new",
|
|
59
|
+
"1": "partially filled",
|
|
60
|
+
"2": "filled",
|
|
61
|
+
"3": "done for day",
|
|
62
|
+
"4": "cancelled",
|
|
63
|
+
"5": "replaced",
|
|
64
|
+
"6": "pending cancel",
|
|
65
|
+
"7": "stopped",
|
|
66
|
+
"8": "rejected",
|
|
67
|
+
"9": "suspended",
|
|
68
|
+
A: "pending new",
|
|
69
|
+
C: "expired",
|
|
70
|
+
E: "pending replace",
|
|
71
|
+
};
|
|
72
|
+
// orders/search has its OWN 3-value scale (docs enum) — NOT the FIX codes above:
|
|
73
|
+
// there "1" means cancelled, not partially filled
|
|
74
|
+
const SEARCH_ORDER_STATUS_LABELS = {
|
|
75
|
+
"1": "cancelled",
|
|
76
|
+
"2": "filled",
|
|
77
|
+
"3": "active",
|
|
78
|
+
};
|
|
79
|
+
const label = (map, v) => map[String(v)] ?? String(v ?? "");
|
|
80
|
+
const BOND_PRICE_NOTE = "Bond prices are quoted in % of face value, not currency.";
|
|
81
|
+
const round2 = (x) => Math.round(x * 100) / 100;
|
|
82
|
+
/**
|
|
83
|
+
* /trading-schedule/daily-schedule times come as bare UTC "HH:MM:SS" (verified
|
|
84
|
+
* against the real MOEX schedule) while every other endpoint is +03:00 —
|
|
85
|
+
* convert to MSK wall time (fixed UTC+3 for current dates).
|
|
86
|
+
*/
|
|
87
|
+
const utcTimeToMsk = (s) => {
|
|
88
|
+
const m = /^(\d{2}):(\d{2}):(\d{2})$/.exec(String(s ?? ""));
|
|
89
|
+
if (!m)
|
|
90
|
+
return String(s ?? "");
|
|
91
|
+
return `${String((Number(m[1]) + 3) % 24).padStart(2, "0")}:${m[2]}:${m[3]}`;
|
|
92
|
+
};
|
|
93
|
+
/** Is `now` inside [start, end)? All "HH:MM:SS" in one zone; the interval may wrap past midnight */
|
|
94
|
+
const inDayInterval = (now, start, end) => start <= end ? now >= start && now < end : now >= start || now < end;
|
|
95
|
+
// One daily-schedule fetch per board per day: get_trading_schedule serves from it
|
|
96
|
+
// and get_trading_status cross-checks against it without extra API fan-out
|
|
97
|
+
const dayScheduleCache = new Map();
|
|
98
|
+
function fetchDaySchedule(ticker, classCode) {
|
|
99
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
100
|
+
const hit = dayScheduleCache.get(classCode);
|
|
101
|
+
if (hit && hit.day === day)
|
|
102
|
+
return hit.promise;
|
|
103
|
+
const promise = bcsFetch(API.information, "/trading-schedule/daily-schedule", {
|
|
104
|
+
query: { ticker, classCode },
|
|
105
|
+
});
|
|
106
|
+
promise.catch(() => dayScheduleCache.delete(classCode)); // failures don't stick
|
|
107
|
+
dayScheduleCache.set(classCode, { day, promise });
|
|
108
|
+
return promise;
|
|
109
|
+
}
|
|
110
|
+
async function fetchBars(ticker, classCode, from, to, timeFrame) {
|
|
111
|
+
const res = await bcsFetch(API.marketData, "/candles-chart", {
|
|
112
|
+
query: {
|
|
113
|
+
ticker,
|
|
114
|
+
classCode,
|
|
115
|
+
startDate: from.toISOString(),
|
|
116
|
+
endDate: to.toISOString(),
|
|
117
|
+
timeFrame,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
// The API returns bars newest-first — normalize to ascending by the raw instant.
|
|
121
|
+
// Ordering/dedup always uses the raw epoch (_t): localized MSK strings are not
|
|
122
|
+
// monotonic across historical clock changes and must never be compared.
|
|
123
|
+
return pickList(res, "bars")
|
|
124
|
+
.map((b) => ({ ...b, _t: Date.parse(String(b.time ?? "")) }))
|
|
125
|
+
.sort((a, b) => (a._t ?? 0) - (b._t ?? 0));
|
|
126
|
+
}
|
|
127
|
+
const mapBar = (b) => ({
|
|
128
|
+
time: toMsk(b.time),
|
|
129
|
+
open: b.open,
|
|
130
|
+
close: b.close,
|
|
131
|
+
high: b.high,
|
|
132
|
+
low: b.low,
|
|
133
|
+
volume: b.volume,
|
|
134
|
+
});
|
|
135
|
+
/** Card fields that only add noise for an LLM consumer */
|
|
136
|
+
const NOISY_CARD_KEYS = [
|
|
137
|
+
"logoLink",
|
|
138
|
+
"excludeTypeFlags",
|
|
139
|
+
"excludeTypes",
|
|
140
|
+
"bcsScoreColor",
|
|
141
|
+
"displayNameSecond",
|
|
142
|
+
"cfi",
|
|
143
|
+
"currencyStepPrice",
|
|
144
|
+
"businessSectorId",
|
|
145
|
+
"businessCountryCode",
|
|
146
|
+
"isBcsProduct",
|
|
147
|
+
];
|
|
148
|
+
const NOISY_POSITION_KEYS = ["logoLink", "scale", "ratioQuantity", "subAccountId", "agreementId"];
|
|
149
|
+
const slimInstrument = (raw) => omit(raw, NOISY_CARD_KEYS);
|
|
150
|
+
export function registerReadTools(server) {
|
|
151
|
+
server.registerTool("get_portfolio", {
|
|
152
|
+
title: "Get Portfolio",
|
|
153
|
+
description: "Portfolio of the account bound to the token: every position (securities, money, metals) with quantity, average/current price, value in RUB/USD/EUR, unrealized and daily P&L, portfolio share, accrued interest for bonds. Includes totals by instrument type. " +
|
|
154
|
+
"The API reports each position once per settlement term (T0/T1/T2/T365) — by default a single slice is returned and totals cover only it; term=all returns the raw duplicates (totals then count every position several times). csv output writes the positions array.",
|
|
155
|
+
inputSchema: {
|
|
156
|
+
term: z
|
|
157
|
+
.enum(["T0", "T1", "T2", "T365", "all"])
|
|
158
|
+
.default("T0")
|
|
159
|
+
.describe("Settlement slice to return (the API duplicates positions per term)"),
|
|
160
|
+
...outputParams,
|
|
161
|
+
},
|
|
162
|
+
annotations: RO,
|
|
163
|
+
}, async ({ term, outputPath, outputFormat }) => {
|
|
164
|
+
try {
|
|
165
|
+
const res = await bcsFetch(API.portfolio, "/portfolio");
|
|
166
|
+
const positions = pickList(res, "positions");
|
|
167
|
+
let slice = term === "all" ? positions : positions.filter((p) => String(p.term ?? "") === term);
|
|
168
|
+
if (!slice.length && positions.length) {
|
|
169
|
+
// unknown term labels from the API — fall back to one record per instrument, never ×N totals
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
slice = positions.filter((p) => {
|
|
172
|
+
const k = `${p.ticker}|${p.board ?? ""}|${p.instrumentType}`;
|
|
173
|
+
if (seen.has(k))
|
|
174
|
+
return false;
|
|
175
|
+
seen.add(k);
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const value = (p) => (typeof p.currentValueRub === "number" ? p.currentValueRub : 0);
|
|
180
|
+
const byType = {};
|
|
181
|
+
let totalRub = 0;
|
|
182
|
+
for (const p of slice) {
|
|
183
|
+
totalRub += value(p);
|
|
184
|
+
const t = String(p.upperType ?? p.instrumentType ?? "OTHER");
|
|
185
|
+
byType[t] = (byType[t] ?? 0) + value(p);
|
|
186
|
+
}
|
|
187
|
+
totalRub = round2(totalRub);
|
|
188
|
+
for (const k of Object.keys(byType))
|
|
189
|
+
byType[k] = round2(byType[k]);
|
|
190
|
+
// The API always sends portfolioShare=0 — compute it WITHIN each term slice
|
|
191
|
+
// (with term=all the grand total is ×N terms, a share of it would be meaningless)
|
|
192
|
+
const termTotal = new Map();
|
|
193
|
+
for (const p of slice) {
|
|
194
|
+
const k = String(p.term ?? "");
|
|
195
|
+
termTotal.set(k, (termTotal.get(k) ?? 0) + value(p));
|
|
196
|
+
}
|
|
197
|
+
const rows = slice.map((p) => {
|
|
198
|
+
const tt = termTotal.get(String(p.term ?? "")) ?? 0;
|
|
199
|
+
return {
|
|
200
|
+
...omit(p, NOISY_POSITION_KEYS),
|
|
201
|
+
portfolioShare: tt > 0 ? round2((value(p) / tt) * 100) : 0,
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
const account = positions[0]?.account ?? "";
|
|
205
|
+
const data = { account, term, totalRub, byType, positions: rows };
|
|
206
|
+
return await deliver(data, rows, { outputPath, outputFormat }, { account, totalRub });
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
return fail(e);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
server.registerTool("get_limits", {
|
|
213
|
+
title: "Get Limits",
|
|
214
|
+
description: "Account limits: money limits per currency with computed free (= quantity − locked, summarized in freeByCurrency), securities (depo) limits, futures holdings and limits. " +
|
|
215
|
+
"Note: the API returns a start-of-day snapshot (see loadDate) in a single T365 slice — not intraday. Zero rows are hidden unless includeZero=true.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
includeZero: z.boolean().default(false).describe("Include zero money/depo rows (empty positions)"),
|
|
218
|
+
...outputParams,
|
|
219
|
+
},
|
|
220
|
+
annotations: RO,
|
|
221
|
+
}, async ({ includeZero, outputPath, outputFormat }) => {
|
|
222
|
+
try {
|
|
223
|
+
const data = await bcsFetch(API.limits, "/limits");
|
|
224
|
+
const qty = (r) => {
|
|
225
|
+
const q = r.quantity;
|
|
226
|
+
return typeof q?.value === "number" ? q.value : 0;
|
|
227
|
+
};
|
|
228
|
+
const num = (v) => (typeof v === "number" ? v : 0);
|
|
229
|
+
const moneyRaw = Array.isArray(data.moneyLimits) ? data.moneyLimits : [];
|
|
230
|
+
const depoRaw = Array.isArray(data.depoLimit) ? data.depoLimit : [];
|
|
231
|
+
const money = moneyRaw
|
|
232
|
+
.map((r) => ({ ...r, free: round2(qty(r) - num(r.locked)) }))
|
|
233
|
+
.filter((r) => includeZero || qty(r) !== 0 || num(r.locked) !== 0);
|
|
234
|
+
const depo = depoRaw.filter((r) => includeZero || qty(r) !== 0 || num(r.averagePrice) !== 0);
|
|
235
|
+
const freeByCurrency = {};
|
|
236
|
+
for (const r of money) {
|
|
237
|
+
const c = String(r.currencyCode ?? "?");
|
|
238
|
+
freeByCurrency[c] = round2((freeByCurrency[c] ?? 0) + r.free);
|
|
239
|
+
}
|
|
240
|
+
return await deliver({ ...data, freeByCurrency, moneyLimits: money, depoLimit: depo }, null, { outputPath, outputFormat });
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
return fail(e);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
server.registerTool("get_discounts", {
|
|
247
|
+
title: "Get Instrument Discounts",
|
|
248
|
+
description: "Margin discount rates per instrument from the marginal-indicators service. The API exposes discountLong only (no short rates), and values are raw BCS coefficients — observed as 1 for every instrument, so verify against the web cabinet before relying on them. Optional tickers filter is applied client-side.",
|
|
249
|
+
inputSchema: {
|
|
250
|
+
tickers: z.array(z.string()).optional().describe("Filter to these tickers (uppercase)"),
|
|
251
|
+
...outputParams,
|
|
252
|
+
},
|
|
253
|
+
annotations: RO,
|
|
254
|
+
}, async ({ tickers, outputPath, outputFormat }) => {
|
|
255
|
+
try {
|
|
256
|
+
const res = await bcsFetch(API.marginal, "/instruments-discounts");
|
|
257
|
+
let rows = pickList(res, "discounts");
|
|
258
|
+
if (tickers?.length) {
|
|
259
|
+
const want = new Set(tickers.map((t) => t.toUpperCase()));
|
|
260
|
+
rows = rows.filter((r) => want.has(String(r.ticker ?? "").toUpperCase()));
|
|
261
|
+
}
|
|
262
|
+
return await deliver(rows, rows, { outputPath, outputFormat });
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
return fail(e);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
server.registerTool("find_instrument", {
|
|
269
|
+
title: "Find Instruments",
|
|
270
|
+
description: "Instrument cards by tickers (batch) or ISINs: name, type, boards (classCode/exchange — classCode is needed by candles/orders), lot size, ISIN, issuer; for bonds — face value, maturity, coupon rate/frequency, accrued interest; for stocks — dividend yield, sector, EPS growth, credit rating, BCS score. Start here to resolve a ticker before other calls. " +
|
|
271
|
+
"By default one merged card per instrument is returned (primary board; other listings in otherBoards; off-exchange rows flagged offExchange) — pass allBoards=true for the raw per-board cards.",
|
|
272
|
+
inputSchema: {
|
|
273
|
+
tickers: z.array(z.string()).min(1).max(50).optional().describe("Tickers, e.g. [\"SBER\", \"LKOH\"]"),
|
|
274
|
+
isins: z.array(z.string()).min(1).max(50).optional().describe("ISINs (alternative to tickers)"),
|
|
275
|
+
allBoards: z
|
|
276
|
+
.boolean()
|
|
277
|
+
.default(false)
|
|
278
|
+
.describe("Return every per-board card instead of one merged card per instrument"),
|
|
279
|
+
...outputParams,
|
|
280
|
+
},
|
|
281
|
+
annotations: RO,
|
|
282
|
+
}, async ({ tickers, isins, allBoards, outputPath, outputFormat }) => {
|
|
283
|
+
try {
|
|
284
|
+
if (tickers?.length && isins?.length) {
|
|
285
|
+
return fail(new Error("pass either tickers or isins, not both (results would be ambiguous)"));
|
|
286
|
+
}
|
|
287
|
+
if (!tickers?.length && !isins?.length) {
|
|
288
|
+
return fail(new Error("pass tickers or isins"));
|
|
289
|
+
}
|
|
290
|
+
const cards = tickers?.length
|
|
291
|
+
? await fetchInstruments(tickers.map((t) => t.toUpperCase()))
|
|
292
|
+
: pickList(await bcsFetch(API.information, "/instruments/by-isins", {
|
|
293
|
+
method: "POST",
|
|
294
|
+
body: { isins },
|
|
295
|
+
}), "instruments");
|
|
296
|
+
let rows;
|
|
297
|
+
if (allBoards) {
|
|
298
|
+
rows = cards.map(slimInstrument);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// One card per instrument: analytics (bcsScore, priceChange*) are only
|
|
302
|
+
// filled on the primary board — the rest degrade to otherBoards
|
|
303
|
+
const groups = new Map();
|
|
304
|
+
for (const c of cards) {
|
|
305
|
+
const k = String(c.ticker ?? c.isin ?? "?");
|
|
306
|
+
const g = groups.get(k);
|
|
307
|
+
if (g)
|
|
308
|
+
g.push(c);
|
|
309
|
+
else
|
|
310
|
+
groups.set(k, [c]);
|
|
311
|
+
}
|
|
312
|
+
rows = [...groups.values()].map((g) => {
|
|
313
|
+
const main = pickPrimaryCard(g) ?? g[0];
|
|
314
|
+
const otherBoards = g
|
|
315
|
+
.filter((c) => c !== main)
|
|
316
|
+
.flatMap((c) => (Array.isArray(c.boards) ? c.boards : []));
|
|
317
|
+
const mainBoards = Array.isArray(main.boards) ? main.boards : [];
|
|
318
|
+
const offExchange = !mainBoards.some((b) => b.exchange === "MOEX" || b.exchange === "SPB");
|
|
319
|
+
return {
|
|
320
|
+
...slimInstrument(main),
|
|
321
|
+
...(otherBoards.length ? { otherBoards } : {}),
|
|
322
|
+
...(offExchange ? { offExchange: true } : {}),
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return await deliver(rows, rows, { outputPath, outputFormat });
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
return fail(e);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
server.registerTool("get_instruments_by_type", {
|
|
333
|
+
title: "Get Instruments By Type",
|
|
334
|
+
description: "Tradable instruments of a type, paginated (page/size, max 100 per page). " +
|
|
335
|
+
"Pass outputPath to fetch ALL pages into a file; without it a single page is returned inline. " +
|
|
336
|
+
"baseAssetTicker is required for type=OPTIONS.",
|
|
337
|
+
inputSchema: {
|
|
338
|
+
type: z
|
|
339
|
+
.enum([
|
|
340
|
+
"CURRENCY",
|
|
341
|
+
"STOCK",
|
|
342
|
+
"FOREIGN_STOCK",
|
|
343
|
+
"BONDS",
|
|
344
|
+
"NOTES",
|
|
345
|
+
"DEPOSITARY_RECEIPTS",
|
|
346
|
+
"EURO_BONDS",
|
|
347
|
+
"MUTUAL_FUNDS",
|
|
348
|
+
"ETF",
|
|
349
|
+
"FUTURES",
|
|
350
|
+
"OPTIONS",
|
|
351
|
+
"GOODS",
|
|
352
|
+
"INDICES",
|
|
353
|
+
])
|
|
354
|
+
.describe("Instrument type"),
|
|
355
|
+
baseAssetTicker: z.string().optional().describe("Base asset ticker (required for OPTIONS)"),
|
|
356
|
+
page: z.number().int().min(0).default(0).describe("Page number (ignored with outputPath — all pages are fetched)"),
|
|
357
|
+
size: z.number().int().min(1).max(100).default(50).describe("Records per page"),
|
|
358
|
+
...outputParams,
|
|
359
|
+
},
|
|
360
|
+
annotations: RO,
|
|
361
|
+
}, async ({ type, baseAssetTicker, page, size, outputPath, outputFormat }, extra) => {
|
|
362
|
+
try {
|
|
363
|
+
const fetchPage = async (p, s) => pickList(await bcsFetch(API.information, "/instruments/by-type", {
|
|
364
|
+
query: {
|
|
365
|
+
type,
|
|
366
|
+
...(baseAssetTicker ? { baseAssetTicker } : {}),
|
|
367
|
+
page: String(p),
|
|
368
|
+
size: String(s),
|
|
369
|
+
},
|
|
370
|
+
}), "instruments");
|
|
371
|
+
if (!outputPath) {
|
|
372
|
+
const items = (await fetchPage(page, size)).map(slimInstrument);
|
|
373
|
+
return await deliver({ page, count: items.length, has_more: items.length === size, instruments: items }, items, {});
|
|
374
|
+
}
|
|
375
|
+
// File mode: walk all pages (a full page means there may be more)
|
|
376
|
+
const all = [];
|
|
377
|
+
for (let p = 0; p < MAX_CATALOG_PAGES; p++) {
|
|
378
|
+
const items = await fetchPage(p, 100);
|
|
379
|
+
all.push(...items.map(slimInstrument));
|
|
380
|
+
await notifyProgress(extra, p + 1, undefined, `page ${p + 1}: ${all.length} instruments`);
|
|
381
|
+
if (items.length < 100)
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
return await deliver(all, all, { outputPath, outputFormat }, { total: all.length });
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
return fail(e);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
server.registerTool("get_trading_schedule", {
|
|
391
|
+
title: "Get Trading Schedule",
|
|
392
|
+
description: "Trading sessions of an instrument for the current day: session types with start/end times in MSK (+03:00), sorted. " +
|
|
393
|
+
"Intervals with tradingSessionStatus=OPEN are the periods when trading is on.",
|
|
394
|
+
inputSchema: {
|
|
395
|
+
ticker: z.string().describe("Ticker, e.g. SBER"),
|
|
396
|
+
classCode: z.string().optional().describe("Board class code (resolved via find_instrument when omitted)"),
|
|
397
|
+
...outputParams,
|
|
398
|
+
},
|
|
399
|
+
annotations: RO,
|
|
400
|
+
}, async ({ ticker, classCode, outputPath, outputFormat }) => {
|
|
401
|
+
try {
|
|
402
|
+
const t = ticker.toUpperCase();
|
|
403
|
+
const cc = await resolveClassCode(t, classCode);
|
|
404
|
+
const data = await fetchDaySchedule(t, cc);
|
|
405
|
+
const intervals = (Array.isArray(data.dailySchedule) ? data.dailySchedule : [])
|
|
406
|
+
.map((iv) => ({ ...iv, startDate: utcTimeToMsk(iv.startDate), endDate: utcTimeToMsk(iv.endDate) }))
|
|
407
|
+
.sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
|
|
408
|
+
const out = {
|
|
409
|
+
ticker: t,
|
|
410
|
+
classCode: cc,
|
|
411
|
+
date: toMsk(new Date()).slice(0, 10),
|
|
412
|
+
timezone: "+03:00 (Europe/Moscow; the API reports bare UTC times — converted here)",
|
|
413
|
+
isWorkDay: data.isWorkDay,
|
|
414
|
+
dailySchedule: intervals,
|
|
415
|
+
};
|
|
416
|
+
return await deliver(out, intervals, { outputPath, outputFormat });
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
return fail(e);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
server.registerTool("get_trading_status", {
|
|
423
|
+
title: "Get Trading Status",
|
|
424
|
+
description: "Current trading session status per instrument: session type, OPEN/CLOSE, time of the next status change. " +
|
|
425
|
+
"Each row is cross-checked against today's schedule; on mismatch a warning + scheduleSaysNow field is added " +
|
|
426
|
+
"(the upstream status endpoint has been observed reporting CLOSE during an open evening session).",
|
|
427
|
+
inputSchema: {
|
|
428
|
+
tickers: z.array(z.string()).min(1).max(50).describe("Tickers"),
|
|
429
|
+
classCodes: z
|
|
430
|
+
.array(z.string())
|
|
431
|
+
.optional()
|
|
432
|
+
.describe("Board class codes, same order as tickers (resolved via find_instrument when omitted)"),
|
|
433
|
+
...outputParams,
|
|
434
|
+
},
|
|
435
|
+
annotations: RO,
|
|
436
|
+
}, async ({ tickers, classCodes, outputPath, outputFormat }) => {
|
|
437
|
+
try {
|
|
438
|
+
const upper = tickers.map((t) => t.toUpperCase());
|
|
439
|
+
const codes = await resolveClassCodes(upper, classCodes);
|
|
440
|
+
const rows = await mapPool(upper, ENRICH_CONCURRENCY, async (t, i) => {
|
|
441
|
+
try {
|
|
442
|
+
const s = await bcsFetch(API.information, "/trading-schedule/status", {
|
|
443
|
+
query: { ticker: t, classCode: codes[i] },
|
|
444
|
+
});
|
|
445
|
+
const row = { ticker: t, classCode: codes[i], ...s, nextSessionDate: toMsk(s.nextSessionDate) };
|
|
446
|
+
// The status endpoint has been seen lying (CLOSE during the open evening
|
|
447
|
+
// session) — cross-check against today's schedule, best-effort
|
|
448
|
+
try {
|
|
449
|
+
const sched = await fetchDaySchedule(t, codes[i]);
|
|
450
|
+
const nowUtc = new Date().toISOString().slice(11, 19);
|
|
451
|
+
const openNow = (Array.isArray(sched.dailySchedule) ? sched.dailySchedule : []).some((iv) => String(iv.tradingSessionStatus) === "OPEN" &&
|
|
452
|
+
inDayInterval(nowUtc, String(iv.startDate ?? ""), String(iv.endDate ?? "")));
|
|
453
|
+
if (openNow !== (String(s.tradingSessionStatus) === "OPEN")) {
|
|
454
|
+
row.scheduleSaysNow = openNow ? "OPEN" : "CLOSE";
|
|
455
|
+
row.warning =
|
|
456
|
+
"status and today's schedule disagree — the status endpoint is known to lag (e.g. evening session); verify with get_order_book";
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
// cross-check only — never fail the status row
|
|
461
|
+
}
|
|
462
|
+
return row;
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
return { ticker: t, error: e instanceof Error ? e.message : String(e) };
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
return await deliver(rows, rows, { outputPath, outputFormat });
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
return fail(e);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
server.registerTool("get_candles", {
|
|
475
|
+
title: "Get Candles",
|
|
476
|
+
description: "OHLCV candles. timeFrame: M1/M5/M15/M30/H1/H4/D/W/MN. The API caps one request at 1000 bars; " +
|
|
477
|
+
"inline calls return the most recent ~1000 TRADING bars of the range (truncated flag set when the range " +
|
|
478
|
+
"was not fully covered). Pass outputPath to fetch the FULL period via chunked requests (streamed to disk, " +
|
|
479
|
+
`deduplicated, with progress). ${BOND_PRICE_NOTE}`,
|
|
480
|
+
inputSchema: {
|
|
481
|
+
ticker: z.string().describe("Ticker, e.g. SBER"),
|
|
482
|
+
classCode: z.string().optional().describe("Board class code (resolved via find_instrument when omitted)"),
|
|
483
|
+
timeFrame: z.enum(["M1", "M5", "M15", "M30", "H1", "H4", "D", "W", "MN"]).default("D"),
|
|
484
|
+
from: z.string().optional().describe("Start, ISO 8601 (default: 30 days ago)"),
|
|
485
|
+
to: z.string().optional().describe("End, ISO 8601 (default: now)"),
|
|
486
|
+
...outputParams,
|
|
487
|
+
},
|
|
488
|
+
annotations: RO,
|
|
489
|
+
}, async ({ ticker, classCode, timeFrame, from, to, outputPath, outputFormat }, extra) => {
|
|
490
|
+
try {
|
|
491
|
+
const t = ticker.toUpperCase();
|
|
492
|
+
const cc = await resolveClassCode(t, classCode);
|
|
493
|
+
const fromD = parseDate(from, daysFromNow(-30));
|
|
494
|
+
const toD = parseDate(to, new Date());
|
|
495
|
+
if (fromD >= toD)
|
|
496
|
+
return fail(new Error("`from` must be before `to`"));
|
|
497
|
+
const chunks = computeChunks(fromD, toD, CHUNK_BARS * TIMEFRAME_MS[timeFrame]);
|
|
498
|
+
if (!outputPath) {
|
|
499
|
+
// Inline: walk chunks backwards (newest first) until enough TRADING bars are
|
|
500
|
+
// collected — a calendar-time clamp would return nothing after weekends and
|
|
501
|
+
// needlessly truncate sparse timeframes.
|
|
502
|
+
let acc = [];
|
|
503
|
+
let chunksUsed = 0;
|
|
504
|
+
for (let i = chunks.length - 1; i >= 0 && acc.length < MAX_INLINE_BARS && chunksUsed < MAX_INLINE_CHUNKS; i--) {
|
|
505
|
+
const got = await fetchBars(t, cc, chunks[i].from, chunks[i].to, timeFrame);
|
|
506
|
+
const headEpoch = acc[0]?._t;
|
|
507
|
+
acc = (headEpoch == null ? got : got.filter((b) => (b._t ?? NaN) < headEpoch)).concat(acc);
|
|
508
|
+
chunksUsed++;
|
|
509
|
+
}
|
|
510
|
+
const truncated = acc.length > MAX_INLINE_BARS || chunksUsed < chunks.length;
|
|
511
|
+
const bars = acc.slice(-MAX_INLINE_BARS).map(mapBar);
|
|
512
|
+
return await deliver({
|
|
513
|
+
ticker: t,
|
|
514
|
+
classCode: cc,
|
|
515
|
+
timeFrame,
|
|
516
|
+
...(truncated ? { truncated: true, note: "range not fully covered inline — use outputPath for the full period" } : {}),
|
|
517
|
+
bars,
|
|
518
|
+
}, null, {});
|
|
519
|
+
}
|
|
520
|
+
// File mode: stream every chunk to disk — full multi-year histories must not
|
|
521
|
+
// accumulate in memory. Bars have a fixed schema, so csv columns are static.
|
|
522
|
+
const format = outputFormat ?? (outputPath.endsWith(".csv") ? "csv" : "json");
|
|
523
|
+
const writer = await createSafeWriter(outputPath);
|
|
524
|
+
const sample = [];
|
|
525
|
+
let records = 0;
|
|
526
|
+
let lastEpoch = -Infinity;
|
|
527
|
+
try {
|
|
528
|
+
if (format === "csv")
|
|
529
|
+
await writer.write("time,open,close,high,low,volume\n");
|
|
530
|
+
else
|
|
531
|
+
await writer.write(`{"ticker":${JSON.stringify(t)},"classCode":${JSON.stringify(cc)},"timeFrame":${JSON.stringify(timeFrame)},"bars":[`);
|
|
532
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
533
|
+
const got = await fetchBars(t, cc, chunks[i].from, chunks[i].to, timeFrame);
|
|
534
|
+
for (const b of got) {
|
|
535
|
+
const epoch = b._t ?? NaN;
|
|
536
|
+
if (epoch <= lastEpoch)
|
|
537
|
+
continue; // chunk borders overlap — dedupe by the raw instant
|
|
538
|
+
const row = mapBar(b);
|
|
539
|
+
if (sample.length < 2)
|
|
540
|
+
sample.push(row);
|
|
541
|
+
if (format === "csv") {
|
|
542
|
+
await writer.write(`${row.time},${b.open ?? ""},${b.close ?? ""},${b.high ?? ""},${b.low ?? ""},${b.volume ?? ""}\n`);
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
await writer.write(`${records ? "," : ""}${JSON.stringify(row)}`);
|
|
546
|
+
}
|
|
547
|
+
records++;
|
|
548
|
+
if (!Number.isNaN(epoch))
|
|
549
|
+
lastEpoch = epoch;
|
|
550
|
+
}
|
|
551
|
+
await notifyProgress(extra, i + 1, chunks.length, `chunk ${i + 1}/${chunks.length}: ${records} bars`);
|
|
552
|
+
}
|
|
553
|
+
if (format === "json")
|
|
554
|
+
await writer.write("]}\n");
|
|
555
|
+
const bytes = await writer.close();
|
|
556
|
+
return ok({ savedTo: writer.savedTo, format, records, sample, bytes, ticker: t, timeFrame });
|
|
557
|
+
}
|
|
558
|
+
catch (e) {
|
|
559
|
+
await writer.close();
|
|
560
|
+
await writer.remove();
|
|
561
|
+
throw e;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
return fail(e);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
server.registerTool("get_quotes", {
|
|
569
|
+
title: "Get Quotes",
|
|
570
|
+
description: "Real-time quotes for up to 100 instruments: bid/offer, last price, day open/close/high/low, change. " +
|
|
571
|
+
`classCode is resolved via find_instrument when omitted. ${BOND_PRICE_NOTE} ` +
|
|
572
|
+
"Note: your own positions already carry currentPrice in get_portfolio.",
|
|
573
|
+
inputSchema: {
|
|
574
|
+
tickers: z.array(z.string()).min(1).max(100).describe("Tickers, e.g. [\"SBER\", \"LKOH\"]"),
|
|
575
|
+
classCodes: z
|
|
576
|
+
.array(z.string())
|
|
577
|
+
.optional()
|
|
578
|
+
.describe("Board class codes, same order as tickers; missing entries are resolved automatically"),
|
|
579
|
+
...outputParams,
|
|
580
|
+
},
|
|
581
|
+
annotations: RO,
|
|
582
|
+
}, async ({ tickers, classCodes, outputPath, outputFormat }) => {
|
|
583
|
+
try {
|
|
584
|
+
const upper = tickers.map((t) => t.toUpperCase());
|
|
585
|
+
// Per-index fallback: an explicit code is used where given, the rest are
|
|
586
|
+
// batch-resolved (one by-tickers request per 50, not N single requests)
|
|
587
|
+
const codes = await resolveClassCodes(upper, classCodes);
|
|
588
|
+
const instruments = upper.map((ticker, i) => ({ ticker, classCode: codes[i] }));
|
|
589
|
+
const res = await bcsFetch(API.marketData, "/quotes", {
|
|
590
|
+
method: "POST",
|
|
591
|
+
body: { instruments },
|
|
592
|
+
});
|
|
593
|
+
const rows = pickList(res, "records").map((q) => ({ ...q, dateTime: toMsk(q.dateTime) }));
|
|
594
|
+
return await deliver(rows, rows, { outputPath, outputFormat });
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
return fail(e);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
server.registerTool("get_order_book", {
|
|
601
|
+
title: "Get Order Book",
|
|
602
|
+
description: "Order book (L2 depth of market) for one instrument: bids/asks with prices and quantities, total volumes. " +
|
|
603
|
+
`${BOND_PRICE_NOTE} Outside the trading session the API may report 404 "no data".`,
|
|
604
|
+
inputSchema: {
|
|
605
|
+
ticker: z.string().describe("Ticker, e.g. SBER"),
|
|
606
|
+
classCode: z.string().optional().describe("Board class code (resolved via find_instrument when omitted)"),
|
|
607
|
+
...outputParams,
|
|
608
|
+
},
|
|
609
|
+
annotations: RO,
|
|
610
|
+
}, async ({ ticker, classCode, outputPath, outputFormat }) => {
|
|
611
|
+
try {
|
|
612
|
+
const t = ticker.toUpperCase();
|
|
613
|
+
const cc = await resolveClassCode(t, classCode);
|
|
614
|
+
const data = await bcsFetch(API.marketData, "/order-book", {
|
|
615
|
+
query: { ticker: t, classCode: cc },
|
|
616
|
+
});
|
|
617
|
+
return await deliver({ ...data, dateTime: toMsk(data.dateTime) }, null, { outputPath, outputFormat });
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
return fail(e);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
server.registerTool("get_recent_trades", {
|
|
624
|
+
title: "Get Recent Trades",
|
|
625
|
+
description: "Anonymized recent trades feed for one instrument: time, side, price, quantity, volume. " +
|
|
626
|
+
"Returns the newest `limit` trades, fetched hour-by-hour backwards from `to` (the BCS backend caps one request at a 1-hour period and ~4 MB response). " +
|
|
627
|
+
"Optional from/to select an explicit window. " +
|
|
628
|
+
"IMPORTANT: the API serves trades of the CURRENT UTC DAY only (from 00:00 UTC = 03:00 MSK) — earlier window starts are clamped, yesterday is unavailable. " +
|
|
629
|
+
"Records carry no trade id: identical rows within the same second are genuine distinct trades, not duplicates. " +
|
|
630
|
+
`Pass outputPath to dump a period (default: the whole current UTC day) to a file via hour-by-hour windows. ${BOND_PRICE_NOTE}`,
|
|
631
|
+
inputSchema: {
|
|
632
|
+
ticker: z.string().describe("Ticker, e.g. SBER"),
|
|
633
|
+
classCode: z.string().optional().describe("Board class code (resolved via find_instrument when omitted)"),
|
|
634
|
+
limit: z
|
|
635
|
+
.number()
|
|
636
|
+
.int()
|
|
637
|
+
.min(1)
|
|
638
|
+
.max(10_000)
|
|
639
|
+
.default(200)
|
|
640
|
+
.describe("Max trades returned inline, newest first (ignored with outputPath — the whole period is written)"),
|
|
641
|
+
from: z.string().optional().describe("Start of period, ISO 8601 (default: auto-widening recent window inline / start of the current UTC day with outputPath); clamped to 00:00 UTC of the current day — the API keeps no older trades"),
|
|
642
|
+
to: z.string().optional().describe("End of period, ISO 8601 (default: now)"),
|
|
643
|
+
...outputParams,
|
|
644
|
+
},
|
|
645
|
+
annotations: RO,
|
|
646
|
+
}, async ({ ticker, classCode, limit, from, to, outputPath, outputFormat }, extra) => {
|
|
647
|
+
try {
|
|
648
|
+
const t = ticker.toUpperCase();
|
|
649
|
+
const cc = await resolveClassCode(t, classCode);
|
|
650
|
+
const fetchWindow = async (fromD, toD) => {
|
|
651
|
+
const res = await bcsFetch(API.marketData, "/last-trades", {
|
|
652
|
+
method: "POST",
|
|
653
|
+
body: { ticker: t, classCode: cc, startDateTime: fromD.toISOString(), endDateTime: toD.toISOString() },
|
|
654
|
+
});
|
|
655
|
+
const lo = fromD.getTime();
|
|
656
|
+
const hi = toD.getTime();
|
|
657
|
+
// window bound semantics are undocumented — enforce [from, to) client-side
|
|
658
|
+
// so adjacent windows never duplicate or drop boundary trades
|
|
659
|
+
return pickList(res, "records")
|
|
660
|
+
.map((r) => ({ ...r, _t: Date.parse(String(r.dateTime ?? "")) }))
|
|
661
|
+
.filter((r) => !Number.isNaN(r._t) && r._t >= lo && r._t < hi);
|
|
662
|
+
};
|
|
663
|
+
const mapRow = ({ _t, ...r }) => ({
|
|
664
|
+
...r,
|
|
665
|
+
side: label(SIDE_LABELS, r.side),
|
|
666
|
+
dateTime: toMsk(r.dateTime),
|
|
667
|
+
});
|
|
668
|
+
// Fetch [lo, hi) into sink; callers keep hi−lo ≤ 1 hour (the period cap),
|
|
669
|
+
// bisection handles the rarer ~4 MB response cap
|
|
670
|
+
const fetchChunked = async (lo, hi, sink) => {
|
|
671
|
+
try {
|
|
672
|
+
sink.push(...(await fetchWindow(new Date(lo), new Date(hi))));
|
|
673
|
+
}
|
|
674
|
+
catch (e) {
|
|
675
|
+
if (e instanceof BcsApiError && e.kind === "too-large" && hi - lo > MIN_TRADE_WINDOW_MS) {
|
|
676
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
677
|
+
await fetchChunked(lo, mid, sink);
|
|
678
|
+
await fetchChunked(mid, hi, sink);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
throw e;
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
const toD = parseDate(to, new Date());
|
|
685
|
+
const dayStart = startOfUtcDay(new Date());
|
|
686
|
+
if (toD <= dayStart) {
|
|
687
|
+
return fail(new Error(`the API keeps trades of the current UTC day only (from ${dayStart.toISOString()}) — the requested window ends before that`));
|
|
688
|
+
}
|
|
689
|
+
const CLAMP_NOTE = "window start clamped to 00:00 UTC — the API keeps no older trades";
|
|
690
|
+
// Both modes honor the clamp; the requested `from` defaults differ
|
|
691
|
+
const requested = parseDate(from, dayStart); // default: the whole current UTC day
|
|
692
|
+
const clamped = Boolean(from) && requested < dayStart;
|
|
693
|
+
const fromD = requested < dayStart ? dayStart : requested;
|
|
694
|
+
if (fromD >= toD)
|
|
695
|
+
return fail(new Error("`from` must be before `to`"));
|
|
696
|
+
if (!outputPath) {
|
|
697
|
+
// Walk hour windows backwards from `to`, stop once `limit` trades are collected
|
|
698
|
+
const feed = [];
|
|
699
|
+
let cursor = toD.getTime();
|
|
700
|
+
const floor = fromD.getTime();
|
|
701
|
+
while (cursor > floor && feed.length < limit) {
|
|
702
|
+
const lo = Math.max(cursor - TRADE_WINDOW_MS, floor);
|
|
703
|
+
await fetchChunked(lo, cursor, feed);
|
|
704
|
+
cursor = lo;
|
|
705
|
+
}
|
|
706
|
+
// stopped early with an explicit `from` — flag that windowFrom is the COVERED start
|
|
707
|
+
const partial = cursor > floor && Boolean(from);
|
|
708
|
+
feed.sort((a, b) => b._t - a._t); // newest first
|
|
709
|
+
const rows = feed.slice(0, limit).map(mapRow);
|
|
710
|
+
return await deliver({
|
|
711
|
+
ticker: t,
|
|
712
|
+
classCode: cc,
|
|
713
|
+
windowFrom: toMsk(new Date(cursor)),
|
|
714
|
+
windowTo: toMsk(toD),
|
|
715
|
+
...(clamped ? { note: CLAMP_NOTE } : {}),
|
|
716
|
+
...(partial ? { coverage: "stopped after collecting `limit` trades — windowFrom is the covered start, not the requested one" } : {}),
|
|
717
|
+
feedTotal: feed.length,
|
|
718
|
+
trades: rows,
|
|
719
|
+
}, rows, {});
|
|
720
|
+
}
|
|
721
|
+
// File mode: hour windows over the whole period, chronological file
|
|
722
|
+
const all = [];
|
|
723
|
+
const windows = computeChunks(fromD, toD, TRADE_WINDOW_MS);
|
|
724
|
+
for (let i = 0; i < windows.length; i++) {
|
|
725
|
+
const got = [];
|
|
726
|
+
await fetchChunked(windows[i].from.getTime(), windows[i].to.getTime(), got);
|
|
727
|
+
got.sort((a, b) => a._t - b._t);
|
|
728
|
+
all.push(...got.map(mapRow));
|
|
729
|
+
await notifyProgress(extra, i + 1, windows.length, `window ${i + 1}/${windows.length}: ${all.length} trades`);
|
|
730
|
+
}
|
|
731
|
+
return await deliver(all, all, { outputPath, outputFormat }, {
|
|
732
|
+
windowFrom: toMsk(fromD),
|
|
733
|
+
windowTo: toMsk(toD),
|
|
734
|
+
...(clamped ? { note: CLAMP_NOTE } : {}),
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
catch (e) {
|
|
738
|
+
return fail(e);
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
// ─── Orders / trades search (one factory — the two tools must never drift apart) ───
|
|
742
|
+
const SEARCH_404_HINT = "no records matched — note the search endpoints only hold data since 2026-01-26; widen or drop the date/ticker filters.";
|
|
743
|
+
const registerSearchTool = (spec) => {
|
|
744
|
+
server.registerTool(spec.name, {
|
|
745
|
+
title: spec.title,
|
|
746
|
+
description: spec.description,
|
|
747
|
+
inputSchema: {
|
|
748
|
+
page: z.number().int().min(0).default(0).describe("Page number, from 0 (ignored with outputPath — all pages are fetched)"),
|
|
749
|
+
size: z.number().int().min(1).max(100).default(50).describe("Records per page"),
|
|
750
|
+
from: z.string().optional().describe("Filter: start of period, ISO 8601"),
|
|
751
|
+
to: z.string().optional().describe("Filter: end of period, ISO 8601"),
|
|
752
|
+
tickers: z.array(z.string()).optional().describe("Filter by tickers"),
|
|
753
|
+
...outputParams,
|
|
754
|
+
},
|
|
755
|
+
annotations: RO,
|
|
756
|
+
}, async ({ page, size, from, to, tickers, outputPath, outputFormat }, extra) => {
|
|
757
|
+
try {
|
|
758
|
+
const body = {
|
|
759
|
+
...(from ? { startDateTime: parseDate(from, new Date()).toISOString() } : {}),
|
|
760
|
+
...(to ? { endDateTime: parseDate(to, new Date()).toISOString() } : {}),
|
|
761
|
+
...(tickers?.length ? { tickers: tickers.map((t) => t.toUpperCase()) } : {}),
|
|
762
|
+
};
|
|
763
|
+
const fetchPage = (p, s) => bcsFetch(spec.base, spec.path, {
|
|
764
|
+
method: "POST",
|
|
765
|
+
query: { page: String(p), size: String(s) },
|
|
766
|
+
body,
|
|
767
|
+
});
|
|
768
|
+
if (!outputPath) {
|
|
769
|
+
const res = await fetchPage(page, size);
|
|
770
|
+
const rows = (res.records ?? []).map(spec.mapRow);
|
|
771
|
+
return await deliver({ page, totalPages: res.totalPages, totalRecords: res.totalRecords, [spec.resultKey]: rows }, rows, { outputPath, outputFormat }, { totalRecords: res.totalRecords });
|
|
772
|
+
}
|
|
773
|
+
// File mode: walk ALL pages, like every other outputPath tool
|
|
774
|
+
const all = [];
|
|
775
|
+
let totalRecords;
|
|
776
|
+
for (let p = 0; p < MAX_SEARCH_PAGES; p++) {
|
|
777
|
+
const res = await fetchPage(p, 100);
|
|
778
|
+
const rows = res.records ?? [];
|
|
779
|
+
all.push(...rows.map(spec.mapRow));
|
|
780
|
+
totalRecords = res.totalRecords ?? totalRecords;
|
|
781
|
+
await notifyProgress(extra, p + 1, res.totalPages, `page ${p + 1}: ${all.length} records`);
|
|
782
|
+
if (rows.length < 100 || (res.totalPages != null && p + 1 >= res.totalPages))
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
return await deliver(all, all, { outputPath, outputFormat }, { totalRecords });
|
|
786
|
+
}
|
|
787
|
+
catch (e) {
|
|
788
|
+
return fail(e, { 404: SEARCH_404_HINT });
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
};
|
|
792
|
+
// For get_order_status (FIX status scale)
|
|
793
|
+
const mapOrderRow = (o) => ({
|
|
794
|
+
...o,
|
|
795
|
+
side: label(SIDE_LABELS, o.side),
|
|
796
|
+
orderType: label(ORDER_TYPE_LABELS, o.orderType),
|
|
797
|
+
...(o.orderStatus != null ? { orderStatus: label(ORDER_STATUS_LABELS, o.orderStatus) } : {}),
|
|
798
|
+
orderDateTime: toMsk(o.orderDateTime),
|
|
799
|
+
updateDateTime: toMsk(o.updateDateTime),
|
|
800
|
+
});
|
|
801
|
+
// For get_orders rows (search status scale; executionDateTime comes in UTC unlike its siblings)
|
|
802
|
+
const mapSearchOrderRow = (o) => ({
|
|
803
|
+
...o,
|
|
804
|
+
side: label(SIDE_LABELS, o.side),
|
|
805
|
+
orderType: label(ORDER_TYPE_LABELS, o.orderType),
|
|
806
|
+
orderStatus: label(SEARCH_ORDER_STATUS_LABELS, o.orderStatus),
|
|
807
|
+
orderDateTime: toMsk(o.orderDateTime),
|
|
808
|
+
updateDateTime: toMsk(o.updateDateTime),
|
|
809
|
+
...(o.executionDateTime ? { executionDateTime: toMsk(o.executionDateTime) } : {}),
|
|
810
|
+
});
|
|
811
|
+
registerSearchTool({
|
|
812
|
+
name: "get_orders",
|
|
813
|
+
title: "Search Orders",
|
|
814
|
+
description: "Your orders on the account (data since 2026-01-26): ticker, side, type, quantity/filled, price, orderStatus (cancelled/filled/active), timestamps. " +
|
|
815
|
+
"Paginated (page/size), optional date and ticker filters; with outputPath ALL pages are dumped to a file. " +
|
|
816
|
+
"Orders rejected before reaching the exchange do NOT appear here — check them via get_order_status by client UUID.",
|
|
817
|
+
base: API.orderDetails,
|
|
818
|
+
path: "/orders/search",
|
|
819
|
+
resultKey: "orders",
|
|
820
|
+
mapRow: mapSearchOrderRow,
|
|
821
|
+
});
|
|
822
|
+
registerSearchTool({
|
|
823
|
+
name: "get_trades",
|
|
824
|
+
title: "Search Trades",
|
|
825
|
+
description: "Your executed trades on the account (data since 2026-01-26): ticker, side, quantity, price, volume, trade time (tradeDateTime, MSK). " +
|
|
826
|
+
"Paginated (page/size), optional date and ticker filters; with outputPath ALL pages are dumped to a file. " +
|
|
827
|
+
"Note: trades only — cash operations (dividends received, fees, deposits) are not available in BCS Trade API.",
|
|
828
|
+
base: API.tradeDetails,
|
|
829
|
+
path: "/trades/search",
|
|
830
|
+
resultKey: "trades",
|
|
831
|
+
// executionDateTime comes back always-empty from the API — drop it, tradeDateTime is the real time
|
|
832
|
+
mapRow: ({ executionDateTime, ...d }) => ({
|
|
833
|
+
...d,
|
|
834
|
+
side: label(SIDE_LABELS, d.side),
|
|
835
|
+
tradeDateTime: toMsk(d.tradeDateTime),
|
|
836
|
+
...(typeof d.volume === "number" ? { volume: round2(d.volume) } : {}),
|
|
837
|
+
...(executionDateTime ? { executionDateTime: toMsk(executionDateTime) } : {}),
|
|
838
|
+
}),
|
|
839
|
+
});
|
|
840
|
+
server.registerTool("get_order_status", {
|
|
841
|
+
title: "Get Order Status",
|
|
842
|
+
description: "Status of one order by ID. orderIdType 1 = client UUID (from place_order), 2 = exchange order number in the form YYMMDD-CLASSCODE-NUMBER with a 6-digit date (e.g. 260501-TQBR-79628540663).",
|
|
843
|
+
inputSchema: {
|
|
844
|
+
orderId: z.string().describe("Order ID"),
|
|
845
|
+
orderIdType: z.enum(["1", "2"]).default("1").describe("1 — client UUID, 2 — exchange order number"),
|
|
846
|
+
...outputParams,
|
|
847
|
+
},
|
|
848
|
+
annotations: RO,
|
|
849
|
+
}, async ({ orderId, orderIdType, outputPath, outputFormat }) => {
|
|
850
|
+
try {
|
|
851
|
+
const o = await bcsFetch(API.operations, "/orders", {
|
|
852
|
+
query: { orderIdType, orderId },
|
|
853
|
+
});
|
|
854
|
+
// Real responses nest the execution report: {clientOrderId, originalClientOrderId, data:{side, orderType, orderStatus, transactionTime, …}}
|
|
855
|
+
const data = o?.data && typeof o.data === "object" ? o.data : null;
|
|
856
|
+
const mapped = data
|
|
857
|
+
? {
|
|
858
|
+
...o,
|
|
859
|
+
data: {
|
|
860
|
+
...data,
|
|
861
|
+
side: label(SIDE_LABELS, data.side),
|
|
862
|
+
orderType: label(ORDER_TYPE_LABELS, data.orderType),
|
|
863
|
+
orderStatus: label(ORDER_STATUS_LABELS, data.orderStatus),
|
|
864
|
+
transactionTime: toMsk(data.transactionTime),
|
|
865
|
+
},
|
|
866
|
+
}
|
|
867
|
+
: mapOrderRow(o ?? {});
|
|
868
|
+
return await deliver(mapped, null, { outputPath, outputFormat });
|
|
869
|
+
}
|
|
870
|
+
catch (e) {
|
|
871
|
+
return fail(e, { 404: "order not found — check orderId and orderIdType (1 = client UUID, 2 = exchange number YYMMDD-CLASS-NUM)" });
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
/** Order row decorated for humans — shared with get_order_status via trading confirm context */
|
|
876
|
+
export function describeOrderRow(o) {
|
|
877
|
+
// GET /orders nests the execution report under `data`; older shapes were flat
|
|
878
|
+
const d = (o.data && typeof o.data === "object" ? o.data : o);
|
|
879
|
+
const side = label(SIDE_LABELS, d.side);
|
|
880
|
+
const type = label(ORDER_TYPE_LABELS, d.orderType);
|
|
881
|
+
const qty = d.orderQuantity ?? d.quantity ?? "?";
|
|
882
|
+
return `${d.ticker ?? "?"} ${side} ${type} ${qty} шт.${d.price != null ? ` @ ${d.price}` : ""}`;
|
|
883
|
+
}
|
|
884
|
+
//# sourceMappingURL=read.js.map
|