neuroalpha 0.5.21 → 0.5.24
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/package.json +1 -1
- package/public/app.js +7 -1
- package/server/api/routes.js +28 -5
- package/server/brokers/index.js +137 -31
- package/server/data/moex.js +59 -28
- package/server/db.js +4 -0
- package/server/model/dataset.js +32 -12
- package/server/model/edge.js +39 -7
- package/server/model/forecast.js +83 -19
- package/server/model/live.js +29 -7
- package/server/model/models.js +27 -5
- package/server/model/research.js +114 -28
- package/server/strategist/tools.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.24",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -1060,6 +1060,10 @@ async function loadForecast(force, silent = false) {
|
|
|
1060
1060
|
reasons.push(`Аварийная защита от обвала свечи: бумага падает на текущей свече (${fmtNum(r.lastMovePct, 2)}% < −4,5%), покупка запрещена`);
|
|
1061
1061
|
label = '⛔ провал';
|
|
1062
1062
|
}
|
|
1063
|
+
if (r.buyBlockedByOverheat) {
|
|
1064
|
+
reasons.push(`Защита от покупки на вершине пампа: бумага уже перегрета (+${fmtNum(r.buyBlockedByOverheat, 1)}% сверх рынка) — высок риск отката`);
|
|
1065
|
+
if (!label) label = '⛔ перегрев';
|
|
1066
|
+
}
|
|
1063
1067
|
if (r.buyBlockedByDiv) {
|
|
1064
1068
|
reasons.push(`Защита от дивидендного гэпа: ${r.buyBlockedByDiv}`);
|
|
1065
1069
|
if (!label) label = '⛔ отсечка';
|
|
@@ -4088,7 +4092,9 @@ async function openStockModal(symbol) {
|
|
|
4088
4092
|
? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
|
|
4089
4093
|
: fc?.buyBlockedByDip
|
|
4090
4094
|
? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
|
|
4091
|
-
:
|
|
4095
|
+
: fc?.buyBlockedByOverheat
|
|
4096
|
+
? `<span class="tag blocked" title="Бумага уже перегрета (+${fmtNum(fc.buyBlockedByOverheat, 1)}% сверх рынка) — покупка на вершине пампа запрещена">⛔ перегрев (+${fmtNum(fc.buyBlockedByOverheat, 1)}%)</span>`
|
|
4097
|
+
: '<span class="tag">нейтрально (вне порога)</span>';
|
|
4092
4098
|
const shortBadge = d.shortAllowedByBroker === true
|
|
4093
4099
|
? '<span class="tag ok" title="Доступна для маржинальных коротких продаж в Т-Банке">шорт в Т-Банке: разрешён ✓</span>'
|
|
4094
4100
|
: d.shortAllowedByBroker === false
|
package/server/api/routes.js
CHANGED
|
@@ -5,7 +5,7 @@ import express from 'express';
|
|
|
5
5
|
import { ROOT, strategyDefaults } from '../config.js';
|
|
6
6
|
import { activeModelInfo } from '../llm/client.js';
|
|
7
7
|
import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
|
|
8
|
-
import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles } from '../db.js';
|
|
8
|
+
import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles, saveCandles } from '../db.js';
|
|
9
9
|
import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, parseAllCookieValues, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
|
|
10
10
|
import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments, listUserPayments } from '../billing/yookassa.js';
|
|
11
11
|
import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
|
|
@@ -14,9 +14,9 @@ import { getSettings, updateSettings, resetSettings, revertSettingsChange } from
|
|
|
14
14
|
import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
|
|
15
15
|
import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
|
|
16
16
|
import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
|
|
17
|
-
import { ensureDividends } from '../data/moex.js';
|
|
17
|
+
import { ensureDividends, ensureCandles } from '../data/moex.js';
|
|
18
18
|
import { live } from '../model/live.js';
|
|
19
|
-
import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs, resetBrokerDailyStop, listBrokerEvents, stopAllBrokerTrading } from '../brokers/index.js';
|
|
19
|
+
import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders, cancelBrokerOrder, triggerBrokerFastEntryCheck, ensureBoardSpecs, resetBrokerDailyStop, listBrokerEvents, stopAllBrokerTrading, fetchTbankCandles } from '../brokers/index.js';
|
|
20
20
|
import { listNews } from '../news/index.js';
|
|
21
21
|
import * as strategist from '../strategist/index.js';
|
|
22
22
|
import { round, MSK, DAY } from '../util.js';
|
|
@@ -653,11 +653,18 @@ export function createRouter() {
|
|
|
653
653
|
const sym = String(req.params.symbol || '').toUpperCase().trim();
|
|
654
654
|
if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
|
|
655
655
|
|
|
656
|
-
const
|
|
656
|
+
const nowTs = Date.now();
|
|
657
|
+
const [fc, rawSpecs, divs, tbLiveBars] = await Promise.all([
|
|
657
658
|
forecastNow(getSettings()).catch(() => null),
|
|
658
659
|
ensureBoardSpecs().catch(() => ({})),
|
|
659
660
|
ensureDividends(sym).catch(() => []),
|
|
661
|
+
fetchTbankCandles(sym, nowTs - 2 * DAY, nowTs, { includeIncomplete: true }).catch(() => null),
|
|
662
|
+
ensureCandles(sym, 10, nowTs - 5 * DAY, nowTs, { signal: AbortSignal.timeout(4000) }).catch(() => null),
|
|
660
663
|
]);
|
|
664
|
+
if (tbLiveBars?.length) {
|
|
665
|
+
const doneBars = tbLiveBars.filter((b) => b.isComplete);
|
|
666
|
+
if (doneBars.length) saveCandles(sym, 10, doneBars);
|
|
667
|
+
}
|
|
661
668
|
const tbMap = kvGet('tbankShortableMap') || {};
|
|
662
669
|
const tbSpecs = tbMap.specMap || {};
|
|
663
670
|
const shortableMap = tbMap.map || {};
|
|
@@ -669,7 +676,6 @@ export function createRouter() {
|
|
|
669
676
|
const tierMult = symbolEdgeMult(sym);
|
|
670
677
|
|
|
671
678
|
// 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
|
|
672
|
-
const nowTs = Date.now();
|
|
673
679
|
const rawBars30d = loadCandles(sym, 10, nowTs - 35 * DAY, nowTs + DAY);
|
|
674
680
|
const intraday = rawBars30d.slice(-1500).map((b) => ({
|
|
675
681
|
ts: b.ts,
|
|
@@ -698,6 +704,23 @@ export function createRouter() {
|
|
|
698
704
|
const daily = [...dailyMap.values()];
|
|
699
705
|
|
|
700
706
|
const curPrice = fcRow?.price || intraday[intraday.length - 1]?.c || daily[daily.length - 1]?.c || sp.lastPrice || 0;
|
|
707
|
+
if (curPrice > 0 && intraday.length) {
|
|
708
|
+
const lastBar = intraday[intraday.length - 1];
|
|
709
|
+
const liveSlotTs = Math.floor((fc?.liveTs || nowTs) / 600000) * 600000;
|
|
710
|
+
if (liveSlotTs > lastBar.ts && liveSlotTs - lastBar.ts <= 6 * 3600000) {
|
|
711
|
+
intraday.push({ ts: liveSlotTs, o: lastBar.c, h: Math.max(lastBar.c, curPrice), l: Math.min(lastBar.c, curPrice), c: curPrice, v: 0 });
|
|
712
|
+
} else if (lastBar.c !== curPrice) {
|
|
713
|
+
lastBar.c = curPrice;
|
|
714
|
+
if (curPrice > lastBar.h) lastBar.h = curPrice;
|
|
715
|
+
if (lastBar.l <= 0 || curPrice < lastBar.l) lastBar.l = curPrice;
|
|
716
|
+
}
|
|
717
|
+
if (daily.length) {
|
|
718
|
+
const lastDay = daily[daily.length - 1];
|
|
719
|
+
lastDay.c = curPrice;
|
|
720
|
+
if (curPrice > lastDay.h) lastDay.h = curPrice;
|
|
721
|
+
if (lastDay.l <= 0 || curPrice < lastDay.l) lastDay.l = curPrice;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
701
724
|
const prevDayClose = daily.length >= 2 ? daily[daily.length - 2].c : (daily[0]?.o || curPrice);
|
|
702
725
|
const dayChangePct = prevDayClose > 0 && curPrice > 0 ? round(((curPrice - prevDayClose) / prevDayClose) * 100, 2) : 0;
|
|
703
726
|
const monthAgoClose = daily.length >= 22 ? daily[daily.length - 22].c : (daily[0]?.o || curPrice);
|
package/server/brokers/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
|
|
|
5
5
|
import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser } from '../auth/index.js';
|
|
6
6
|
import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
|
|
7
7
|
import { getSettings, normalizeParams } from '../settings.js';
|
|
8
|
-
import { latestBarTs } from '../data/moex.js';
|
|
8
|
+
import { latestBarTs, registerRealtimeCandleFetcher } from '../data/moex.js';
|
|
9
9
|
import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
|
|
10
10
|
import { forecastNow } from '../model/forecast.js';
|
|
11
11
|
import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
|
|
@@ -118,15 +118,15 @@ export function roundToStep(price, minStep = 0.01, decimals = 2) {
|
|
|
118
118
|
return Number(v.toFixed(Math.max(0, Math.min(6, decimals))));
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// Снайперский расчёт лимитной
|
|
122
|
-
// отталкивается от живой секундной цены basePx и
|
|
123
|
-
//
|
|
121
|
+
// Снайперский расчёт лимитной цены:
|
|
122
|
+
// отталкивается от живой секундной цены basePx и задаёт защитный предел исполнения по offsetPct (но не менее 1 минимального шага цены minStep),
|
|
123
|
+
// а в момент фактической отправки в стакан sendOrderToBroker дополнительно подстраивает цену под лучший bid/ask живого стакана 1×1!
|
|
124
124
|
export function sniperLimitPrice(basePx, dir, offsetPct = 0.02, sp = { minStep: 0.01, decimals: 2 }) {
|
|
125
125
|
const step = sp.minStep > 0 ? sp.minStep : 0.01;
|
|
126
|
-
const maxOffRatio = Math.max(0, Math.min(0.
|
|
126
|
+
const maxOffRatio = Math.max(0, Math.min(0.001, (Number(offsetPct) || 0.02) / 100));
|
|
127
127
|
const byPct = basePx * (1 + dir * maxOffRatio);
|
|
128
128
|
const byOneTick = basePx + dir * step;
|
|
129
|
-
const rawLimit = dir > 0 ? Math.
|
|
129
|
+
const rawLimit = dir > 0 ? Math.max(byPct, byOneTick) : Math.min(byPct, byOneTick);
|
|
130
130
|
return roundToStep(rawLimit, step, sp.decimals);
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -498,6 +498,7 @@ export function saveBrokerAccount(input = {}) {
|
|
|
498
498
|
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
499
499
|
autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
|
|
500
500
|
lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
|
|
501
|
+
lastFullStepWallTs: strategyOrCapChanged ? 0 : (prev.lastFullStepWallTs || 0),
|
|
501
502
|
// дневной стоп при сохранении настроек не снимается — только отдельным действием «Снять стоп до конца дня»
|
|
502
503
|
haltedReason: prev.haltedReason || null,
|
|
503
504
|
ddOverrideDay: prev.ddOverrideDay || null,
|
|
@@ -579,9 +580,17 @@ export function deleteBrokerAccount(id) {
|
|
|
579
580
|
}
|
|
580
581
|
|
|
581
582
|
// ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
|
|
582
|
-
// HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.)
|
|
583
|
+
// HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.) и пулом постоянных соединений Keep-Alive
|
|
584
|
+
const BROKER_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 6, ca: TRUSTED_CA });
|
|
585
|
+
|
|
583
586
|
function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
584
587
|
return new Promise((resolve, reject) => {
|
|
588
|
+
let settled = false;
|
|
589
|
+
const done = (fn, val) => {
|
|
590
|
+
if (settled) return;
|
|
591
|
+
settled = true;
|
|
592
|
+
fn(val);
|
|
593
|
+
};
|
|
585
594
|
const u = new URL(url);
|
|
586
595
|
const payload = body === null || body === undefined ? null : (typeof body === 'string' ? body : body.toString());
|
|
587
596
|
const reqHeaders = { ...headers };
|
|
@@ -594,6 +603,7 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
|
|
|
594
603
|
path: u.pathname + u.search,
|
|
595
604
|
method,
|
|
596
605
|
headers: reqHeaders,
|
|
606
|
+
agent: BROKER_HTTPS_AGENT,
|
|
597
607
|
ca: TRUSTED_CA,
|
|
598
608
|
rejectUnauthorized: true,
|
|
599
609
|
timeout: timeoutMs,
|
|
@@ -603,16 +613,19 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
|
|
|
603
613
|
res.on('end', () => {
|
|
604
614
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
605
615
|
const status = res.statusCode || 0;
|
|
606
|
-
resolve
|
|
616
|
+
done(resolve, {
|
|
607
617
|
ok: status >= 200 && status < 300,
|
|
608
618
|
status,
|
|
609
619
|
text: async () => text,
|
|
610
620
|
json: async () => (text ? JSON.parse(text) : {}),
|
|
611
621
|
});
|
|
612
622
|
});
|
|
623
|
+
res.on('error', (err) => done(reject, err));
|
|
624
|
+
res.on('close', () => done(reject, new Error('Соединение закрыто до завершения ответа')));
|
|
613
625
|
});
|
|
614
626
|
req.on('timeout', () => { req.destroy(new Error('Превышено время ожидания ответа от брокера')); });
|
|
615
|
-
req.on('error', reject);
|
|
627
|
+
req.on('error', (err) => done(reject, err));
|
|
628
|
+
req.on('close', () => done(reject, new Error('Запрос закрыт до получения ответа')));
|
|
616
629
|
if (payload !== null) req.write(payload);
|
|
617
630
|
req.end();
|
|
618
631
|
});
|
|
@@ -698,8 +711,9 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
|
|
|
698
711
|
// Возвращает живые секундные цены акций из T-Invest API (GetLastPrices, 0 секунд задержки), если у любого пользователя подключён счёт Т-Банка
|
|
699
712
|
export async function fetchTbankRealtimePrices() {
|
|
700
713
|
try {
|
|
701
|
-
// только токен владельца текущих данных — токены других пользователей не используются никогда
|
|
702
|
-
const
|
|
714
|
+
// только токен владельца текущих данных — токены других пользователей не используются никогда (предпочитаем боевой контур песочнице)
|
|
715
|
+
const rawAccs = loadRawAccounts();
|
|
716
|
+
const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
|
|
703
717
|
if (!tbAcc?.token) return null;
|
|
704
718
|
await ensureTbankShortableMap(tbAcc);
|
|
705
719
|
const uidMap = kvGet('tbankShortableMap')?.uidMap || {};
|
|
@@ -729,6 +743,64 @@ export async function fetchTbankRealtimePrices() {
|
|
|
729
743
|
}
|
|
730
744
|
}
|
|
731
745
|
|
|
746
|
+
// Возвращает свежие 10-минутные свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
|
|
747
|
+
export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
|
|
748
|
+
try {
|
|
749
|
+
const rawAccs = loadRawAccounts();
|
|
750
|
+
const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
|
|
751
|
+
if (!tbAcc?.token) return null;
|
|
752
|
+
await ensureTbankShortableMap(tbAcc);
|
|
753
|
+
const shortMap = kvGet('tbankShortableMap');
|
|
754
|
+
const uid = shortMap?.uidMap?.[symbol];
|
|
755
|
+
if (!uid) return null;
|
|
756
|
+
const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
|
|
757
|
+
const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
758
|
+
const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
|
|
759
|
+
// Для CANDLE_INTERVAL_10_MIN максимальный диапазон одного запроса в T-Invest API — 3 суток
|
|
760
|
+
const safeFrom = Math.max(fromTs, tillTs - 3 * 86400000 + 60000);
|
|
761
|
+
if (safeFrom >= tillTs) return null;
|
|
762
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`, {
|
|
763
|
+
method: 'POST',
|
|
764
|
+
headers,
|
|
765
|
+
body: JSON.stringify({
|
|
766
|
+
instrumentId: uid,
|
|
767
|
+
from: new Date(safeFrom).toISOString(),
|
|
768
|
+
to: new Date(tillTs).toISOString(),
|
|
769
|
+
interval: 'CANDLE_INTERVAL_10_MIN',
|
|
770
|
+
}),
|
|
771
|
+
timeoutMs: 5000,
|
|
772
|
+
});
|
|
773
|
+
if (!r.ok) return null;
|
|
774
|
+
const j = await r.json();
|
|
775
|
+
const out = [];
|
|
776
|
+
for (const c of j.candles || []) {
|
|
777
|
+
if (!includeIncomplete && c.isComplete === false) continue;
|
|
778
|
+
const ts = Date.parse(c.time);
|
|
779
|
+
const open = qVal(c.open);
|
|
780
|
+
const high = qVal(c.high);
|
|
781
|
+
const low = qVal(c.low);
|
|
782
|
+
const close = qVal(c.close);
|
|
783
|
+
if (!(ts > 0 && close > 0)) continue;
|
|
784
|
+
const volShares = (Number(c.volume) || 0) * lotSize;
|
|
785
|
+
const avgPx = (open + high + low + close) / 4;
|
|
786
|
+
out.push({
|
|
787
|
+
ts,
|
|
788
|
+
open,
|
|
789
|
+
high,
|
|
790
|
+
low,
|
|
791
|
+
close,
|
|
792
|
+
volume: volShares,
|
|
793
|
+
value: Math.round(volShares * avgPx),
|
|
794
|
+
isComplete: c.isComplete !== false,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
return out.length ? out : null;
|
|
798
|
+
} catch {
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
registerRealtimeCandleFetcher(fetchTbankCandles);
|
|
803
|
+
|
|
732
804
|
// Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
|
|
733
805
|
async function ensureTbankShortableMap(acc) {
|
|
734
806
|
if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
|
|
@@ -1702,7 +1774,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1702
1774
|
if (aQty > 0) {
|
|
1703
1775
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1704
1776
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1705
|
-
const hitHardStopLoss = curGainPct <= -5.0;
|
|
1777
|
+
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.buyBlockedByDip);
|
|
1706
1778
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
|
|
1707
1779
|
const dg = divGuardOf(sym, curPx);
|
|
1708
1780
|
const divExit = Boolean(dg?.exitLong);
|
|
@@ -1727,7 +1799,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1727
1799
|
} else if (aQty < 0) {
|
|
1728
1800
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1729
1801
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1730
|
-
const hitHardStopLoss = curGainPct <= -4.5;
|
|
1802
|
+
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.shortBlockedBySqueeze);
|
|
1731
1803
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
|
|
1732
1804
|
const dg = divGuardOf(sym, curPx);
|
|
1733
1805
|
const divExit = Boolean(dg?.exitShort);
|
|
@@ -1830,7 +1902,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1830
1902
|
}
|
|
1831
1903
|
}
|
|
1832
1904
|
const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
|
|
1833
|
-
const
|
|
1905
|
+
const coverSpentRub = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
|
|
1906
|
+
const openShortRub = [...heldMap.values()]
|
|
1907
|
+
.filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
|
|
1908
|
+
.reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
|
|
1909
|
+
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
|
|
1834
1910
|
let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
1835
1911
|
|
|
1836
1912
|
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
@@ -2042,9 +2118,13 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2042
2118
|
return brokersState();
|
|
2043
2119
|
}
|
|
2044
2120
|
|
|
2121
|
+
const batchNowTs = Date.now();
|
|
2122
|
+
if (!onlyEntries) {
|
|
2123
|
+
acc.lastFullStepWallTs = batchNowTs;
|
|
2124
|
+
}
|
|
2045
2125
|
acc.pendingBatch = {
|
|
2046
|
-
id: `batch_${
|
|
2047
|
-
createdAt:
|
|
2126
|
+
id: `batch_${batchNowTs}`,
|
|
2127
|
+
createdAt: batchNowTs,
|
|
2048
2128
|
bar: fc.bar,
|
|
2049
2129
|
budgetRub: budget,
|
|
2050
2130
|
availRub: round(availRub, 0),
|
|
@@ -2121,19 +2201,35 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2121
2201
|
if (bestBid > 0 && bestAsk >= bestBid) {
|
|
2122
2202
|
const midPx = (bestBid + bestAsk) / 2;
|
|
2123
2203
|
const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
|
|
2204
|
+
const step = sp.minStep || 0.01;
|
|
2124
2205
|
const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
|
|
2125
2206
|
if (isEntry && spreadPct > maxSpreadPct) {
|
|
2126
2207
|
logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
|
|
2127
2208
|
throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
|
|
2128
2209
|
}
|
|
2129
|
-
if (
|
|
2130
|
-
// Пассивный вход на середине спреда (или на 1 шаг внутри спреда от своей стороны), но не хуже расчётной limitPrice
|
|
2131
|
-
const passivePx = isBuy
|
|
2132
|
-
? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + (sp.minStep || 0.01)), sp.minStep, sp.decimals))
|
|
2133
|
-
: Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - (sp.minStep || 0.01)), sp.minStep, sp.decimals));
|
|
2134
|
-
if (passivePx > 0) finalLimitPx = passivePx;
|
|
2135
|
-
} else if (ord.symbol === 'LQDT') {
|
|
2210
|
+
if (ord.symbol === 'LQDT') {
|
|
2136
2211
|
finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
|
|
2212
|
+
} else if (!isEntry) {
|
|
2213
|
+
// Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
|
|
2214
|
+
// чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
|
|
2215
|
+
if (spreadPct <= 0.80) {
|
|
2216
|
+
const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
|
|
2217
|
+
if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
|
|
2218
|
+
}
|
|
2219
|
+
} else {
|
|
2220
|
+
// Входящие заявки (buy / short):
|
|
2221
|
+
// • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx, чтобы не упускать движение;
|
|
2222
|
+
// • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
|
|
2223
|
+
const tightSpread = spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step;
|
|
2224
|
+
if (tightSpread) {
|
|
2225
|
+
const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
|
|
2226
|
+
if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
|
|
2227
|
+
} else {
|
|
2228
|
+
const passivePx = isBuy
|
|
2229
|
+
? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
|
|
2230
|
+
: Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
|
|
2231
|
+
if (passivePx > 0) finalLimitPx = passivePx;
|
|
2232
|
+
}
|
|
2137
2233
|
}
|
|
2138
2234
|
}
|
|
2139
2235
|
}
|
|
@@ -2682,14 +2778,21 @@ async function tickBrokerSchedule() {
|
|
|
2682
2778
|
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
2683
2779
|
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
2684
2780
|
const minStepMs = stepBars * BAR_MS;
|
|
2685
|
-
const
|
|
2781
|
+
const lastFullWallTs = acc.lastFullStepWallTs || 0;
|
|
2782
|
+
const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
|
|
2783
|
+
const hasOpenAlgoPos = (acc.lastSync?.positions || []).some((p) => p.qty && p.symbol !== 'LQDT' && p.symbol !== 'TMON@' && p.symbol !== 'TMON' && p.symbol !== 'SBMM' && algoQtyFor(acc, p.symbol, p.qty) !== 0);
|
|
2784
|
+
const nowTs = Date.now();
|
|
2785
|
+
const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
|
|
2686
2786
|
const dueFullStep = Boolean(
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2787
|
+
nightExitDue || (
|
|
2788
|
+
latest &&
|
|
2789
|
+
(latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
|
|
2790
|
+
!(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
|
|
2791
|
+
),
|
|
2690
2792
|
);
|
|
2691
2793
|
if (dueFullStep) {
|
|
2692
|
-
acc.lastScheduledBarTs = latest;
|
|
2794
|
+
acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
|
|
2795
|
+
acc.lastFullStepWallTs = nowTs;
|
|
2693
2796
|
saveRawAccounts(list);
|
|
2694
2797
|
if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
|
|
2695
2798
|
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
@@ -2700,7 +2803,7 @@ async function tickBrokerSchedule() {
|
|
|
2700
2803
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
2701
2804
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
2702
2805
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
2703
|
-
if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 &&
|
|
2806
|
+
if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
2704
2807
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
2705
2808
|
}
|
|
2706
2809
|
}
|
|
@@ -2987,6 +3090,9 @@ async function checkIntradayProfitLock(accId) {
|
|
|
2987
3090
|
for (const ord of lockOrders) {
|
|
2988
3091
|
logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
|
|
2989
3092
|
try {
|
|
3093
|
+
if (acc.brokerStopBySymbol?.[ord.symbol]) {
|
|
3094
|
+
await closeBrokerStopBeforeExit(acc, ord.symbol);
|
|
3095
|
+
}
|
|
2990
3096
|
const r = await sendOrderToBroker(acc, ord);
|
|
2991
3097
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
2992
3098
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
@@ -2999,10 +3105,10 @@ async function checkIntradayProfitLock(accId) {
|
|
|
2999
3105
|
}
|
|
3000
3106
|
acc.pendingBatch = {
|
|
3001
3107
|
...(acc.pendingBatch || {}),
|
|
3002
|
-
id: `
|
|
3108
|
+
id: `batch_stop_${execNowTs}`,
|
|
3003
3109
|
createdAt: execNowTs,
|
|
3004
3110
|
executedAt: execNowTs,
|
|
3005
|
-
bar: `${fmtBarMsk(execNowTs)} (
|
|
3111
|
+
bar: `${fmtBarMsk(execNowTs)} (Защитный стоп)`,
|
|
3006
3112
|
orders: lockOrders,
|
|
3007
3113
|
status: results.every((r) => r.ok) ? 'executed' : 'partial',
|
|
3008
3114
|
results,
|
package/server/data/moex.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
|
|
2
|
+
import { saveCandles, loadCandles, candleRange, candleMaxBetween, kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { DAY, MSK } from '../util.js';
|
|
4
4
|
|
|
5
|
+
let realtimeCandleFetcher = null;
|
|
6
|
+
export function registerRealtimeCandleFetcher(fn) {
|
|
7
|
+
realtimeCandleFetcher = fn;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
const ISS = 'https://iss.moex.com/iss';
|
|
6
11
|
const INDEXES = new Set(['IMOEX', 'RGBI', 'MOEXBMI']);
|
|
7
12
|
// макро-инструменты: валюта (CETS) и фьючерсы (FORTS/RFUD)
|
|
@@ -75,9 +80,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
75
80
|
const now = Date.now();
|
|
76
81
|
const pKey = `candProbe:${symbol}:${interval}`;
|
|
77
82
|
const probe = kvGet(pKey);
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
83
|
+
// Метка v3: fromTs — до какой даты левый край подтверждён (свечи есть в базе или на бирже раньше их нет).
|
|
84
|
+
// Метки v1/v2 не доверяем: старое условие range.n < 500 записывало fromTs без скачивания, и у бумаг с накопленной
|
|
85
|
+
// историей левый край (например, 2022 год) так и не догружался — датасет зависел от того, когда создана база.
|
|
86
|
+
const verifiedFrom = probe?.v === 3 ? probe.fromTs : Infinity;
|
|
87
|
+
let leftFill = probe?.v === 3 ? probe.leftFill || null : null;
|
|
81
88
|
|
|
82
89
|
if (!range.n) {
|
|
83
90
|
// В базе ещё нет свечей этой бумаги — качаем весь запрошенный диапазон один раз
|
|
@@ -85,28 +92,35 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
85
92
|
signal,
|
|
86
93
|
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
87
94
|
});
|
|
88
|
-
kvSet(pKey, { v:
|
|
95
|
+
kvSet(pKey, { v: 3, fromTs });
|
|
89
96
|
lastTailCheckAt.set(`${symbol}:${interval}`, now);
|
|
90
97
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
// 1) ЛЕВЫЙ КРАЙ:
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
100
|
+
// 1) ЛЕВЫЙ КРАЙ: история докачивается один раз, дальше работаем только по SQLite.
|
|
101
|
+
// Докачка идёт от ранних свечей к поздним; пока она не завершена, в метке хранится leftFill,
|
|
102
|
+
// чтобы после обрыва продолжить с последней сохранённой свечи, а не оставить дыру в середине истории.
|
|
103
|
+
let probeFrom = Math.min(verifiedFrom, range.min);
|
|
104
|
+
if (!leftFill && range.min > fromTs + 5 * DAY && verifiedFrom > fromTs + 5 * DAY) {
|
|
97
105
|
const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
|
|
98
106
|
if (!first || first.ts >= range.min - DAY) {
|
|
99
|
-
|
|
107
|
+
probeFrom = fromTs; // раньше имеющихся свечей на бирже истории нет
|
|
100
108
|
} else {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
signal,
|
|
104
|
-
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
105
|
-
});
|
|
106
|
-
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
|
|
109
|
+
leftFill = { from: fromTs, till: range.min };
|
|
110
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, leftFill, tailCheckedAt: probe?.tailCheckedAt });
|
|
107
111
|
}
|
|
108
|
-
}
|
|
109
|
-
|
|
112
|
+
}
|
|
113
|
+
if (leftFill) {
|
|
114
|
+
const resumeTs = candleMaxBetween(symbol, interval, leftFill.from, leftFill.till - 1) ?? leftFill.from;
|
|
115
|
+
await fetchCandles(symbol, interval, tsToDate(resumeTs), tsToDate(leftFill.till + DAY), {
|
|
116
|
+
signal,
|
|
117
|
+
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
118
|
+
});
|
|
119
|
+
probeFrom = Math.min(probeFrom, leftFill.from);
|
|
120
|
+
leftFill = null;
|
|
121
|
+
}
|
|
122
|
+
if (probe?.v !== 3 || probe.fromTs !== probeFrom || probe.leftFill) {
|
|
123
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: probe?.tailCheckedAt });
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
// 2) ПРАВЫЙ КРАЙ (свежие свечи текущего дня):
|
|
@@ -121,18 +135,30 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
121
135
|
const mskHour = mskDate.getUTCHours();
|
|
122
136
|
const mskMins = mskHour * 60 + mskDate.getUTCMinutes();
|
|
123
137
|
const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskMins >= 6 * 60 + 50 && mskHour < 24;
|
|
124
|
-
const
|
|
125
|
-
const
|
|
138
|
+
const isTbankEligible = Boolean(realtimeCandleFetcher && Number(interval) === 10 && now - range.max <= 3 * DAY && !INDEXES.has(symbol) && !CURRENCY.has(symbol) && !FUTURES.test(symbol));
|
|
139
|
+
const isExpiredFuture = FUTURES.test(symbol) && (now - range.max > 10 * DAY);
|
|
140
|
+
const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - barMs(interval)) / barMs(interval)) * barMs(interval);
|
|
141
|
+
const checkCooldownMs = isExpiredFuture ? DAY : isTradingHours ? (isTbankEligible ? 45000 : 4 * 60000) : 4 * 3600000;
|
|
142
|
+
const dbIsAlreadyFresh = isExpiredFuture || (range.max >= latestClosedSlotTs) || (!isTradingHours && now - range.max < 18 * 3600000);
|
|
126
143
|
|
|
127
|
-
if (!dbIsAlreadyFresh && range.max <
|
|
128
|
-
// Записываем время проверки хвоста ВСЕГДА (даже если новых свечей у бумаги на бирже ещё не появилось),
|
|
129
|
-
// чтобы не опрашивать MOEX ISS по всем 100 бумагам каждые 2 минуты!
|
|
144
|
+
if (!dbIsAlreadyFresh && range.max < latestClosedSlotTs && (now - lastCheck >= checkCooldownMs)) {
|
|
130
145
|
lastTailCheckAt.set(tailKey, now);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
146
|
+
// В первую очередь берём свежие 10-минутные свечи из T-Invest API (GetCandles — 0 сек задержки против 20–25 мин у бесплатного MOEX ISS)
|
|
147
|
+
let filledByTbank = false;
|
|
148
|
+
if (isTbankEligible) {
|
|
149
|
+
const tbRows = await realtimeCandleFetcher(symbol, Math.max(range.max - 20 * 60000, now - 3 * DAY + 120000), Math.min(tillTs, now)).catch(() => null);
|
|
150
|
+
if (tbRows?.length) {
|
|
151
|
+
saveCandles(symbol, interval, tbRows);
|
|
152
|
+
filledByTbank = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!filledByTbank) {
|
|
156
|
+
await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now - DAY ? tsToDate(tillTs) : null, {
|
|
157
|
+
signal,
|
|
158
|
+
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
kvSet(pKey, { v: 3, fromTs: probeFrom, tailCheckedAt: now });
|
|
136
162
|
}
|
|
137
163
|
|
|
138
164
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
|
@@ -140,6 +166,11 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
140
166
|
|
|
141
167
|
// Последняя свеча инструмента прямо с биржи (без кэша) — чтобы быстро понять, закрылась ли новая свеча
|
|
142
168
|
export async function latestBarTs(symbol, interval = 60) {
|
|
169
|
+
if (realtimeCandleFetcher && Number(interval) === 10) {
|
|
170
|
+
const tbSym = INDEXES.has(symbol) ? 'SBER' : symbol;
|
|
171
|
+
const tbRows = await realtimeCandleFetcher(tbSym, Date.now() - 6 * 3600000, Date.now()).catch(() => null);
|
|
172
|
+
if (tbRows?.length) return tbRows[tbRows.length - 1].ts;
|
|
173
|
+
}
|
|
143
174
|
const from = tsToDate(Date.now() - 5 * DAY);
|
|
144
175
|
const rows = await fetchCandles(symbol, interval, from, null);
|
|
145
176
|
return rows.length ? rows[rows.length - 1].ts : null;
|
package/server/db.js
CHANGED
|
@@ -343,6 +343,10 @@ export function candleRange(symbol, interval) {
|
|
|
343
343
|
return db.prepare('SELECT MIN(ts) AS min, MAX(ts) AS max, COUNT(*) AS n FROM candles WHERE symbol = ? AND interval = ?').get(symbol, interval);
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
export function candleMaxBetween(symbol, interval, fromTs, tillTs) {
|
|
347
|
+
return db.prepare('SELECT MAX(ts) AS max FROM candles WHERE symbol = ? AND interval = ? AND ts >= ? AND ts <= ?').get(symbol, interval, fromTs, tillTs)?.max ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
346
350
|
export function createRun(mode, params, uid = getUid()) {
|
|
347
351
|
const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid ?? 1);
|
|
348
352
|
return Number(r.lastInsertRowid);
|