Вход 🔒
Введи один пароль (он же используется для Worker).
/* ===================== [B] CSS ===================== */ #moexrs-widget.moexrs{ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif; color:#111827; } /* Overlay */ #moexrs-widget .moexrs__overlay{ position:fixed; inset:0; background:rgba(17,24,39,.60); display:flex; align-items:center; justify-content:center; z-index:99999; padding:16px; } #moexrs-widget .moexrs__loginbox{ width:min(420px,100%); background:#fff; border-radius:14px; padding:18px; box-shadow:0 12px 30px rgba(0,0,0,.25); } #moexrs-widget .moexrs__title{font-size:18px;font-weight:900;margin-bottom:6px;} #moexrs-widget .moexrs__subtitle{font-size:13px;line-height:1.35;margin-bottom:12px;} #moexrs-widget .moexrs__hint{margin-top:10px;font-size:13px;} #moexrs-widget .moexrs__showpass{display:flex;gap:8px;align-items:center;font-size:12px;color:#374151;margin:8px 0 10px;} #moexrs-widget .moexrs__showpass input{transform:translateY(1px);} /* App layout */ #moexrs-widget .moexrs__app{ border:1px solid #e5e7eb; border-radius:14px; overflow:hidden; background:#fff; } #moexrs-widget .moexrs__topbar{ display:flex; gap:14px; align-items:center; justify-content:space-between; padding:12px 14px; border-bottom:1px solid #e5e7eb; background:#f9fafb; } #moexrs-widget .moexrs__brandTitle{font-weight:900;font-size:15px;} #moexrs-widget .moexrs__brandSub{font-size:12px;color:#6b7280;margin-top:2px;} #moexrs-widget .moexrs__controls{ display:flex; gap:10px; align-items:center; flex-wrap:wrap; justify-content:flex-end; } #moexrs-widget .moexrs__body{display:flex; min-height:540px;} #moexrs-widget .moexrs__sidebar{ width:280px; border-right:1px solid #e5e7eb; background:#fff; display:flex; flex-direction:column; } #moexrs-widget .moexrs__sidebarTitle{ padding:12px 14px; font-weight:900; border-bottom:1px solid #e5e7eb; } #moexrs-widget .moexrs__sectorList{ padding:10px; display:flex; flex-direction:column; gap:8px; overflow:auto; max-height:100%; } #moexrs-widget .moexrs__sidebarFooter{ margin-top:auto; padding:12px 10px; border-top:1px solid #e5e7eb; display:grid; gap:8px; } #moexrs-widget .moexrs__content{flex:1; padding:14px; background:#fff; overflow:auto;} #moexrs-widget .moexrs__status{ padding:10px 12px; border:1px solid #e5e7eb; background:#f9fafb; border-radius:12px; font-size:13px; margin-bottom:12px; } #moexrs-widget .moexrs__panel{border:1px solid #e5e7eb; border-radius:12px; overflow:hidden;} #moexrs-widget .moexrs__panelHeader{ display:flex; justify-content:space-between; gap:12px; align-items:center; padding:12px 12px; border-bottom:1px solid #e5e7eb; background:#f9fafb; } #moexrs-widget .moexrs__panelTitle{font-weight:900;font-size:14px;margin-bottom:2px;} #moexrs-widget .moexrs__panelMeta{font-size:12px;} /* Inputs + buttons */ #moexrs-widget .moexrs__input{ padding:9px 10px; border:1px solid #d1d5db; border-radius:10px; outline:none; font-size:13px; min-width:220px; background:#fff; box-sizing:border-box; } #moexrs-widget .moexrs__input:focus{ border-color:#60a5fa; box-shadow:0 0 0 3px rgba(96,165,250,.25); } #moexrs-widget .moexrs__btn{ padding:9px 10px; border:1px solid #d1d5db; background:#fff; border-radius:10px; cursor:pointer; font-size:13px; } #moexrs-widget .moexrs__btn:hover{background:#f3f4f6;} #moexrs-widget .moexrs__btn:disabled{opacity:.55; cursor:not-allowed;} #moexrs-widget .moexrs__btn--primary{ border-color:#2563eb; background:#2563eb; color:#fff; } #moexrs-widget .moexrs__btn--primary:hover{background:#1d4ed8;} #moexrs-widget .moexrs__btn--ghost{background:#fff;} #moexrs-widget .moexrs__btn--pill{ border-radius:999px; padding:7px 10px; } #moexrs-widget .moexrs__btn--pill.is-active{ border-color:#2563eb; background:#eff6ff; font-weight:900; } #moexrs-widget .moexrs__search{display:flex; gap:8px; align-items:center;} #moexrs-widget .moexrs__periods{display:flex; gap:6px; align-items:center;} /* Sector buttons */ #moexrs-widget .moexrs__sectorBtn{ width:100%; text-align:left; padding:10px 10px; border-radius:12px; border:1px solid #e5e7eb; background:#fff; cursor:pointer; display:grid; gap:4px; } #moexrs-widget .moexrs__sectorBtn:hover{background:#f9fafb;} #moexrs-widget .moexrs__sectorBtn.is-active{ border-color:#2563eb; background:#eff6ff; } #moexrs-widget .moexrs__sectorBtnTitle{font-weight:900;font-size:13px;} #moexrs-widget .moexrs__sectorBtnMeta{font-size:12px;color:#6b7280;} /* Table */ #moexrs-widget .moexrs__tableWrap{ overflow:auto; max-height:520px; border-bottom:1px solid #e5e7eb; } #moexrs-widget table.moexrs__table{ width:100%; border-collapse:collapse; font-size:12px; } #moexrs-widget table.moexrs__table thead th{ position:sticky; top:0; z-index:2; background:#f3f4f6; border-bottom:1px solid #e5e7eb; text-align:left; padding:8px; white-space:nowrap; } #moexrs-widget table.moexrs__table tbody td{ border-bottom:1px solid #f1f5f9; padding:7px 8px; vertical-align:middle; } #moexrs-widget table.moexrs__table tbody tr:hover td{background:#f9fafb;} #moexrs-widget .col-ticker{width:90px;} #moexrs-widget .col-name{min-width:220px;} #moexrs-widget .col-rank{width:70px;} #moexrs-widget .col-points{width:70px;} #moexrs-widget .col-small{width:44px;text-align:center;} #moexrs-widget .col-note{min-width:220px;} #moexrs-widget td.is-center{text-align:center;} /* Highlight rows */ #moexrs-widget tr.is-top td{background:#dcfce7;} /* зелёный */ #moexrs-widget tr.is-bottom td{background:#fee2e2;} /* красный */ /* Excluded list */ #moexrs-widget .moexrs__excluded{padding:10px 12px; display:grid; gap:8px;} #moexrs-widget .moexrs__excludedItem{ padding:8px 10px; border-radius:10px; border:1px dashed #d1d5db; background:#fff; font-size:12px; } /* Matrix */ #moexrs-widget .moexrs__matrixWrap{ padding:10px 12px 12px; border-top:1px solid #e5e7eb; background:#fff; } #moexrs-widget .moexrs__matrixWrap.is-hidden{display:none;} #moexrs-widget .moexrs__matrixHint{font-size:12px; margin-bottom:8px;} #moexrs-widget .moexrs__matrixScroll{ overflow:auto; max-height:520px; border:1px solid #e5e7eb; border-radius:12px; } #moexrs-widget table.moexrs__matrix{ border-collapse:collapse; font-size:11px; table-layout:fixed; width:max-content; } #moexrs-widget table.moexrs__matrix th, #moexrs-widget table.moexrs__matrix td{ border:1px solid #f1f5f9; padding:5px 6px; text-align:center; width:40px; min-width:40px; white-space:nowrap; } #moexrs-widget table.moexrs__matrix thead th{ position:sticky; top:0; z-index:3; background:#f3f4f6; } #moexrs-widget table.moexrs__matrix th.sticky-left, #moexrs-widget table.moexrs__matrix td.sticky-left{ position:sticky; left:0; z-index:4; background:#f9fafb; text-align:left; width:78px; min-width:78px; max-width:78px; overflow:hidden; text-overflow:ellipsis; } /* Matrix cell colors */ #moexrs-widget td.mwin{background:#dcfce7;} #moexrs-widget td.mlose{background:#fee2e2;} #moexrs-widget td.mtie{background:#e5e7eb;} #moexrs-widget td.mdiag{background:#ffffff;} /* Utilities */ #moexrs-widget .moexrs__muted{color:#6b7280;} #moexrs-widget .moexrs__tiny{font-size:12px;} /* ===================== [C] JS ===================== */ (function(){ // ========================================================== // НАСТРОЙКИ (2 строки, не больше) ✅ // ========================================================== const WORKER_BASE_URL = "https://lingering-truth-eeb2personalanaliseswapimoex.studentwyckoff.workers.dev/"; // пример: https://personalanalise_sw_api_moex..workers.dev const PASSWORD = "128Farcry"; // ОДИН пароль: и для входа, и для Worker (APP_PASSWORD) // ========================================================== // DOM // ========================================================== const root = document.getElementById("moexrs-widget"); if(!root) return; const overlay = document.getElementById("moexrsOverlay"); const app = document.getElementById("moexrsApp"); const loginBtn = document.getElementById("moexrsLoginBtn"); const passInput = document.getElementById("moexrsPass"); const showPassCb = document.getElementById("moexrsShowPass"); const loginHint = document.getElementById("moexrsLoginHint"); const logoutBtn = document.getElementById("moexrsLogoutBtn"); const sectorListEl = document.getElementById("moexrsSectorList"); const statusEl = document.getElementById("moexrsStatus"); const sectorTitleEl = document.getElementById("moexrsSectorTitle"); const sectorMetaEl = document.getElementById("moexrsSectorMeta"); const tableBodyEl = document.getElementById("moexrsTableBody"); const refreshBtn = document.getElementById("moexrsRefreshBtn"); const periodsEl = document.getElementById("moexrsPeriods"); const searchInput = document.getElementById("moexrsSearchInput"); const findBtn = document.getElementById("moexrsFindBtn"); const datalistEl = document.getElementById("moexrsTickerDatalist"); const excludedEl = document.getElementById("moexrsExcluded"); const toggleMatrixBtn = document.getElementById("moexrsToggleMatrixBtn"); const matrixWrap = document.getElementById("moexrsMatrixWrap"); const matrixScroll = document.getElementById("moexrsMatrixScroll"); // ========================================================== // STATE // ========================================================== const LS_KEY = "moexrs_auth_v5"; let currentSectorId = null; let currentSectorName = null; let constituents = []; // [{ticker, shortname}] let universeMap = new Map(); // ticker -> {shortname, sectors:[{id,name}]} let selectedLookback = 7; let matrixVisible = false; let lastRsData = null; // ========================================================== // HELPERS // ========================================================== function normPass(x){ return String(x ?? "").trim(); } function configuredPass(){ const p = normPass(PASSWORD); if(!p) return ""; if(p.includes("PASTE_")) return ""; return p; } function setStatus(text, kind){ statusEl.textContent = text; statusEl.style.borderColor = (kind === "error") ? "#ef4444" : "#e5e7eb"; statusEl.style.background = (kind === "error") ? "#fef2f2" : "#f9fafb"; } function safeUpper(s){ return String(s || "").trim().toUpperCase(); } function parseTickerFromInput(val){ const v = String(val || "").trim(); if(!v) return ""; const first = v.split(/[\\s—–-]+/)[0]; return safeUpper(first); } async function apiGet(path, params){ if(!WORKER_BASE_URL || WORKER_BASE_URL.includes("PASTE_")){ throw new Error("WORKER_BASE_URL не настроен. Вставь ссылку на Worker в JS."); } const pw = configuredPass(); if(!pw){ throw new Error("Пароль не настроен. В JS замени PASSWORD на свой пароль (в кавычках) и опубликуй."); } const url = new URL(WORKER_BASE_URL.replace(/\\/+$/, "") + path); if(params){ Object.keys(params).forEach(k => url.searchParams.set(k, params[k])); } const res = await fetch(url.toString(), { method: "GET", headers: { "x-app-password": pw } }); if(!res.ok){ const t = await res.text().catch(()=> ""); throw new Error(res.status + " " + res.statusText + " :: " + t); } return await res.json(); } function clearTable(message){ tableBodyEl.innerHTML = "" + message + ""; } function renderSectorButtons(sectors, countsBySector){ sectorListEl.innerHTML = ""; (sectors || []).forEach(sec => { const btn = document.createElement("button"); btn.className = "moexrs__sectorBtn"; btn.dataset.sector = sec.id; const count = countsBySector?.get(sec.id) || 0; btn.innerHTML = `
${sec.name}
${sec.id} · компаний: ${count || "—"}
`; btn.addEventListener("click", () => selectSector(sec.id, sec.name)); sectorListEl.appendChild(btn); }); } function setActiveSectorBtn(sectorId){ const buttons = sectorListEl.querySelectorAll(".moexrs__sectorBtn"); buttons.forEach(b => b.classList.toggle("is-active", b.dataset.sector === sectorId)); } function setActiveLookbackBtn(lookback){ const buttons = periodsEl.querySelectorAll(".moexrs__btn--pill"); buttons.forEach(b => b.classList.toggle("is-active", Number(b.dataset.lookback) === Number(lookback))); } function renderSkeletonTable(items){ tableBodyEl.innerHTML = ""; (items || []).forEach(it => { const tr = document.createElement("tr"); tr.id = "row_" + it.ticker; tr.innerHTML = ` ${it.ticker} ${it.shortname || ""} — — — — — ждёт обновления `; tableBodyEl.appendChild(tr); }); } function renderExcluded(excluded){ excludedEl.innerHTML = ""; if(!excluded || !excluded.length) return; const title = document.createElement("div"); title.className = "moexrs__muted"; title.style.fontSize = "12px"; title.textContent = "Не подтянулись (проверь тикер/свечи):"; excludedEl.appendChild(title); excluded.slice(0, 80).forEach(ex => { const div = document.createElement("div"); div.className = "moexrs__excludedItem"; div.innerHTML = `${ex.ticker} ${ex.shortname ? "— " + ex.shortname : ""}
${ex.reason}`; excludedEl.appendChild(div); }); if(excluded.length > 80){ const more = document.createElement("div"); more.className = "moexrs__muted"; more.style.fontSize = "12px"; more.textContent = "…и ещё " + (excluded.length - 80) + " шт."; excludedEl.appendChild(more); } } function applyResultsToTable(rsData){ const ranking = rsData.ranking || []; const excluded = rsData.excluded || []; const used = rsData.usedTickers || 0; const total = rsData.totalTickers || 0; const baseEnd = rsData.base?.end || "—"; const rankMap = new Map(); ranking.forEach(r => rankMap.set(r.ticker, r)); const exclMap = new Map(); excluded.forEach(e => exclMap.set(e.ticker, e)); constituents.forEach(it => { const tr = document.getElementById("row_" + it.ticker); if(!tr) return; tr.classList.remove("is-top", "is-bottom"); const r = rankMap.get(it.ticker); const e = exclMap.get(it.ticker); const tds = tr.querySelectorAll("td"); if(!tds || tds.length < 8) return; if(r){ tds[2].textContent = String(r.rank); tds[3].textContent = String(r.points); tds[4].textContent = String(r.wins); tds[5].textContent = String(r.ties); tds[6].textContent = String(r.losses); tds[7].textContent = r.highlight === "top" ? "ТОП 20%" : (r.highlight === "bottom" ? "НИЗ 20%" : "OK"); if(r.highlight === "top") tr.classList.add("is-top"); if(r.highlight === "bottom") tr.classList.add("is-bottom"); } else if(e){ tds[2].textContent = "—"; tds[3].textContent = "—"; tds[4].textContent = "—"; tds[5].textContent = "—"; tds[6].textContent = "—"; tds[7].textContent = "нет данных"; } else { tds[7].textContent = "—"; } }); sectorMetaEl.textContent = `Компании: ${total} · с данными: ${used} · дата базы: ${baseEnd} · период: ${selectedLookback} баров`; } function renderMatrix(rsData){ matrixScroll.innerHTML = ""; const m = rsData?.matrix; if(!m || !m.tickers || !m.values || !m.tickers.length){ matrixScroll.innerHTML = "
Матрица недоступна (нет данных)
"; return; } const tickers = m.tickers; const values = m.values; const table = document.createElement("table"); table.className = "moexrs__matrix"; // THEAD const thead = document.createElement("thead"); const hr = document.createElement("tr"); const th0 = document.createElement("th"); th0.className = "sticky-left"; th0.textContent = "Ticker"; hr.appendChild(th0); tickers.forEach(t => { const th = document.createElement("th"); th.textContent = t; th.title = t; hr.appendChild(th); }); thead.appendChild(hr); table.appendChild(thead); // TBODY const tbody = document.createElement("tbody"); for(let i=0;i { row.style.outline = ""; }, 1200); } // ========================================================== // ACTIONS // ========================================================== async function selectSector(sectorId, sectorName){ currentSectorId = sectorId; currentSectorName = sectorName || sectorId; setActiveSectorBtn(sectorId); sectorTitleEl.textContent = "Сектор: " + currentSectorName + " (" + sectorId + ")"; sectorMetaEl.textContent = "Компании: загружаю список…"; excludedEl.innerHTML = ""; clearTable("Загружаю компании сектора…"); refreshBtn.disabled = true; try{ const data = await apiGet("/sector", { sector: sectorId }); constituents = (data.items || []).map(x => ({ ticker: x.ticker, shortname: x.shortname || "" })); if(!constituents.length){ clearTable("В секторе нет компаний (пустой список)."); sectorMetaEl.textContent = "Компании: 0"; refreshBtn.disabled = true; return; } renderSkeletonTable(constituents); sectorMetaEl.textContent = "Компании: " + constituents.length + " · период: " + selectedLookback + " баров"; refreshBtn.disabled = false; setStatus("Сектор выбран ✅ Теперь нажми «Обновить сектор», чтобы загрузить свечи и посчитать RS.", "ok"); } catch(e){ setStatus("Ошибка загрузки сектора: " + (e.message || e), "error"); clearTable("Ошибка. Проверь Worker URL и пароль."); refreshBtn.disabled = true; } } async function refreshSector(){ if(!currentSectorId) return; refreshBtn.disabled = true; setStatus("Считаю RS по сектору " + currentSectorName + "… (период " + selectedLookback + " баров)", "ok"); try{ const data = await apiGet("/rs", { sector: currentSectorId, lookback: String(selectedLookback) }); if(!data.ok){ setStatus("RS вернул ошибку: " + (data.error || "unknown"), "error"); refreshBtn.disabled = false; return; } lastRsData = data; applyResultsToTable(data); renderExcluded(data.excluded || []); if(matrixVisible) renderMatrix(data); setStatus("Готово ✅ RS посчитан. (Сектор: " + currentSectorName + ", период: " + selectedLookback + ")", "ok"); } catch(e){ setStatus("Ошибка расчёта RS: " + (e.message || e), "error"); } finally { refreshBtn.disabled = false; } } async function loadUniverseAndFillDatalist(){ try{ const data = await apiGet("/universe"); const tickers = data.tickers || []; universeMap = new Map(); datalistEl.innerHTML = ""; const countsBySector = new Map(); (data.sectors || []).forEach(s => countsBySector.set(s.id, 0)); tickers.forEach(it => { universeMap.set(it.ticker, it); const opt = document.createElement("option"); opt.value = it.ticker + (it.shortname ? " — " + it.shortname : ""); datalistEl.appendChild(opt); (it.sectors || []).forEach(s => { countsBySector.set(s.id, (countsBySector.get(s.id) || 0) + 1); }); }); return countsBySector; } catch(e){ setStatus("Не удалось загрузить список компаний (universe): " + (e.message || e), "error"); return null; } } async function initApp(){ setStatus("Загружаю сектора…", "ok"); clearTable("Загружаю…"); let sectorsData; try{ sectorsData = await apiGet("/sectors"); } catch(e){ setStatus("Ошибка /sectors: " + (e.message || e), "error"); clearTable("Ошибка. Проверь Worker URL и пароль."); return; } const countsBySector = await loadUniverseAndFillDatalist(); renderSectorButtons(sectorsData.sectors || [], countsBySector); clearTable("Выбери сектор слева…"); setStatus("Готово ✅ Выбери сектор слева. Потом нажми «Обновить сектор».", "ok"); } // ========================================================== // LOGIN // ========================================================== function showApp(){ overlay.style.display = "none"; app.style.display = "block"; app.setAttribute("aria-hidden", "false"); } function showLogin(){ overlay.style.display = "flex"; app.style.display = "none"; app.setAttribute("aria-hidden", "true"); passInput.value = ""; loginHint.textContent = ""; } function isAuthed(){ return localStorage.getItem(LS_KEY) === "1"; } function setAuthed(flag){ localStorage.setItem(LS_KEY, flag ? "1" : "0"); } showPassCb.addEventListener("change", function(){ passInput.type = showPassCb.checked ? "text" : "password"; }); loginBtn.addEventListener("click", function(){ const pw = configuredPass(); if(!pw){ loginHint.textContent = "⚠️ Пароль не настроен. В JS замени PASSWORD на свой пароль и опубликуй страницу."; loginHint.style.color = "#b45309"; return; } const entered = normPass(passInput.value); if(entered === pw){ setAuthed(true); showApp(); initApp(); } else { loginHint.textContent = "❌ Неверный пароль. Проверь раскладку/пробелы. (Можно включить «Показать пароль»)"; loginHint.style.color = "#ef4444"; } }); passInput.addEventListener("keydown", function(e){ if(e.key === "Enter") loginBtn.click(); }); logoutBtn.addEventListener("click", function(){ setAuthed(false); showLogin(); }); // ========================================================== // UI EVENTS // ========================================================== refreshBtn.addEventListener("click", refreshSector); periodsEl.addEventListener("click", function(e){ const btn = e.target.closest(".moexrs__btn--pill"); if(!btn) return; selectedLookback = Number(btn.dataset.lookback); setActiveLookbackBtn(selectedLookback); if(currentSectorId){ sectorMetaEl.textContent = `Компании: ${constituents.length} · период: ${selectedLookback} баров (нажми “Обновить сектор”)`; } }); findBtn.addEventListener("click", async function(){ const ticker = parseTickerFromInput(searchInput.value); if(!ticker) return; const info = universeMap.get(ticker); if(!info){ setStatus("Тикер не найден в списке MOEX-секторов: " + ticker, "error"); return; } const sec = (info.sectors && info.sectors.length) ? info.sectors[0] : null; if(!sec){ setStatus("Тикер найден, но без сектора: " + ticker, "error"); return; } await selectSector(sec.id, sec.name); setTimeout(() => scrollToTicker(ticker), 200); }); searchInput.addEventListener("keydown", function(e){ if(e.key === "Enter") findBtn.click(); }); toggleMatrixBtn.addEventListener("click", function(){ matrixVisible = !matrixVisible; matrixWrap.classList.toggle("is-hidden", !matrixVisible); toggleMatrixBtn.textContent = matrixVisible ? "Матрица: скрыть" : "Матрица: показать"; if(matrixVisible){ if(lastRsData){ renderMatrix(lastRsData); } else { matrixScroll.innerHTML = "
Нажми «Обновить сектор», чтобы построить матрицу.
"; } } }); // ========================================================== // START // ========================================================== setActiveLookbackBtn(selectedLookback); if(isAuthed()){ showApp(); initApp(); } else { showLogin(); } })();