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.
@@ -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
- { id: 'tbank', name: 'Т-Банк (T-Invest API)', accountHint: 'ID счёта (оставьте пустым для выбора первого доступного)', tokenHint: 'Токен T-Invest API (t.…)' },
18
- { id: 'finam', name: 'Финам (Trade API)', accountHint: 'Код клиента (ClientId, например 123456R)', tokenHint: 'Токен Finam Trade API (CA…)' },
19
- { id: 'alor', name: 'Алор (OpenAPI)', accountHint: 'Портфель Мосбиржи (например D12345)', tokenHint: 'Refresh-токен Alor OpenAPI' },
20
- { id: 'bcs', name: 'БКС Мир инвестиций', accountHint: 'Номер соглашения / субсчёта (необязательно)', tokenHint: 'Refresh-токен БКС Trade API' },
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: input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null),
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: Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1))),
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
- if (!acc.token) throw new Error('У счёта не задан токен API');
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.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
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.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(r.expectedPct) / needPct))) : 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 rawShortCands = rawDir !== '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) : [];
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
- diagNote = `На свече ${fc.bar} ни одна бумага не превысила порог выгоды +${fc.thresholdPct}% (текущие позиции соответствуют прогнозу).`;
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 { operations: [], summary: { tradesCount: 0, buySumRub: 0, sellSumRub: 0, commissionsRub: 0, dividendsRub: 0, inOutRub: 0 } };
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('Сначала нажмите «Обновить портфель», чтобы определить ID счёта');
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
- for (let y = curYear; y >= curYear - 4; y--) {
1195
- const startH2 = Date.UTC(y, 6, 1);
1196
- const endH2 = Math.min(Date.now(), Date.UTC(y, 11, 31, 23, 59, 59));
1197
- if (startH2 < Date.now()) intervals.push([new Date(startH2).toISOString(), new Date(endH2).toISOString()]);
1198
- const startH1 = Date.UTC(y, 0, 1);
1199
- const endH1 = Math.min(Date.now(), Date.UTC(y, 5, 30, 23, 59, 59));
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 operations = rawOps.slice(0, 5000).map((op) => {
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
- totalCount: rawOps.length,
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
- // Полушаговое сопровождение H/2 (каждые 1 ч 15 мин) активно в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
1326
- const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(2, Math.floor(H / 2)) : H;
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
  }