neuroalpha 0.3.13 → 0.3.15
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 +1 -1
- package/docs//320/221/320/240/320/236/320/232/320/225/320/240/320/253_API.md +118 -0
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
- package/package.json +1 -1
- package/public/app.js +434 -49
- package/public/index.html +22 -8
- package/public/styles.css +4 -2
- package/server/api/routes.js +1 -1
- package/server/brokers/index.js +388 -45
- package/server/model/forecast.js +176 -18
- package/server/model/live.js +31 -7
- package/server/model/research.js +3 -3
package/server/brokers/index.js
CHANGED
|
@@ -14,10 +14,60 @@ import { round, MSK, DAY } from '../util.js';
|
|
|
14
14
|
import { hub } from '../hub.js';
|
|
15
15
|
|
|
16
16
|
export const BROKER_META = [
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
{
|
|
18
|
+
id: 'tbank',
|
|
19
|
+
name: 'Т-Банк (T-Invest API)',
|
|
20
|
+
accountHint: 'ID счёта (или нажмите «Найти мои счета по токену»)',
|
|
21
|
+
tokenHint: 'Токен T-Invest API (t.…)',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'finam',
|
|
25
|
+
name: 'Финам (Finam Trade API)',
|
|
26
|
+
accountHint: 'Код клиента (ClientId, например 123456R)',
|
|
27
|
+
tokenHint: 'Токен Finam Trade API (CA…)',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'bcs',
|
|
31
|
+
name: 'БКС Мир инвестиций (Trade API)',
|
|
32
|
+
accountHint: 'Номер субсчёта / соглашения (необязательно)',
|
|
33
|
+
tokenHint: 'Refresh-токен БКС Trade API',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'alor',
|
|
37
|
+
name: 'Алор Брокер (Alor OpenAPI v2)',
|
|
38
|
+
accountHint: 'Фондовый портфель Мосбиржи (например D12345)',
|
|
39
|
+
tokenHint: 'Refresh-токен Alor OpenAPI',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'cifra',
|
|
43
|
+
name: 'Цифра брокер (Tradernet API)',
|
|
44
|
+
accountHint: 'Номер счёта Tradernet (необязательно)',
|
|
45
|
+
tokenHint: 'Ключи Tradernet в формате PublicKey:SecretKey',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'iticapital',
|
|
49
|
+
name: 'ITI Capital (OpenAPI)',
|
|
50
|
+
accountHint: 'Код портфеля Мосбиржи (например BP12345)',
|
|
51
|
+
tokenHint: 'Refresh-токен ITI Capital OpenAPI',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'sber',
|
|
55
|
+
name: 'СберИнвестиции (Сбербанк)',
|
|
56
|
+
accountHint: 'Номер брокерского счёта (например 400ABCD)',
|
|
57
|
+
tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'vtb',
|
|
61
|
+
name: 'ВТБ Мои Инвестиции',
|
|
62
|
+
accountHint: 'Номер брокерского счёта / субсчёта ВТБ',
|
|
63
|
+
tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'alfa',
|
|
67
|
+
name: 'Альфа-Инвестиции',
|
|
68
|
+
accountHint: 'Номер брокерского счёта Альфа-Инвестиций',
|
|
69
|
+
tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
|
|
70
|
+
},
|
|
21
71
|
];
|
|
22
72
|
|
|
23
73
|
// ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
|
|
@@ -136,6 +186,15 @@ export function saveBrokerAccount(input = {}) {
|
|
|
136
186
|
const rawMaxCap = Math.max(10000, Math.round(Number(input.maxCapitalRub ?? prev.maxCapitalRub ?? 100000)));
|
|
137
187
|
const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
|
|
138
188
|
const maxCapitalRub = Math.min(rawMaxCap, capCeiling);
|
|
189
|
+
const nextBaseRunId = input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null);
|
|
190
|
+
const nextLimitOffset = Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1)));
|
|
191
|
+
const strategyOrCapChanged = idx < 0
|
|
192
|
+
|| acctChanged
|
|
193
|
+
|| nextBaseRunId !== (prev.baseRunId || null)
|
|
194
|
+
|| maxCapitalRub !== (prev.maxCapitalRub || 100000)
|
|
195
|
+
|| legacyMode !== (prev.legacyMode || 'isolate')
|
|
196
|
+
|| mode !== (prev.mode || 'confirm')
|
|
197
|
+
|| nextLimitOffset !== (prev.limitOffsetPct ?? 0.1);
|
|
139
198
|
const next = {
|
|
140
199
|
id,
|
|
141
200
|
name,
|
|
@@ -148,24 +207,35 @@ export function saveBrokerAccount(input = {}) {
|
|
|
148
207
|
tbankAccountName: acctChanged ? '' : (prev.tbankAccountName || ''),
|
|
149
208
|
equityHistory: acctChanged ? [] : (prev.equityHistory || []),
|
|
150
209
|
lastHistory: acctChanged ? null : (prev.lastHistory || null),
|
|
151
|
-
baseRunId:
|
|
210
|
+
baseRunId: nextBaseRunId,
|
|
152
211
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
153
212
|
maxCapitalRub,
|
|
154
213
|
maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
|
|
155
|
-
limitOffsetPct:
|
|
214
|
+
limitOffsetPct: nextLimitOffset,
|
|
156
215
|
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
157
216
|
autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
|
|
158
|
-
lastScheduledBarTs: prev.lastScheduledBarTs || 0,
|
|
217
|
+
lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
|
|
159
218
|
haltedReason: input.resetHalt ? null : (prev.haltedReason || null),
|
|
160
219
|
token,
|
|
161
220
|
lastSync: prev.lastSync || null,
|
|
162
|
-
pendingBatch: prev.pendingBatch || null,
|
|
221
|
+
pendingBatch: strategyOrCapChanged ? null : (prev.pendingBatch || null),
|
|
163
222
|
};
|
|
164
223
|
if (idx >= 0) list[idx] = next;
|
|
165
224
|
else list.push(next);
|
|
166
225
|
saveRawAccounts(list);
|
|
167
226
|
const st = brokersState();
|
|
168
227
|
hub.emit({ type: 'brokers:update', state: st });
|
|
228
|
+
// При изменении стратегии, лимита капитала или режима счёта сразу пересинхронизируем счёт и пересчитываем заявки под новые настройки
|
|
229
|
+
const canAutoInit = Boolean(token) || ['sber', 'vtb', 'alfa'].includes(broker);
|
|
230
|
+
if (strategyOrCapChanged && canAutoInit) {
|
|
231
|
+
const activeUser = currentUser();
|
|
232
|
+
setImmediate(() => {
|
|
233
|
+
runWithUser(activeUser, async () => {
|
|
234
|
+
await syncBrokerAccount(id).catch(() => {});
|
|
235
|
+
await prepareBrokerOrders(id).catch(() => {});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
169
239
|
return st;
|
|
170
240
|
}
|
|
171
241
|
|
|
@@ -293,6 +363,36 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
|
|
|
293
363
|
return cache;
|
|
294
364
|
}
|
|
295
365
|
|
|
366
|
+
// Загружает и кэширует на 12 часов справочник доступности шортов по тикерам в Т-Банке (shortEnabledFlag)
|
|
367
|
+
async function ensureTbankShortableMap(acc) {
|
|
368
|
+
if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
|
|
369
|
+
const cached = kvGet('tbankShortableMap');
|
|
370
|
+
if (cached?.map && Date.now() - cached.at < 12 * 3600000) return cached.map;
|
|
371
|
+
try {
|
|
372
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
373
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
374
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/Shares`, {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
headers,
|
|
377
|
+
body: JSON.stringify({ instrumentStatus: 'INSTRUMENT_STATUS_BASE' }),
|
|
378
|
+
timeoutMs: 15000,
|
|
379
|
+
});
|
|
380
|
+
if (!r.ok) return cached?.map || null;
|
|
381
|
+
const j = await r.json();
|
|
382
|
+
const map = {};
|
|
383
|
+
for (const inst of j.instruments || []) {
|
|
384
|
+
if (inst.ticker && inst.classCode === 'TQBR') {
|
|
385
|
+
map[inst.ticker] = Boolean(inst.shortEnabledFlag);
|
|
386
|
+
if (inst.uid || inst.figi) tbankUidCache.set(inst.ticker, inst.uid || inst.figi);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
kvSet('tbankShortableMap', { at: Date.now(), map });
|
|
390
|
+
return map;
|
|
391
|
+
} catch {
|
|
392
|
+
return cached?.map || null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
296
396
|
async function resolveTbankInstrumentId(base, headers, symbol) {
|
|
297
397
|
if (tbankUidCache.has(symbol)) return tbankUidCache.get(symbol);
|
|
298
398
|
try {
|
|
@@ -648,11 +748,109 @@ async function syncBcs(acc) {
|
|
|
648
748
|
return { accountId: acc.accountId || 'основной', totalRub: round(totalRub, 2), cashRub: round(cashRub, 2), positions };
|
|
649
749
|
}
|
|
650
750
|
|
|
751
|
+
async function syncCifra(acc) {
|
|
752
|
+
const [apiKey, secKey] = String(acc.token || '').split(':').map((s) => s.trim());
|
|
753
|
+
if (!apiKey || !secKey) throw new Error('Для Цифра брокер (Tradernet API) укажите ключи в формате PublicKey:SecretKey');
|
|
754
|
+
const qObj = { cmd: 'getPositionJson', params: {} };
|
|
755
|
+
const qStr = `q=${JSON.stringify(qObj)}`;
|
|
756
|
+
const sig = crypto.createHmac('sha256', secKey).update(qStr).digest('hex');
|
|
757
|
+
const r = await fetch('https://tradernet.ru/api/v2/cmd/getPositionJson', {
|
|
758
|
+
method: 'POST',
|
|
759
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-NtApi-PublicKey': apiKey, 'X-NtApi-Sig': sig },
|
|
760
|
+
body: qStr,
|
|
761
|
+
signal: AbortSignal.timeout(15000),
|
|
762
|
+
});
|
|
763
|
+
if (!r.ok) throw new Error(`Tradernet API: HTTP ${r.status}`);
|
|
764
|
+
const j = await r.json();
|
|
765
|
+
const ps = j?.result?.ps || {};
|
|
766
|
+
const accList = Array.isArray(ps.acc) ? ps.acc : [];
|
|
767
|
+
const rubAcc = accList.find((c) => c.curr === 'RUR' || c.curr === 'RUB') || accList[0] || {};
|
|
768
|
+
const cashRub = round(Number(rubAcc.s || 0), 2);
|
|
769
|
+
const posRaw = Array.isArray(ps.pos) ? ps.pos : [];
|
|
770
|
+
let posSum = 0, pnlSum = 0;
|
|
771
|
+
const positions = posRaw.map((p) => {
|
|
772
|
+
const qty = Number(p.q || 0);
|
|
773
|
+
const price = round(Number(p.mkt_price || p.price_a || 0), 4);
|
|
774
|
+
const val = round(Number(p.s || qty * price), 2);
|
|
775
|
+
const pnl = round(Number(p.vm || 0), 2);
|
|
776
|
+
posSum += val;
|
|
777
|
+
pnlSum += pnl;
|
|
778
|
+
return { symbol: String(p.i || '').replace(/\..*$/, ''), name: p.name || p.i || '', qty, price, valueRub: val, pnlRub: pnl };
|
|
779
|
+
});
|
|
780
|
+
return { accountId: acc.accountId || 'Tradernet', marginEnabled: true, totalRub: round(cashRub + posSum, 2), cashRub, totalPnlRub: round(pnlSum, 2), positions };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function syncItiCapital(acc) {
|
|
784
|
+
if (!acc.accountId) throw new Error('Укажите портфель ITI Capital (например BP12345)');
|
|
785
|
+
const oauth = acc.sandbox ? 'https://oauthdev.iticapital.ru' : 'https://oauth.iticapital.ru';
|
|
786
|
+
const api = acc.sandbox ? 'https://apidev.iticapital.ru' : 'https://api.iticapital.ru';
|
|
787
|
+
const rTok = await fetch(`${oauth}/refresh?token=${encodeURIComponent(acc.token)}`, { method: 'POST', signal: AbortSignal.timeout(15000) });
|
|
788
|
+
if (!rTok.ok) throw new Error(`ITI Capital OAuth: HTTP ${rTok.status}`);
|
|
789
|
+
const { AccessToken } = await rTok.json();
|
|
790
|
+
if (!AccessToken) throw new Error('ITI Capital не вернул AccessToken');
|
|
791
|
+
const headers = { Authorization: `Bearer ${AccessToken}` };
|
|
792
|
+
const [rSum, rPos] = await Promise.all([
|
|
793
|
+
fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/summary`, { headers, signal: AbortSignal.timeout(15000) }),
|
|
794
|
+
fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/positions`, { headers, signal: AbortSignal.timeout(15000) }),
|
|
795
|
+
]);
|
|
796
|
+
if (!rSum.ok) throw new Error(`ITI Capital summary: HTTP ${rSum.status}`);
|
|
797
|
+
const sum = await rSum.json();
|
|
798
|
+
const posList = rPos.ok ? await rPos.json() : [];
|
|
799
|
+
let cashRub = round(Number(sum.buyingPower || sum.cash || 0), 2);
|
|
800
|
+
const positions = [];
|
|
801
|
+
for (const p of Array.isArray(posList) ? posList : []) {
|
|
802
|
+
if (p.isCurrency || p.symbol === 'RUB') { cashRub = round(Number(p.qtyUnits || p.qty || cashRub), 2); continue; }
|
|
803
|
+
const qty = Number(p.qtyUnits || p.qty || 0);
|
|
804
|
+
const val = Number(p.volume || 0);
|
|
805
|
+
positions.push({ symbol: p.symbol, qty, price: qty ? round(val / qty, 4) : 0, valueRub: round(val, 2), pnlRub: round(Number(p.unrealisedPl || 0), 2) });
|
|
806
|
+
}
|
|
807
|
+
return { accountId: acc.accountId, marginEnabled: true, totalRub: round(Number(sum.portfolioLiquidationValue || cashRub), 2), cashRub, positions };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function syncQuikOrManual(acc) {
|
|
811
|
+
const tokenStr = String(acc.token || '').trim();
|
|
812
|
+
if (tokenStr.startsWith('http://') || tokenStr.startsWith('https://')) {
|
|
813
|
+
const base = tokenStr.replace(/\/+$/, '');
|
|
814
|
+
const r = await fetch(`${base}/portfolio?account=${encodeURIComponent(acc.accountId || '')}`, { signal: AbortSignal.timeout(12000) });
|
|
815
|
+
if (!r.ok) throw new Error(`QUIK HTTP-шлюз: HTTP ${r.status}`);
|
|
816
|
+
const j = await r.json();
|
|
817
|
+
const positions = (j.positions || []).map((p) => ({
|
|
818
|
+
symbol: p.symbol || p.secCode,
|
|
819
|
+
name: p.name || p.symbol || p.secCode,
|
|
820
|
+
qty: Number(p.qty || p.currentBal || 0),
|
|
821
|
+
price: round(Number(p.price || p.lastPrice || 0), 4),
|
|
822
|
+
valueRub: round(Number(p.valueRub || 0), 2),
|
|
823
|
+
pnlRub: round(Number(p.pnlRub || 0), 2),
|
|
824
|
+
}));
|
|
825
|
+
const cashRub = round(Number(j.cashRub ?? j.cash ?? acc.maxCapitalRub ?? 100000), 2);
|
|
826
|
+
const totalRub = round(Number(j.totalRub ?? (cashRub + positions.reduce((s, x) => s + x.valueRub, 0))), 2);
|
|
827
|
+
return { accountId: acc.accountId || 'QUIK', marginEnabled: Boolean(j.marginEnabled), totalRub, cashRub, positions };
|
|
828
|
+
}
|
|
829
|
+
// Режим подтверждения заявок (СберИнвестиции / ВТБ / Альфа без QUIK-шлюза): учёт позиций и лимита внутри платформы
|
|
830
|
+
const specs = await ensureBoardSpecs();
|
|
831
|
+
const prevPos = acc.lastSync?.positions || [];
|
|
832
|
+
let posVal = 0, pnlSum = 0;
|
|
833
|
+
const positions = prevPos.filter((p) => p.qty !== 0).map((p) => {
|
|
834
|
+
const px = specs[p.symbol]?.lastPrice || p.price || 0;
|
|
835
|
+
const avg = p.avgPrice || p.price || px;
|
|
836
|
+
const valueRub = round(p.qty * px, 2);
|
|
837
|
+
const pnlRub = round(p.qty * (px - avg), 2);
|
|
838
|
+
posVal += Math.abs(valueRub);
|
|
839
|
+
pnlSum += pnlRub;
|
|
840
|
+
return { ...p, avgPrice: avg, price: round(px, 4), valueRub, pnlRub };
|
|
841
|
+
});
|
|
842
|
+
const baseCap = acc.maxCapitalRub || 100000;
|
|
843
|
+
const cashRub = acc.lastSync?.cashRub !== undefined ? round(acc.lastSync.cashRub, 2) : round(Math.max(0, baseCap - posVal), 2);
|
|
844
|
+
const totalRub = round(cashRub + posVal, 2);
|
|
845
|
+
return { accountId: acc.accountId || 'Ручное подтверждение', marginEnabled: false, totalRub, cashRub, totalPnlRub: round(pnlSum, 2), positions };
|
|
846
|
+
}
|
|
847
|
+
|
|
651
848
|
export async function syncBrokerAccount(id) {
|
|
652
849
|
const list = loadRawAccounts();
|
|
653
850
|
const acc = list.find((x) => x.id === id);
|
|
654
851
|
if (!acc) throw new Error('Счёт не найден');
|
|
655
|
-
|
|
852
|
+
const isManualCapable = ['sber', 'vtb', 'alfa'].includes(acc.broker);
|
|
853
|
+
if (!acc.token && !isManualCapable) throw new Error('У счёта не задан токен API');
|
|
656
854
|
const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
|
|
657
855
|
try {
|
|
658
856
|
let data;
|
|
@@ -660,6 +858,9 @@ export async function syncBrokerAccount(id) {
|
|
|
660
858
|
else if (acc.broker === 'finam') data = await syncFinam(acc);
|
|
661
859
|
else if (acc.broker === 'alor') data = await syncAlor(acc);
|
|
662
860
|
else if (acc.broker === 'bcs') data = await syncBcs(acc);
|
|
861
|
+
else if (acc.broker === 'cifra') data = await syncCifra(acc);
|
|
862
|
+
else if (acc.broker === 'iticapital') data = await syncItiCapital(acc);
|
|
863
|
+
else if (isManualCapable) data = await syncQuikOrManual(acc);
|
|
663
864
|
else throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
664
865
|
if (data.accountId && !acc.accountId) acc.accountId = data.accountId;
|
|
665
866
|
if (data.accountName) acc.tbankAccountName = data.accountName;
|
|
@@ -746,9 +947,10 @@ export async function prepareBrokerOrders(id) {
|
|
|
746
947
|
}
|
|
747
948
|
const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
|
|
748
949
|
const params = normalizeParams(baseRun?.params || getSettings());
|
|
749
|
-
const [fc, specs] = await Promise.all([
|
|
950
|
+
const [fc, specs, shortableMap] = await Promise.all([
|
|
750
951
|
forecastNow(params),
|
|
751
952
|
ensureBoardSpecs(),
|
|
953
|
+
ensureTbankShortableMap(acc),
|
|
752
954
|
]);
|
|
753
955
|
const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
|
|
754
956
|
const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
|
|
@@ -770,7 +972,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
770
972
|
// • VIP (без ограничений): полный форсаж (Conviction Slot Sizing до 42% капитала в лучших сигналах + полушаговый перехват H/2)
|
|
771
973
|
let slotRub;
|
|
772
974
|
if (vipBoost && autoAdapt) {
|
|
773
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.
|
|
975
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
|
|
774
976
|
slotRub = budget * maxSlotWeight;
|
|
775
977
|
} else if (plan.id === 'pro') {
|
|
776
978
|
slotRub = budget * Math.min(0.25, 1 / Math.max(2, slots));
|
|
@@ -791,14 +993,24 @@ export async function prepareBrokerOrders(id) {
|
|
|
791
993
|
}
|
|
792
994
|
}
|
|
793
995
|
const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
|
|
794
|
-
const convOf = (r) => (vipBoost && autoAdapt && r.expectedPct ? Math.min(1.
|
|
996
|
+
const convOf = (r) => (vipBoost && autoAdapt && r.expectedPct ? Math.min(1.25, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct))) : 1);
|
|
795
997
|
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
796
998
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
797
999
|
const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
|
|
798
1000
|
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
799
1001
|
|
|
800
1002
|
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);
|
|
801
|
-
const
|
|
1003
|
+
const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1004
|
+
const nonShortableSkipped = [];
|
|
1005
|
+
const rawShortCands = [];
|
|
1006
|
+
for (const r of allShortSignals) {
|
|
1007
|
+
if (shortableMap && shortableMap[r.symbol] === false) {
|
|
1008
|
+
nonShortableSkipped.push(r);
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
rawShortCands.push(r);
|
|
1012
|
+
if (rawShortCands.length >= K) break;
|
|
1013
|
+
}
|
|
802
1014
|
const shortCands = (marginAllowed && planAllowsShorts) ? rawShortCands : [];
|
|
803
1015
|
|
|
804
1016
|
const legacyMode = acc.legacyMode || 'isolate';
|
|
@@ -957,7 +1169,8 @@ export async function prepareBrokerOrders(id) {
|
|
|
957
1169
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
958
1170
|
diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
|
|
959
1171
|
} else if (!newBuys.length && !newShorts.length) {
|
|
960
|
-
|
|
1172
|
+
const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
1173
|
+
diagNote = `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
|
|
961
1174
|
}
|
|
962
1175
|
}
|
|
963
1176
|
|
|
@@ -1040,7 +1253,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1040
1253
|
if (Number.isFinite(maxLots)) {
|
|
1041
1254
|
if (maxLots < 1) {
|
|
1042
1255
|
if (ord.action === 'short') {
|
|
1043
|
-
throw new Error(
|
|
1256
|
+
throw new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`);
|
|
1044
1257
|
}
|
|
1045
1258
|
throw new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`);
|
|
1046
1259
|
}
|
|
@@ -1134,6 +1347,97 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1134
1347
|
const j = await r.json();
|
|
1135
1348
|
return { ok: true, orderId: j.orderId || body.clientOrderId };
|
|
1136
1349
|
}
|
|
1350
|
+
if (acc.broker === 'cifra') {
|
|
1351
|
+
const [apiKey, secKey] = String(acc.token || '').split(':').map((s) => s.trim());
|
|
1352
|
+
if (!apiKey || !secKey) throw new Error('Для Цифра брокер укажите ключи в формате PublicKey:SecretKey');
|
|
1353
|
+
const qObj = {
|
|
1354
|
+
cmd: 'putTradeOrder',
|
|
1355
|
+
params: {
|
|
1356
|
+
instr_name: `${ord.symbol}.MOEX`,
|
|
1357
|
+
action_id: ord.action === 'buy' || ord.action === 'cover' ? 1 : 3,
|
|
1358
|
+
order_type_id: 2,
|
|
1359
|
+
curr: 'RUR',
|
|
1360
|
+
limit_price: ord.limitPrice,
|
|
1361
|
+
qty: ord.shares || ord.lots,
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
const qStr = `q=${JSON.stringify(qObj)}`;
|
|
1365
|
+
const sig = crypto.createHmac('sha256', secKey).update(qStr).digest('hex');
|
|
1366
|
+
const r = await fetch('https://tradernet.ru/api/v2/cmd/putTradeOrder', {
|
|
1367
|
+
method: 'POST',
|
|
1368
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-NtApi-PublicKey': apiKey, 'X-NtApi-Sig': sig },
|
|
1369
|
+
body: qStr,
|
|
1370
|
+
signal: AbortSignal.timeout(15000),
|
|
1371
|
+
});
|
|
1372
|
+
if (!r.ok) throw new Error(`Tradernet PostOrder HTTP ${r.status}`);
|
|
1373
|
+
const j = await r.json();
|
|
1374
|
+
return { ok: true, orderId: String(j.order_id || j.result?.order_id || 'ok') };
|
|
1375
|
+
}
|
|
1376
|
+
if (acc.broker === 'iticapital') {
|
|
1377
|
+
const oauth = acc.sandbox ? 'https://oauthdev.iticapital.ru' : 'https://oauth.iticapital.ru';
|
|
1378
|
+
const api = acc.sandbox ? 'https://apidev.iticapital.ru' : 'https://api.iticapital.ru';
|
|
1379
|
+
const rTok = await fetch(`${oauth}/refresh?token=${encodeURIComponent(acc.token)}`, { method: 'POST', signal: AbortSignal.timeout(15000) });
|
|
1380
|
+
if (!rTok.ok) throw new Error(`ITI Capital OAuth HTTP ${rTok.status}`);
|
|
1381
|
+
const { AccessToken } = await rTok.json();
|
|
1382
|
+
const body = {
|
|
1383
|
+
side: ord.action === 'buy' || ord.action === 'cover' ? 'buy' : 'sell',
|
|
1384
|
+
type: 'limit',
|
|
1385
|
+
quantity: ord.lots,
|
|
1386
|
+
price: ord.limitPrice,
|
|
1387
|
+
instrument: { symbol: ord.symbol, exchange: 'MOEX', instrumentGroup: 'TQBR' },
|
|
1388
|
+
user: { portfolio: acc.accountId },
|
|
1389
|
+
};
|
|
1390
|
+
const r = await fetch(`${api}/commandapi/warptrans/TRADE/v2/client/orders/actions/limit`, {
|
|
1391
|
+
method: 'POST',
|
|
1392
|
+
headers: { Authorization: `Bearer ${AccessToken}`, 'Content-Type': 'application/json', 'X-ALOR-REQID': crypto.randomUUID() },
|
|
1393
|
+
body: JSON.stringify(body),
|
|
1394
|
+
signal: AbortSignal.timeout(15000),
|
|
1395
|
+
});
|
|
1396
|
+
if (!r.ok) throw new Error(`ITI Capital PostOrder HTTP ${r.status}`);
|
|
1397
|
+
const j = await r.json();
|
|
1398
|
+
return { ok: true, orderId: j.orderNumber || 'ok' };
|
|
1399
|
+
}
|
|
1400
|
+
if (['sber', 'vtb', 'alfa'].includes(acc.broker)) {
|
|
1401
|
+
const tokenStr = String(acc.token || '').trim();
|
|
1402
|
+
if (tokenStr.startsWith('http://') || tokenStr.startsWith('https://')) {
|
|
1403
|
+
const base = tokenStr.replace(/\/+$/, '');
|
|
1404
|
+
const r = await fetch(`${base}/orders`, {
|
|
1405
|
+
method: 'POST',
|
|
1406
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1407
|
+
body: JSON.stringify({ accountId: acc.accountId, ...ord }),
|
|
1408
|
+
signal: AbortSignal.timeout(12000),
|
|
1409
|
+
});
|
|
1410
|
+
if (!r.ok) throw new Error(`QUIK HTTP-шлюз PostOrder: HTTP ${r.status}`);
|
|
1411
|
+
const j = await r.json();
|
|
1412
|
+
return { ok: true, orderId: j.orderId || 'quik_ok' };
|
|
1413
|
+
}
|
|
1414
|
+
// Режим ручного подтверждения (СберИнвестиции / ВТБ / Альфа без QUIK): учитываем исполнение в портфеле платформы
|
|
1415
|
+
const prevPos = [...(acc.lastSync?.positions || [])];
|
|
1416
|
+
const isBuy = ord.action === 'buy' || ord.action === 'cover';
|
|
1417
|
+
const deltaQty = isBuy ? (ord.shares || ord.lots) : -(ord.shares || ord.lots);
|
|
1418
|
+
const deltaCash = isBuy ? -ord.amountRub : ord.amountRub;
|
|
1419
|
+
const pIdx = prevPos.findIndex((p) => p.symbol === ord.symbol);
|
|
1420
|
+
if (pIdx >= 0) {
|
|
1421
|
+
const cur = prevPos[pIdx];
|
|
1422
|
+
const nextQty = cur.qty + deltaQty;
|
|
1423
|
+
if (nextQty === 0) prevPos.splice(pIdx, 1);
|
|
1424
|
+
else prevPos[pIdx] = { ...cur, qty: nextQty, price: ord.limitPrice, valueRub: round(nextQty * ord.limitPrice, 2) };
|
|
1425
|
+
} else if (deltaQty !== 0) {
|
|
1426
|
+
prevPos.push({ symbol: ord.symbol, name: ord.symbol, instrumentType: 'share', qty: deltaQty, lots: ord.lots, avgPrice: ord.limitPrice, price: ord.limitPrice, valueRub: round(deltaQty * ord.limitPrice, 2), pnlRub: 0 });
|
|
1427
|
+
}
|
|
1428
|
+
const prevCash = acc.lastSync?.cashRub ?? (acc.maxCapitalRub || 100000);
|
|
1429
|
+
const nextCash = round(Math.max(0, prevCash + deltaCash), 2);
|
|
1430
|
+
const posSum = prevPos.reduce((s, p) => s + Math.abs(p.valueRub || 0), 0);
|
|
1431
|
+
acc.lastSync = {
|
|
1432
|
+
...(acc.lastSync || {}),
|
|
1433
|
+
at: Date.now(),
|
|
1434
|
+
ok: true,
|
|
1435
|
+
cashRub: nextCash,
|
|
1436
|
+
totalRub: round(nextCash + posSum, 2),
|
|
1437
|
+
positions: prevPos,
|
|
1438
|
+
};
|
|
1439
|
+
return { ok: true, orderId: 'учтено в портфеле' };
|
|
1440
|
+
}
|
|
1137
1441
|
throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
1138
1442
|
}
|
|
1139
1443
|
|
|
@@ -1174,31 +1478,29 @@ export function rejectBrokerOrders(id) {
|
|
|
1174
1478
|
return st;
|
|
1175
1479
|
}
|
|
1176
1480
|
|
|
1177
|
-
// ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны,
|
|
1178
|
-
export async function fetchBrokerHistory(id) {
|
|
1481
|
+
// ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения; ленивая подгрузка по годам) ----------
|
|
1482
|
+
export async function fetchBrokerHistory(id, opts = {}) {
|
|
1179
1483
|
const list = loadRawAccounts();
|
|
1180
1484
|
const acc = list.find((x) => x.id === id);
|
|
1181
1485
|
if (!acc) throw new Error('Счёт не найден');
|
|
1182
1486
|
if (!acc.token) throw new Error('У счёта не задан токен API');
|
|
1183
1487
|
if (acc.broker !== 'tbank') {
|
|
1184
|
-
return
|
|
1488
|
+
return brokersState();
|
|
1185
1489
|
}
|
|
1186
1490
|
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
1187
1491
|
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
1188
1492
|
const accountId = acc.accountId || acc.lastSync?.accountId;
|
|
1189
|
-
if (!accountId) throw new Error('Сначала
|
|
1493
|
+
if (!accountId) throw new Error('Сначала дождитесь синхронизации портфеля, чтобы определить ID счёта');
|
|
1190
1494
|
|
|
1191
|
-
// Запрашиваем историю полугодовыми интервалами за последние 5 лет, чтобы не терять операции из-за лимита 1000 записей на один запрос Т-Банка
|
|
1192
1495
|
const curYear = new Date(Date.now() + MSK).getUTCFullYear();
|
|
1496
|
+
const targetYear = Number(opts?.year) || curYear;
|
|
1193
1497
|
const intervals = [];
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
|
|
1201
|
-
}
|
|
1498
|
+
const startH2 = Date.UTC(targetYear, 6, 1);
|
|
1499
|
+
const endH2 = Math.min(Date.now(), Date.UTC(targetYear, 11, 31, 23, 59, 59));
|
|
1500
|
+
if (startH2 < Date.now()) intervals.push([new Date(startH2).toISOString(), new Date(endH2).toISOString()]);
|
|
1501
|
+
const startH1 = Date.UTC(targetYear, 0, 1);
|
|
1502
|
+
const endH1 = Math.min(Date.now(), Date.UTC(targetYear, 5, 30, 23, 59, 59));
|
|
1503
|
+
if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
|
|
1202
1504
|
|
|
1203
1505
|
const chunkResults = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
|
|
1204
1506
|
try {
|
|
@@ -1226,22 +1528,10 @@ export async function fetchBrokerHistory(id) {
|
|
|
1226
1528
|
rawOps.push(op);
|
|
1227
1529
|
}
|
|
1228
1530
|
}
|
|
1229
|
-
rawOps.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
1230
1531
|
|
|
1231
1532
|
const figis = [...new Set(rawOps.map((o) => o.figi || o.instrumentUid).filter(Boolean))];
|
|
1232
1533
|
const metaMap = await resolveTbankMetaMap(base, headers, figis);
|
|
1233
1534
|
|
|
1234
|
-
let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
|
|
1235
|
-
for (const op of rawOps) {
|
|
1236
|
-
const pay = qVal(op.payment);
|
|
1237
|
-
const t = String(op.operationType || '');
|
|
1238
|
-
if (t.includes('BUY')) { tradesCount++; buySumRub += Math.abs(pay); }
|
|
1239
|
-
else if (t.includes('SELL')) { tradesCount++; sellSumRub += Math.abs(pay); }
|
|
1240
|
-
else if (t.includes('FEE') || t.includes('COMMISSION') || t.includes('TAX')) { commissionsRub += Math.abs(pay); }
|
|
1241
|
-
else if (t.includes('DIVIDEND') || t.includes('COUPON')) { dividendsRub += pay; }
|
|
1242
|
-
else if (t.includes('INPUT') || t.includes('OUTPUT')) { inOutRub += pay; }
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
1535
|
const OP_KIND = (t = '') => {
|
|
1246
1536
|
if (t.includes('BUY')) return ['buy', 'Покупка'];
|
|
1247
1537
|
if (t.includes('SELL')) return ['sell', 'Продажа'];
|
|
@@ -1254,7 +1544,7 @@ export async function fetchBrokerHistory(id) {
|
|
|
1254
1544
|
return ['other', 'Операция'];
|
|
1255
1545
|
};
|
|
1256
1546
|
|
|
1257
|
-
const
|
|
1547
|
+
const fetchedOps = rawOps.map((op) => {
|
|
1258
1548
|
const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
|
|
1259
1549
|
const [kind, kindLabel] = OP_KIND(op.operationType);
|
|
1260
1550
|
return {
|
|
@@ -1271,9 +1561,35 @@ export async function fetchBrokerHistory(id) {
|
|
|
1271
1561
|
};
|
|
1272
1562
|
});
|
|
1273
1563
|
|
|
1564
|
+
// Сохраняем операции других годов, которые уже были загружены ранее, и заменяем только targetYear
|
|
1565
|
+
const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
1566
|
+
const otherYearsOps = prevOps.filter((op) => {
|
|
1567
|
+
const y = op.date ? new Date(op.date + MSK).getUTCFullYear() : curYear;
|
|
1568
|
+
return y !== targetYear;
|
|
1569
|
+
});
|
|
1570
|
+
const mergedOps = [...fetchedOps, ...otherYearsOps]
|
|
1571
|
+
.sort((a, b) => (b.date || 0) - (a.date || 0))
|
|
1572
|
+
.slice(0, 6000);
|
|
1573
|
+
|
|
1574
|
+
let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
|
|
1575
|
+
for (const op of mergedOps) {
|
|
1576
|
+
const pay = Number(op.paymentRub) || 0;
|
|
1577
|
+
if (op.kind === 'buy') { tradesCount++; buySumRub += Math.abs(pay); }
|
|
1578
|
+
else if (op.kind === 'sell') { tradesCount++; sellSumRub += Math.abs(pay); }
|
|
1579
|
+
else if (op.kind === 'fee') { commissionsRub += Math.abs(pay); }
|
|
1580
|
+
else if (op.kind === 'div') { dividendsRub += pay; }
|
|
1581
|
+
else if (op.kind === 'in' || op.kind === 'out') { inOutRub += pay; }
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const prevLoadedYears = Array.isArray(acc.lastHistory?.loadedYears)
|
|
1585
|
+
? acc.lastHistory.loadedYears
|
|
1586
|
+
: [...new Set(prevOps.map((op) => op.date ? new Date(op.date + MSK).getUTCFullYear() : null).filter(Boolean))];
|
|
1587
|
+
const loadedYears = [...new Set([...prevLoadedYears, targetYear])].sort((a, b) => b - a);
|
|
1588
|
+
|
|
1274
1589
|
const histData = {
|
|
1275
1590
|
at: Date.now(),
|
|
1276
|
-
|
|
1591
|
+
loadedYears,
|
|
1592
|
+
totalCount: mergedOps.length,
|
|
1277
1593
|
summary: {
|
|
1278
1594
|
tradesCount,
|
|
1279
1595
|
buySumRub: round(buySumRub, 0),
|
|
@@ -1282,7 +1598,7 @@ export async function fetchBrokerHistory(id) {
|
|
|
1282
1598
|
dividendsRub: round(dividendsRub, 0),
|
|
1283
1599
|
inOutRub: round(inOutRub, 0),
|
|
1284
1600
|
},
|
|
1285
|
-
operations,
|
|
1601
|
+
operations: mergedOps,
|
|
1286
1602
|
};
|
|
1287
1603
|
acc.lastHistory = histData;
|
|
1288
1604
|
saveRawAccounts(list);
|
|
@@ -1322,8 +1638,8 @@ async function tickBrokerSchedule() {
|
|
|
1322
1638
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
1323
1639
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
1324
1640
|
const H = barsFromHours(effHorizon);
|
|
1325
|
-
//
|
|
1326
|
-
const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(
|
|
1641
|
+
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
1642
|
+
const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1327
1643
|
if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
|
|
1328
1644
|
acc.lastScheduledBarTs = latest;
|
|
1329
1645
|
saveRawAccounts(list);
|
|
@@ -1362,8 +1678,35 @@ async function resumeBrokerAccountsOnBoot() {
|
|
|
1362
1678
|
} catch { /* ignore startup sync errors */ }
|
|
1363
1679
|
}
|
|
1364
1680
|
|
|
1681
|
+
// Автоматическое периодическое фоновое обновление портфеля и истории текущего года (раз в 2 минуты)
|
|
1682
|
+
let autoRefreshBusy = false;
|
|
1683
|
+
async function tickBrokerAutoRefresh() {
|
|
1684
|
+
if (autoRefreshBusy || schedulerBusy) return;
|
|
1685
|
+
autoRefreshBusy = true;
|
|
1686
|
+
try {
|
|
1687
|
+
const curYear = new Date(Date.now() + MSK).getUTCFullYear();
|
|
1688
|
+
const users = db.prepare('SELECT * FROM users').all();
|
|
1689
|
+
for (const u of users) {
|
|
1690
|
+
await runWithUser(u, async () => {
|
|
1691
|
+
const list = loadRawAccounts();
|
|
1692
|
+
for (const acc of list) {
|
|
1693
|
+
if (!acc.token) continue;
|
|
1694
|
+
await syncBrokerAccount(acc.id).catch(() => {});
|
|
1695
|
+
if (acc.broker === 'tbank') {
|
|
1696
|
+
await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
} finally {
|
|
1702
|
+
autoRefreshBusy = false;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1365
1706
|
export function startBrokerScheduler() {
|
|
1366
1707
|
setTimeout(resumeBrokerAccountsOnBoot, 2500);
|
|
1367
1708
|
const timer = setInterval(tickBrokerSchedule, 60000);
|
|
1709
|
+
const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);
|
|
1368
1710
|
timer.unref?.();
|
|
1711
|
+
syncTimer.unref?.();
|
|
1369
1712
|
}
|