/* top_spreads.js * Price Watch with Settings Drawer + PvE/PvP game mode switch * Trader → Flea view includes Trader name + required Loyalty Level (LL) * Adds “Under Avg ₽” column based on current (lastLow → low24h) vs avg24h * Flea Market is NEVER treated as a trader. Unknown sources are skipped. */ /* ========================= Configuration & defaults ========================= */ const ENDPOINT = 'https://api.tarkov.dev/graphql'; // Tarkov.dev GraphQL const CACHE_KEY = 'tarkov_items_cache_v1'; const CACHE_MS = 5 * 60 * 1000; // 5 minutes // Recognized traders (strict allow-list) const TRADERS = new Set([ 'Prapor', 'Therapist', 'Fence', 'Skier', 'Peacekeeper', 'Mechanic', 'Ragman', 'Jaeger' ]); // Persisted settings const SETTINGS_KEY = 'tarkov_spread_settings_v1'; const DEFAULT_SETTINGS = { // Denoising / realism gates minOffers: 30, // gate thin markets (0 disables; try 30–80) maxSpreadRatio: 2.0, // reject if high24h / low24h > X maxAvgToLow: 1.25, // reject if avg24h / lastLow > X // Flea fee approximation (rough % cut to avoid overstating profit) applyFleaFee: true, feePercent: 9, // displayed as percent; converted to 0.09 // Game mode: 'regular' (PvP economy) or 'pve' gameMode: 'regular' }; // UI state (not persisted) let itemsCache = null; // raw items from GraphQL let currentMode = 'spread'; // 'spread' | 'trader' let currentRows = []; /* ========================= DOM helpers / formatting ========================= */ const $ = (sel) => document.querySelector(sel); function byId(id) { return document.getElementById(id); } function asInt(n) { return Number.isFinite(n) ? Math.trunc(n) : null; } function fmtRUB(n) { return Number.isFinite(n) ? n.toLocaleString('en-US') : '–'; } function nowFmt(d = new Date()) { return d.toLocaleString([], { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }); } function safeDiv(a, b) { return (Number.isFinite(a) && Number.isFinite(b) && b !== 0) ? (a / b) : null; } /* ========================= Settings load/save ========================= */ function loadSettings() { try { const raw = localStorage.getItem(SETTINGS_KEY); if (!raw) return { ...DEFAULT_SETTINGS }; const saved = JSON.parse(raw); return { ...DEFAULT_SETTINGS, ...saved }; } catch { return { ...DEFAULT_SETTINGS }; } } function saveSettings(settings) { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch {} } let SETTINGS = loadSettings(); /* ========================= GraphQL query & fetch ========================= */ const QUERY = ` query ItemsWithPrices($mode: GameMode) { items(lang: en, gameMode: $mode) { id name shortName basePrice lastLowPrice low24hPrice high24hPrice avg24hPrice lastOfferCount buyFor { source currency price priceRUB vendor { name } requirements { type value } # parsed for loyalty level } sellFor { source currency price priceRUB vendor { name } } } } `; async function fetchItems(gameMode = 'regular') { const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: QUERY, variables: { mode: gameMode } }) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); const items = json?.data?.items ?? []; writeCache(gameMode, items); return items; } /* ========================= Cache (per game mode) ========================= */ function readCache(gameMode) { try { const raw = localStorage.getItem(CACHE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); const pack = parsed[gameMode]; if (!pack) return null; if (Date.now() - (pack.savedAt || 0) > CACHE_MS) return null; return pack.items; } catch { return null; } } function writeCache(gameMode, items) { try { const raw = localStorage.getItem(CACHE_KEY); const parsed = raw ? JSON.parse(raw) : {}; parsed[gameMode] = { savedAt: Date.now(), items }; localStorage.setItem(CACHE_KEY, JSON.stringify(parsed)); } catch {} } /* ========================= Price helpers ========================= */ function priceRUB(p) { if (!p) return null; if (Number.isFinite(p.priceRUB)) return p.priceRUB; if (p.currency === 'RUB' && Number.isFinite(p.price)) return p.price; return null; } function normalizedTraderName(offer) { const n = offer?.vendor?.name || offer?.source || null; if (!n || n === 'Flea Market') return null; return TRADERS.has(n) ? n : null; } // Only real traders: exclude Flea and unknown sources; require a usable RUB price function bestTraderBuyOffer(item) { const offers = (item.buyFor || []).filter(o => { if (!o || o.source === 'Flea Market') return false; const name = normalizedTraderName(o); const rub = priceRUB(o); return name && Number.isFinite(rub); }); let best = null; for (const o of offers) { const rub = priceRUB(o); if (!best || rub < priceRUB(best)) best = o; } return best; // may be null if no acceptable trader offer exists } function bestTraderSellRUB(item) { const arr = (item.sellFor || []) .filter(o => o.source !== 'Flea Market' && normalizedTraderName(o)) .map(priceRUB) .filter(Number.isFinite); return arr.length ? Math.max(...arr) : null; } function conservativeFleaSell(item) { // Very conservative: lastLowPrice -> low24hPrice -> avg24hPrice return item.lastLowPrice ?? item.low24hPrice ?? item.avg24hPrice ?? null; } // Extract name + loyalty (LL). If unknown or flea, return nulls so the row can be skipped. function extractTraderInfo(offer) { const name = normalizedTraderName(offer); if (!name) return { name: null, loyalty: null }; const req = (offer.requirements || []).find( r => String(r.type || '').toLowerCase().includes('loyalty') ); const loyaltyNum = req ? Number.parseInt(req.value, 10) : null; return { name, loyalty: Number.isFinite(loyaltyNum) ? loyaltyNum : null }; } function estimateFleaFee(listPriceRUB) { if (!SETTINGS.applyFleaFee || !Number.isFinite(listPriceRUB)) return 0; const factor = Math.max(0, (SETTINGS.feePercent || 0)) / 100; return Math.floor(listPriceRUB * factor); } // --- New: Under-Avg helper function underAvgDiff(item) { const avg = asInt(item.avg24hPrice ?? null); const current = asInt(item.lastLowPrice ?? item.low24hPrice ?? null); if (!Number.isFinite(avg) || !Number.isFinite(current)) return null; if (current >= avg) return null; return avg - current; // positive number meaning “current is X under average” } /* ========================= Computations ========================= */ function computeSpreadRows(items) { const rows = []; const { minOffers, maxSpreadRatio, maxAvgToLow } = SETTINGS; for (const it of items) { const offers = it.lastOfferCount ?? 0; if (offers < minOffers) continue; const low = asInt(it.low24hPrice ?? it.lastLowPrice ?? null); const high = asInt(it.high24hPrice ?? it.avg24hPrice ?? null); if (!Number.isFinite(low) || !Number.isFinite(high)) continue; // Outlier guards const spreadRatio = safeDiv(high, low); if (spreadRatio !== null && spreadRatio > maxSpreadRatio) continue; const consLow = it.lastLowPrice ?? it.low24hPrice ?? null; const avgToLow = safeDiv(it.avg24hPrice ?? null, consLow ?? null); if (avgToLow !== null && avgToLow > maxAvgToLow) continue; const gap = high - low; if (gap <= 0) continue; rows.push({ id: it.id, name: it.name, shortName: it.shortName, gap, low24h: low, high24h: high, avg24h: asInt(it.avg24hPrice ?? null), underAvg: underAvgDiff(it), // NEW traderBuy: asInt(priceRUB(bestTraderBuyOffer(it))), traderSell: asInt(bestTraderSellRUB(it)), offers }); } rows.sort((a, b) => b.gap - a.gap); return rows.slice(0, 100); } function computeTraderFlipRows(items) { const rows = []; const { minOffers, maxAvgToLow } = SETTINGS; for (const it of items) { const offers = it.lastOfferCount ?? 0; if (offers < minOffers) continue; const bestOffer = bestTraderBuyOffer(it); // Strict trader-only offer const traderBuy = asInt(priceRUB(bestOffer)); // Buy from trader (RUB) const fleaSellRaw = asInt(conservativeFleaSell(it));// Conservative flea sell target if (!Number.isFinite(traderBuy) || !Number.isFinite(fleaSellRaw)) continue; // Derive trader name + LL, and skip if we can't identify a known trader const tInfo = extractTraderInfo(bestOffer); if (!tInfo.name) continue; // Apply fee estimate to flea sell side const fee = estimateFleaFee(fleaSellRaw); const netFleaSell = fleaSellRaw - fee; // Guard skew (inflated averages) const consLow = it.lastLowPrice ?? it.low24hPrice ?? null; const avgToLow = safeDiv(it.avg24hPrice ?? null, consLow ?? null); if (avgToLow !== null && avgToLow > maxAvgToLow) continue; const profit = netFleaSell - traderBuy; if (profit <= 0) continue; rows.push({ id: it.id, name: it.name, shortName: it.shortName, profit, traderBuy, traderName: tInfo.name, traderLL: tInfo.loyalty, fleaSell: netFleaSell, // net after fee avg24h: asInt(it.avg24hPrice ?? null), underAvg: underAvgDiff(it), // NEW offers }); } rows.sort((a, b) => b.profit - a.profit); return rows.slice(0, 100); } /* ========================= Rendering (table) ========================= */ function renderHead(mode) { const thead = byId('spread-thead'); if (mode === 'spread') { thead.innerHTML = `