neuroalpha 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -3
- package/bin/cli.js +69 -0
- package/config/strategy.json +33 -0
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +156 -0
- package/package.json +43 -6
- package/public/app.js +2410 -0
- package/public/index.html +337 -0
- package/public/landing.html +870 -0
- package/public/legal.html +90 -0
- package/public/styles.css +343 -0
- package/server/api/routes.js +333 -0
- package/server/auth/index.js +340 -0
- package/server/billing/yookassa.js +132 -0
- package/server/brokers/index.js +645 -0
- package/server/config.js +44 -0
- package/server/data/moex.js +165 -0
- package/server/db.js +420 -0
- package/server/hub.js +14 -0
- package/server/index.js +92 -0
- package/server/llm/client.js +187 -0
- package/server/llm/providers.js +355 -0
- package/server/model/dataset.js +352 -0
- package/server/model/forecast.js +121 -0
- package/server/model/gpu-worker.html +11 -0
- package/server/model/gpu-worker.js +60 -0
- package/server/model/linalg.js +142 -0
- package/server/model/live.js +486 -0
- package/server/model/metrics.js +134 -0
- package/server/model/models.js +459 -0
- package/server/model/profiles.js +166 -0
- package/server/model/research.js +880 -0
- package/server/model/tf-node.js +26 -0
- package/server/model/transformer-core.js +330 -0
- package/server/model/transformer-gpu.js +221 -0
- package/server/model/transformer-pool.js +200 -0
- package/server/model/transformer-worker.js +28 -0
- package/server/model/transformer.js +57 -0
- package/server/model/vocab.js +28 -0
- package/server/news/index.js +309 -0
- package/server/notify/index.js +168 -0
- package/server/params-meta.js +63 -0
- package/server/settings.js +79 -0
- package/server/strategist/analytics.js +150 -0
- package/server/strategist/index.js +484 -0
- package/server/strategist/tools.js +302 -0
- package/server/util.js +9 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
|
|
3
|
+
import { encryptSecret, decryptSecret, currentUserId, runWithUser } from '../auth/index.js';
|
|
4
|
+
import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
|
|
5
|
+
import { getSettings, normalizeParams } from '../settings.js';
|
|
6
|
+
import { latestBarTs } from '../data/moex.js';
|
|
7
|
+
import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
|
|
8
|
+
import { forecastNow } from '../model/forecast.js';
|
|
9
|
+
import { MODELS } from '../model/models.js';
|
|
10
|
+
import { blockingNews } from '../news/index.js';
|
|
11
|
+
import { round, MSK, DAY } from '../util.js';
|
|
12
|
+
import { hub } from '../hub.js';
|
|
13
|
+
|
|
14
|
+
export const BROKER_META = [
|
|
15
|
+
{ id: 'tbank', name: 'Т-Банк (T-Invest API)', accountHint: 'ID счёта (оставьте пустым для выбора первого доступного)', tokenHint: 'Токен T-Invest API (t.…)' },
|
|
16
|
+
{ id: 'finam', name: 'Финам (Trade API)', accountHint: 'Код клиента (ClientId, например 123456R)', tokenHint: 'Токен Finam Trade API (CA…)' },
|
|
17
|
+
{ id: 'alor', name: 'Алор (OpenAPI)', accountHint: 'Портфель Мосбиржи (например D12345)', tokenHint: 'Refresh-токен Alor OpenAPI' },
|
|
18
|
+
{ id: 'bcs', name: 'БКС Мир инвестиций', accountHint: 'Номер соглашения / субсчёта (необязательно)', tokenHint: 'Refresh-токен БКС Trade API' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
|
|
22
|
+
const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
|
|
23
|
+
export async function ensureBoardSpecs() {
|
|
24
|
+
const cached = kvGet('moexSpecs');
|
|
25
|
+
if (cached?.specs && Date.now() - cached.at < DAY) return cached.specs;
|
|
26
|
+
try {
|
|
27
|
+
const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
|
|
28
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
29
|
+
const j = await r.json();
|
|
30
|
+
const sc = j.securities.columns, mc = j.marketdata.columns;
|
|
31
|
+
const is = (k) => sc.indexOf(k), im = (k) => mc.indexOf(k);
|
|
32
|
+
const prices = new Map();
|
|
33
|
+
for (const row of j.marketdata.data) {
|
|
34
|
+
const sym = row[im('SECID')];
|
|
35
|
+
const px = Number(row[im('LAST')] || row[im('MARKETPRICE')] || row[im('LCURRENTPRICE')] || 0);
|
|
36
|
+
if (sym && px > 0) prices.set(sym, px);
|
|
37
|
+
}
|
|
38
|
+
const specs = {};
|
|
39
|
+
for (const row of j.securities.data) {
|
|
40
|
+
const sym = row[is('SECID')];
|
|
41
|
+
if (!sym) continue;
|
|
42
|
+
const lotSize = Math.max(1, Number(row[is('LOTSIZE')]) || 1);
|
|
43
|
+
const minStep = Number(row[is('MINSTEP')]) > 0 ? Number(row[is('MINSTEP')]) : 0.01;
|
|
44
|
+
const decimals = Math.max(0, Number(row[is('DECIMALS')]) || 0);
|
|
45
|
+
const prev = Number(row[is('PREVPRICE')] || row[is('PREVADMITTEDQUOTE')] || 0);
|
|
46
|
+
specs[sym] = { lotSize, minStep, decimals, lastPrice: prices.get(sym) || prev || 0 };
|
|
47
|
+
}
|
|
48
|
+
kvSet('moexSpecs', { at: Date.now(), specs });
|
|
49
|
+
return specs;
|
|
50
|
+
} catch {
|
|
51
|
+
return cached?.specs || {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function roundToStep(price, minStep = 0.01, decimals = 2) {
|
|
56
|
+
const step = minStep > 0 ? minStep : 0.01;
|
|
57
|
+
const v = Math.round(price / step) * step;
|
|
58
|
+
return Number(v.toFixed(Math.max(0, Math.min(6, decimals))));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function calcLotOrder(symbol, targetSumRub, refPrice, action, offsetPct = 0.1, specs = {}) {
|
|
62
|
+
const sp = specs[symbol] || { lotSize: 1, minStep: 0.01, decimals: 2, lastPrice: 0 };
|
|
63
|
+
const basePx = refPrice > 0 ? refPrice : sp.lastPrice;
|
|
64
|
+
if (!(basePx > 0)) return null;
|
|
65
|
+
const dir = action === 'buy' || action === 'cover' ? 1 : -1;
|
|
66
|
+
const rawLimit = basePx * (1 + dir * (Number(offsetPct) || 0) / 100);
|
|
67
|
+
const limitPrice = roundToStep(rawLimit, sp.minStep, sp.decimals);
|
|
68
|
+
const lotCost = limitPrice * sp.lotSize;
|
|
69
|
+
const lots = Math.floor(targetSumRub / lotCost);
|
|
70
|
+
if (lots < 1) return null;
|
|
71
|
+
const shares = lots * sp.lotSize;
|
|
72
|
+
return {
|
|
73
|
+
symbol,
|
|
74
|
+
action,
|
|
75
|
+
lotSize: sp.lotSize,
|
|
76
|
+
lots,
|
|
77
|
+
shares,
|
|
78
|
+
refPrice: round(basePx, 4),
|
|
79
|
+
limitPrice,
|
|
80
|
+
amountRub: round(shares * limitPrice, 2),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------- хранение счетов (с изоляцией по user_id и AES-256-GCM шифрованием токенов) ----------
|
|
85
|
+
function loadRawAccounts() {
|
|
86
|
+
const list = userKvGet('brokerAccounts');
|
|
87
|
+
if (!Array.isArray(list)) return [];
|
|
88
|
+
return list.map((a) => ({ ...a, token: decryptSecret(a.token) }));
|
|
89
|
+
}
|
|
90
|
+
function saveRawAccounts(list) {
|
|
91
|
+
userKvSet('brokerAccounts', list.map((a) => ({ ...a, token: a.token ? encryptSecret(a.token) : '' })));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function publicAccount(a) {
|
|
95
|
+
const { token, ...rest } = a;
|
|
96
|
+
let baseRunLabel = null;
|
|
97
|
+
if (a.baseRunId) {
|
|
98
|
+
const r = getRun(Number(a.baseRunId));
|
|
99
|
+
if (r) baseRunLabel = `№${r.id}${r.params?._title ? ` (${r.params._title})` : ''}`;
|
|
100
|
+
}
|
|
101
|
+
return { ...rest, hasToken: Boolean(token && String(token).trim()), baseRunLabel };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function brokersState() {
|
|
105
|
+
return {
|
|
106
|
+
brokers: BROKER_META,
|
|
107
|
+
accounts: loadRawAccounts().map(publicAccount),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function saveBrokerAccount(input = {}) {
|
|
112
|
+
const list = loadRawAccounts();
|
|
113
|
+
const id = input.id && String(input.id).trim() ? String(input.id).trim() : `brk_${crypto.randomBytes(4).toString('hex')}`;
|
|
114
|
+
const idx = list.findIndex((x) => x.id === id);
|
|
115
|
+
const prev = idx >= 0 ? list[idx] : {};
|
|
116
|
+
const broker = BROKER_META.some((b) => b.id === input.broker) ? input.broker : (prev.broker || 'tbank');
|
|
117
|
+
const name = String(input.name ?? prev.name ?? '').trim() || BROKER_META.find((b) => b.id === broker).name;
|
|
118
|
+
const mode = ['readonly', 'confirm', 'auto'].includes(input.mode) ? input.mode : (prev.mode || 'confirm');
|
|
119
|
+
let token = prev.token || '';
|
|
120
|
+
if (input.clearToken) token = '';
|
|
121
|
+
else if (typeof input.token === 'string' && input.token.trim()) token = input.token.trim();
|
|
122
|
+
|
|
123
|
+
const next = {
|
|
124
|
+
id,
|
|
125
|
+
name,
|
|
126
|
+
broker,
|
|
127
|
+
accountId: String(input.accountId ?? prev.accountId ?? '').trim(),
|
|
128
|
+
sandbox: input.sandbox !== undefined ? Boolean(input.sandbox) : Boolean(prev.sandbox),
|
|
129
|
+
mode,
|
|
130
|
+
baseRunId: input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null),
|
|
131
|
+
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
132
|
+
maxCapitalRub: Math.max(10000, Math.round(Number(input.maxCapitalRub ?? prev.maxCapitalRub ?? 100000))),
|
|
133
|
+
maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
|
|
134
|
+
limitOffsetPct: Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1))),
|
|
135
|
+
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
136
|
+
autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
|
|
137
|
+
lastScheduledBarTs: prev.lastScheduledBarTs || 0,
|
|
138
|
+
haltedReason: input.resetHalt ? null : (prev.haltedReason || null),
|
|
139
|
+
token,
|
|
140
|
+
lastSync: prev.lastSync || null,
|
|
141
|
+
pendingBatch: prev.pendingBatch || null,
|
|
142
|
+
};
|
|
143
|
+
if (idx >= 0) list[idx] = next;
|
|
144
|
+
else list.push(next);
|
|
145
|
+
saveRawAccounts(list);
|
|
146
|
+
const st = brokersState();
|
|
147
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
148
|
+
return st;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function deleteBrokerAccount(id) {
|
|
152
|
+
const list = loadRawAccounts().filter((x) => x.id !== id);
|
|
153
|
+
saveRawAccounts(list);
|
|
154
|
+
const st = brokersState();
|
|
155
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
156
|
+
return st;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
|
|
160
|
+
const qVal = (q) => (q ? Number(q.units || 0) + Number(q.nano || 0) / 1e9 : 0);
|
|
161
|
+
const tbankUidCache = new Map();
|
|
162
|
+
|
|
163
|
+
async function resolveTbankInstrumentId(base, headers, symbol) {
|
|
164
|
+
if (tbankUidCache.has(symbol)) return tbankUidCache.get(symbol);
|
|
165
|
+
try {
|
|
166
|
+
const r = await fetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/ShareBy`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers,
|
|
169
|
+
body: JSON.stringify({ idType: 'INSTRUMENT_ID_TYPE_TICKER', classCode: 'TQBR', id: symbol }),
|
|
170
|
+
signal: AbortSignal.timeout(10000),
|
|
171
|
+
});
|
|
172
|
+
if (r.ok) {
|
|
173
|
+
const j = await r.json();
|
|
174
|
+
const uid = j.instrument?.uid || j.instrument?.figi;
|
|
175
|
+
if (uid) { tbankUidCache.set(symbol, uid); return uid; }
|
|
176
|
+
}
|
|
177
|
+
} catch { /* fallback */ }
|
|
178
|
+
return `${symbol}_TQBR`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function syncTbank(acc) {
|
|
182
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
183
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
184
|
+
let accountId = acc.accountId;
|
|
185
|
+
if (!accountId) {
|
|
186
|
+
const rAcc = await fetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetAccounts`, {
|
|
187
|
+
method: 'POST', headers, body: JSON.stringify({}), signal: AbortSignal.timeout(15000),
|
|
188
|
+
});
|
|
189
|
+
if (!rAcc.ok) throw new Error(`T-Invest GetAccounts: HTTP ${rAcc.status}`);
|
|
190
|
+
const jAcc = await rAcc.json();
|
|
191
|
+
accountId = jAcc.accounts?.[0]?.id;
|
|
192
|
+
if (!accountId) throw new Error('В Т-Банке не найдено открытых счетов');
|
|
193
|
+
}
|
|
194
|
+
const rPort = await fetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
|
|
195
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, currency: 'RUB' }), signal: AbortSignal.timeout(15000),
|
|
196
|
+
});
|
|
197
|
+
if (!rPort.ok) throw new Error(`T-Invest GetPortfolio: HTTP ${rPort.status}`);
|
|
198
|
+
const p = await rPort.json();
|
|
199
|
+
const totalRub = round(qVal(p.totalAmountPortfolio), 2);
|
|
200
|
+
const positions = [];
|
|
201
|
+
let cashRub = 0;
|
|
202
|
+
for (const pos of p.positions || []) {
|
|
203
|
+
const ticker = pos.ticker || pos.figi || '';
|
|
204
|
+
if (pos.ticker && (pos.instrumentUid || pos.figi)) tbankUidCache.set(pos.ticker, pos.instrumentUid || pos.figi);
|
|
205
|
+
const qty = qVal(pos.quantity);
|
|
206
|
+
const px = qVal(pos.currentPrice);
|
|
207
|
+
if (pos.instrumentType === 'currency' && (ticker.includes('RUB') || pos.figi === 'RUB000UTSTOM')) {
|
|
208
|
+
cashRub += qty;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
positions.push({ symbol: ticker, qty, price: round(px, 4), valueRub: round(qty * px, 2), pnlRub: round(qVal(pos.expectedYield), 2) });
|
|
212
|
+
}
|
|
213
|
+
return { accountId, totalRub: totalRub || cashRub, cashRub: round(cashRub, 2), positions };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function syncFinam(acc) {
|
|
217
|
+
if (!acc.accountId) throw new Error('Укажите код клиента (ClientId) Финам');
|
|
218
|
+
const headers = { 'X-Api-Key': acc.token };
|
|
219
|
+
const url = `https://trade-api.finam.ru/public/api/v1/portfolio?ClientId=${encodeURIComponent(acc.accountId)}&Content.IncludeCurrencies=true&Content.IncludePositions=true`;
|
|
220
|
+
const r = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
|
|
221
|
+
if (!r.ok) throw new Error(`Финам Trade API: HTTP ${r.status}`);
|
|
222
|
+
const j = await r.json();
|
|
223
|
+
const data = j.data || j;
|
|
224
|
+
const totalRub = round(Number(data.equity || 0), 2);
|
|
225
|
+
const cashRub = round(Number((data.currencies || []).find((c) => c.name === 'RUB')?.balance || data.balance || 0), 2);
|
|
226
|
+
const positions = (data.positions || []).map((p) => ({
|
|
227
|
+
symbol: p.securityCode, qty: Number(p.balance || 0), price: round(Number(p.currentPrice || 0), 4),
|
|
228
|
+
valueRub: round(Number(p.balance || 0) * Number(p.currentPrice || 0), 2), pnlRub: round(Number(p.unrealizedProfit || 0), 2),
|
|
229
|
+
}));
|
|
230
|
+
return { accountId: acc.accountId, totalRub, cashRub, positions };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function alorAccessToken(refreshToken, sandbox) {
|
|
234
|
+
const oauth = sandbox ? 'https://oauthdev.alor.ru' : 'https://oauth.alor.ru';
|
|
235
|
+
const r = await fetch(`${oauth}/refresh?token=${encodeURIComponent(refreshToken)}`, { method: 'POST', signal: AbortSignal.timeout(15000) });
|
|
236
|
+
if (!r.ok) throw new Error(`Алор OAuth: HTTP ${r.status}`);
|
|
237
|
+
const j = await r.json();
|
|
238
|
+
if (!j.AccessToken) throw new Error('Алор не вернул AccessToken');
|
|
239
|
+
return j.AccessToken;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function syncAlor(acc) {
|
|
243
|
+
if (!acc.accountId) throw new Error('Укажите портфель Алор (например D12345)');
|
|
244
|
+
const jwt = await alorAccessToken(acc.token, acc.sandbox);
|
|
245
|
+
const api = acc.sandbox ? 'https://apidev.alor.ru' : 'https://api.alor.ru';
|
|
246
|
+
const headers = { Authorization: `Bearer ${jwt}` };
|
|
247
|
+
const [rSum, rPos] = await Promise.all([
|
|
248
|
+
fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/summary`, { headers, signal: AbortSignal.timeout(15000) }),
|
|
249
|
+
fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/positions`, { headers, signal: AbortSignal.timeout(15000) }),
|
|
250
|
+
]);
|
|
251
|
+
if (!rSum.ok) throw new Error(`Алор summary: HTTP ${rSum.status}`);
|
|
252
|
+
const sum = await rSum.json();
|
|
253
|
+
const posList = rPos.ok ? await rPos.json() : [];
|
|
254
|
+
let cashRub = round(Number(sum.buyingPower || sum.cash || 0), 2);
|
|
255
|
+
const positions = [];
|
|
256
|
+
for (const p of Array.isArray(posList) ? posList : []) {
|
|
257
|
+
if (p.isCurrency || p.symbol === 'RUB') { cashRub = round(Number(p.qtyUnits || p.qty || cashRub), 2); continue; }
|
|
258
|
+
const qty = Number(p.qtyUnits || p.qty || 0);
|
|
259
|
+
const val = Number(p.volume || 0);
|
|
260
|
+
positions.push({ symbol: p.symbol, qty, price: qty ? round(val / qty, 4) : 0, valueRub: round(val, 2), pnlRub: round(Number(p.unrealisedPl || 0), 2) });
|
|
261
|
+
}
|
|
262
|
+
return { accountId: acc.accountId, totalRub: round(Number(sum.portfolioLiquidationValue || cashRub), 2), cashRub, positions };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function bcsAccessToken(refreshToken) {
|
|
266
|
+
const body = new URLSearchParams({ client_id: 'trade-api-write', refresh_token: refreshToken, grant_type: 'refresh_token' });
|
|
267
|
+
const r = await fetch('https://be.broker.ru/trade-api-keycloak/realms/tradeapi/protocol/openid-connect/token', {
|
|
268
|
+
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(15000),
|
|
269
|
+
});
|
|
270
|
+
if (!r.ok) throw new Error(`БКС OAuth: HTTP ${r.status}`);
|
|
271
|
+
const j = await r.json();
|
|
272
|
+
if (!j.access_token) throw new Error('БКС не вернул access_token');
|
|
273
|
+
return j.access_token;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function syncBcs(acc) {
|
|
277
|
+
const jwt = await bcsAccessToken(acc.token);
|
|
278
|
+
const r = await fetch('https://be.broker.ru/trade-api-bff-portfolio/api/v1/portfolio', {
|
|
279
|
+
headers: { Authorization: `Bearer ${jwt}` }, signal: AbortSignal.timeout(15000),
|
|
280
|
+
});
|
|
281
|
+
if (!r.ok) throw new Error(`БКС портфель: HTTP ${r.status}`);
|
|
282
|
+
const j = await r.json();
|
|
283
|
+
const items = j.positions || j.data || [];
|
|
284
|
+
let cashRub = 0, totalRub = 0;
|
|
285
|
+
const positions = [];
|
|
286
|
+
for (const p of Array.isArray(items) ? items : []) {
|
|
287
|
+
if (p.type === 'MONEY' || p.ticker === 'RUB') {
|
|
288
|
+
cashRub += Number(p.CurrentValue || p.balance || 0);
|
|
289
|
+
totalRub += Number(p.CurrentValue || p.balance || 0);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const qty = Number(p.quantity || p.balance || 0);
|
|
293
|
+
const px = Number(p.currentPrice || p.price || 0);
|
|
294
|
+
const val = Number(p.currentValue || qty * px || 0);
|
|
295
|
+
totalRub += val;
|
|
296
|
+
positions.push({ symbol: p.ticker || p.symbol, qty, price: round(px, 4), valueRub: round(val, 2), pnlRub: round(Number(p.unrealizedPL || 0), 2) });
|
|
297
|
+
}
|
|
298
|
+
return { accountId: acc.accountId || 'основной', totalRub: round(totalRub, 2), cashRub: round(cashRub, 2), positions };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function syncBrokerAccount(id) {
|
|
302
|
+
const list = loadRawAccounts();
|
|
303
|
+
const acc = list.find((x) => x.id === id);
|
|
304
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
305
|
+
if (!acc.token) throw new Error('У счёта не задан токен API');
|
|
306
|
+
const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
|
|
307
|
+
try {
|
|
308
|
+
let data;
|
|
309
|
+
if (acc.broker === 'tbank') data = await syncTbank(acc);
|
|
310
|
+
else if (acc.broker === 'finam') data = await syncFinam(acc);
|
|
311
|
+
else if (acc.broker === 'alor') data = await syncAlor(acc);
|
|
312
|
+
else if (acc.broker === 'bcs') data = await syncBcs(acc);
|
|
313
|
+
else throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
314
|
+
if (data.accountId && !acc.accountId) acc.accountId = data.accountId;
|
|
315
|
+
const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
|
|
316
|
+
const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
|
|
317
|
+
const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
|
|
318
|
+
if (ddPct >= acc.maxDailyDrawdownPct) {
|
|
319
|
+
acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
|
|
320
|
+
}
|
|
321
|
+
acc.lastSync = { at: Date.now(), ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
|
|
322
|
+
} catch (e) {
|
|
323
|
+
acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
|
|
324
|
+
saveRawAccounts(list);
|
|
325
|
+
hub.emit({ type: 'brokers:update', state: brokersState() });
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
328
|
+
saveRawAccounts(list);
|
|
329
|
+
const st = brokersState();
|
|
330
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
331
|
+
return st;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------- предохранитель времени сессии (первые и последние 10 минут торгов МСК) ----------
|
|
335
|
+
function sessionEdgeWarning(now = Date.now()) {
|
|
336
|
+
const d = new Date(now + MSK);
|
|
337
|
+
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
338
|
+
// утреннее открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
|
|
339
|
+
if ((mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
|
|
340
|
+
return 'Первые или последние 10 минут сессии (широкий спред в стакане)';
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
346
|
+
export async function prepareBrokerOrders(id) {
|
|
347
|
+
const list = loadRawAccounts();
|
|
348
|
+
const acc = list.find((x) => x.id === id);
|
|
349
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
350
|
+
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
351
|
+
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
352
|
+
|
|
353
|
+
const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
|
|
354
|
+
const params = normalizeParams(baseRun?.params || getSettings());
|
|
355
|
+
const [fc, specs] = await Promise.all([
|
|
356
|
+
forecastNow(params),
|
|
357
|
+
ensureBoardSpecs(),
|
|
358
|
+
]);
|
|
359
|
+
const budget = Math.min(acc.maxCapitalRub, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : acc.maxCapitalRub);
|
|
360
|
+
const K = params.modelTopK || 3;
|
|
361
|
+
const dir = params.modelDirection || 'long';
|
|
362
|
+
const autoAdapt = params.modelAutoAdapt !== false;
|
|
363
|
+
const exitRatio = autoAdapt ? 0.75 : Number(params.modelExitRatio ?? 0.5);
|
|
364
|
+
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
365
|
+
const slots = dir === 'long' ? K : 2 * K;
|
|
366
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : (dir === 'neutral' ? Math.min(0.22, 1 / (2 * K)) : Math.min(0.30, 1.2 / (2 * K)));
|
|
367
|
+
const slotRub = autoAdapt ? budget * maxSlotWeight : budget / slots;
|
|
368
|
+
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
369
|
+
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
370
|
+
const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
|
|
371
|
+
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
372
|
+
|
|
373
|
+
const buyCands = fc.rows.filter((r) => r.signal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
374
|
+
const shortCands = dir !== 'long' ? fc.rows.filter((r) => r.shortSignal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct).slice(0, K) : [];
|
|
375
|
+
|
|
376
|
+
const orders = [];
|
|
377
|
+
const closing = new Set();
|
|
378
|
+
// 1) закрытие бумаг (покупок и шортов), чей прогноз ослаб ниже порога выхода exitThresholdPct
|
|
379
|
+
for (const [sym, pos] of heldMap) {
|
|
380
|
+
if (!pos.qty) continue;
|
|
381
|
+
const r = rowMap.get(sym);
|
|
382
|
+
const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
|
|
383
|
+
const lots = Math.floor(Math.abs(pos.qty) / sp.lotSize);
|
|
384
|
+
if (lots < 1) continue;
|
|
385
|
+
if (pos.qty > 0) {
|
|
386
|
+
const keep = r && r.liquid && r.expectedPct > exitThresholdPct;
|
|
387
|
+
if (!keep) {
|
|
388
|
+
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
389
|
+
orders.push({
|
|
390
|
+
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
391
|
+
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
392
|
+
reason: `Выход из покупки: ожидание ${r ? `${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%` : '—'} ниже порога удержания +${exitThresholdPct}%`,
|
|
393
|
+
});
|
|
394
|
+
closing.add(sym);
|
|
395
|
+
}
|
|
396
|
+
} else if (pos.qty < 0) {
|
|
397
|
+
const keep = dir !== 'long' && r && r.liquid && r.expectedPct < -exitThresholdPct;
|
|
398
|
+
if (!keep) {
|
|
399
|
+
const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
400
|
+
orders.push({
|
|
401
|
+
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
402
|
+
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
403
|
+
reason: `Закрытие шорта: ожидание ${r ? `${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%` : '—'} выше порога удержания −${exitThresholdPct}%`,
|
|
404
|
+
});
|
|
405
|
+
closing.add(sym);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const activeLongs = [...heldMap.values()].filter((p) => p.qty > 0 && !closing.has(p.symbol)).length;
|
|
410
|
+
const activeShorts = [...heldMap.values()].filter((p) => p.qty < 0 && !closing.has(p.symbol)).length;
|
|
411
|
+
const usedRub = [...heldMap.values()].filter((p) => p.qty && !closing.has(p.symbol)).reduce((s, p) => s + Math.abs(p.valueRub || 0), 0);
|
|
412
|
+
let availRub = Math.max(0, budget * 0.99 - usedRub);
|
|
413
|
+
|
|
414
|
+
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи и свободного лимита)
|
|
415
|
+
const newBuys = buyCands.filter((r) => !((heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol))).slice(0, Math.max(0, K - activeLongs));
|
|
416
|
+
const newShorts = shortCands.filter((r) => !((heldMap.get(r.symbol)?.qty || 0) < 0 && !closing.has(r.symbol))).slice(0, Math.max(0, K - activeShorts));
|
|
417
|
+
const totalNew = newBuys.length + newShorts.length;
|
|
418
|
+
let remNew = totalNew;
|
|
419
|
+
|
|
420
|
+
for (const r of newBuys) {
|
|
421
|
+
const rawSum = params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub;
|
|
422
|
+
const sum = Math.min(rawSum, availRub / Math.max(1, remNew));
|
|
423
|
+
remNew--;
|
|
424
|
+
const refPx = specs[r.symbol]?.lastPrice || 0;
|
|
425
|
+
const ord = calcLotOrder(r.symbol, sum, refPx, 'buy', acc.limitOffsetPct, specs);
|
|
426
|
+
if (ord) {
|
|
427
|
+
availRub = Math.max(0, availRub - ord.amountRub);
|
|
428
|
+
orders.push({
|
|
429
|
+
...ord,
|
|
430
|
+
expectedPct: r.expectedPct,
|
|
431
|
+
reason: `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const r of newShorts) {
|
|
436
|
+
const rawSum = params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub;
|
|
437
|
+
const sum = Math.min(rawSum, availRub / Math.max(1, remNew));
|
|
438
|
+
remNew--;
|
|
439
|
+
const refPx = specs[r.symbol]?.lastPrice || 0;
|
|
440
|
+
const ord = calcLotOrder(r.symbol, sum, refPx, 'short', acc.limitOffsetPct, specs);
|
|
441
|
+
if (ord) {
|
|
442
|
+
availRub = Math.max(0, availRub - ord.amountRub);
|
|
443
|
+
orders.push({
|
|
444
|
+
...ord,
|
|
445
|
+
expectedPct: r.expectedPct,
|
|
446
|
+
reason: `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
acc.pendingBatch = {
|
|
452
|
+
id: `batch_${Date.now()}`,
|
|
453
|
+
createdAt: Date.now(),
|
|
454
|
+
bar: fc.bar,
|
|
455
|
+
budgetRub: budget,
|
|
456
|
+
edgeWarning: edgeWarn,
|
|
457
|
+
orders,
|
|
458
|
+
status: 'pending',
|
|
459
|
+
results: [],
|
|
460
|
+
};
|
|
461
|
+
saveRawAccounts(list);
|
|
462
|
+
notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
|
|
463
|
+
if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
|
|
464
|
+
return executeBrokerOrders(id);
|
|
465
|
+
}
|
|
466
|
+
const st = brokersState();
|
|
467
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
468
|
+
return st;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function sendOrderToBroker(acc, ord) {
|
|
472
|
+
if (!acc.token) throw new Error('Не задан токен API брокера');
|
|
473
|
+
if (acc.broker === 'tbank') {
|
|
474
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
475
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
476
|
+
const instrumentId = await resolveTbankInstrumentId(base, headers, ord.symbol);
|
|
477
|
+
const units = Math.trunc(ord.limitPrice);
|
|
478
|
+
const nano = Math.round((ord.limitPrice - units) * 1e9);
|
|
479
|
+
const body = {
|
|
480
|
+
accountId: acc.accountId,
|
|
481
|
+
instrumentId,
|
|
482
|
+
quantity: String(ord.lots),
|
|
483
|
+
price: { units: String(units), nano },
|
|
484
|
+
direction: ord.action === 'buy' || ord.action === 'cover' ? 'ORDER_DIRECTION_BUY' : 'ORDER_DIRECTION_SELL',
|
|
485
|
+
orderType: 'ORDER_TYPE_LIMIT',
|
|
486
|
+
orderId: crypto.randomUUID(),
|
|
487
|
+
};
|
|
488
|
+
const r = await fetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/PostOrder`, {
|
|
489
|
+
method: 'POST',
|
|
490
|
+
headers,
|
|
491
|
+
body: JSON.stringify(body),
|
|
492
|
+
signal: AbortSignal.timeout(15000),
|
|
493
|
+
});
|
|
494
|
+
if (!r.ok) throw new Error(`T-Invest PostOrder HTTP ${r.status}`);
|
|
495
|
+
const j = await r.json();
|
|
496
|
+
return { ok: true, orderId: j.orderId || body.orderId };
|
|
497
|
+
}
|
|
498
|
+
if (acc.broker === 'finam') {
|
|
499
|
+
const body = {
|
|
500
|
+
clientId: acc.accountId,
|
|
501
|
+
securityBoard: 'TQBR',
|
|
502
|
+
securityCode: ord.symbol,
|
|
503
|
+
buySell: ord.action === 'buy' || ord.action === 'cover' ? 'Buy' : 'Sell',
|
|
504
|
+
quantity: ord.lots,
|
|
505
|
+
useCredit: true,
|
|
506
|
+
price: ord.limitPrice,
|
|
507
|
+
property: 'PutInQueue',
|
|
508
|
+
};
|
|
509
|
+
const r = await fetch('https://trade-api.finam.ru/public/api/v1/orders', {
|
|
510
|
+
method: 'POST',
|
|
511
|
+
headers: { 'X-Api-Key': acc.token, 'Content-Type': 'application/json' },
|
|
512
|
+
body: JSON.stringify(body),
|
|
513
|
+
signal: AbortSignal.timeout(15000),
|
|
514
|
+
});
|
|
515
|
+
if (!r.ok) throw new Error(`Финам PostOrder HTTP ${r.status}`);
|
|
516
|
+
const j = await r.json();
|
|
517
|
+
return { ok: true, orderId: j.data?.transactionId || 'ok' };
|
|
518
|
+
}
|
|
519
|
+
if (acc.broker === 'alor') {
|
|
520
|
+
const jwt = await alorAccessToken(acc.token, acc.sandbox);
|
|
521
|
+
const api = acc.sandbox ? 'https://apidev.alor.ru' : 'https://api.alor.ru';
|
|
522
|
+
const body = {
|
|
523
|
+
side: ord.action === 'buy' || ord.action === 'cover' ? 'buy' : 'sell',
|
|
524
|
+
type: 'limit',
|
|
525
|
+
quantity: ord.lots,
|
|
526
|
+
price: ord.limitPrice,
|
|
527
|
+
instrument: { symbol: ord.symbol, exchange: 'MOEX', instrumentGroup: 'TQBR' },
|
|
528
|
+
user: { portfolio: acc.accountId },
|
|
529
|
+
};
|
|
530
|
+
const r = await fetch(`${api}/commandapi/warptrans/TRADE/v2/client/orders/actions/limit`, {
|
|
531
|
+
method: 'POST',
|
|
532
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json', 'X-ALOR-REQID': crypto.randomUUID() },
|
|
533
|
+
body: JSON.stringify(body),
|
|
534
|
+
signal: AbortSignal.timeout(15000),
|
|
535
|
+
});
|
|
536
|
+
if (!r.ok) throw new Error(`Алор PostOrder HTTP ${r.status}`);
|
|
537
|
+
const j = await r.json();
|
|
538
|
+
return { ok: true, orderId: j.orderNumber || 'ok' };
|
|
539
|
+
}
|
|
540
|
+
if (acc.broker === 'bcs') {
|
|
541
|
+
const jwt = await bcsAccessToken(acc.token);
|
|
542
|
+
const body = {
|
|
543
|
+
clientOrderId: crypto.randomUUID(),
|
|
544
|
+
side: ord.action === 'buy' || ord.action === 'cover' ? '1' : '2',
|
|
545
|
+
orderType: '2',
|
|
546
|
+
orderQuantity: ord.lots,
|
|
547
|
+
price: ord.limitPrice,
|
|
548
|
+
ticker: ord.symbol,
|
|
549
|
+
classCode: 'TQBR',
|
|
550
|
+
};
|
|
551
|
+
const r = await fetch('https://be.broker.ru/trade-api-bff-operations/api/v1/orders', {
|
|
552
|
+
method: 'POST',
|
|
553
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
554
|
+
body: JSON.stringify(body),
|
|
555
|
+
signal: AbortSignal.timeout(15000),
|
|
556
|
+
});
|
|
557
|
+
if (!r.ok) throw new Error(`БКС PostOrder HTTP ${r.status}`);
|
|
558
|
+
const j = await r.json();
|
|
559
|
+
return { ok: true, orderId: j.orderId || body.clientOrderId };
|
|
560
|
+
}
|
|
561
|
+
throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function executeBrokerOrders(id) {
|
|
565
|
+
const list = loadRawAccounts();
|
|
566
|
+
const acc = list.find((x) => x.id === id);
|
|
567
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
568
|
+
if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
|
|
569
|
+
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
570
|
+
const batch = acc.pendingBatch;
|
|
571
|
+
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
572
|
+
const results = [];
|
|
573
|
+
for (const ord of batch.orders) {
|
|
574
|
+
try {
|
|
575
|
+
const r = await sendOrderToBroker(acc, ord);
|
|
576
|
+
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
577
|
+
} catch (e) {
|
|
578
|
+
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
batch.status = results.every((r) => r.ok) ? 'executed' : 'partial';
|
|
582
|
+
batch.results = results;
|
|
583
|
+
batch.executedAt = Date.now();
|
|
584
|
+
saveRawAccounts(list);
|
|
585
|
+
const st = brokersState();
|
|
586
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
587
|
+
return st;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function rejectBrokerOrders(id) {
|
|
591
|
+
const list = loadRawAccounts();
|
|
592
|
+
const acc = list.find((x) => x.id === id);
|
|
593
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
594
|
+
acc.pendingBatch = null;
|
|
595
|
+
saveRawAccounts(list);
|
|
596
|
+
const st = brokersState();
|
|
597
|
+
hub.emit({ type: 'brokers:update', state: st });
|
|
598
|
+
return st;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
registerBrokerHandlers({ execute: executeBrokerOrders, reject: rejectBrokerOrders });
|
|
602
|
+
|
|
603
|
+
// ---------- авто-расписание расчёта заявок по закрытию свечи (раз в горизонт прогноза) ----------
|
|
604
|
+
let lastSeenBar = null;
|
|
605
|
+
let schedulerBusy = false;
|
|
606
|
+
|
|
607
|
+
async function tickBrokerSchedule() {
|
|
608
|
+
if (schedulerBusy) return;
|
|
609
|
+
const nowMsk = new Date(Date.now() + MSK);
|
|
610
|
+
const dow = nowMsk.getUTCDay();
|
|
611
|
+
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
612
|
+
if (dow === 0 || dow === 6 || mins < 9 * 60 + 50 || mins > 23 * 60 + 55) return;
|
|
613
|
+
schedulerBusy = true;
|
|
614
|
+
try {
|
|
615
|
+
const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
|
|
616
|
+
if (!latest || latest === lastSeenBar) return;
|
|
617
|
+
lastSeenBar = latest;
|
|
618
|
+
const users = db.prepare('SELECT * FROM users').all();
|
|
619
|
+
for (const u of users) {
|
|
620
|
+
await runWithUser(u, async () => {
|
|
621
|
+
const list = loadRawAccounts();
|
|
622
|
+
if (!list.length) return;
|
|
623
|
+
for (const acc of list) {
|
|
624
|
+
if (acc.autoSchedule === false || acc.haltedReason) continue;
|
|
625
|
+
const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
|
|
626
|
+
const params = normalizeParams(baseRun?.params || getSettings());
|
|
627
|
+
const H = barsFromHours(params.modelHorizon);
|
|
628
|
+
const stepBars = params.modelAutoAdapt !== false ? Math.max(2, Math.floor(H / 2)) : H;
|
|
629
|
+
if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
|
|
630
|
+
acc.lastScheduledBarTs = latest;
|
|
631
|
+
saveRawAccounts(list);
|
|
632
|
+
if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
|
|
633
|
+
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
} finally {
|
|
638
|
+
schedulerBusy = false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function startBrokerScheduler() {
|
|
643
|
+
const timer = setInterval(tickBrokerSchedule, 60000);
|
|
644
|
+
timer.unref?.();
|
|
645
|
+
}
|