Virtual trading simulator sidebar for Polymarket with unified settlement, watchlist, alerts, and backups
// ==UserScript==
// @name Paper
// @namespace http://tampermonkey.net/
// @version 3.2.0
// @description Virtual trading simulator sidebar for Polymarket with unified settlement, watchlist, alerts, and backups
// @match https://polymarket.com/*
// @match https://www.polymarket.com/*
// @match https://gamma.polymarket.com/*
// @match https://clob.polymarket.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_notification
// @connect gamma-api.polymarket.com
// @connect clob.polymarket.com
// @connect gamma.polymarket.com
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function (root) {
'use strict';
const STORAGE_KEY = 'polymarket_sim_v2';
const UI_KEY = 'polymarket_sim_ui_v2';
const MAX_HISTORY = 300;
const MARKET_UI_THROTTLE_MS = 450;
const PORTFOLIO_UI_DELAY_MS = 140;
const MARKET_CACHE_MS = 300000;
const MARKET_PRICE_CACHE_MS = 1200;
const GAMMA_BASE = 'https://gamma-api.polymarket.com';
const CLOB_BASE = 'https://clob.polymarket.com';
const PRICE_TYPES = ['BUY', 'SELL', 'MID'];
const HOLDINGS_SORTS = ['pnl_desc', 'value_desc', 'qty_desc', 'newest'];
const PANEL_WIDTH = { DEFAULT: 760, MIN: 320, MAX: 760 };
const PANEL_EDGE_OFFSET = 12;
const PANEL_SNAP_GAP = 18;
const MAIN_TABS = ['market', 'holdings', 'watchlist', 'history', 'alerts', 'settings'];
function noop() {}
function deepClone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function roundNumber(value, digits) {
if (!Number.isFinite(Number(value))) return 0;
const factor = 10 ** (digits == null ? 6 : digits);
return Math.round(Number(value) * factor) / factor;
}
function roundMoney(value) {
return roundNumber(value, 6);
}
function clampPanelWidth(value) {
return Math.max(PANEL_WIDTH.MIN, Math.min(PANEL_WIDTH.MAX, Number(value) || PANEL_WIDTH.DEFAULT));
}
function getDefaultPanelHeight() {
const viewportHeight = Math.max(root.innerHeight || 0, 640);
return Math.max(500, Math.min(980, Math.round(viewportHeight * 0.84)));
}
function clampPanelHeight(value) {
const viewportHeight = Math.max(root.innerHeight || 0, 640);
const maxHeight = Math.max(420, Math.min(960, viewportHeight - 20));
return Math.max(360, Math.min(maxHeight, Number(value) || getDefaultPanelHeight()));
}
function clampPanelPosition(left, top, width, height) {
const viewportWidth = Math.max(root.innerWidth || 0, 320);
const viewportHeight = Math.max(root.innerHeight || 0, 240);
const maxLeft = Math.max(0, viewportWidth - width - 8);
const maxTop = Math.max(0, viewportHeight - Math.min(height, viewportHeight) - 8);
return {
left: Math.max(0, Math.min(maxLeft, left)),
top: Math.max(0, Math.min(maxTop, top))
};
}
function snapPanelPosition(left, top, width, height) {
const clamped = clampPanelPosition(left, top, width, height);
const viewportWidth = Math.max(root.innerWidth || 0, 320);
const rightDistance = Math.abs((viewportWidth - width - PANEL_EDGE_OFFSET) - clamped.left);
const leftDistance = Math.abs(PANEL_EDGE_OFFSET - clamped.left);
let snappedLeft = clamped.left;
if (leftDistance <= PANEL_SNAP_GAP) snappedLeft = PANEL_EDGE_OFFSET;
else if (rightDistance <= PANEL_SNAP_GAP) snappedLeft = Math.max(0, viewportWidth - width - PANEL_EDGE_OFFSET);
return clampPanelPosition(snappedLeft, clamped.top, width, height);
}
function pulseLiveValue(node, direction) {
if (!node) return;
node.classList.remove('psim-tick-up', 'psim-tick-down');
void node.offsetWidth;
node.classList.add(direction === 'down' ? 'psim-tick-down' : 'psim-tick-up');
root.setTimeout(() => {
node.classList.remove('psim-tick-up', 'psim-tick-down');
}, 320);
}
function updateLiveText(node, nextText, numericValue, options) {
if (!node) return;
const config = options || {};
const prevText = node.textContent;
const prevValue = Number(node.dataset.value);
if (prevText === nextText) return;
node.textContent = nextText;
if (Number.isFinite(numericValue)) {
node.dataset.value = String(numericValue);
if (config.pulse !== false && Number.isFinite(prevValue) && prevValue !== numericValue) {
pulseLiveValue(node, numericValue < prevValue ? 'down' : 'up');
}
}
}
function bindOrderSizeControls(scope) {
const rootNode = scope || root.document;
const orderQtyInput = rootNode.querySelector('#psim-order-qty');
if (!orderQtyInput) return;
const chips = Array.from(rootNode.querySelectorAll('[data-order-size]'));
const setOrderQuantity = (value) => {
const nextQty = roundNumber(normalizePositiveNumber(value, getState().settings.defaultQuantity), 6);
engine.patchUI({ orderQuantity: nextQty });
orderQtyInput.value = String(nextQty);
chips.forEach((chip) => {
const chipValue = roundNumber(normalizePositiveNumber(chip.dataset.orderSize, 0), 6);
chip.classList.toggle('active', Math.abs(chipValue - nextQty) < 0.000001);
});
return nextQty;
};
const persistOrderQty = () => setOrderQuantity(orderQtyInput.value);
orderQtyInput.onchange = persistOrderQty;
orderQtyInput.onblur = persistOrderQty;
chips.forEach((btn) => {
btn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
setOrderQuantity(btn.dataset.orderSize);
};
});
}
function normalizePositiveNumber(value, fallback) {
const parsed = typeof value === 'number' ? value : parseFloat(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback == null ? 0 : fallback;
return parsed;
}
function normalizeBoolean(value, fallback) {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return !!fallback;
}
function generateId(prefix) {
return `${prefix || 'id'}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function truncate(str, len) {
const text = str == null ? '' : String(str);
return text.length > len ? `${text.slice(0, len)}...` : text;
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function fmt$(value) {
return `$${roundNumber(value || 0, 4).toFixed(2)}`;
}
function fmtPct(value) {
return `${roundNumber(value || 0, 2).toFixed(2)}%`;
}
function formatTime(value) {
try {
return new Date(value).toLocaleTimeString();
} catch (_err) {
return '';
}
}
function formatHistoryAction(action) {
if (action === 'BUY') return '买入';
if (action === 'SELL') return '减仓';
if (action === 'CLOSE') return '平仓';
if (action === 'CLOSE_ALL') return '全平';
return action || '—';
}
function getOutcomeSide(outcome) {
return String(outcome || '').startsWith('NO: ') ? 'NO' : 'YES';
}
function getOutcomeDisplayName(outcome) {
const text = String(outcome || 'Unknown');
return text.startsWith('NO: ') ? text.slice(4) : text;
}
function getOrderQuantity(uiState, stateSnapshot) {
return roundNumber(normalizePositiveNumber(uiState && uiState.orderQuantity, stateSnapshot && stateSnapshot.settings && stateSnapshot.settings.defaultQuantity), 6);
}
function getOrderSizePresets(stateSnapshot) {
const sizes = [1, 5, 10, 25, 50, stateSnapshot && stateSnapshot.settings ? stateSnapshot.settings.defaultQuantity : 10];
return [...new Set(sizes.map((value) => roundNumber(normalizePositiveNumber(value, 0), 6)).filter((value) => value > 0))].sort((left, right) => left - right);
}
function renderSideBadge(side, compact) {
const normalized = side === 'NO' ? 'NO' : 'YES';
return `<span class="psim-side-badge psim-side-${normalized.toLowerCase()}${compact ? ' psim-side-compact' : ''}">${normalized}</span>`;
}
function normalizeMainTab(value) {
return MAIN_TABS.includes(value) ? value : 'market';
}
function parseArrayish(value) {
if (Array.isArray(value)) return value;
if (typeof value !== 'string' || !value.trim()) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
} catch (_err) {
// noop
}
return value.split(',').map((item) => item.trim()).filter(Boolean);
}
function normalizeOutcomeList(value, fallbackConditionId) {
const parsed = parseArrayish(value);
if (parsed.length > 0) return parsed;
return fallbackConditionId ? ['Yes', 'No'] : [];
}
function normalizeTokenIdList(raw) {
if (Array.isArray(raw)) return raw.filter(Boolean);
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.filter(Boolean);
} catch (_err) {
return raw.split(',').map((item) => item.trim()).filter(Boolean);
}
}
return [];
}
function defaultState() {
return {
initialBalance: 100,
cash: 100,
realizedPnl: 0,
positions: [],
history: [],
alerts: [],
watchlist: [],
settings: {
refreshSeconds: 5,
defaultQuantity: 10,
feeRate: 0.002,
slippageRate: 0.01,
soundEnabled: true,
darkMode: true
}
};
}
function defaultUI() {
return {
collapsed: false,
panelX: 0,
panelY: 0,
historyOpen: false,
settingsOpen: false,
alertsOpen: false,
watchlistOpen: true,
searchQuery: '',
searchResults: [],
searchOffset: 0,
searchHasMore: false,
pendingResetConfirm: false,
selectedSearchMarket: null,
alertDraft: null,
orderQuantity: defaultState().settings.defaultQuantity,
activeTab: 'market',
holdingsSort: 'pnl_desc',
panelWidth: PANEL_WIDTH.DEFAULT,
panelHeight: getDefaultPanelHeight(),
binarySide: 'YES'
};
}
function hydratePosition(raw) {
if (!raw || !raw.tokenId) return null;
const parsedQuantity = Number(raw.quantity);
const quantity = Number.isFinite(parsedQuantity) ? roundNumber(Math.max(0, parsedQuantity), 6) : 0;
return {
id: raw.id || generateId('pos'),
marketSlug: raw.marketSlug || '',
marketTitle: raw.marketTitle || '',
tokenId: raw.tokenId,
quantity,
avgPrice: roundNumber(Math.max(0, Number(raw.avgPrice) || 0), 6),
outcome: raw.outcome || 'Unknown',
openedAt: raw.openedAt || new Date().toISOString(),
lastPrice: raw.lastPrice == null || raw.lastPrice === '' ? null : roundNumber(Number(raw.lastPrice), 6),
lastPriceType: PRICE_TYPES.includes(raw.lastPriceType) ? raw.lastPriceType : 'SELL'
};
}
function hydrateAlert(raw) {
if (!raw || !raw.tokenId) return null;
return {
id: raw.id || generateId('alert'),
marketSlug: raw.marketSlug || '',
tokenId: raw.tokenId,
priceTarget: roundNumber(normalizePositiveNumber(raw.priceTarget, 0), 6),
side: raw.side === 'BELOW' ? 'BELOW' : 'ABOVE',
active: raw.active !== false,
outcomeName: raw.outcomeName || 'Unknown',
priceType: PRICE_TYPES.includes(raw.priceType) ? raw.priceType : 'BUY'
};
}
function hydrateWatchlistItem(raw) {
if (!raw || !raw.marketSlug) return null;
return {
id: raw.id || generateId('watch'),
marketSlug: raw.marketSlug,
marketTitle: raw.marketTitle || raw.marketSlug,
addedAt: raw.addedAt || new Date().toISOString(),
collapsed: raw.collapsed || false
};
}
function hydrateHistoryEntry(raw) {
if (!raw) return null;
return {
time: raw.time || new Date().toISOString(),
action: raw.action || 'UNKNOWN',
marketTitle: raw.marketTitle || '',
outcome: raw.outcome || '',
tokenId: raw.tokenId || '',
quantity: roundNumber(Number(raw.quantity) || 0, 6),
price: roundNumber(Number(raw.price) || 0, 6),
grossValue: roundNumber(Number(raw.grossValue) || 0, 6),
fee: roundNumber(Number(raw.fee) || 0, 6),
netValue: roundNumber(Number(raw.netValue) || 0, 6),
pnl: roundNumber(Number(raw.pnl) || 0, 6),
cashAfter: roundNumber(Number(raw.cashAfter) || 0, 6),
realizedAfter: roundNumber(Number(raw.realizedAfter) || 0, 6),
positionAfter: roundNumber(Number(raw.positionAfter) || 0, 6)
};
}
function hydrateState(raw) {
const def = defaultState();
const source = raw && typeof raw === 'object' ? raw : {};
const settings = {
...def.settings,
...(source.settings || {})
};
settings.refreshSeconds = Math.min(60, Math.max(2, Math.round(normalizePositiveNumber(settings.refreshSeconds, def.settings.refreshSeconds))));
settings.defaultQuantity = Math.max(1, roundNumber(normalizePositiveNumber(settings.defaultQuantity, def.settings.defaultQuantity), 6));
settings.feeRate = Math.max(0, roundNumber(Number(settings.feeRate) || 0, 6));
settings.slippageRate = Math.max(0, roundNumber(Number(settings.slippageRate) || 0, 6));
settings.soundEnabled = normalizeBoolean(settings.soundEnabled, def.settings.soundEnabled);
settings.darkMode = normalizeBoolean(settings.darkMode, def.settings.darkMode);
return {
initialBalance: roundMoney(normalizePositiveNumber(source.initialBalance, def.initialBalance)),
cash: roundMoney(Number(source.cash != null ? source.cash : def.cash) || 0),
realizedPnl: roundMoney(Number(source.realizedPnl != null ? source.realizedPnl : def.realizedPnl) || 0),
positions: Array.isArray(source.positions) ? source.positions.map(hydratePosition).filter(Boolean) : [],
history: Array.isArray(source.history) ? source.history.map(hydrateHistoryEntry).filter(Boolean).slice(0, MAX_HISTORY) : [],
alerts: Array.isArray(source.alerts) ? source.alerts.map(hydrateAlert).filter(Boolean) : [],
watchlist: Array.isArray(source.watchlist) ? source.watchlist.map(hydrateWatchlistItem).filter(Boolean) : [],
settings
};
}
function normalizeAlertDraft(raw) {
if (!raw || !raw.tokenId) return null;
const priceTarget = Number(raw.priceTarget);
return {
marketSlug: raw.marketSlug || '',
marketTitle: raw.marketTitle || raw.marketSlug || '',
tokenId: raw.tokenId,
outcomeName: raw.outcomeName || 'Unknown',
priceTarget: Number.isFinite(priceTarget) && priceTarget > 0 ? roundNumber(priceTarget, 6) : '',
side: raw.side === 'BELOW' ? 'BELOW' : 'ABOVE',
priceType: PRICE_TYPES.includes(raw.priceType) ? raw.priceType : 'MID'
};
}
function hydrateUI(raw) {
const def = defaultUI();
const source = raw && typeof raw === 'object' ? raw : {};
return {
collapsed: normalizeBoolean(source.collapsed, def.collapsed),
panelX: Math.max(0, Number(source.panelX) || 0),
panelY: Math.max(0, Number(source.panelY) || 0),
historyOpen: normalizeBoolean(source.historyOpen, def.historyOpen),
settingsOpen: normalizeBoolean(source.settingsOpen, def.settingsOpen),
alertsOpen: normalizeBoolean(source.alertsOpen, def.alertsOpen),
watchlistOpen: normalizeBoolean(source.watchlistOpen, def.watchlistOpen),
searchQuery: source.searchQuery ? String(source.searchQuery) : '',
searchResults: Array.isArray(source.searchResults) ? source.searchResults.filter(Boolean) : [],
searchOffset: Math.max(0, parseInt(source.searchOffset, 10) || 0),
searchHasMore: normalizeBoolean(source.searchHasMore, def.searchHasMore),
pendingResetConfirm: normalizeBoolean(source.pendingResetConfirm, def.pendingResetConfirm),
selectedSearchMarket: source.selectedSearchMarket ? String(source.selectedSearchMarket) : null,
alertDraft: normalizeAlertDraft(source.alertDraft),
orderQuantity: roundNumber(normalizePositiveNumber(source.orderQuantity, def.orderQuantity), 6),
activeTab: normalizeMainTab(source.activeTab),
holdingsSort: HOLDINGS_SORTS.includes(source.holdingsSort) ? source.holdingsSort : def.holdingsSort,
panelWidth: clampPanelWidth(source.panelWidth || def.panelWidth),
panelHeight: clampPanelHeight(source.panelHeight || def.panelHeight),
binarySide: source.binarySide === 'NO' ? 'NO' : 'YES'
};
}
function normalizeMarket(raw, fallbackSlug) {
if (!raw) return null;
return {
slug: raw.slug || fallbackSlug || '',
question: raw.question || raw.title || '',
title: raw.title || raw.question || '',
outcomes: normalizeOutcomeList(raw.outcomes, raw.condition_id),
token_ids: normalizeTokenIdList(raw.token_ids || raw.clobTokenIds || (raw.tokens || []).map((token) => token && token.token_id)),
prices: raw.prices || null,
closed: raw.closed || false,
active: raw.active !== false,
event_slug: raw.event_slug || '',
condition_id: raw.condition_id || ''
};
}
function mergeEventMarkets(markets, evt) {
const list = (markets || []).filter(Boolean);
if (list.length === 0) return null;
return {
slug: (evt && evt.slug) || list[0].slug,
question: (evt && evt.title) || list[0].question,
title: (evt && evt.title) || list[0].title,
outcomes: list.map((market, index) => `${index + 1}. ${market.question || market.title || `Outcome ${index + 1}`}`),
token_ids: list.map((market) => (market.token_ids || market.clobTokenIds || [])[0]).filter(Boolean),
prices: null,
closed: list.every((market) => market.closed),
active: list.some((market) => market.active),
event_slug: (evt && evt.slug) || '',
condition_id: '',
_subMarkets: list
};
}
function mergeSearchMarkets(existing, incoming) {
const merged = [];
const seen = new Set();
[...(existing || []), ...(incoming || [])].forEach((item) => {
if (!item || !item.slug || seen.has(item.slug)) return;
seen.add(item.slug);
merged.push({
slug: item.slug,
title: item.title || item.question || item.slug,
marketTitle: item.marketTitle || item.title || item.question || item.slug,
eventTitle: item.eventTitle || '',
question: item.question || item.title || item.slug
});
});
return merged;
}
function getWatchlistSnapshots(priceCacheMap, watchlist, marketLookup) {
return (watchlist || []).map((item) => {
const market = marketLookup && marketLookup[item.marketSlug];
const title = (market && (market.question || market.title)) || item.marketTitle || item.marketSlug;
const tokenId = market ? getOutcomeTokenId(market, 0) : null;
const priceRow = tokenId && priceCacheMap ? priceCacheMap[tokenId] : null;
const snapshot = buildPriceSnapshot(priceRow || {});
return {
...item,
marketTitle: title,
tokenId,
buyPrice: snapshot.BUY,
sellPrice: snapshot.SELL,
midPrice: snapshot.MID
};
});
}
function getOutcomeCount(market) {
if (!market) return 0;
if (Array.isArray(market._subMarkets) && market._subMarkets.length > 0) return market._subMarkets.length;
return Array.isArray(market.outcomes) ? market.outcomes.length : 0;
}
function getOutcomeName(market, index, side) {
if (!market) return `Outcome ${index + 1}`;
if (Array.isArray(market._subMarkets) && market._subMarkets[index]) {
const name = market._subMarkets[index].question || market._subMarkets[index].title || `Outcome ${index + 1}`;
return side === 'NO' ? `NO: ${name}` : name;
}
const name = (market.outcomes || [])[index] || `Outcome ${index + 1}`;
return side === 'NO' ? `NO: ${name}` : name;
}
function getOutcomeTokenId(market, index, side) {
if (!market) return null;
if (Array.isArray(market._subMarkets) && market._subMarkets[index]) {
const sub = market._subMarkets[index];
const tokenIdx = side === 'NO' ? 1 : 0;
return (sub.token_ids || sub.clobTokenIds || [])[tokenIdx] || null;
}
return (market.token_ids || market.clobTokenIds || [])[index] || null;
}
function getMidPrice(priceRow) {
if (!priceRow) return null;
const buy = Number(priceRow.buyPrice);
const sell = Number(priceRow.sellPrice);
if (Number.isFinite(buy) && buy > 0 && Number.isFinite(sell) && sell > 0) {
return roundNumber((buy + sell) / 2, 6);
}
if (Number.isFinite(sell) && sell > 0) return roundNumber(sell, 6);
if (Number.isFinite(buy) && buy > 0) return roundNumber(buy, 6);
return null;
}
function buildPriceSnapshot(input) {
if (!input) return { BUY: null, SELL: null, MID: null };
const buy = Number.isFinite(Number(input.BUY)) ? roundNumber(Number(input.BUY), 6) : (Number.isFinite(Number(input.buyPrice)) ? roundNumber(Number(input.buyPrice), 6) : null);
const sell = Number.isFinite(Number(input.SELL)) ? roundNumber(Number(input.SELL), 6) : (Number.isFinite(Number(input.sellPrice)) ? roundNumber(Number(input.sellPrice), 6) : null);
const mid = Number.isFinite(Number(input.MID)) ? roundNumber(Number(input.MID), 6) : getMidPrice({ buyPrice: buy, sellPrice: sell });
return { BUY: buy, SELL: sell, MID: mid };
}
function getQuoteSnapshot(priceRow, side) {
if (side === 'NO') {
return buildPriceSnapshot({
BUY: priceRow && priceRow.noBuyPrice,
SELL: priceRow && priceRow.noSellPrice,
MID: priceRow && priceRow.noMidPrice
});
}
return buildPriceSnapshot({
BUY: priceRow && priceRow.buyPrice,
SELL: priceRow && priceRow.sellPrice,
MID: priceRow && priceRow.midPrice
});
}
function renderQuoteStack(label, snapshot, tone, emptyText, actionHtml, attributeHtml) {
const quote = snapshot || { BUY: null, SELL: null, MID: null };
const hasPrice = quote.MID != null || quote.BUY != null || quote.SELL != null;
if (!hasPrice && emptyText) {
return `
<div class="psim-quote-stack psim-quote-${tone} psim-quote-empty" ${attributeHtml || ''}>
<span class="psim-quote-label">${escapeHtml(label)}</span>
<span class="psim-quote-best"><span class="psim-quote-best-value">${escapeHtml(emptyText)}</span></span>
<span class="psim-quote-meta">M — · 出 —</span>
${actionHtml || ''}
</div>
`;
}
return `
<div class="psim-quote-stack psim-quote-${tone}" ${attributeHtml || ''}>
<span class="psim-quote-label">${escapeHtml(label)}</span>
<span class="psim-quote-best"><span class="psim-quote-best-tag">B</span><span class="psim-quote-best-value">${fmt$(quote.BUY || 0)}</span></span>
<span class="psim-quote-meta">M ${fmt$(quote.MID || 0)} · 出 ${fmt$(quote.SELL || 0)}</span>
${actionHtml || ''}
</div>
`;
}
function applySlippage(price, side, settings) {
const numericPrice = Number(price);
if (!Number.isFinite(numericPrice) || numericPrice <= 0) return null;
const slippage = Math.max(0, Number(settings && settings.slippageRate) || 0);
const adjusted = side === 'BUY' ? numericPrice * (1 + slippage) : numericPrice * Math.max(0, 1 - slippage);
return roundNumber(adjusted, 6);
}
function computeBuyExecution(quantity, rawPrice, settings) {
const qty = roundNumber(normalizePositiveNumber(quantity, 0), 6);
const execPrice = applySlippage(rawPrice, 'BUY', settings);
if (!qty || !execPrice) return null;
const gross = roundMoney(qty * execPrice);
const fee = roundMoney(gross * ((settings && settings.feeRate) || 0));
const total = roundMoney(gross + fee);
return { qty, rawPrice: roundNumber(rawPrice, 6), execPrice, gross, fee, total, basisPerUnit: qty > 0 ? roundNumber(total / qty, 6) : 0 };
}
function computeSellExecution(quantity, rawPrice, settings) {
const qty = roundNumber(normalizePositiveNumber(quantity, 0), 6);
const execPrice = applySlippage(rawPrice, 'SELL', settings);
if (!qty || !execPrice) return null;
const gross = roundMoney(qty * execPrice);
const fee = roundMoney(gross * ((settings && settings.feeRate) || 0));
const net = roundMoney(gross - fee);
return { qty, rawPrice: roundNumber(rawPrice, 6), execPrice, gross, fee, net };
}
function getObservedAlertPrice(alert, prices) {
const snapshot = buildPriceSnapshot(prices);
const priceType = PRICE_TYPES.includes(alert && alert.priceType) ? alert.priceType : 'BUY';
return snapshot[priceType];
}
function shouldTriggerAlert(alert, prices) {
if (!alert || alert.active === false) return false;
const observed = getObservedAlertPrice(alert, prices);
if (!Number.isFinite(observed)) return false;
return alert.side === 'BELOW' ? observed <= alert.priceTarget : observed >= alert.priceTarget;
}
function createHistoryEntry(input) {
return hydrateHistoryEntry({
time: input.time || new Date().toISOString(),
action: input.action,
marketTitle: truncate(input.marketTitle || input.market?.question || input.market?.title || 'Unknown', 80),
outcome: input.outcome || '',
tokenId: input.tokenId || '',
quantity: input.quantity,
price: input.price,
grossValue: input.grossValue,
fee: input.fee,
netValue: input.netValue,
pnl: input.pnl,
cashAfter: input.cashAfter,
realizedAfter: input.realizedAfter,
positionAfter: input.positionAfter
});
}
function escapeCSVField(value) {
if (value == null) return '';
const text = String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function parseCSVLine(line) {
const text = String(line == null ? '' : line);
const result = [];
let current = '';
let inQuotes = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
if (inQuotes && text[index + 1] === '"') {
current += '"';
index += 1;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}
function parseBackupSections(text) {
const sections = {};
let current = null;
String(text || '').replace(/\r\n/g, '\n').split('\n').forEach((line) => {
if (!line.trim() || line.startsWith('#')) return;
const row = parseCSVLine(line);
const firstCell = String(row[0] || '').trim();
if (/^\[[^\]]+\]$/.test(firstCell)) {
current = firstCell.slice(1, -1).toLowerCase();
sections[current] = { headers: null, rows: [] };
return;
}
if (!current) return;
if (!sections[current].headers) {
sections[current].headers = row;
} else {
sections[current].rows.push(row);
}
});
return sections;
}
function rowToObject(headers, row) {
const result = {};
(headers || []).forEach((header, index) => {
const key = String(header || '').trim();
if (!key) return;
result[key] = row[index];
});
return result;
}
function readStoredJson(key, fallbackValue) {
if (typeof root.GM_getValue !== 'function') return fallbackValue;
try {
const raw = root.GM_getValue(key, null);
if (raw == null) return fallbackValue;
return typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (_err) {
return fallbackValue;
}
}
function saveStoredJson(key, value) {
if (typeof root.GM_setValue !== 'function') return;
try {
root.GM_setValue(key, JSON.stringify(value));
} catch (_err) {
// noop
}
}
function downloadFile(content, filename, type) {
const blob = new root.Blob([content], { type });
const url = root.URL.createObjectURL(blob);
const anchor = root.document.createElement('a');
anchor.href = url;
anchor.download = filename;
root.document.body.appendChild(anchor);
anchor.click();
root.document.body.removeChild(anchor);
root.URL.revokeObjectURL(url);
}
function notify(message, level) {
const text = truncate(message, 140);
try {
if (typeof root.GM_notification === 'function') {
root.GM_notification({ text, title: level ? `Paper · ${String(level).toUpperCase()}` : 'Paper', timeout: 3500 });
} else if (root.console && typeof root.console.log === 'function') {
root.console.log('[Paper]', text);
}
} catch (_err) {
if (root.console && typeof root.console.log === 'function') root.console.log('[Paper]', text);
}
}
function createEngine(options) {
const settings = options || {};
let state = hydrateState(settings.state);
let ui = hydrateUI(settings.ui);
const hooks = {
onStateChange: typeof settings.onStateChange === 'function' ? settings.onStateChange : noop,
onUIChange: typeof settings.onUIChange === 'function' ? settings.onUIChange : noop
};
function emitState() {
hooks.onStateChange(deepClone(state));
}
function emitUI() {
hooks.onUIChange(deepClone(ui));
}
function getState() {
return { ...deepClone(state), ui: deepClone(ui) };
}
function getUI() {
return deepClone(ui);
}
function replaceState(nextState) {
state = hydrateState(nextState);
emitState();
return getState();
}
function replaceUI(nextUI) {
ui = hydrateUI(nextUI);
emitUI();
return getUI();
}
function patchUI(partial) {
ui = hydrateUI({ ...ui, ...(partial || {}) });
emitUI();
return getUI();
}
function prefillAlertDraft(draft) {
ui = hydrateUI({ ...ui, alertDraft: normalizeAlertDraft(draft) });
emitUI();
return getUI();
}
function clearAlertDraft() {
ui = hydrateUI({ ...ui, alertDraft: null });
emitUI();
return getUI();
}
function submitAlertDraft() {
const draft = normalizeAlertDraft(ui.alertDraft);
if (!draft || !draft.tokenId) return { ok: false, error: '警报草稿不完整' };
const priceTarget = Number(draft.priceTarget);
if (!Number.isFinite(priceTarget) || priceTarget <= 0 || priceTarget > 1) return { ok: false, error: '无效的目标价格' };
const alert = addAlert(draft);
clearAlertDraft();
return { ok: true, alert };
}
function pushHistory(entry) {
state.history.unshift(entry);
if (state.history.length > MAX_HISTORY) state.history = state.history.slice(0, MAX_HISTORY);
}
function getPortfolioSummary() {
let totalCost = 0;
let marketValue = 0;
state.positions.forEach((position) => {
const cost = roundMoney(position.avgPrice * position.quantity);
const price = Number.isFinite(Number(position.lastPrice)) && Number(position.lastPrice) > 0 ? Number(position.lastPrice) : position.avgPrice;
totalCost += cost;
marketValue += roundMoney(price * position.quantity);
});
totalCost = roundMoney(totalCost);
marketValue = roundMoney(marketValue);
return {
cash: roundMoney(state.cash),
totalCost,
marketValue,
realizedPnl: roundMoney(state.realizedPnl),
unrealizedPnl: roundMoney(marketValue - totalCost),
equity: roundMoney(state.cash + marketValue)
};
}
function getHoldingsView(sortKey) {
const summary = getPortfolioSummary();
const rows = state.positions.map((position) => {
const currentPrice = Number.isFinite(Number(position.lastPrice)) && Number(position.lastPrice) > 0 ? Number(position.lastPrice) : position.avgPrice;
const totalCost = roundMoney(position.avgPrice * position.quantity);
const marketValue = roundMoney(currentPrice * position.quantity);
const pnl = roundMoney(marketValue - totalCost);
const exposurePct = summary.equity > 0 ? roundNumber((marketValue / summary.equity) * 100, 4) : 0;
const costRecoveryPct = totalCost > 0 ? roundNumber((marketValue / totalCost) * 100, 4) : 0;
const breakEvenPct = position.avgPrice > 0 ? roundNumber(((currentPrice - position.avgPrice) / position.avgPrice) * 100, 4) : 0;
return {
...deepClone(position),
currentPrice: roundNumber(currentPrice, 6),
totalCost,
marketValue,
pnl,
pnlPct: totalCost > 0 ? roundNumber((pnl / totalCost) * 100, 4) : 0,
exposurePct,
costRecoveryPct,
breakEvenPct,
remainingCost: totalCost
};
});
const sort = HOLDINGS_SORTS.includes(sortKey) ? sortKey : ui.holdingsSort;
rows.sort((left, right) => {
if (sort === 'value_desc') return right.marketValue - left.marketValue;
if (sort === 'qty_desc') return right.quantity - left.quantity;
if (sort === 'newest') return new Date(right.openedAt) - new Date(left.openedAt);
return right.pnl - left.pnl;
});
return rows;
}
function buy(market, outcomeIdx, quantity, prices, side) {
const buySide = side === 'NO' ? 'NO' : 'YES';
const qty = roundNumber(normalizePositiveNumber(quantity, 0), 6);
const tokenId = getOutcomeTokenId(market, outcomeIdx, buySide);
if (!qty) return { ok: false, error: '无效数量' };
if (!tokenId) return { ok: false, error: '无法交易: 无 token ID' };
const priceRow = Array.isArray(prices) ? prices[outcomeIdx] : null;
const rawPrice = buySide === 'NO'
? (priceRow && Number.isFinite(Number(priceRow.noBuyPrice)) ? Number(priceRow.noBuyPrice) : null)
: (priceRow && Number.isFinite(Number(priceRow.buyPrice)) ? Number(priceRow.buyPrice) : null);
const execution = computeBuyExecution(qty, rawPrice, state.settings);
if (!execution) return { ok: false, error: '价格不可用' };
if (execution.total > state.cash + 1e-9) {
return { ok: false, error: `资金不足: 需要 ${fmt$(execution.total)}, 当前 ${fmt$(state.cash)}` };
}
const marketTitle = truncate(market.question || market.title || 'Unknown', 80);
const outcome = getOutcomeName(market, outcomeIdx, buySide);
let position = state.positions.find((item) => item.marketSlug === (market.slug || '') && item.tokenId === tokenId);
if (!position) {
position = hydratePosition({
id: generateId('pos'),
marketSlug: market.slug || '',
marketTitle,
tokenId,
quantity: 0,
avgPrice: 0,
outcome,
openedAt: new Date().toISOString(),
lastPrice: execution.rawPrice,
lastPriceType: 'BUY'
});
state.positions.push(position);
}
const existingCost = roundMoney(position.avgPrice * position.quantity);
const nextQuantity = roundNumber(position.quantity + execution.qty, 6);
position.quantity = nextQuantity;
position.avgPrice = nextQuantity > 0 ? roundNumber((existingCost + execution.total) / nextQuantity, 6) : 0;
position.lastPrice = execution.rawPrice;
position.lastPriceType = 'BUY';
state.cash = roundMoney(state.cash - execution.total);
pushHistory(createHistoryEntry({
action: 'BUY',
marketTitle,
outcome,
tokenId,
quantity: execution.qty,
price: execution.execPrice,
grossValue: execution.gross,
fee: execution.fee,
netValue: execution.total,
pnl: 0,
cashAfter: state.cash,
realizedAfter: state.realizedPnl,
positionAfter: position.quantity
}));
emitState();
return { ok: true, execution, position: deepClone(position), side: buySide };
}
function sellByPositionId(positionId, quantity, rawSellPrice, action, marketTitleOverride) {
const index = state.positions.findIndex((item) => item.id === positionId);
if (index === -1) return { ok: false, error: '无持仓可卖出' };
const position = state.positions[index];
const qty = Math.min(roundNumber(normalizePositiveNumber(quantity, 0), 6), position.quantity);
if (!qty) return { ok: false, error: '无效数量' };
const execution = computeSellExecution(qty, rawSellPrice, state.settings);
if (!execution) return { ok: false, error: '卖出价格不可用' };
const costBasis = roundMoney(position.avgPrice * qty);
const pnl = roundMoney(execution.net - costBasis);
position.quantity = roundNumber(position.quantity - qty, 6);
position.lastPrice = execution.rawPrice;
position.lastPriceType = 'SELL';
if (position.quantity <= 0.000001) {
state.positions.splice(index, 1);
}
state.cash = roundMoney(state.cash + execution.net);
state.realizedPnl = roundMoney(state.realizedPnl + pnl);
pushHistory(createHistoryEntry({
action: action || 'SELL',
marketTitle: marketTitleOverride || position.marketTitle,
outcome: position.outcome,
tokenId: position.tokenId,
quantity: execution.qty,
price: execution.execPrice,
grossValue: execution.gross,
fee: execution.fee,
netValue: execution.net,
pnl,
cashAfter: state.cash,
realizedAfter: state.realizedPnl,
positionAfter: position.quantity
}));
emitState();
return { ok: true, execution, pnl };
}
function sell(market, outcomeIdx, quantity, prices) {
const tokenId = getOutcomeTokenId(market, outcomeIdx);
if (!tokenId) return { ok: false, error: '无法交易: 无 token ID' };
const priceRow = Array.isArray(prices) ? prices[outcomeIdx] : null;
const rawPrice = priceRow && Number.isFinite(Number(priceRow.sellPrice)) ? Number(priceRow.sellPrice) : null;
if (!rawPrice || rawPrice <= 0) return { ok: false, error: '卖出价格不可用' };
const position = state.positions.find((item) => item.marketSlug === (market.slug || '') && item.tokenId === tokenId);
if (!position) return { ok: false, error: '无持仓可卖出' };
return sellByPositionId(position.id, quantity, rawPrice, 'SELL', truncate(market.question || market.title || position.marketTitle, 80));
}
function closePosition(positionId, rawSellPrice) {
const position = state.positions.find((item) => item.id === positionId);
if (!position) return { ok: false, error: '未找到持仓' };
const rawPrice = Number.isFinite(Number(rawSellPrice)) && Number(rawSellPrice) > 0 ? Number(rawSellPrice) : position.lastPrice;
return sellByPositionId(position.id, position.quantity, rawPrice, 'CLOSE', position.marketTitle);
}
function closeAllPositions(priceMap) {
const prices = priceMap || {};
let closedCount = 0;
let totalPnl = 0;
const targets = [...state.positions];
targets.forEach((position) => {
const raw = prices[position.tokenId];
const rawPrice = Number.isFinite(Number(raw)) && Number(raw) > 0 ? Number(raw) : position.lastPrice;
if (!rawPrice || rawPrice <= 0) return;
const result = sellByPositionId(position.id, position.quantity, rawPrice, 'CLOSE_ALL', position.marketTitle);
if (result.ok) {
closedCount += 1;
totalPnl = roundMoney(totalPnl + result.pnl);
}
});
return { ok: closedCount > 0, closedCount, totalPnl, error: closedCount > 0 ? null : '无实时价格可平仓' };
}
function updatePositionPrice(tokenId, rawPrice, priceType) {
const position = state.positions.find((item) => item.tokenId === tokenId);
if (!position) return false;
if (!Number.isFinite(Number(rawPrice)) || Number(rawPrice) <= 0) return false;
position.lastPrice = roundNumber(Number(rawPrice), 6);
position.lastPriceType = PRICE_TYPES.includes(priceType) ? priceType : 'SELL';
emitState();
return true;
}
function addAlert(alert) {
const hydrated = hydrateAlert(alert);
if (!hydrated) return null;
state.alerts.push(hydrated);
emitState();
return deepClone(hydrated);
}
function removeAlert(alertId) {
const before = state.alerts.length;
state.alerts = state.alerts.filter((alert) => alert.id !== alertId);
if (state.alerts.length !== before) emitState();
return before !== state.alerts.length;
}
function evaluateAlerts(priceResolver) {
const triggered = [];
state.alerts.forEach((alert) => {
if (!alert.active) return;
const prices = typeof priceResolver === 'function' ? priceResolver(alert) : null;
if (!shouldTriggerAlert(alert, prices)) return;
const observed = getObservedAlertPrice(alert, prices);
alert.active = false;
triggered.push({ alert: deepClone(alert), observedPrice: observed });
});
if (triggered.length > 0) emitState();
return triggered;
}
function addWatchlistItem(item) {
const hydrated = hydrateWatchlistItem(item);
if (!hydrated) return null;
if (state.watchlist.some((entry) => entry.marketSlug === hydrated.marketSlug)) return deepClone(state.watchlist.find((entry) => entry.marketSlug === hydrated.marketSlug));
state.watchlist.unshift(hydrated);
emitState();
return deepClone(hydrated);
}
function removeWatchlistItem(marketSlug) {
const before = state.watchlist.length;
state.watchlist = state.watchlist.filter((item) => item.marketSlug !== marketSlug);
if (state.watchlist.length !== before) emitState();
return before !== state.watchlist.length;
}
function clearHistory() {
state.history = [];
emitState();
}
function clearAlerts() {
state.alerts = [];
emitState();
}
function clearWatchlist() {
state.watchlist = [];
emitState();
}
function resetAll() {
state = hydrateState(defaultState());
emitState();
return getState();
}
function exportBackupJSON() {
return JSON.stringify({ version: 3, exportedAt: new Date().toISOString(), state: getState(), ui: getUI() }, null, 2);
}
function importBackupJSON(text) {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object') throw new Error('Invalid JSON backup');
state = hydrateState(parsed.state || parsed);
if (parsed.ui) ui = hydrateUI(parsed.ui);
emitState();
emitUI();
return getState();
}
function exportBackupCSV() {
const rows = [];
rows.push(['# Polymarket Simulator Backup']);
rows.push(['# Exported', new Date().toISOString()]);
rows.push([]);
rows.push(['[SETTINGS]']);
rows.push(['key', 'value']);
rows.push(['initialBalance', state.initialBalance]);
rows.push(['cash', state.cash]);
rows.push(['realizedPnl', state.realizedPnl]);
rows.push(['refreshSeconds', state.settings.refreshSeconds]);
rows.push(['defaultQuantity', state.settings.defaultQuantity]);
rows.push(['feeRate', state.settings.feeRate]);
rows.push(['slippageRate', state.settings.slippageRate]);
rows.push(['soundEnabled', state.settings.soundEnabled]);
rows.push(['darkMode', state.settings.darkMode]);
rows.push([]);
rows.push(['[ALERTS]']);
rows.push(['id', 'marketSlug', 'tokenId', 'priceTarget', 'side', 'active', 'outcomeName', 'priceType']);
state.alerts.forEach((alert) => {
rows.push([alert.id, alert.marketSlug, alert.tokenId, alert.priceTarget, alert.side, alert.active, alert.outcomeName, alert.priceType]);
});
rows.push([]);
rows.push(['[WATCHLIST]']);
rows.push(['id', 'marketSlug', 'marketTitle', 'addedAt']);
state.watchlist.forEach((item) => {
rows.push([item.id, item.marketSlug, item.marketTitle, item.addedAt]);
});
rows.push([]);
rows.push(['[POSITIONS]']);
rows.push(['id', 'marketSlug', 'marketTitle', 'tokenId', 'quantity', 'avgPrice', 'outcome', 'openedAt', 'lastPrice', 'lastPriceType']);
state.positions.forEach((position) => {
rows.push([position.id, position.marketSlug, position.marketTitle, position.tokenId, position.quantity, position.avgPrice, position.outcome, position.openedAt, position.lastPrice, position.lastPriceType]);
});
rows.push([]);
rows.push(['[HISTORY]']);
rows.push(['time', 'action', 'marketTitle', 'outcome', 'tokenId', 'quantity', 'price', 'grossValue', 'fee', 'netValue', 'pnl']);
state.history.forEach((entry) => {
rows.push([entry.time, entry.action, entry.marketTitle, entry.outcome, entry.tokenId, entry.quantity, entry.price, entry.grossValue, entry.fee, entry.netValue, entry.pnl]);
});
return rows.map((row) => row.map(escapeCSVField).join(',')).join('\n');
}
function importBackupCSV(text) {
const sections = parseBackupSections(text);
const nextState = hydrateState(defaultState());
(sections.settings && sections.settings.rows || []).forEach((row) => {
const key = row[0];
const value = row[1];
if (key === 'initialBalance') nextState.initialBalance = roundMoney(normalizePositiveNumber(value, nextState.initialBalance));
else if (key === 'cash') nextState.cash = roundMoney(Number(value) || 0);
else if (key === 'realizedPnl') nextState.realizedPnl = roundMoney(Number(value) || 0);
else if (key === 'refreshSeconds') nextState.settings.refreshSeconds = Math.min(60, Math.max(2, parseInt(value, 10) || nextState.settings.refreshSeconds));
else if (key === 'defaultQuantity') nextState.settings.defaultQuantity = Math.max(1, parseFloat(value) || nextState.settings.defaultQuantity);
else if (key === 'feeRate') nextState.settings.feeRate = Math.max(0, parseFloat(value) || 0);
else if (key === 'slippageRate') nextState.settings.slippageRate = Math.max(0, parseFloat(value) || 0);
else if (key === 'soundEnabled') nextState.settings.soundEnabled = value === 'true';
else if (key === 'darkMode') nextState.settings.darkMode = value === 'true';
});
const alertHeaders = sections.alerts && sections.alerts.headers || [];
nextState.alerts = (sections.alerts && sections.alerts.rows || []).map((row) => {
const data = rowToObject(alertHeaders, row);
return hydrateAlert({
id: data.id,
marketSlug: data.marketSlug,
tokenId: data.tokenId,
priceTarget: data.priceTarget,
side: data.side,
active: data.active !== 'false',
outcomeName: data.outcomeName,
priceType: data.priceType
});
}).filter(Boolean);
const watchlistHeaders = sections.watchlist && sections.watchlist.headers || [];
nextState.watchlist = (sections.watchlist && sections.watchlist.rows || []).map((row) => {
const data = rowToObject(watchlistHeaders, row);
return hydrateWatchlistItem({
id: data.id,
marketSlug: data.marketSlug,
marketTitle: data.marketTitle,
addedAt: data.addedAt
});
}).filter(Boolean);
const positionHeaders = sections.positions && sections.positions.headers || [];
nextState.positions = (sections.positions && sections.positions.rows || []).map((row) => {
const data = rowToObject(positionHeaders, row);
return hydratePosition({
id: data.id,
marketSlug: data.marketSlug,
marketTitle: data.marketTitle,
tokenId: data.tokenId,
quantity: data.quantity,
avgPrice: data.avgPrice,
outcome: data.outcome,
openedAt: data.openedAt,
lastPrice: data.lastPrice,
lastPriceType: data.lastPriceType
});
}).filter(Boolean);
const historyHeaders = sections.history && sections.history.headers || [];
nextState.history = (sections.history && sections.history.rows || []).map((row) => {
const data = rowToObject(historyHeaders, row);
return hydrateHistoryEntry({
time: data.time,
action: data.action,
marketTitle: data.marketTitle,
outcome: data.outcome,
tokenId: data.tokenId,
quantity: data.quantity,
price: data.price,
grossValue: data.grossValue,
fee: data.fee,
netValue: data.netValue,
pnl: data.pnl
});
}).filter(Boolean).slice(0, MAX_HISTORY);
state = hydrateState(nextState);
emitState();
return getState();
}
return {
getState,
getUI,
replaceState,
replaceUI,
patchUI,
buy,
sell,
sellByPositionId,
closePosition,
closeAllPositions,
updatePositionPrice,
getPortfolioSummary,
getHoldingsView,
prefillAlertDraft,
clearAlertDraft,
submitAlertDraft,
addAlert,
removeAlert,
evaluateAlerts,
addWatchlistItem,
removeWatchlistItem,
clearHistory,
clearAlerts,
clearWatchlist,
resetAll,
exportBackupCSV,
importBackupCSV,
exportBackupJSON,
importBackupJSON
};
}
const exportedApi = {
createEngine,
defaultState,
defaultUI,
hydrateState,
hydrateUI,
normalizeMarket,
mergeEventMarkets,
parseCSVLine,
shouldTriggerAlert,
buildPriceSnapshot,
getObservedAlertPrice,
mergeSearchMarkets,
normalizeAlertDraft,
getWatchlistSnapshots,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = exportedApi;
return;
}
let engine = createEngine({
state: hydrateState(readStoredJson(STORAGE_KEY, defaultState())),
ui: hydrateUI(readStoredJson(UI_KEY, defaultUI())),
onStateChange(nextState) {
saveStoredJson(STORAGE_KEY, nextState);
},
onUIChange(nextUI) {
saveStoredJson(UI_KEY, nextUI);
}
});
let panelEl = null;
let contentEl = null;
let refreshTimer = null;
let marketCache = {};
let marketInflight = {};
let marketPriceCache = {};
let marketPriceInflight = {};
let priceCache = {};
let marketUiUpdateTimer = null;
let marketUiLastUpdateAt = 0;
let portfolioUiUpdateTimer = null;
let snapGuideEl = null;
let currentPageSlug = null;
let marketWorkspaceCacheEl = null;
let marketWorkspaceCacheKey = null;
root.console.info('[PolySim] Script loaded. Version 3.1.0');
function getState() {
return engine.getState();
}
function getUI() {
return engine.getUI();
}
function getMarketWorkspaceCacheKey() {
const ui = getUI();
const slug = getPageSlug() || '';
const draft = ui.alertDraft;
return `${slug}|${draft && draft.tokenId ? draft.tokenId : ''}`;
}
function stashMarketWorkspace() {
const marketWorkspace = root.document.getElementById('psim-market-workspace');
if (!marketWorkspace) return;
marketWorkspaceCacheEl = marketWorkspace;
marketWorkspaceCacheKey = getMarketWorkspaceCacheKey();
marketWorkspace.remove();
}
let apiErrorCount = 0;
let lastApiError = null;
function gmFetch(url, callback, retries) {
if (typeof root.GM_xmlhttpRequest !== 'function') {
callback(new Error('GM_xmlhttpRequest unavailable'), null);
return;
}
const attempt = retries == null ? 0 : retries;
root.GM_xmlhttpRequest({
method: 'GET',
url,
timeout: 15000,
onload(resp) {
if (resp.status >= 200 && resp.status < 300) {
try {
callback(null, JSON.parse(resp.responseText));
} catch (err) {
apiErrorCount += 1;
lastApiError = new Error('Invalid JSON response from API');
callback(new Error('Invalid JSON response'), null);
}
} else if (resp.status === 403 || resp.status === 429) {
apiErrorCount += 1;
lastApiError = new Error(`API forbidden (${resp.status}): ${url}`);
if (attempt < 2) {
root.setTimeout(() => gmFetch(url, callback, attempt + 1), 2000 * (attempt + 1));
} else {
callback(lastApiError, null);
}
} else {
apiErrorCount += 1;
lastApiError = new Error(`HTTP ${resp.status}`);
callback(new Error(`HTTP ${resp.status}`), null);
}
},
onerror() {
apiErrorCount += 1;
lastApiError = new Error('Network error');
if (attempt < 2) {
root.setTimeout(() => gmFetch(url, callback, attempt + 1), 1500 * (attempt + 1));
} else {
callback(new Error('Network error'), null);
}
},
ontimeout() {
apiErrorCount += 1;
lastApiError = new Error('Timeout');
if (attempt < 2) {
root.setTimeout(() => gmFetch(url, callback, attempt + 1), 1500);
} else {
callback(new Error('Timeout'), null);
}
}
});
}
function getPageSlug() {
const path = root.window.location.pathname || '';
const patterns = [
/\/event\/([^/?#]+)/,
/\/markets\/([^/?#]+)/,
/\/e\/([^/?#]+)/,
/\/m\/([^/?#]+)/,
/\/c\/([^/?#]+)/,
/\/question\/([^/?#]+)/,
/\/group\/([^/?#]+)/
];
for (const pattern of patterns) {
const match = path.match(pattern);
if (match) return match[1];
}
const scripts = root.document.querySelectorAll('script[type="application/json"]');
for (const script of scripts) {
try {
const parsed = JSON.parse(script.textContent);
const slug = parsed?.props?.pageProps?.market?.slug
|| parsed?.props?.pageProps?.event?.slug
|| parsed?.query?.slug;
if (slug) return slug;
} catch (_) {}
}
return null;
}
function resolveInflightMarket(slug, err, market) {
const callbacks = marketInflight[slug] || [];
delete marketInflight[slug];
callbacks.forEach((cb) => {
cb(err, market);
});
}
function getMarketPriceCacheKey(market) {
return market && (market.slug || market.event_slug || market.title || market.question || '');
}
function resolveInflightMarketPrices(key, err, prices) {
const callbacks = marketPriceInflight[key] || [];
delete marketPriceInflight[key];
callbacks.forEach((cb) => {
cb(err, prices ? deepClone(prices) : prices);
});
}
function getMarketBySlug(slug, callback) {
if (!slug) { callback(new Error('Missing slug'), null); return; }
if (marketCache[slug] && Date.now() - marketCache[slug].ts < MARKET_CACHE_MS) {
callback(null, marketCache[slug].data);
return;
}
if (marketInflight[slug]) {
marketInflight[slug].push(callback);
return;
}
marketInflight[slug] = [callback];
let settled = false;
let pending = 3;
let lastError = new Error('Market not found');
const finish = (err, market) => {
if (settled) return;
if (market) {
settled = true;
marketCache[slug] = { data: market, ts: Date.now() };
resolveInflightMarket(slug, null, market);
return;
}
pending -= 1;
if (err) lastError = err;
if (pending === 0) {
settled = true;
resolveInflightMarket(slug, lastError, null);
}
};
gmFetch(`${GAMMA_BASE}/markets/slug/${encodeURIComponent(slug)}`, (err, data) => {
if (!err && data) {
const market = normalizeMarket(Array.isArray(data) ? data[0] : data, slug);
if (market) { finish(null, market); return; }
}
finish(err, null);
});
gmFetch(`${GAMMA_BASE}/markets?slug=${encodeURIComponent(slug)}&limit=1`, (err, data) => {
if (!err && Array.isArray(data) && data.length > 0) {
const market = normalizeMarket(data[0], slug);
if (market) { finish(null, market); return; }
}
finish(err, null);
});
gmFetch(`${GAMMA_BASE}/events/slug/${encodeURIComponent(slug)}`, (err, evt) => {
if (!err && evt) {
const eventData = Array.isArray(evt) ? evt[0] : evt;
const markets = (eventData.markets || []).map((market) => normalizeMarket(market, market.slug || slug));
const merged = markets.length === 1 ? markets[0] : mergeEventMarkets(markets, eventData);
if (merged) { finish(null, merged); return; }
}
finish(err, null);
});
}
function getClobPrice(tokenId, side, callback) {
const cacheKey = `${tokenId}:${side}`;
if (priceCache[cacheKey] && Date.now() - priceCache[cacheKey].ts < 15000) {
callback(null, priceCache[cacheKey].price);
return;
}
gmFetch(`${CLOB_BASE}/price?token_id=${tokenId}&side=${side}`, (err, data) => {
if (err) { callback(err, null); return; }
const price = data ? parseFloat(data.price || 0) : null;
if (Number.isFinite(price) && price > 0) priceCache[cacheKey] = { price, ts: Date.now() };
callback(null, Number.isFinite(price) && price > 0 ? price : null);
});
}
function getClobPricePair(tokenId, callback) {
let pending = 2;
let buyPrice = null;
let sellPrice = null;
let lastError = null;
const done = () => {
pending -= 1;
if (pending === 0) callback(lastError, { buyPrice, sellPrice, midPrice: getMidPrice({ buyPrice, sellPrice }) });
};
getClobPrice(tokenId, 'BUY', (err, price) => {
if (err) lastError = err;
buyPrice = price;
done();
});
getClobPrice(tokenId, 'SELL', (err, price) => {
if (err) lastError = err;
sellPrice = price;
done();
});
}
function fetchAllClobPrices(market, callback) {
const count = getOutcomeCount(market);
if (!count) { callback(null, []); return; }
const hasSubMarkets = Array.isArray(market._subMarkets) && market._subMarkets.length > 0;
const results = new Array(count);
let pending = count;
for (let index = 0; index < count; index += 1) {
const tokenId = getOutcomeTokenId(market, index);
const noTokenId = hasSubMarkets ? getOutcomeTokenId(market, index, 'NO') : null;
if (!tokenId) {
results[index] = { tokenId: null, buyPrice: null, sellPrice: null, midPrice: null, noTokenId: null, noBuyPrice: null, noSellPrice: null, noMidPrice: null };
pending -= 1;
if (pending === 0) callback(null, results);
continue;
}
getClobPricePair(tokenId, (_yesErr, yesPair) => {
const yesResult = { tokenId, buyPrice: yesPair.buyPrice, sellPrice: yesPair.sellPrice, midPrice: yesPair.midPrice };
if (Number.isFinite(yesPair.sellPrice) && yesPair.sellPrice > 0) engine.updatePositionPrice(tokenId, yesPair.sellPrice, 'SELL');
if (!noTokenId) {
results[index] = { ...yesResult, noTokenId: null, noBuyPrice: null, noSellPrice: null, noMidPrice: null };
pending -= 1;
if (pending === 0) callback(null, results);
return;
}
getClobPricePair(noTokenId, (_noErr, noPair) => {
results[index] = { ...yesResult, noTokenId, noBuyPrice: noPair.buyPrice, noSellPrice: noPair.sellPrice, noMidPrice: noPair.midPrice };
if (Number.isFinite(noPair.sellPrice) && noPair.sellPrice > 0) engine.updatePositionPrice(noTokenId, noPair.sellPrice, 'SELL');
pending -= 1;
if (pending === 0) callback(null, results);
});
});
}
}
function fetchMarketPriceSnapshot(market, callback, forceFresh) {
const key = getMarketPriceCacheKey(market);
if (!market || !key) {
fetchAllClobPrices(market, callback);
return;
}
const cached = marketPriceCache[key];
if (!forceFresh && cached && Date.now() - cached.ts < MARKET_PRICE_CACHE_MS) {
callback(null, deepClone(cached.data));
return;
}
if (marketPriceInflight[key]) {
marketPriceInflight[key].push(callback);
return;
}
marketPriceInflight[key] = [callback];
fetchAllClobPrices(market, (err, prices) => {
if (!err && Array.isArray(prices)) {
marketPriceCache[key] = { data: deepClone(prices), ts: Date.now() };
}
resolveInflightMarketPrices(key, err, prices);
});
}
function triggerNotifications(items) {
items.forEach((item) => {
notify(`Alert: "${item.alert.outcomeName}" ${item.alert.side === 'ABOVE' ? 'above' : 'below'} ${fmt$(item.alert.priceTarget)} (${item.alert.priceType} now ${fmt$(item.observedPrice)})`, 'info');
});
}
function el(tag, attrs) {
const node = root.document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([key, value]) => {
node.setAttribute(key, value);
});
}
return node;
}
function createPanel() {
panelEl = root.document.createElement('div');
panelEl.id = 'polymarket-sim-panel';
panelEl.innerHTML = `
<div id="psim-header">
<span id="psim-title">Paper</span>
<div id="psim-header-btns">
<button id="psim-collapse-btn" title="折叠">─</button>
<button id="psim-close-btn" title="关闭">✕</button>
</div>
</div>
<div id="psim-resize-top-handle" title="拖动调整高度"></div>
<div id="psim-content"></div>
<div id="psim-resize-handle" title="拖动右边缘调宽,双击恢复默认宽度"></div>
<div id="psim-resize-bottom-handle" title="拖动调整高度"></div>
`;
contentEl = panelEl.querySelector('#psim-content');
root.document.body.appendChild(panelEl);
const ui = getUI();
if (ui.panelX > 0 || ui.panelY > 0) {
panelEl.style.right = 'auto';
panelEl.style.left = `${ui.panelX}px`;
panelEl.style.top = `${ui.panelY}px`;
}
panelEl.style.width = `${ui.panelWidth}px`;
panelEl.style.height = `${ui.panelHeight}px`;
makeDraggable(panelEl, panelEl.querySelector('#psim-header'));
makeResizable(
panelEl,
panelEl.querySelector('#psim-resize-handle'),
panelEl.querySelector('#psim-resize-top-handle'),
panelEl.querySelector('#psim-resize-bottom-handle')
);
panelEl.querySelector('#psim-header').ondblclick = (event) => {
if (event.target && event.target.closest && event.target.closest('button')) return;
const nextHeight = getDefaultPanelHeight();
panelEl.style.width = `${PANEL_WIDTH.DEFAULT}px`;
panelEl.style.height = `${nextHeight}px`;
engine.patchUI({ panelWidth: PANEL_WIDTH.DEFAULT, panelHeight: nextHeight });
};
panelEl.querySelector('#psim-collapse-btn').onclick = () => {
const nextUI = engine.patchUI({ collapsed: !getUI().collapsed });
panelEl.classList.toggle('psim-collapsed', nextUI.collapsed);
render();
};
panelEl.querySelector('#psim-close-btn').onclick = () => {
panelEl.remove();
if (refreshTimer) clearInterval(refreshTimer);
};
panelEl.classList.toggle('psim-collapsed', ui.collapsed);
}
function updatePrices() {
const summary = engine.getPortfolioSummary();
const setText = (id, text) => { const el = root.document.getElementById(id); if (el) el.textContent = text; };
setText('psim-val-cash', fmt$(summary.cash));
setText('psim-val-equity', fmt$(summary.equity));
const pnlEl = root.document.getElementById('psim-val-realized-pnl');
if (pnlEl) { pnlEl.textContent = fmt$(summary.realizedPnl); pnlEl.className = `psim-value ${summary.realizedPnl >= 0 ? 'psim-positive' : 'psim-negative'}`; }
const rows = engine.getHoldingsView();
getState().positions.forEach((pos) => {
const row = rows.find((r) => r.id === pos.id);
if (!row) return;
const priceEl = root.document.getElementById(`psim-pos-price-${pos.id}`);
const pnlEl2 = root.document.getElementById(`psim-pos-pnl-${pos.id}`);
const mainDetailEl = root.document.getElementById(`psim-pos-details-main-${pos.id}`);
const subDetailEl = root.document.getElementById(`psim-pos-details-sub-${pos.id}`);
if (priceEl) priceEl.textContent = fmt$(row.currentPrice);
if (pnlEl2) {
pnlEl2.textContent = `${fmt$(row.pnl)} (${fmtPct(row.pnlPct)})`;
pnlEl2.className = `psim-pos-pnl ${row.pnl >= 0 ? 'psim-positive' : 'psim-negative'}`;
}
if (mainDetailEl) mainDetailEl.textContent = `持仓 ${row.quantity}x · 暴露 ${fmt$(row.marketValue)} (${fmtPct(row.exposurePct)}) · 剩余成本 ${fmt$(row.remainingCost)}`;
if (subDetailEl) subDetailEl.innerHTML = `回本线 ${fmt$(row.avgPrice)} · 标记 <span id="psim-pos-price-${pos.id}">${fmt$(row.currentPrice)}</span> · 离回本 ${fmtPct(row.breakEvenPct)} · 成本回收 ${fmtPct(row.costRecoveryPct)}`;
});
}
function renderDashboard() {
const state = getState();
const summary = engine.getPortfolioSummary();
const apiStatus = apiErrorCount > 0
? `<span class="psim-api-error" title="${escapeHtml(lastApiError ? lastApiError.message : '')}">⚠ API错误: ${apiErrorCount}</span>`
: `<span class="psim-api-ok">● 已连接</span>`;
return `
<div class="psim-section" id="psim-dashboard">
<div class="psim-section-title">概览 <span id="psim-api-status">${apiStatus}</span></div>
<div class="psim-dash-strip">
<div class="psim-dash-item"><span class="psim-label">现金</span><span class="psim-value" id="psim-val-cash">${fmt$(summary.cash)}</span></div>
<div class="psim-dash-item"><span class="psim-label">总权益</span><span class="psim-value" id="psim-val-equity">${fmt$(summary.equity)}</span></div>
<div class="psim-dash-item"><span class="psim-label">已实现盈亏</span><span class="psim-value ${summary.realizedPnl >= 0 ? 'psim-positive' : 'psim-negative'}" id="psim-val-realized-pnl">${fmt$(summary.realizedPnl)}</span></div>
</div>
<div class="psim-subtle">持仓数: ${state.positions.length} · 警报: ${state.alerts.filter((item) => item.active).length} · 自选: ${state.watchlist.length}</div>
</div>
`;
}
function renderCurrentMarket() {
const slug = getPageSlug();
if (!slug) return `<div class="psim-section" id="psim-current-market-section"><div class="psim-section-title">当前市场</div><div class="psim-empty">当前页面未检测到市场</div></div>`;
const market = marketCache[slug] && marketCache[slug].data;
if (!market) {
if (marketInflight[slug]) {
return `<div class="psim-section" id="psim-current-market-section"><div class="psim-section-title">当前市场</div><div class="psim-empty">正在加载市场...</div></div>`;
}
return `<div class="psim-section" id="psim-current-market-section"><div class="psim-section-title">当前市场</div><div class="psim-empty"><button class="psim-btn" id="psim-load-market">加载市场</button></div></div>`;
}
let html = `
<div class="psim-section" id="psim-current-market-section">
<div class="psim-section-title">当前市场 <button class="psim-watch-star" id="psim-watch-star" title="加入自选">☆</button></div>
<div class="psim-market-title" title="${escapeHtml(market.question || market.title)}">${escapeHtml(truncate(market.question || market.title || 'Unknown', 80))}</div>
<div id="psim-current-market-body" class="psim-empty">正在获取价格...</div>
</div>
`;
{
const count = getOutcomeCount(market);
root.console.info('[PolySim] Market outcomes:', count, 'outcomes:', market.outcomes);
fetchMarketPriceSnapshot(market, (_err, prices) => {
const body = root.document.getElementById('psim-current-market-body');
const starBtn = root.document.getElementById('psim-watch-star');
const stateSnapshot = getState();
const uiState = getUI();
const orderQty = getOrderQuantity(uiState, stateSnapshot);
const orderSizePresets = getOrderSizePresets(stateSnapshot);
if (starBtn) {
const isWatched = stateSnapshot.watchlist.some((item) => item.marketSlug === (market.slug || ''));
starBtn.textContent = isWatched ? '★' : '☆';
starBtn.onclick = () => {
if (isWatched) {
engine.removeWatchlistItem(market.slug || '');
notify('已从自选移除', 'info');
} else {
engine.addWatchlistItem({ marketSlug: market.slug || '', marketTitle: market.question || market.title || '' });
notify('已添加到自选', 'success');
}
render();
};
}
if (!body) return;
if (_err || !prices || prices.length === 0) {
body.innerHTML = `<div class="psim-empty" style="color:#ef4444">获取价格失败: ${_err ? _err.message : 'No data'}.</div>`;
return;
}
body.innerHTML = `
<div class="psim-ticket-toolbar">
<div class="psim-ticket-size-box">
<span class="psim-ticket-size-label">下单量</span>
<input type="number" id="psim-order-qty" class="psim-setting-input psim-order-qty" min="0.1" step="0.1" value="${orderQty}">
</div>
<div class="psim-size-presets">
${orderSizePresets.map((size) => `<button class="psim-size-chip ${Math.abs(size - orderQty) < 0.000001 ? 'active' : ''}" data-order-size="${size}">${size}x</button>`).join('')}
</div>
<div class="psim-ticket-hint">先定 size,再点 YES / NO</div>
</div>
<div class="psim-trade-board" id="psim-trade-board"></div>
`;
bindOrderSizeControls(body);
const board = root.document.getElementById('psim-trade-board');
if (!board) return;
const readOrderQty = () => {
const input = root.document.getElementById('psim-order-qty');
return roundNumber(normalizePositiveNumber(input && input.value, orderQty), 6);
};
const openAlertDraftFor = (tokenId, outcomeName, snapshot) => {
if (!tokenId) { notify('无 token ID', 'error'); return; }
engine.prefillAlertDraft({
marketSlug: market.slug || '',
marketTitle: market.question || market.title || '',
tokenId,
outcomeName,
priceTarget: snapshot.MID || '',
side: 'ABOVE',
priceType: 'MID'
});
render();
};
const count = getOutcomeCount(market);
const isBinary = count === 2;
if (isBinary) {
[0, 1].forEach((index) => {
const outcomeName = getOutcomeName(market, index);
const quote = getQuoteSnapshot(prices[index] || {}, 'YES');
const row = el('div', { class: 'psim-outcome-row psim-trade-ticket' });
row.innerHTML = `
<div class="psim-ticket-main">
<div class="psim-ticket-head">
${renderSideBadge(index === 0 ? 'YES' : 'NO')}
<span class="psim-outcome-name" title="${escapeHtml(outcomeName)}">${escapeHtml(outcomeName)}</span>
</div>
<div class="psim-quote-group">
${renderQuoteStack(index === 0 ? 'YES' : 'NO', quote, index === 0 ? 'yes' : 'no', '', `
<div class="psim-quote-actions">
<button class="psim-btn ${index === 0 ? 'psim-buy' : 'psim-buy-no'}" data-action="buy-binary" data-index="${index}">买${index === 0 ? 'Y' : 'N'}</button>
<button class="psim-btn psim-alert-sm" data-action="alert-binary" data-index="${index}">🔔</button>
</div>
`, `data-quote-index="${index}" data-quote-side="YES"`)}
</div>
</div>
`;
board.appendChild(row);
});
board.insertAdjacentHTML('beforeend', `<div class="psim-binary-info">双边报价直接开仓;减仓与平仓在持仓区处理</div>`);
board.querySelectorAll('[data-action="buy-binary"]').forEach((button) => {
button.onclick = () => {
const idx = parseInt(button.dataset.index, 10);
const quantity = readOrderQty();
const result = engine.buy(market, idx, quantity, prices);
if (!result.ok) notify(result.error, 'error');
else notify(`买入 ${getOutcomeName(market, idx)} ${quantity}x @ ${fmt$(result.execution.execPrice)}`, 'success');
updateDashboard(); updateHoldings();
};
});
board.querySelectorAll('[data-action="alert-binary"]').forEach((button) => {
button.onclick = () => {
const idx = parseInt(button.dataset.index, 10);
const tokenId = getOutcomeTokenId(market, idx);
openAlertDraftFor(tokenId, getOutcomeName(market, idx), getQuoteSnapshot(prices[idx] || {}, 'YES'));
};
});
} else {
// non-binary: sort by probability high→low, keep original order if price unavailable
const hasSubMarkets = Array.isArray(market._subMarkets) && market._subMarkets.length > 0;
const sortedIndices = Array.from({ length: count }, (_, i) => i).sort((a, b) => {
const pa = (prices[a] || {}).midPrice || 0;
const pb = (prices[b] || {}).midPrice || 0;
if (pa === pb) return a - b; // stable: original order when equal
return pb - pa;
});
for (let i = 0; i < sortedIndices.length; i += 1) {
const index = sortedIndices[i];
const price = prices[index] || {};
const outcomeName = getOutcomeName(market, index);
const hasNo = hasSubMarkets && !!getOutcomeTokenId(market, index, 'NO');
const yesQuote = getQuoteSnapshot(price, 'YES');
const noQuote = getQuoteSnapshot(price, 'NO');
const row = el('div', { class: 'psim-outcome-row psim-trade-ticket' });
row.innerHTML = `
<div class="psim-ticket-main">
<div class="psim-ticket-head">
<span class="psim-outcome-name" title="${escapeHtml(outcomeName)}">${escapeHtml(outcomeName)}</span>
</div>
<div class="psim-quote-group">
${renderQuoteStack('YES', yesQuote, 'yes', '', `
<div class="psim-quote-actions">
<button class="psim-btn psim-buy" data-action="buy-yes" data-index="${index}">买Y</button>
<button class="psim-btn psim-alert-sm" data-action="alert-yes" data-index="${index}">🔔</button>
</div>
`, `data-quote-index="${index}" data-quote-side="YES"`)}
${hasNo
? renderQuoteStack('NO', noQuote, 'no', '', `
<div class="psim-quote-actions">
<button class="psim-btn psim-buy-no" data-action="buy-no" data-index="${index}">买N</button>
<button class="psim-btn psim-alert-sm" data-action="alert-no" data-index="${index}">🔔</button>
</div>
`, `data-quote-index="${index}" data-quote-side="NO"`)
: renderQuoteStack('NO', null, 'no', 'NO盘口缺失', `<div class="psim-quote-actions"><button class="psim-btn psim-disabled" data-action="buy-no-disabled" disabled title="该腿当前没有可交易的 NO token">等待NO流动性</button></div>`, `data-quote-index="${index}" data-quote-side="NO"`)}
</div>
</div>
`;
board.appendChild(row);
}
board.querySelectorAll('[data-action="buy-yes"]').forEach((button) => {
button.onclick = () => {
const index = parseInt(button.dataset.index, 10);
const quantity = readOrderQty();
const result = engine.buy(market, index, quantity, prices);
if (!result.ok) notify(result.error, 'error');
else notify(`买入 YES ${quantity}x "${getOutcomeName(market, index)}" @ ${fmt$(result.execution.execPrice)}`, 'success');
updateDashboard(); updateHoldings();
};
});
board.querySelectorAll('[data-action="buy-no"]').forEach((button) => {
button.onclick = () => {
const index = parseInt(button.dataset.index, 10);
const quantity = readOrderQty();
const result = engine.buy(market, index, quantity, prices, 'NO');
if (!result.ok) notify(result.error, 'error');
else notify(`买入 NO ${quantity}x "${getOutcomeName(market, index)}" @ ${fmt$(result.execution.execPrice)}`, 'success');
updateDashboard(); updateHoldings();
};
});
board.querySelectorAll('[data-action="alert-yes"]').forEach((button) => {
button.onclick = () => {
const index = parseInt(button.dataset.index, 10);
const tokenId = getOutcomeTokenId(market, index);
openAlertDraftFor(tokenId, getOutcomeName(market, index), getQuoteSnapshot(prices[index] || {}, 'YES'));
};
});
board.querySelectorAll('[data-action="alert-no"]').forEach((button) => {
button.onclick = () => {
const index = parseInt(button.dataset.index, 10);
const tokenId = getOutcomeTokenId(market, index, 'NO');
openAlertDraftFor(tokenId, getOutcomeName(market, index, 'NO'), getQuoteSnapshot(prices[index] || {}, 'NO'));
};
});
}
});
}
return html;
}
function renderAlertForm() {
const draft = getUI().alertDraft;
if (!draft) return '';
const cachedBuy = priceCache[`${draft.tokenId}:BUY`];
const cachedSell = priceCache[`${draft.tokenId}:SELL`];
const snapshot = buildPriceSnapshot({
BUY: cachedBuy && cachedBuy.price,
SELL: cachedSell && cachedSell.price
});
const observed = snapshot[draft.priceType];
const priceTypes = ['MID', 'BUY', 'SELL'];
const directions = [{ value: 'ABOVE', label: '高于↑' }, { value: 'BELOW', label: '低于↓' }];
return `
<div class="psim-section psim-alert-form">
<div class="psim-section-title">警报设置</div>
<div class="psim-alert-outcome">${escapeHtml(draft.outcomeName || '')}</div>
${observed != null ? `<div class="psim-alert-observed">当前参考价: <span class="psim-value">${fmt$(observed)}</span></div>` : ''}
<div class="psim-alert-row">
<label class="psim-alert-label">目标价格</label>
<input type="number" id="psim-alert-price" class="psim-setting-input psim-alert-input" min="0.01" max="0.99" step="0.01" value="${draft.priceTarget || ''}" placeholder="0.00">
</div>
<div class="psim-alert-row">
<label class="psim-alert-label">价格类型</label>
<div class="psim-alert-toggle-group" id="psim-alert-ptype">
${priceTypes.map((t) => `<button class="psim-toggle-btn ${draft.priceType === t ? 'active' : ''}" data-ptype="${t}">${t}</button>`).join('')}
</div>
</div>
<div class="psim-alert-row">
<label class="psim-alert-label">触发条件</label>
<div class="psim-alert-toggle-group" id="psim-alert-side">
${directions.map((d) => `<button class="psim-toggle-btn ${draft.side === d.value ? 'active' : ''}" data-side="${d.value}">${d.label}</button>`).join('')}
</div>
</div>
<div class="psim-alert-btns">
<button class="psim-btn psim-buy" id="psim-alert-confirm">确认</button>
<button class="psim-btn" id="psim-alert-cancel">取消</button>
</div>
</div>
`;
}
function renderWatchlist() {
const state = getState();
const items = state.watchlist;
let html = `
<div class="psim-section" id="psim-watchlist-section">
<div class="psim-section-title">自选 (${items.length})</div>
`;
if (items.length === 0) {
html += '<div class="psim-empty">暂无自选</div>';
} else {
const allItems = items.map((item) => {
const market = marketCache[item.marketSlug] && marketCache[item.marketSlug].data;
const tokenId = market ? getOutcomeTokenId(market, 0) : null;
const cached = tokenId && priceCache[`${tokenId}:BUY`] ? priceCache[`${tokenId}:BUY`] : null;
const cachedSell = tokenId && priceCache[`${tokenId}:SELL`] ? priceCache[`${tokenId}:SELL`] : null;
const mid = cached && cachedSell ? getMidPrice({ buyPrice: cached.price, sellPrice: cachedSell.price }) : null;
return { item, midPrice: mid };
});
const withPrices2 = allItems.filter(({ midPrice }) => midPrice != null && midPrice > 0).sort((a, b) => b.midPrice - a.midPrice);
const lowProb = withPrices2.filter(({ midPrice }) => midPrice < 0.01);
const highProb = withPrices2.filter(({ midPrice }) => midPrice >= 0.01);
const noPrice = allItems.filter(({ midPrice }) => midPrice == null || midPrice <= 0);
const orderedItems = [...highProb, ...lowProb, ...noPrice].map(({ item }) => item);
html += '<div class="psim-watchlist">';
orderedItems.forEach((item) => {
const midPrice = allItems.find((x) => x.item.marketSlug === item.marketSlug)?.midPrice;
const isCollapsed = item.collapsed || (midPrice != null && midPrice < 0.01);
const prob = midPrice != null ? ` (${(midPrice * 100).toFixed(1)}%)` : '';
if (isCollapsed) {
html += `
<div class="psim-watch-item psim-watch-collapsed">
<button class="psim-watch-expand" data-watch-expand="${escapeHtml(item.marketSlug)}">▶</button>
<a class="psim-watch-title" href="/event/${escapeHtml(item.marketSlug)}" target="_blank">${escapeHtml(truncate(item.marketTitle, 44))}</a>${prob}
<button class="psim-btn psim-alert-rm" data-watch-remove="${escapeHtml(item.marketSlug)}">✕</button>
</div>
`;
} else {
html += `
<div class="psim-watch-item">
<a class="psim-watch-title" href="/event/${escapeHtml(item.marketSlug)}" title="${escapeHtml(item.marketTitle)}" target="_blank">${escapeHtml(truncate(item.marketTitle, 44))}</a>${prob}
<button class="psim-btn psim-alert-rm" data-watch-remove="${escapeHtml(item.marketSlug)}">✕</button>
</div>
`;
}
});
html += '</div>';
}
html += '</div>';
return html;
}
function renderHoldings() {
const ui = getUI();
const rows = engine.getHoldingsView(ui.holdingsSort);
let html = `
<div class="psim-section" id="psim-holdings-section">
<div class="psim-section-title">持仓</div>
<div class="psim-search-row">
<select id="psim-holdings-sort" class="psim-search-input">
<option value="pnl_desc" ${ui.holdingsSort === 'pnl_desc' ? 'selected' : ''}>按盈亏</option>
<option value="value_desc" ${ui.holdingsSort === 'value_desc' ? 'selected' : ''}>按市值</option>
<option value="qty_desc" ${ui.holdingsSort === 'qty_desc' ? 'selected' : ''}>按数量</option>
<option value="newest" ${ui.holdingsSort === 'newest' ? 'selected' : ''}>按最新</option>
</select>
<button class="psim-btn psim-close-all" id="psim-close-all-btn">全部平仓</button>
</div>
`;
if (rows.length === 0) {
html += '<div class="psim-empty">暂无持仓</div>';
} else {
rows.forEach((row) => {
const side = getOutcomeSide(row.outcome);
const outcomeName = getOutcomeDisplayName(row.outcome);
html += `
<div class="psim-position-row">
<div class="psim-pos-header">
<div class="psim-pos-title-wrap">
${renderSideBadge(side, true)}
<a class="psim-pos-outcome" href="/event/${escapeHtml(row.marketSlug)}" target="_blank" title="${escapeHtml(row.outcome)}">${escapeHtml(truncate(outcomeName, 32))}</a>
</div>
<span class="psim-pos-pnl ${row.pnl >= 0 ? 'psim-positive' : 'psim-negative'}" id="psim-pos-pnl-${row.id}">${fmt$(row.pnl)} (${fmtPct(row.pnlPct)})</span>
</div>
<div class="psim-pos-details" id="psim-pos-details-main-${row.id}">持仓 ${row.quantity}x · 暴露 ${fmt$(row.marketValue)} (${fmtPct(row.exposurePct)}) · 剩余成本 ${fmt$(row.remainingCost)}</div>
<div class="psim-pos-details psim-pos-subdetails" id="psim-pos-details-sub-${row.id}">回本线 ${fmt$(row.avgPrice)} · 标记 <span id="psim-pos-price-${row.id}">${fmt$(row.currentPrice)}</span> · 离回本 ${fmtPct(row.breakEvenPct)} · 成本回收 ${fmtPct(row.costRecoveryPct)}</div>
<div class="psim-pos-actions">
<input type="number" class="psim-qty-input" min="0.1" step="0.1" value="${row.quantity}" data-position-qty="${row.id}">
<button class="psim-btn psim-sell" data-position-sell="${row.id}">减仓</button>
<button class="psim-btn psim-close-position" data-position-close="${row.id}">平仓</button>
</div>
</div>
`;
});
}
html += '</div>';
return html;
}
function renderHistory() {
const history = getState().history;
let html = `
<div class="psim-section" id="psim-history-section">
<div class="psim-section-title">历史 (${history.length})</div>
`;
if (history.length === 0) {
html += '<div class="psim-empty">暂无历史</div>';
} else {
html += '<div class="psim-history-list">';
history.slice(0, 60).forEach((entry) => {
const side = getOutcomeSide(entry.outcome);
const outcomeName = getOutcomeDisplayName(entry.outcome);
html += `
<div class="psim-history-row" title="${escapeHtml(`${entry.marketTitle} · ${entry.outcome}`)}">
<div class="psim-h-main">
<span class="psim-h-time">${escapeHtml(formatTime(entry.time))}</span>
<span class="psim-h-action">${escapeHtml(formatHistoryAction(entry.action))}</span>
${renderSideBadge(side, true)}
<span class="psim-h-market">${escapeHtml(truncate(outcomeName, 28))}</span>
<span class="psim-h-fill">${entry.quantity}x @ ${fmt$(entry.price)}</span>
</div>
<div class="psim-h-meta">
<span class="psim-h-net">净额 ${fmt$(entry.netValue || entry.grossValue)}</span>
<span class="psim-h-fee">费 ${fmt$(entry.fee || 0)}</span>
<span class="psim-h-pnl ${entry.pnl >= 0 ? 'psim-positive' : 'psim-negative'}">${entry.pnl ? `盈亏 ${fmt$(entry.pnl)}` : '盈亏 —'}</span>
<span class="psim-h-tail">后: 现金 ${fmt$(entry.cashAfter)} · 持仓 ${entry.positionAfter}x · 已实 ${fmt$(entry.realizedAfter)}</span>
</div>
</div>
`;
});
html += '</div>';
html += `
<div class="psim-history-btns">
<button class="psim-btn" id="psim-export-csv">导出 CSV</button>
<button class="psim-btn" id="psim-export-json">导出 JSON</button>
<button class="psim-btn" id="psim-import-csv">导入 CSV</button>
<button class="psim-btn" id="psim-import-json">导入 JSON</button>
</div>
`;
}
html += '</div>';
return html;
}
function renderSettings() {
const state = getState();
const settings = state.settings;
let html = `
<div class="psim-section" id="psim-settings-section">
<div class="psim-section-title">设置</div>
`;
html += `
<div class="psim-collapsible-body">
<div class="psim-setting-row"><label>刷新(秒)</label><input type="number" class="psim-setting-input" id="psim-set-refresh" min="2" max="60" value="${settings.refreshSeconds}"></div>
<div class="psim-setting-row"><label>默认数量</label><input type="number" class="psim-setting-input" id="psim-set-qty" min="0.1" step="0.1" value="${settings.defaultQuantity}"></div>
<div class="psim-setting-row"><label>初始资金</label><input type="number" class="psim-setting-input" id="psim-set-balance" min="100" value="${state.initialBalance}"></div>
<div class="psim-setting-row"><label>费率(%)</label><input type="number" class="psim-setting-input" id="psim-set-fee" min="0" max="10" step="0.1" value="${roundNumber(settings.feeRate * 100, 2)}"></div>
<div class="psim-setting-row"><label>滑点(%)</label><input type="number" class="psim-setting-input" id="psim-set-slip" min="0" max="20" step="0.1" value="${roundNumber(settings.slippageRate * 100, 2)}"></div>
<div class="psim-setting-row"><label>深色模式</label><input type="checkbox" id="psim-set-dark" ${settings.darkMode ? 'checked' : ''}></div>
<div class="psim-btn-row">
<button class="psim-btn" id="psim-save-settings">保存</button>
<button class="psim-btn" id="psim-clear-history">清空历史</button>
<button class="psim-btn" id="psim-clear-alerts">清空警报</button>
<button class="psim-btn" id="psim-clear-watchlist">清空自选</button>
<button class="psim-btn psim-danger" id="psim-reset-state">重置全部</button>
</div>
</div>
`;
html += '</div>';
return html;
}
function renderAlertsPanel() {
const state = getState();
const alerts = [...state.alerts];
let html = '';
if (getUI().alertDraft) html += renderAlertForm();
html += `
<div class="psim-section" id="psim-alerts-section">
<div class="psim-section-title">警报 (${alerts.length})</div>
`;
if (alerts.length === 0) {
html += '<div class="psim-empty">暂无警报</div>';
} else {
html += '<div class="psim-alerts-list">';
alerts.forEach((alert) => {
html += `
<div class="psim-alert-item">
<div class="psim-alert-item-main">
<span class="psim-alert-item-name">${escapeHtml(truncate(alert.outcomeName, 44))}</span>
<span class="psim-alert-item-meta">${escapeHtml(alert.side === 'ABOVE' ? '≥' : '≤')} ${fmt$(alert.priceTarget)} · ${escapeHtml(alert.priceType)} · ${alert.active ? 'ACTIVE' : 'HIT'}</span>
</div>
<button class="psim-btn psim-alert-rm" data-alert-remove="${escapeHtml(alert.id)}">✕</button>
</div>
`;
});
html += '</div>';
}
html += '</div>';
return html;
}
function renderMainTabs() {
const ui = getUI();
const state = getState();
const tabs = [
{ key: 'market', label: '市场' },
{ key: 'holdings', label: `持仓 ${state.positions.length}` },
{ key: 'watchlist', label: `自选 ${state.watchlist.length}` },
{ key: 'history', label: `历史 ${state.history.length}` },
{ key: 'alerts', label: `警报 ${state.alerts.length}` },
{ key: 'settings', label: '设置' }
];
return `
<div class="psim-main-tabs" id="psim-main-tabs">
${tabs.map((tab) => `<button class="psim-main-tab ${ui.activeTab === tab.key ? 'active' : ''}" data-main-tab="${tab.key}">${escapeHtml(tab.label)}</button>`).join('')}
</div>
`;
}
function renderWorkspace() {
const ui = getUI();
const activeTab = normalizeMainTab(ui.activeTab);
let body = '';
if (activeTab === 'market') {
body = `<div class="psim-workspace" id="psim-market-workspace">${renderCurrentMarket()}${renderAlertForm()}</div>`;
} else if (activeTab === 'holdings') {
body = `<div class="psim-workspace">${renderHoldings()}</div>`;
} else if (activeTab === 'watchlist') {
body = `<div class="psim-workspace">${renderWatchlist()}</div>`;
} else if (activeTab === 'history') {
body = `<div class="psim-workspace">${renderHistory()}</div>`;
} else if (activeTab === 'alerts') {
body = `<div class="psim-workspace">${renderAlertsPanel()}</div>`;
} else {
body = `<div class="psim-workspace">${renderSettings()}</div>`;
}
return `<div id="psim-workspace-root">${body}</div>`;
}
function render() {
if (!contentEl) return;
marketWorkspaceCacheEl = null;
marketWorkspaceCacheKey = null;
contentEl.innerHTML = [renderDashboard(), renderMainTabs(), renderWorkspace()].join('');
bindUi();
}
function updateMainTabs() {
const el = root.document.getElementById('psim-main-tabs');
if (!el) return;
const tmp = root.document.createElement('div');
tmp.innerHTML = renderMainTabs();
el.outerHTML = tmp.innerHTML;
bindUi();
}
function updateWorkspace() {
const el = root.document.getElementById('psim-workspace-root');
if (!el) return;
const activeTab = normalizeMainTab(getUI().activeTab);
const cacheKey = getMarketWorkspaceCacheKey();
if (activeTab === 'market' && marketWorkspaceCacheEl && marketWorkspaceCacheKey === cacheKey) {
el.innerHTML = '';
el.appendChild(marketWorkspaceCacheEl);
bindUi();
scheduleCurrentMarketPriceUpdate();
return;
}
const tmp = root.document.createElement('div');
tmp.innerHTML = renderWorkspace();
el.outerHTML = tmp.innerHTML;
bindUi();
}
function updateDashboard() {
const el = root.document.getElementById('psim-dashboard');
if (el) {
el.outerHTML = renderDashboard();
updateMainTabs();
}
else contentEl.innerHTML = [renderDashboard(), renderMainTabs(), renderWorkspace()].join('');
bindUi();
}
function updateCurrentMarket() {
const current = root.document.getElementById('psim-current-market-section');
if (!current) return;
const tmp = root.document.createElement('div');
tmp.innerHTML = renderCurrentMarket();
current.outerHTML = tmp.innerHTML;
bindUi();
}
function updateCurrentMarketPrices() {
marketUiLastUpdateAt = Date.now();
const slug = getPageSlug();
if (!slug) return;
const market = marketCache[slug] && marketCache[slug].data;
if (!market) return;
const board = root.document.getElementById('psim-trade-board');
if (!board) return;
fetchMarketPriceSnapshot(market, (_err, prices) => {
if (_err || !Array.isArray(prices)) return;
board.querySelectorAll('.psim-quote-stack[data-quote-index][data-quote-side]').forEach((stack) => {
const index = parseInt(stack.getAttribute('data-quote-index'), 10);
const side = stack.getAttribute('data-quote-side') === 'NO' ? 'NO' : 'YES';
const snapshot = getQuoteSnapshot(prices[index] || {}, side);
const bestEl = stack.querySelector('.psim-quote-best');
const bestValueEl = stack.querySelector('.psim-quote-best-value');
const metaEl = stack.querySelector('.psim-quote-meta');
const hasPrice = snapshot.MID != null || snapshot.BUY != null || snapshot.SELL != null;
stack.classList.toggle('psim-quote-empty', !hasPrice);
if (hasPrice) {
if (bestEl && !bestEl.querySelector('.psim-quote-best-tag')) {
bestEl.innerHTML = `<span class="psim-quote-best-tag">B</span><span class="psim-quote-best-value">${fmt$(snapshot.BUY || 0)}</span>`;
}
updateLiveText(bestValueEl || bestEl, fmt$(snapshot.BUY || 0), Number(snapshot.BUY || 0));
} else {
updateLiveText(bestEl, 'NO盘口缺失', Number.NaN, { pulse: false });
}
updateLiveText(metaEl, hasPrice ? `M ${fmt$(snapshot.MID || 0)} · 出 ${fmt$(snapshot.SELL || 0)}` : 'M — · 出 —', hasPrice ? Number(snapshot.MID || 0) : Number.NaN, { pulse: false });
});
}, true);
}
function scheduleCurrentMarketPriceUpdate() {
const now = Date.now();
const remaining = MARKET_UI_THROTTLE_MS - (now - marketUiLastUpdateAt);
if (remaining <= 0) {
if (marketUiUpdateTimer) {
root.clearTimeout(marketUiUpdateTimer);
marketUiUpdateTimer = null;
}
updateCurrentMarketPrices();
return;
}
if (marketUiUpdateTimer) return;
marketUiUpdateTimer = root.setTimeout(() => {
marketUiUpdateTimer = null;
updateCurrentMarketPrices();
}, remaining);
}
function schedulePortfolioPriceUpdate() {
if (portfolioUiUpdateTimer) root.clearTimeout(portfolioUiUpdateTimer);
portfolioUiUpdateTimer = root.setTimeout(() => {
portfolioUiUpdateTimer = null;
updatePrices();
}, PORTFOLIO_UI_DELAY_MS);
}
function ensureSnapGuide() {
if (snapGuideEl && root.document.body.contains(snapGuideEl)) return snapGuideEl;
snapGuideEl = root.document.createElement('div');
snapGuideEl.id = 'psim-snap-guide';
root.document.body.appendChild(snapGuideEl);
return snapGuideEl;
}
function hideSnapGuide() {
if (!snapGuideEl) return;
snapGuideEl.classList.remove('visible');
}
function showSnapGuide(side) {
const guide = ensureSnapGuide();
const viewportWidth = Math.max(root.innerWidth || 0, 320);
guide.classList.toggle('left', side === 'left');
guide.classList.toggle('right', side === 'right');
guide.style.left = side === 'right' ? `${viewportWidth - PANEL_EDGE_OFFSET}px` : `${PANEL_EDGE_OFFSET}px`;
guide.classList.add('visible');
}
function updateHoldings() {
const el = root.document.getElementById('psim-holdings-section');
if (el) {
const tmp = root.document.createElement('div');
tmp.innerHTML = renderHoldings();
el.outerHTML = tmp.innerHTML;
bindHoldingsEvents();
}
}
function updateHistory() {
const el = root.document.getElementById('psim-history-section');
if (el) {
const tmp = root.document.createElement('div');
tmp.innerHTML = renderHistory();
el.outerHTML = tmp.innerHTML;
bindHistoryEvents();
}
}
function updateWatchlist() {
const el = root.document.getElementById('psim-watchlist-section');
if (!el) return;
const state = getState();
const items = [...state.watchlist];
if (items.length === 0) {
const tmp = root.document.createElement('div');
tmp.innerHTML = renderWatchlist();
el.outerHTML = tmp.innerHTML;
bindWatchlistEvents();
return;
}
let pending = items.length;
const slugPriceMap = {};
const slugIdxMap = {};
items.forEach((item, idx) => { slugIdxMap[item.marketSlug] = idx; });
items.forEach((item) => {
getMarketBySlug(item.marketSlug, (err, market) => {
if (err || !market) { pending -= 1; if (pending === 0) renderWatchlistWithPrices(items, slugPriceMap, el); return; }
const tokenId = getOutcomeTokenId(market, 0);
if (!tokenId) { pending -= 1; if (pending === 0) renderWatchlistWithPrices(items, slugPriceMap, el); return; }
getClobPrice(tokenId, 'BUY', (_err, buyPrice) => {
getClobPrice(tokenId, 'SELL', (_err2, sellPrice) => {
slugPriceMap[item.marketSlug] = { buyPrice, sellPrice, midPrice: getMidPrice({ buyPrice, sellPrice }) };
pending -= 1;
if (pending === 0) renderWatchlistWithPrices(items, slugPriceMap, el);
});
});
});
});
}
function renderWatchlistWithPrices(items, slugPriceMap, containerEl) {
const allWithPrice = items.map((item) => ({ item, price: slugPriceMap[item.marketSlug] || {} }))
.sort((a, b) => {
const aP = a.price.midPrice;
const bP = b.price.midPrice;
const aValid = aP != null && aP > 0;
const bValid = bP != null && bP > 0;
if (aValid && bValid) return bP - aP;
if (aValid) return -1;
if (bValid) return 1;
return 0; // both invalid: keep original order
});
const lowProb = allWithPrice.filter(({ price }) => price.midPrice != null && price.midPrice > 0 && price.midPrice < 0.01);
const highProb = allWithPrice.filter(({ price }) => price.midPrice != null && price.midPrice >= 0.01);
const noPrice = allWithPrice.filter(({ price }) => price.midPrice == null || price.midPrice <= 0);
const ordered = [...highProb, ...lowProb, ...noPrice];
let html = `
<div class="psim-section" id="psim-watchlist-section">
<div class="psim-section-title">自选 (${items.length})</div>
<div class="psim-watchlist">
`;
ordered.forEach(({ item, price }) => {
if (item.collapsed === undefined) item.collapsed = price.midPrice != null && price.midPrice < 0.01;
const isCollapsed = item.collapsed;
const prob = price.midPrice != null && price.midPrice > 0 ? ` (${(price.midPrice * 100).toFixed(1)}%)` : price.midPrice != null ? ' (?)' : '';
if (isCollapsed) {
html += `
<div class="psim-watch-item psim-watch-collapsed">
<button class="psim-watch-expand" data-watch-expand="${escapeHtml(item.marketSlug)}">▶</button>
<a class="psim-watch-title" href="/event/${escapeHtml(item.marketSlug)}" target="_blank">${escapeHtml(truncate(item.marketTitle, 44))}</a>${prob}
<button class="psim-btn psim-alert-rm" data-watch-remove="${escapeHtml(item.marketSlug)}">✕</button>
</div>
`;
} else {
html += `
<div class="psim-watch-item">
<a class="psim-watch-title" href="/event/${escapeHtml(item.marketSlug)}" title="${escapeHtml(item.marketTitle)}" target="_blank">${escapeHtml(truncate(item.marketTitle, 44))}</a>${prob}
<button class="psim-btn psim-alert-rm" data-watch-remove="${escapeHtml(item.marketSlug)}">✕</button>
</div>
`;
}
});
html += '</div></div>';
const tmp = root.document.createElement('div');
tmp.innerHTML = html;
containerEl.outerHTML = tmp.innerHTML;
bindWatchlistEvents();
}
function bindHoldingsEvents() {
const sortSelect = root.document.getElementById('psim-holdings-sort');
if (sortSelect) sortSelect.onchange = () => { engine.patchUI({ holdingsSort: sortSelect.value }); updateHoldings(); };
const closeAll = root.document.getElementById('psim-close-all-btn');
if (closeAll) closeAll.onclick = () => {
const priceMap = {};
getState().positions.forEach((p) => { if (p.lastPrice) priceMap[p.tokenId] = p.lastPrice; });
const result = engine.closeAllPositions(priceMap);
if (!result.ok) notify(result.error, 'error');
else notify(`已平仓 ${result.closedCount} 个仓位。总盈亏: ${fmt$(result.totalPnl)}`, result.totalPnl >= 0 ? 'success' : 'info');
updateDashboard(); updateHoldings();
};
root.document.querySelectorAll('[data-position-sell]').forEach((btn) => {
btn.onclick = () => {
const posId = btn.dataset.positionSell;
const input = root.document.querySelector(`[data-position-qty="${posId}"]`);
const qty = parseFloat(input && input.value) || 0;
const pos = getState().positions.find((p) => p.id === posId);
if (!pos) return;
const result = engine.sellByPositionId(posId, qty, pos.lastPrice, 'SELL', pos.marketTitle);
if (!result.ok) notify(result.error, 'error');
else notify(`减仓 ${qty}x ${pos.outcome} @ ${fmt$(result.execution.execPrice)} | 盈亏: ${fmt$(result.pnl)}`, result.pnl >= 0 ? 'success' : 'info');
updateDashboard(); updateHoldings();
};
});
root.document.querySelectorAll('[data-position-close]').forEach((btn) => {
btn.onclick = () => {
const posId = btn.dataset.positionClose;
const pos = getState().positions.find((p) => p.id === posId);
if (!pos) return;
const result = engine.closePosition(posId, pos.lastPrice);
if (!result.ok) notify(result.error, 'error');
else notify(`已平仓 ${pos.outcome}: ${fmt$(result.pnl)}`, result.pnl >= 0 ? 'success' : 'info');
updateDashboard(); updateHoldings();
};
});
}
function bindHistoryEvents() {
root.document.querySelectorAll('[data-position-sell]').forEach((btn) => {
btn.onclick = () => {
const posId = btn.dataset.positionSell;
const input = root.document.querySelector(`[data-position-qty="${posId}"]`);
const qty = parseFloat(input && input.value) || 0;
const pos = getState().positions.find((p) => p.id === posId);
if (!pos) return;
const result = engine.sellByPositionId(posId, qty, pos.lastPrice, 'SELL', pos.marketTitle);
if (!result.ok) notify(result.error, 'error');
else notify(`减仓 ${qty}x ${pos.outcome} @ ${fmt$(result.execution.execPrice)} | 盈亏: ${fmt$(result.pnl)}`, result.pnl >= 0 ? 'success' : 'info');
updateDashboard(); updateHoldings();
};
});
root.document.querySelectorAll('[data-position-close]').forEach((btn) => {
btn.onclick = () => {
const posId = btn.dataset.positionClose;
const pos = getState().positions.find((p) => p.id === posId);
if (!pos) return;
const result = engine.closePosition(posId, pos.lastPrice);
if (!result.ok) notify(result.error, 'error');
else notify(`已平仓 ${pos.outcome}: ${fmt$(result.pnl)}`, result.pnl >= 0 ? 'success' : 'info');
updateDashboard(); updateHoldings();
};
});
}
function bindWatchlistEvents() {
root.document.querySelectorAll('[data-watch-remove]').forEach((btn) => {
btn.onclick = () => { engine.removeWatchlistItem(btn.dataset.watchRemove); updateWatchlist(); };
});
root.document.querySelectorAll('[data-watch-expand]').forEach((btn) => {
btn.onclick = () => {
const slug = btn.dataset.watchExpand;
const item = getState().watchlist.find((i) => i.marketSlug === slug);
if (item) { item.collapsed = false; updateWatchlist(); }
};
});
}
function bindUi() {
const currentPanel = panelEl;
if (!currentPanel) return;
const ui = getUI();
currentPanel.classList.toggle('psim-dark-off', !getState().settings.darkMode);
currentPanel.classList.toggle('psim-collapsed', ui.collapsed);
currentPanel.classList.toggle('psim-wide', ui.panelWidth >= 560);
currentPanel.classList.toggle('psim-narrow', ui.panelWidth <= 380);
const collapseButton = root.document.getElementById('psim-collapse-btn');
if (collapseButton) collapseButton.textContent = ui.collapsed ? '+' : '─';
const loadMarketBtn = root.document.getElementById('psim-load-market');
if (loadMarketBtn) loadMarketBtn.onclick = () => {
const slug = getPageSlug();
if (!slug) return;
getMarketBySlug(slug, (err, market) => {
if (err || !market) notify('加载市场失败', 'error');
else { marketCache[slug] = { data: market, ts: Date.now() }; render(); }
});
};
root.document.querySelectorAll('[data-main-tab]').forEach((btn) => {
btn.onclick = () => {
const nextTab = normalizeMainTab(btn.dataset.mainTab);
const prevTab = normalizeMainTab(getUI().activeTab);
if (prevTab === 'market' && nextTab !== 'market') stashMarketWorkspace();
engine.patchUI({ activeTab: nextTab });
updateMainTabs();
updateWorkspace();
};
});
const sortSelect = root.document.getElementById('psim-holdings-sort');
if (sortSelect) sortSelect.onchange = () => { engine.patchUI({ holdingsSort: sortSelect.value }); updateHoldings(); };
const closeAll = root.document.getElementById('psim-close-all-btn');
if (closeAll) closeAll.onclick = () => {
const priceMap = {};
getState().positions.forEach((position) => { if (position.lastPrice) priceMap[position.tokenId] = position.lastPrice; });
const result = engine.closeAllPositions(priceMap);
if (!result.ok) notify(result.error, 'error');
else notify(`已平仓 ${result.closedCount} 个仓位。总盈亏: ${fmt$(result.totalPnl)}`, result.totalPnl >= 0 ? 'success' : 'info');
updateDashboard(); updateHoldings();
};
// position sell/close events now bound via bindHoldingsEvents()
bindHoldingsEvents();
root.document.querySelectorAll('[data-alert-remove]').forEach((btn) => {
btn.onclick = () => {
engine.removeAlert(btn.dataset.alertRemove);
render();
};
});
bindOrderSizeControls(currentPanel);
root.document.querySelectorAll('[data-watch-remove]').forEach((btn) => {
btn.onclick = () => { engine.removeWatchlistItem(btn.dataset.watchRemove); updateWatchlist(); };
});
const alertConfirm = root.document.getElementById('psim-alert-confirm');
if (alertConfirm) alertConfirm.onclick = () => {
const priceInput = root.document.getElementById('psim-alert-price');
const priceTarget = parseFloat(priceInput && priceInput.value);
if (!Number.isFinite(priceTarget) || priceTarget <= 0 || priceTarget > 1) { notify('价格无效', 'error'); return; }
const currentDraft = getUI().alertDraft;
engine.prefillAlertDraft({ ...currentDraft, priceTarget });
const result = engine.submitAlertDraft();
if (!result.ok) { notify(result.error, 'error'); return; }
notify('警报已添加', 'success');
render();
};
const alertCancel = root.document.getElementById('psim-alert-cancel');
if (alertCancel) alertCancel.onclick = () => { engine.clearAlertDraft(); render(); };
root.document.querySelectorAll('#psim-alert-ptype .psim-toggle-btn').forEach((btn) => {
btn.onclick = () => {
const draft = getUI().alertDraft;
engine.prefillAlertDraft({ ...draft, priceType: btn.dataset.ptype });
render();
};
});
root.document.querySelectorAll('#psim-alert-side .psim-toggle-btn').forEach((btn) => {
btn.onclick = () => {
const draft = getUI().alertDraft;
engine.prefillAlertDraft({ ...draft, side: btn.dataset.side });
render();
};
});
const exportCsv = root.document.getElementById('psim-export-csv');
if (exportCsv) exportCsv.onclick = () => downloadFile(engine.exportBackupCSV(), `polymarket_backup_${Date.now()}.csv`, 'text/csv');
const exportJson = root.document.getElementById('psim-export-json');
if (exportJson) exportJson.onclick = () => downloadFile(engine.exportBackupJSON(), `polymarket_backup_${Date.now()}.json`, 'application/json');
const importCsv = root.document.getElementById('psim-import-csv');
if (importCsv) importCsv.onclick = () => pickFile('.csv', (text) => { engine.importBackupCSV(text); render(); notify('CSV 备份已导入', 'success'); });
const importJson = root.document.getElementById('psim-import-json');
if (importJson) importJson.onclick = () => pickFile('.json', (text) => { engine.importBackupJSON(text); render(); notify('JSON 备份已导入', 'success'); });
const saveSettings = root.document.getElementById('psim-save-settings');
if (saveSettings) saveSettings.onclick = () => {
const state = getState();
const currentUi = getUI();
const newBalance = parseFloat(root.document.getElementById('psim-set-balance').value) || state.initialBalance;
const newDefaultQuantity = Math.max(1, parseFloat(root.document.getElementById('psim-set-qty').value) || state.settings.defaultQuantity);
engine.replaceState({
...state,
initialBalance: newBalance,
cash: newBalance,
realizedPnl: 0,
positions: [],
history: [],
settings: {
...state.settings,
refreshSeconds: Math.max(2, parseInt(root.document.getElementById('psim-set-refresh').value, 10) || state.settings.refreshSeconds),
defaultQuantity: newDefaultQuantity,
feeRate: (parseFloat(root.document.getElementById('psim-set-fee').value) || 0) / 100,
slippageRate: (parseFloat(root.document.getElementById('psim-set-slip').value) || 0) / 100,
darkMode: !!root.document.getElementById('psim-set-dark').checked
}
});
if (Math.abs(Number(currentUi.orderQuantity || 0) - Number(state.settings.defaultQuantity || 0)) < 0.000001) {
engine.patchUI({ orderQuantity: newDefaultQuantity });
}
startRefresh();
render();
notify(`资金已重置为 ${fmt$(newBalance)},持仓已清空`, "success");
};
const clearHistory = root.document.getElementById('psim-clear-history');
if (clearHistory) clearHistory.onclick = () => { engine.clearHistory(); updateDashboard(); updateHistory(); notify('历史已清空', 'info'); };
const clearAlerts = root.document.getElementById('psim-clear-alerts');
if (clearAlerts) clearAlerts.onclick = () => { engine.clearAlerts(); updateDashboard(); notify('警报已清空', 'info'); };
const clearWatchlist = root.document.getElementById('psim-clear-watchlist');
if (clearWatchlist) clearWatchlist.onclick = () => { engine.clearWatchlist(); updateDashboard(); updateWatchlist(); notify('自选已清空', 'info'); };
const resetState = root.document.getElementById('psim-reset-state');
if (resetState) resetState.onclick = () => { if (root.confirm('重置所有数据?')) { engine.resetAll(); render(); notify('已全部重置', 'info'); } };
}
function pickFile(accept, onLoad) {
const input = root.document.createElement('input');
input.type = 'file';
input.accept = accept;
input.onchange = (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
const reader = new root.FileReader();
reader.onload = (loadEvent) => onLoad(loadEvent.target.result);
reader.readAsText(file);
};
input.click();
}
function makeDraggable(panel, handle) {
let dragging = false;
let startX = 0;
let startY = 0;
let originX = 0;
let originY = 0;
handle.style.cursor = 'move';
const isInteractiveTarget = (target) => !!(target && target.closest && target.closest('button, input, select, textarea, a, label, #psim-resize-handle, #psim-resize-top-handle, #psim-resize-bottom-handle, .psim-watch-star, [data-action], [data-order-size], [data-watch-remove], [data-watch-expand], [data-main-tab], [data-alert-remove], #psim-alert-ptype, #psim-alert-side'));
panel.addEventListener('mousedown', (event) => {
if (isInteractiveTarget(event.target)) return;
if (event.button !== 0) return;
dragging = true;
panel.classList.add('psim-dragging');
startX = event.clientX;
startY = event.clientY;
const rect = panel.getBoundingClientRect();
originX = rect.left;
originY = rect.top;
panel.style.right = 'auto';
panel.style.left = `${originX}px`;
panel.style.top = `${originY}px`;
event.preventDefault();
});
root.document.addEventListener('mousemove', (event) => {
if (!dragging) return;
const nextLeft = originX + event.clientX - startX;
const nextTop = originY + event.clientY - startY;
const clamped = clampPanelPosition(nextLeft, nextTop, panel.offsetWidth, panel.offsetHeight);
const viewportWidth = Math.max(root.innerWidth || 0, 320);
const leftDistance = Math.abs(PANEL_EDGE_OFFSET - clamped.left);
const rightDistance = Math.abs((viewportWidth - panel.offsetWidth - PANEL_EDGE_OFFSET) - clamped.left);
if (leftDistance <= PANEL_SNAP_GAP) showSnapGuide('left');
else if (rightDistance <= PANEL_SNAP_GAP) showSnapGuide('right');
else hideSnapGuide();
panel.style.left = `${clamped.left}px`;
panel.style.top = `${clamped.top}px`;
});
root.document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
const rect = panel.getBoundingClientRect();
const snapped = snapPanelPosition(rect.left, rect.top, rect.width, rect.height);
const snappedToEdge = Math.abs(snapped.left - rect.left) > 0.5;
panel.style.left = `${snapped.left}px`;
panel.style.top = `${snapped.top}px`;
panel.classList.remove('psim-dragging');
hideSnapGuide();
if (snappedToEdge) {
panel.classList.remove('psim-snap-settle');
void panel.offsetWidth;
panel.classList.add('psim-snap-settle');
root.setTimeout(() => {
panel.classList.remove('psim-snap-settle');
}, 220);
}
engine.patchUI({ panelX: snapped.left, panelY: snapped.top });
});
}
function makeResizable(panel, widthHandle, topHandle, bottomHandle) {
let resizeMode = null;
let startX = 0;
let startY = 0;
let startW = 0;
let startH = 0;
let startTop = 0;
let anchoredLeft = false;
const startResize = (mode, event) => {
resizeMode = mode;
startX = event.clientX;
startY = event.clientY;
startW = panel.offsetWidth;
startH = panel.offsetHeight;
startTop = panel.getBoundingClientRect().top;
anchoredLeft = !!panel.style.left && panel.style.left !== 'auto';
panel.classList.add('psim-resizing');
event.preventDefault();
event.stopPropagation();
};
if (widthHandle) {
widthHandle.addEventListener('mousedown', (event) => startResize('width', event));
widthHandle.addEventListener('dblclick', (event) => {
event.preventDefault();
event.stopPropagation();
panel.style.width = `${PANEL_WIDTH.DEFAULT}px`;
engine.patchUI({ panelWidth: PANEL_WIDTH.DEFAULT });
});
}
if (topHandle) topHandle.addEventListener('mousedown', (event) => startResize('top-height', event));
if (bottomHandle) bottomHandle.addEventListener('mousedown', (event) => startResize('bottom-height', event));
root.document.addEventListener('mousemove', (event) => {
if (!resizeMode) return;
if (resizeMode === 'width') {
const diff = anchoredLeft ? (event.clientX - startX) : (startX - event.clientX);
panel.style.width = `${clampPanelWidth(startW + diff)}px`;
return;
}
const viewportHeight = Math.max(root.innerHeight || 0, 640);
if (resizeMode === 'bottom-height') {
const nextHeight = clampPanelHeight(startH + (event.clientY - startY));
panel.style.height = `${Math.min(nextHeight, viewportHeight - startTop - 8)}px`;
return;
}
if (resizeMode === 'top-height') {
const bottom = startTop + startH;
const rawHeight = startH - (event.clientY - startY);
const nextHeight = clampPanelHeight(rawHeight);
const nextTop = Math.max(0, bottom - nextHeight);
panel.style.top = `${nextTop}px`;
panel.style.height = `${Math.min(nextHeight, bottom - nextTop)}px`;
}
});
root.document.addEventListener('mouseup', () => {
if (!resizeMode) return;
const patch = {
panelWidth: clampPanelWidth(panel.offsetWidth),
panelHeight: clampPanelHeight(panel.offsetHeight)
};
if (resizeMode === 'top-height') patch.panelY = Math.max(0, panel.getBoundingClientRect().top);
resizeMode = null;
panel.classList.remove('psim-resizing');
engine.patchUI(patch);
});
}
function injectStyles() {
if (root.document.getElementById('psim-styles')) return;
const style = root.document.createElement('style');
style.id = 'psim-styles';
style.textContent = `
#polymarket-sim-panel { position: fixed; right: 12px; top: 60px; z-index: 2147483647; width: ${PANEL_WIDTH.DEFAULT}px; height: ${getDefaultPanelHeight()}px; overflow: hidden; display: flex; flex-direction: column; background: rgba(10, 10, 15, 0.97); color: #e2e8f0; border: 1px solid rgba(6, 182, 212, 0.15); border-radius: 10px; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); transition: box-shadow 140ms ease, opacity 140ms ease, transform 140ms ease; }
#polymarket-sim-panel.psim-dark-off { background: rgba(247, 248, 252, 0.97); color: #1f2937; }
#polymarket-sim-panel.psim-dragging { opacity: 0.94; box-shadow: 0 18px 44px rgba(0,0,0,0.58); transform: translateY(-1px); }
#polymarket-sim-panel.psim-snap-settle { box-shadow: 0 12px 34px rgba(0,0,0,0.52); transform: translateY(0); }
#polymarket-sim-panel.psim-resizing { box-shadow: 0 18px 44px rgba(0,0,0,0.58); }
#psim-snap-guide { position: fixed; top: 10px; bottom: 10px; width: 1px; pointer-events: none; opacity: 0; background: rgba(34, 211, 238, 0.42); border-left: 1px dashed rgba(34, 211, 238, 0.58); transition: opacity 90ms ease; z-index: 2147483646; }
#psim-snap-guide.visible { opacity: 1; }
#polymarket-sim-panel.psim-collapsed { width: 58px; overflow: hidden; }
#polymarket-sim-panel.psim-collapsed #psim-content { display: none; }
#polymarket-sim-panel.psim-collapsed #psim-resize-handle, #polymarket-sim-panel.psim-collapsed #psim-resize-top-handle, #polymarket-sim-panel.psim-collapsed #psim-resize-bottom-handle { display: none; }
#psim-resize-handle { position: absolute; top: 42px; right: 0; bottom: 10px; width: 10px; cursor: ew-resize; background: transparent; display: flex; align-items: center; justify-content: center; }
#psim-resize-handle:hover { background: linear-gradient(90deg, transparent 0%, rgba(6,182,212,0.10) 100%); }
#psim-resize-handle::after { content: '⋮'; font-size: 14px; color: #334155; line-height: 1; }
#psim-resize-top-handle, #psim-resize-bottom-handle { position: absolute; left: 10px; right: 10px; height: 10px; cursor: ns-resize; background: transparent; display: flex; align-items: center; justify-content: center; z-index: 2; }
#psim-resize-top-handle { top: 38px; }
#psim-resize-bottom-handle { bottom: 0; }
#psim-resize-top-handle::after, #psim-resize-bottom-handle::after { content: ''; width: 38px; height: 2px; border-radius: 999px; background: rgba(100,116,139,0.6); }
#psim-resize-top-handle:hover::after, #psim-resize-bottom-handle:hover::after { background: rgba(34,211,238,0.78); }
#psim-content::-webkit-scrollbar { width: 5px; }
#psim-content::-webkit-scrollbar-thumb { background: rgba(6,182,212,0.25); border-radius: 3px; }
#psim-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: rgba(15, 15, 25, 0.98); border-radius: 10px 10px 0 0; border-bottom: 1px solid rgba(6, 182, 212, 0.1); }
#psim-title { font-weight: 700; font-size: 15px; color: #06b6d4; letter-spacing: 1px; }
#psim-header-btns { display: flex; gap: 4px; }
#psim-header-btns button { background: rgba(30,30,50,0.8); border: 1px solid rgba(6,182,212,0.2); color: #94a3b8; border-radius: 6px; width: 24px; height: 24px; cursor: pointer; font-size: 12px; }
#psim-header-btns button:hover { color: #e2e8f0; background: rgba(6,182,212,0.06); border-color: rgba(6,182,212,0.4); }
#psim-content { padding: 8px 12px 12px 8px; flex: 1; min-height: 0; overflow: auto; }
.psim-section { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid rgba(6,182,212,0.06); }
.psim-section:last-child { border-bottom: none; }
.psim-section-title { font-size: 11px; font-weight: 600; color: #06b6d4; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 8px; }
.psim-subtle { color: #64748b; font-size: 11px; margin-top: 6px; }
.psim-main-tabs { display: flex; gap: 4px; flex-wrap: wrap; padding: 2px 0 8px; border-bottom: 1px solid rgba(6,182,212,0.06); margin-bottom: 8px; }
.psim-main-tab { background: rgba(15,15,25,0.74); border: 1px solid rgba(6,182,212,0.12); color: #64748b; border-radius: 999px; padding: 4px 9px; font-size: 10px; letter-spacing: 0.3px; cursor: pointer; }
.psim-main-tab.active { color: #67e8f9; border-color: rgba(34,211,238,0.35); background: rgba(6,182,212,0.12); }
.psim-workspace { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.psim-dash-strip { display: flex; gap: 0; }
.psim-dash-item { flex: 1; padding: 8px 10px; background: transparent; border-radius: 0; }
.psim-dash-item + .psim-dash-item { border-left: 1px solid rgba(6,182,212,0.08); }
.psim-dash-item:nth-child(2) .psim-value { font-size: 16px; font-weight: 700; color: #e2e8f0; }
.psim-label { display: block; font-size: 10px; color: #64748b; margin-bottom: 2px; text-transform: uppercase; letter-spacing: 0.5px; }
.psim-value { font-size: 13px; font-weight: 500; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
.psim-positive { color: #34d399 !important; }
.psim-negative { color: #f87171 !important; }
.psim-market-title, .psim-watch-title, .psim-search-title, .psim-outcome-name, .psim-pos-outcome, .psim-h-market { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.psim-pos-outcome { color: #06b6d4; text-decoration: none; }
.psim-pos-outcome:hover { color: #22d3ee; text-decoration: underline; }
.psim-outcomes, .psim-watchlist, .psim-history-list, .psim-positions-list { display: flex; flex-direction: column; gap: 3px; }
.psim-outcome-row, .psim-position-row, .psim-watch-item { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; background: transparent; border-radius: 6px; padding: 5px; }
.psim-outcome-row:hover, .psim-position-row:hover { background: rgba(6,182,212,0.06); }
.psim-watch-item:hover { background: rgba(6,182,212,0.06); border-left: 2px solid #06b6d4; }
.psim-outcome-name { flex: 1; min-width: 0; color: #cbd5e1; font-size: 12px; }
.psim-trade-board { display: flex; flex-direction: column; gap: 4px; }
.psim-ticket-toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 2px; padding: 3px 2px 6px; border-bottom: 1px solid rgba(6,182,212,0.08); }
.psim-ticket-size-box { display: flex; align-items: center; gap: 6px; }
.psim-ticket-size-label { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }
.psim-order-qty { width: 84px; text-align: right; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
.psim-size-presets { display: flex; gap: 4px; flex-wrap: wrap; }
.psim-size-chip { background: rgba(15,15,25,0.8); border: 1px solid rgba(6,182,212,0.14); color: #94a3b8; border-radius: 999px; padding: 3px 9px; font-size: 11px; cursor: pointer; position: relative; z-index: 2; }
.psim-size-chip.active { background: rgba(6,182,212,0.18); border-color: rgba(34,211,238,0.45); color: #67e8f9; }
.psim-ticket-hint { margin-left: auto; font-size: 10px; color: #64748b; }
.psim-trade-ticket { align-items: stretch; padding: 4px 6px; border: 1px solid rgba(6,182,212,0.07); background: rgba(8,12,18,0.16); }
.psim-ticket-main { display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0; }
.psim-ticket-head { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1 1 48%; max-width: 48%; }
.psim-quote-group { display: flex; gap: 3px; flex-wrap: wrap; align-items: stretch; margin-left: auto; flex: 0 0 auto; }
.psim-quote-stack { min-width: 72px; display: flex; flex-direction: column; align-items: flex-end; gap: 0; padding: 2px 4px; border-radius: 5px; background: rgba(15,15,25,0.42); border: 1px solid rgba(6,182,212,0.07); contain: content; }
.psim-quote-yes { border-color: rgba(34,197,94,0.18); }
.psim-quote-no { border-color: rgba(239,68,68,0.18); }
.psim-quote-empty { opacity: 0.68; border-style: dashed; }
.psim-quote-label { font-size: 9px; letter-spacing: 0.4px; color: #64748b; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
.psim-quote-mid, .psim-quote-best { min-width: 72px; text-align: right; color: #f8fafc; font-weight: 800; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 13px; display: block; font-variant-numeric: tabular-nums; letter-spacing: 0.1px; }
.psim-quote-best { display: flex; justify-content: flex-end; align-items: baseline; gap: 3px; }
.psim-quote-best-tag { font-size: 8px; font-weight: 600; color: #64748b; letter-spacing: 0.35px; }
.psim-quote-best-value { display: inline-block; }
.psim-quote-meta { min-width: 72px; font-size: 9px; color: #64748b; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; display: block; text-align: right; font-variant-numeric: tabular-nums; }
.psim-quote-actions { display: flex; gap: 3px; margin-top: 2px; }
.psim-quote-best, .psim-quote-meta { transition: color 120ms ease, background-color 120ms ease; border-radius: 4px; padding: 0 1px; }
.psim-tick-up { color: #67e8f9 !important; background: rgba(34,197,94,0.08); }
.psim-tick-down { color: #fda4af !important; background: rgba(239,68,68,0.08); }
.psim-quote-best.psim-tick-up { background: rgba(34,197,94,0.12); }
.psim-quote-best.psim-tick-down { background: rgba(239,68,68,0.12); }
.psim-side-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 18px; padding: 0 6px; border-radius: 999px; font-size: 10px; font-weight: 700; letter-spacing: 0.6px; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
.psim-side-compact { min-width: 24px; height: 16px; font-size: 9px; padding: 0 5px; }
.psim-side-yes { color: #34d399; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.22); }
.psim-side-no { color: #fda4af; background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.22); }
.psim-outcome-price { min-width: 48px; text-align: right; color: #06b6d4; font-weight: 500; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 13px; display: block; }
.psim-spread { font-size: 10px; color: #475569; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; display: block; text-align: right; margin-top: 2px; }
.psim-no-price { font-size: 10px; color: #f87171; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; display: block; text-align: right; margin-top: 1px; }
.psim-qty-input, .psim-setting-input, .psim-search-input, #psim-holdings-sort { background: rgba(15,15,25,0.8); border: 1px solid rgba(6,182,212,0.15); color: #e2e8f0; border-radius: 6px; padding: 4px 7px; font-size: 12px; }
.psim-qty-input { width: 58px; text-align: center; }
.psim-btn { background: rgba(30,30,50,0.7); border: 1px solid rgba(6,182,212,0.15); color: #94a3b8; border-radius: 6px; padding: 3px 8px; font-size: 11px; cursor: pointer; }
.psim-btn:hover { color: #e2e8f0; background: rgba(6,182,212,0.06); border-color: rgba(6,182,212,0.3); }
.psim-buy { background: rgba(34, 197, 94, 0.12); border-color: rgba(34, 197, 94, 0.25); color: #34d399; padding: 3px 8px; font-size: 11px; }
.psim-buy-no { background: rgba(239, 68, 68, 0.12); border-color: rgba(239, 68, 68, 0.25); color: #fda4af; padding: 3px 8px; font-size: 11px; }
.psim-sell { background: rgba(239, 68, 68, 0.12); border-color: rgba(239, 68, 68, 0.25); color: #f87171; padding: 3px 8px; font-size: 11px; }
.psim-disabled { opacity: 0.45; cursor: not-allowed; border-style: dashed; }
.psim-danger, .psim-alert-rm { background: rgba(180,40,40,0.2); border-color: rgba(220,60,60,0.2); color: #fca5a5; }
.psim-close-position { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.24); color: #fbbf24; }
.psim-close-all { width: 100%; margin-top: 4px; background: rgba(127,29,29,0.28); border-color: rgba(248,113,113,0.3); color: #fecaca; }
.psim-close-all:hover { background: rgba(153,27,27,0.36); border-color: rgba(248,113,113,0.45); }
.psim-empty { color: #475569; font-size: 12px; padding: 8px; text-align: center; }
.psim-search-row, .psim-pos-actions, .psim-search-actions, .psim-history-btns, .psim-btn-row { display: flex; gap: 5px; align-items: center; }
.psim-trade-actions { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-left: auto; }
.psim-search-input { flex: 1; }
.psim-collapsible-header { display: flex; justify-content: space-between; align-items: center; padding: 5px 4px; cursor: pointer; user-select: none; font-size: 12px; font-weight: 600; color: #64748b; }
.psim-collapsible-header:hover { color: #06b6d4; }
.psim-chevron { font-size: 10px; color: #475569; }
.psim-pos-header { display: flex; justify-content: space-between; width: 100%; }
.psim-pos-title-wrap { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
.psim-pos-details { width: 100%; font-size: 11px; color: #64748b; font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, monospace; }
.psim-pos-subdetails { color: #475569; }
.psim-pos-actions { width: 100%; justify-content: flex-end; padding-top: 2px; border-top: 1px solid rgba(6,182,212,0.06); }
.psim-pos-pnl { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 12px; }
.psim-pos-pnl.psim-positive { background: rgba(52,211,153,0.08); padding: 2px 4px; border-radius: 3px; }
.psim-pos-pnl.psim-negative { background: rgba(248,113,113,0.08); padding: 2px 4px; border-radius: 3px; }
.psim-history-row { display: flex; flex-direction: column; gap: 3px; font-size: 11px; padding: 5px 6px; border-radius: 6px; border: 1px solid rgba(6,182,212,0.05); font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, monospace; }
.psim-history-row:hover { background: rgba(6,182,212,0.04); }
.psim-alerts-list { display: flex; flex-direction: column; gap: 4px; }
.psim-alert-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid rgba(6,182,212,0.05); }
.psim-alert-item:last-child { border-bottom: none; }
.psim-alert-item-main { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.psim-alert-item-name { color: #cbd5e1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.psim-alert-item-meta { color: #64748b; font-size: 10px; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
.psim-h-main, .psim-h-meta { display: flex; align-items: center; gap: 6px; width: 100%; flex-wrap: wrap; }
.psim-h-main { color: #cbd5e1; }
.psim-h-time { color: #64748b; min-width: 46px; }
.psim-h-action { color: #e2e8f0; min-width: 32px; }
.psim-h-market { min-width: 0; flex: 1; }
.psim-h-fill { color: #94a3b8; margin-left: auto; }
.psim-h-meta { color: #64748b; padding-left: 84px; }
.psim-h-net, .psim-h-fee { color: #94a3b8; }
.psim-h-tail { color: #64748b; }
.psim-setting-row { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; font-size: 12px; color: #94a3b8; }
.psim-setting-row label { color: #64748b; }
.psim-api-ok { font-size: 10px; color: #22c55e; font-weight: 400; float: right; }
.psim-api-error { font-size: 10px; color: #ef4444; font-weight: 400; float: right; cursor: help; }
#psim-api-status { font-size: 10px; }
.psim-market-title { font-size: 12px; color: #cbd5e1; margin-bottom: 6px; }
.psim-watch-item { padding: 4px 6px; }
.psim-alert-sm { font-size: 9px; padding: 1px 0; width: 20px; min-width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; }
.psim-watch-star { background: none; border: none; color: #64748b; cursor: pointer; font-size: 13px; padding: 0 2px; vertical-align: middle; }
.psim-watch-star:hover { color: #f59e0b; }
.psim-watch-title { color: #cbd5e1; text-decoration: none; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.psim-watch-title:hover { color: #06b6d4; }
.psim-watch-collapsed { opacity: 0.55; }
.psim-watch-expand { background: none; border: none; color: #64748b; cursor: pointer; padding: 0 4px 0 0; font-size: 10px; }
.psim-watch-expand:hover { color: #06b6d4; }
.psim-alert-form { background: rgba(15,15,25,0.6); border: 1px solid rgba(6,182,212,0.12); border-radius: 8px; padding: 10px 12px; }
.psim-alert-outcome { font-size: 12px; color: #cbd5e1; margin-bottom: 6px; font-weight: 500; }
.psim-alert-observed { font-size: 11px; color: #64748b; margin-bottom: 8px; }
.psim-alert-observed .psim-value { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; color: #06b6d4; }
.psim-alert-row { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
.psim-alert-label { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; min-width: 52px; }
.psim-alert-input { flex: 1; text-align: right; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
.psim-alert-toggle-group { display: flex; gap: 3px; }
.psim-toggle-btn { background: rgba(15,15,25,0.8); border: 1px solid rgba(6,182,212,0.15); color: #64748b; border-radius: 5px; padding: 3px 8px; font-size: 11px; cursor: pointer; transition: all 0.15s; }
.psim-toggle-btn:hover { color: #94a3b8; border-color: rgba(6,182,212,0.3); }
.psim-toggle-btn.active { background: rgba(6,182,212,0.15); border-color: rgba(6,182,212,0.4); color: #06b6d4; }
.psim-binary-toggle { display: flex; gap: 6px; margin-bottom: 8px; }
.psim-binary-toggle .psim-toggle-btn { flex: 1; text-align: center; font-size: 13px; padding: 5px 12px; font-weight: 600; }
.psim-binary-toggle .psim-toggle-btn.active { background: rgba(6,182,212,0.2); border-color: #06b6d4; color: #06b6d4; }
.psim-binary-info { font-size: 11px; color: #64748b; text-align: center; margin-top: 4px; }
.psim-alert-btns { display: flex; gap: 6px; margin-top: 10px; }
.psim-alert-btns .psim-btn { flex: 1; text-align: center; }
#polymarket-sim-panel.psim-wide .psim-quote-group { flex-wrap: nowrap; width: auto; }
#polymarket-sim-panel.psim-wide .psim-quote-stack { flex: 1; min-width: 0; }
#polymarket-sim-panel.psim-wide .psim-ticket-toolbar { flex-wrap: nowrap; }
#polymarket-sim-panel.psim-narrow .psim-ticket-main { flex-wrap: wrap; }
#polymarket-sim-panel.psim-wide .psim-ticket-head { max-width: 54%; }
#polymarket-sim-panel.psim-narrow .psim-ticket-head { max-width: 100%; flex-basis: 100%; }
#polymarket-sim-panel.psim-narrow .psim-quote-group { margin-left: 0; width: 100%; }
#polymarket-sim-panel.psim-narrow .psim-h-fill { margin-left: 0; width: 100%; }
#polymarket-sim-panel.psim-narrow .psim-h-meta { padding-left: 0; }
#polymarket-sim-panel.psim-narrow .psim-h-fee { display: none; }
#polymarket-sim-panel.psim-narrow .psim-h-tail { width: 100%; }
`;
root.document.head.appendChild(style);
}
function refreshData() {
const state = getState();
const slug = getPageSlug();
if (slug !== currentPageSlug) {
currentPageSlug = slug;
if (slug) {
getMarketBySlug(slug, (_err, market) => {
if (market) updateCurrentMarket();
});
} else {
updateCurrentMarket();
}
} else if (slug && (!marketCache[slug] || Date.now() - marketCache[slug].ts > MARKET_CACHE_MS)) {
getMarketBySlug(slug, (_err, market) => {
if (market) updateCurrentMarket();
});
}
const tokenIds = [...new Set(state.positions.map((position) => position.tokenId).concat(state.alerts.filter((alert) => alert.active).map((alert) => alert.tokenId)))];
let pending = tokenIds.length;
if (pending === 0) { schedulePortfolioPriceUpdate(); scheduleCurrentMarketPriceUpdate(); return; }
tokenIds.forEach((tokenId) => {
getClobPricePair(tokenId, (_err, pair) => {
const buyPrice = pair.buyPrice;
const sellPrice = pair.sellPrice;
if (Number.isFinite(sellPrice) && sellPrice > 0) engine.updatePositionPrice(tokenId, sellPrice, 'SELL');
const triggered = engine.evaluateAlerts((alert) => alert.tokenId === tokenId ? { BUY: buyPrice, SELL: sellPrice, MID: pair.midPrice } : null);
if (triggered.length > 0) triggerNotifications(triggered);
pending -= 1;
if (pending === 0) { schedulePortfolioPriceUpdate(); scheduleCurrentMarketPriceUpdate(); }
});
});
}
function startRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = root.setInterval(refreshData, Math.max(2, getState().settings.refreshSeconds) * 1000);
}
function init() {
root.console.info('[PolySim] Initializing...');
try {
injectStyles();
root.console.info('[PolySim] Styles injected');
} catch (e) {
root.console.error('[PolySim] injectStyles failed:', e);
}
try {
createPanel();
root.console.info('[PolySim] Panel created, panelEl:', !!panelEl);
} catch (e) {
root.console.error('[PolySim] createPanel failed:', e);
}
const slug = getPageSlug();
currentPageSlug = slug;
root.console.info('[PolySim] Page slug:', slug);
render();
if (slug) {
getMarketBySlug(slug, (err, market) => {
root.console.info('[PolySim] Market loaded:', !!market, 'err:', err && err.message);
if (!err && market) updateCurrentMarket();
});
}
startRefresh();
if (getState().watchlist.length > 0) updateWatchlist();
root.console.info('[PolySim] Init complete');
}
if (root.document.readyState === 'complete') init();
else root.window.addEventListener('load', init);
})(typeof globalThis !== 'undefined' ? globalThis : window);