Files
EFTCOMPANION/top_spreads.js
T
2026-06-25 21:26:53 +00:00

630 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 3080)
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 = `
<tr>
<th scope="col">#</th>
<th scope="col">Item</th>
<th scope="col" id="sort-col" data-key="gap" style="cursor:pointer">Gap ₽</th>
<th scope="col">Low24h ₽</th>
<th scope="col">High24h ₽</th>
<th scope="col">Avg24h ₽</th>
<th scope="col">Under Avg ₽</th>
<th scope="col">Best Trader Buy ₽</th>
<th scope="col">Best Trader Sell ₽</th>
<th scope="col">Offers</th>
</tr>
`;
} else {
thead.innerHTML = `
<tr>
<th scope="col">#</th>
<th scope="col">Item</th>
<th scope="col" id="sort-col" data-key="profit" style="cursor:pointer">Profit ₽</th>
<th scope="col">Trader Buy ₽</th>
<th scope="col">Trader (LL)</th>
<th scope="col">Flea Sell (after fee) ₽</th>
<th scope="col">Avg24h ₽</th>
<th scope="col">Under Avg ₽</th>
<th scope="col">Offers</th>
</tr>
`;
}
}
function renderBody(mode, rows) {
const tbody = byId('spread-tbody');
tbody.innerHTML = '';
rows.forEach((r, i) => {
let inner = '';
if (mode === 'spread') {
inner = `
<td>${i + 1}</td>
<td>${r.name}</td>
<td data-value="${r.gap}">${fmtRUB(r.gap)}</td>
<td>${fmtRUB(r.low24h)}</td>
<td>${fmtRUB(r.high24h)}</td>
<td>${fmtRUB(r.avg24h)}</td>
<td>${fmtRUB(r.underAvg)}</td>
<td>${fmtRUB(r.traderBuy)}</td>
<td>${fmtRUB(r.traderSell)}</td>
<td>${r.offers ?? ''}</td>
`;
} else {
const traderLLText = `${r.traderName}${Number.isFinite(r.traderLL) ? ` (LL${r.traderLL})` : ' (LL)'}`;
inner = `
<td>${i + 1}</td>
<td>${r.name}</td>
<td data-value="${r.profit}">${fmtRUB(r.profit)}</td>
<td>${fmtRUB(r.traderBuy)}</td>
<td>${traderLLText}</td>
<td>${fmtRUB(r.fleaSell)}</td>
<td>${fmtRUB(r.avg24h)}</td>
<td>${fmtRUB(r.underAvg)}</td>
<td>${r.offers ?? ''}</td>
`;
}
const tr = document.createElement('tr');
tr.innerHTML = inner.trim();
tbody.appendChild(tr);
});
}
function wireSort(mode, rows) {
const sortCol = byId('spread-thead').querySelector('#sort-col');
if (!sortCol) return;
let desc = true;
sortCol.addEventListener('click', () => {
desc = !desc;
const key = sortCol.dataset.key;
rows.sort((a, b) => desc ? (b[key] - a[key]) : (a[key] - b[key]));
renderBody(mode, rows);
});
}
/* =========================
Settings Drawer (injected)
========================= */
function injectSettingsUI() {
// Add "Settings" button to the existing .controls row (created in your HTML)
const controls = document.querySelector('.controls');
if (controls && !byId('btn-settings')) {
const btn = document.createElement('button');
btn.id = 'btn-settings';
btn.className = 'mode-btn';
btn.type = 'button';
btn.textContent = 'Settings ⚙️';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const drawer = byId('settings-drawer');
const open = drawer?.classList.contains('open');
if (open) closeSettings(); else openSettings();
});
controls.appendChild(btn);
// Add a small badge to show active game mode
const badge = document.createElement('span');
badge.id = 'mode-badge';
badge.style.marginLeft = '0.5rem';
badge.style.opacity = '0.8';
badge.textContent = `Mode: ${SETTINGS.gameMode.toUpperCase()}`;
controls.appendChild(badge);
}
// Inject drawer container once
if (!byId('settings-drawer')) {
const drawer = document.createElement('div');
drawer.id = 'settings-drawer';
drawer.style.position = 'fixed';
drawer.style.right = '1rem';
drawer.style.top = '5rem';
drawer.style.width = '340px';
drawer.style.maxWidth = '95vw';
drawer.style.border = '1px solid rgba(0,255,102,.35)';
drawer.style.background = '#0b0b0b';
drawer.style.boxShadow = '0 8px 24px rgba(0,0,0,.6), 0 0 14px rgba(0,255,102,.25)';
drawer.style.borderRadius = '8px';
drawer.style.padding = '12px 14px';
drawer.style.zIndex = '9999';
drawer.style.display = 'none';
drawer.innerHTML = `
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;">
<strong style="font-size:1.05rem;">Price Watch Settings</strong>
<span style="margin-left:auto;opacity:.7;">PvE/PvP + filters</span>
<button id="btn-close-settings" class="mode-btn" style="padding:.25rem .5rem;">✕</button>
</div>
<div style="display:flex;flex-direction:column;gap:.5rem;">
<label>
<span>Game Mode</span><br/>
<label style="margin-right:1rem;">
<input type="radio" name="gm" value="regular"> PvP (Regular)
</label>
<label>
<input type="radio" name="gm" value="pve"> PvE
</label>
</label>
<label>
<span>Min Offers (liquidity gate)</span><br/>
<input id="inp-min-offers" type="number" min="0" step="1" style="width:6rem;">
</label>
<label>
<span>Max Spread Ratio (high/low cap)</span><br/>
<input id="inp-max-spread" type="number" min="0" step="0.05" style="width:6rem;">
</label>
<label>
<span>Max Avg/Low Ratio (skew guard)</span><br/>
<input id="inp-max-skew" type="number" min="0" step="0.01" style="width:6rem;">
</label>
<label>
<input id="chk-fee" type="checkbox"> Apply Flea Fee
</label>
<label>
<span>Flea Fee % (if applied)</span><br/>
<input id="inp-fee-pct" type="number" min="0" step="0.5" style="width:6rem;"> %
</label>
</div>
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.75rem;">
<button id="btn-reset-settings" class="mode-btn">Reset</button>
<button id="btn-apply-settings" class="mode-btn is-active">Apply & Refresh</button>
</div>
`;
document.body.appendChild(drawer);
// Wire close button
byId('btn-close-settings').addEventListener('click', () => closeSettings());
// Initialize control values from SETTINGS
const gmRadios = drawer.querySelectorAll('input[name="gm"]');
gmRadios.forEach(r => r.checked = (r.value === SETTINGS.gameMode));
byId('inp-min-offers').value = SETTINGS.minOffers;
byId('inp-max-spread').value = SETTINGS.maxSpreadRatio;
byId('inp-max-skew').value = SETTINGS.maxAvgToLow;
byId('chk-fee').checked = !!SETTINGS.applyFleaFee;
byId('inp-fee-pct').value = SETTINGS.feePercent;
// Wire Apply
byId('btn-apply-settings').addEventListener('click', async () => {
const newSettings = {
...SETTINGS,
gameMode: [...gmRadios].find(r => r.checked)?.value || SETTINGS.gameMode,
minOffers: Math.max(0, parseInt(byId('inp-min-offers').value || DEFAULT_SETTINGS.minOffers, 10)),
maxSpreadRatio: Math.max(0, parseFloat(byId('inp-max-spread').value || DEFAULT_SETTINGS.maxSpreadRatio)),
maxAvgToLow: Math.max(0, parseFloat(byId('inp-max-skew').value || DEFAULT_SETTINGS.maxAvgToLow)),
applyFleaFee: byId('chk-fee').checked,
feePercent: Math.max(0, parseFloat(byId('inp-fee-pct').value || DEFAULT_SETTINGS.feePercent))
};
SETTINGS = newSettings;
saveSettings(SETTINGS);
updateModeBadge();
closeSettings();
await refreshData(); // refetch if game mode changed; otherwise re-render
});
// Wire Reset
byId('btn-reset-settings').addEventListener('click', () => {
SETTINGS = { ...DEFAULT_SETTINGS };
saveSettings(SETTINGS);
// Reset UI fields
gmRadios.forEach(r => r.checked = (r.value === SETTINGS.gameMode));
byId('inp-min-offers').value = SETTINGS.minOffers;
byId('inp-max-spread').value = SETTINGS.maxSpreadRatio;
byId('inp-max-skew').value = SETTINGS.maxAvgToLow;
byId('chk-fee').checked = !!SETTINGS.applyFleaFee;
byId('inp-fee-pct').value = SETTINGS.feePercent;
updateModeBadge();
});
}
}
function openSettings() {
const drawer = byId('settings-drawer');
const btn = byId('btn-settings');
if (!drawer) return;
drawer.style.display = 'block';
drawer.classList.add('open');
btn?.setAttribute('aria-expanded', 'true');
}
function closeSettings() {
const drawer = byId('settings-drawer');
const btn = byId('btn-settings');
if (!drawer) return;
drawer.style.display = 'none';
drawer.classList.remove('open');
btn?.setAttribute('aria-expanded', 'false');
}
function updateModeBadge() {
const badge = byId('mode-badge');
if (badge) badge.textContent = `Mode: ${SETTINGS.gameMode.toUpperCase()}`;
}
/* =========================
Controller: render & refresh
========================= */
function setMode(viewMode) {
if (viewMode !== 'spread' && viewMode !== 'trader') return;
currentMode = viewMode;
// Toggle buttons in your existing controls
$('#toggle-spread')?.classList.toggle('is-active', viewMode === 'spread');
$('#toggle-trader')?.classList.toggle('is-active', viewMode === 'trader');
$('#toggle-spread')?.setAttribute('aria-pressed', String(viewMode === 'spread'));
$('#toggle-trader')?.setAttribute('aria-pressed', String(viewMode === 'trader'));
byId('spread-caption').textContent = viewMode === 'spread'
? 'Top 100 flea spreads (High Low). Data: tarkov.dev'
: 'Top 100 Trader → Flea flips (net flea after fee best trader buy). Data: tarkov.dev';
if (!itemsCache) return;
const rows = (viewMode === 'spread') ? computeSpreadRows(itemsCache) : computeTraderFlipRows(itemsCache);
currentRows = rows;
renderHead(viewMode);
renderBody(viewMode, rows);
wireSort(viewMode, rows);
byId('spread-table').hidden = rows.length === 0;
}
async function refreshData() {
const status = byId('status');
const table = byId('spread-table');
const lastUpdated = byId('last-updated');
// Try cache first for this game mode
const cached = readCache(SETTINGS.gameMode);
if (cached && cached.length) {
itemsCache = cached;
setMode(currentMode);
table.hidden = false;
status.textContent = 'Loaded from cache. Refreshing…';
}
// Always attempt live refresh for the selected mode
try {
const items = await fetchItems(SETTINGS.gameMode);
itemsCache = items;
setMode(currentMode);
status.textContent = `Showing top ${currentRows.length} items. Updated just now.`;
lastUpdated.textContent = `Updated: ${nowFmt(new Date())}`;
table.hidden = false;
} catch (err) {
console.error(err);
if (!itemsCache) {
status.textContent = 'Failed to load price data. Please try again.';
table.hidden = true;
} else {
status.textContent = 'Using cached data (latest refresh failed).';
}
}
}
/* =========================
Boot
========================= */
document.addEventListener('DOMContentLoaded', () => {
// Wire existing view toggles from your HTML
$('#toggle-spread')?.addEventListener('click', () => setMode('spread'));
$('#toggle-trader')?.addEventListener('click', () => setMode('trader'));
// Inject settings UI and show current mode badge
injectSettingsUI();
updateModeBadge();
// Initial load
refreshData();
});