initial commit

This commit is contained in:
2026-06-25 21:26:53 +00:00
commit e5a1511098
68 changed files with 186669 additions and 0 deletions
+629
View File
@@ -0,0 +1,629 @@
/* 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();
});