mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 21:47:46 +00:00
Add Step 1 "Prepare trade" pre-flight ticket (read-only, no execution)
New arb/prepare.py + GET /api/prepare: for one monthly arb pair it re-pulls fresh quotes (incl. real Kalshi top-of-book ask size) and builds the exact order pair a cross-venue hedge would need -- sides from best_side, size capped to the thinner book, limit = ask + slippage buffer, capital required, guaranteed worst-case, and warnings. Places NO orders and touches no order endpoints anywhere. UI: a "Prepare trade" button in the monthly drawer's breakdown card opens a PREVIEW-ONLY ticket modal (max-size + slippage-cent inputs that re-price live; a deliberately disabled Confirm button as the Step-2 placeholder). Also reorders the expanded drawer so the breakdown/Prepare card is the wide left column. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
|||||||
|
"""Build a read-only 'what would be sent' trade ticket for ONE arb pair.
|
||||||
|
|
||||||
|
*** THIS MODULE PLACES NO ORDERS. ***
|
||||||
|
|
||||||
|
It only reads live quotes (the same public / read-only calls the scanner
|
||||||
|
already makes) and computes the exact pair of orders a cross-venue hedge would
|
||||||
|
require — side, size, limit price, capital, and guaranteed worst-case — so the
|
||||||
|
user can preview a trade before any execution path is ever wired up.
|
||||||
|
|
||||||
|
No Kalshi/Polymarket *order* endpoints are touched anywhere in this file.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from . import calc, kalshi, poly, scan
|
||||||
|
|
||||||
|
|
||||||
|
def _int(x):
|
||||||
|
return int(x) if x is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_prepare(ticker, slug, max_size=None, buffer_cents=1.0):
|
||||||
|
"""Return a preview ticket dict for the pair (ticker, slug). Read-only."""
|
||||||
|
if not ticker or not slug:
|
||||||
|
return {"error": "ticker and slug are required"}
|
||||||
|
|
||||||
|
pair = next((p for p in scan.load_pairs()
|
||||||
|
if p.get("kalshi_ticker") == ticker
|
||||||
|
and p.get("poly_slug") == slug), None)
|
||||||
|
if not pair:
|
||||||
|
return {"error": "pair not found in pairs.json"}
|
||||||
|
settings = scan.load_settings()
|
||||||
|
|
||||||
|
# Fresh, read-only quotes for just this one pair.
|
||||||
|
kq = kalshi.fetch_quotes([ticker]).get(ticker)
|
||||||
|
pq = poly.fetch_quotes([slug]).get(slug)
|
||||||
|
# Real Kalshi top-of-book ask size (the scan uses open interest as a proxy
|
||||||
|
# off-candidate; for a trade ticket we want the executable size).
|
||||||
|
szs = (kalshi.fetch_sizes([ticker]) or {}).get(ticker)
|
||||||
|
if szs and kq:
|
||||||
|
if szs.get("yes_ask_size") is not None:
|
||||||
|
kq["yes_ask_size"] = szs["yes_ask_size"]
|
||||||
|
if szs.get("no_ask_size") is not None:
|
||||||
|
kq["no_ask_size"] = szs["no_ask_size"]
|
||||||
|
|
||||||
|
row = calc.evaluate(pair, kq, pq, settings)
|
||||||
|
if not row.get("best_side"):
|
||||||
|
return {"error": "no executable hedge right now",
|
||||||
|
"status": row.get("status"), "asset": row.get("asset")}
|
||||||
|
|
||||||
|
buf = (buffer_cents or 0.0) / 100.0
|
||||||
|
|
||||||
|
# best_side "YES+NO" = Kalshi YES + Poly NO ; "NO+YES" = Kalshi NO + Poly YES
|
||||||
|
k_side, p_side = (("yes", "no") if row["best_side"] == "YES+NO"
|
||||||
|
else ("no", "yes"))
|
||||||
|
k_ask, p_ask = row.get("kalshi_price"), row.get("poly_price")
|
||||||
|
# Marketable limit = ask + buffer, capped below $1. Kalshi ticks in cents,
|
||||||
|
# Polymarket finer — round each to its grid.
|
||||||
|
k_limit = min(0.99, round(k_ask + buf, 2)) if k_ask is not None else None
|
||||||
|
p_limit = min(0.999, round(p_ask + buf, 3)) if p_ask is not None else None
|
||||||
|
|
||||||
|
avail = row.get("max_contracts")
|
||||||
|
size, capped = avail, None
|
||||||
|
if max_size and avail is not None and max_size < avail:
|
||||||
|
size, capped = max_size, "your max"
|
||||||
|
size = _int(size)
|
||||||
|
|
||||||
|
ksz, psz = row.get("kalshi_size"), row.get("poly_size")
|
||||||
|
thin = None
|
||||||
|
if ksz is not None and psz is not None:
|
||||||
|
thin = "Kalshi" if ksz <= psz else "Polymarket"
|
||||||
|
|
||||||
|
k_cost = size * k_ask if (size and k_ask is not None) else None
|
||||||
|
p_cost = size * p_ask if (size and p_ask is not None) else None
|
||||||
|
capital = (k_cost or 0) + (p_cost or 0) if size else None
|
||||||
|
|
||||||
|
w_ct = row.get("worst_pnl")
|
||||||
|
w_total = w_ct * size if (w_ct is not None and size) else None
|
||||||
|
fee_total = (row.get("total_fee") or 0) * size if size else None
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
if not size:
|
||||||
|
warnings.append("No executable size on at least one leg right now.")
|
||||||
|
elif capped:
|
||||||
|
warnings.append("Size capped to %s (%d contracts)." % (capped, size))
|
||||||
|
elif thin:
|
||||||
|
warnings.append(
|
||||||
|
"Size capped to the thinner %s book (%d contracts)." % (thin, size))
|
||||||
|
if w_ct is not None and w_ct < 0:
|
||||||
|
warnings.append(
|
||||||
|
"Worst case is NEGATIVE — this is not a guaranteed-positive arb.")
|
||||||
|
if row.get("status") != "ARB":
|
||||||
|
warnings.append("Scanner status is %s, not ARB." % row.get("status"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"preview_only": True, # never an executable instruction
|
||||||
|
"asset": row.get("asset"),
|
||||||
|
"status": row.get("status"),
|
||||||
|
"as_of": time.strftime("%H:%M:%S"),
|
||||||
|
"size": size,
|
||||||
|
"available": _int(avail),
|
||||||
|
"thin_leg": thin,
|
||||||
|
"guaranteed_positive": (w_ct is not None and w_ct >= 0),
|
||||||
|
"buffer_cents": buffer_cents,
|
||||||
|
"kalshi": {
|
||||||
|
"venue": "Kalshi", "ticker": ticker, "action": "buy",
|
||||||
|
"side": k_side, "ask": k_ask, "limit": k_limit,
|
||||||
|
"size": size, "cost": k_cost, "title": row.get("kalshi_title"),
|
||||||
|
},
|
||||||
|
"poly": {
|
||||||
|
"venue": "Polymarket", "slug": slug, "action": "buy",
|
||||||
|
"side": p_side, "ask": p_ask, "limit": p_limit,
|
||||||
|
"size": size, "cost": p_cost, "question": row.get("poly_question"),
|
||||||
|
},
|
||||||
|
"combined_cost_ct": row.get("combined_cost"),
|
||||||
|
"capital_required": capital,
|
||||||
|
"worst_pnl_ct": w_ct,
|
||||||
|
"worst_pnl_total": w_total,
|
||||||
|
"best_pnl_ct": row.get("best_pnl"),
|
||||||
|
"fees_total": fee_total,
|
||||||
|
"net_return": row.get("net_return"),
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import sys
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
from arb import scan
|
from arb import scan, prepare
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
WEB = os.path.join(ROOT, "web")
|
WEB = os.path.join(ROOT, "web")
|
||||||
@@ -72,6 +72,26 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._send(500, {"error": "%s: %s"
|
return self._send(500, {"error": "%s: %s"
|
||||||
% (type(e).__name__, e)})
|
% (type(e).__name__, e)})
|
||||||
|
if u.path == "/api/prepare":
|
||||||
|
# Read-only: build a preview ticket for one pair. Places NO orders.
|
||||||
|
qs = parse_qs(u.query)
|
||||||
|
ticker = qs.get("ticker", [""])[0]
|
||||||
|
slug = qs.get("slug", [""])[0]
|
||||||
|
raw_max = qs.get("max", [""])[0]
|
||||||
|
try:
|
||||||
|
max_size = int(float(raw_max)) if raw_max else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_size = None
|
||||||
|
try:
|
||||||
|
buffer_cents = float(qs.get("buffer", ["1"])[0])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
buffer_cents = 1.0
|
||||||
|
try:
|
||||||
|
return self._send(200, prepare.run_prepare(
|
||||||
|
ticker, slug, max_size, buffer_cents))
|
||||||
|
except Exception as e:
|
||||||
|
return self._send(500, {"error": "%s: %s"
|
||||||
|
% (type(e).__name__, e)})
|
||||||
if u.path == "/api/settings":
|
if u.path == "/api/settings":
|
||||||
return self._send(200, scan.load_settings())
|
return self._send(200, scan.load_settings())
|
||||||
self._send(404, {"error": "not found"})
|
self._send(404, {"error": "not found"})
|
||||||
|
|||||||
+95
-2
@@ -253,9 +253,13 @@ function drawer(r) {
|
|||||||
${kv("Basis %", fmt(r.basis_pct, "pct") + (r.basis_favorable ? ' <span class="pos">✓ favorable</span>' : ' <span class="neg">✗ risk</span>'))}
|
${kv("Basis %", fmt(r.basis_pct, "pct") + (r.basis_favorable ? ' <span class="pos">✓ favorable</span>' : ' <span class="neg">✗ risk</span>'))}
|
||||||
${kv("Max contracts", r.max_contracts != null ? Math.round(r.max_contracts).toLocaleString() : "—")}
|
${kv("Max contracts", r.max_contracts != null ? Math.round(r.max_contracts).toLocaleString() : "—")}
|
||||||
${hRows}
|
${hRows}
|
||||||
</div></div>`;
|
</div>${MODE === "monthly" && r.best_side && r.kalshi_ticker && r.poly_slug
|
||||||
|
? `<div class="prep-row">
|
||||||
|
<button class="prep-btn" data-ticker="${esc(r.kalshi_ticker)}" data-slug="${esc(r.poly_slug)}">Prepare trade ▸</button>
|
||||||
|
<span class="prep-hint">read-only preview · places nothing</span>
|
||||||
|
</div>` : ""}</div>`;
|
||||||
return `<tr class="detail"><td colspan="${COLS().length}">
|
return `<tr class="detail"><td colspan="${COLS().length}">
|
||||||
<div class="drawer">${kCard}${pCard}${bd}</div></td></tr>`;
|
<div class="drawer">${bd}${kCard}${pCard}</div></td></tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBody() {
|
function renderBody() {
|
||||||
@@ -291,6 +295,10 @@ function renderBody() {
|
|||||||
renderBody();
|
renderBody();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll("#body .prep-btn").forEach((btn) => btn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openTicket(btn.dataset.ticker, btn.dataset.slug);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(s) {
|
function renderSummary(s) {
|
||||||
@@ -680,6 +688,80 @@ async function loadSettings() {
|
|||||||
s_minct.value = s.min_contracts;
|
s_minct.value = s.min_contracts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Prepare-trade ticket (Step 1: read-only preview, places no orders) ----
|
||||||
|
let TICKET = null;
|
||||||
|
|
||||||
|
function openTicket(ticker, slug) {
|
||||||
|
TICKET = { ticker, slug };
|
||||||
|
$("ticketModal").hidden = false;
|
||||||
|
$("tkMax").value = "";
|
||||||
|
$("tkBuf").value = "1";
|
||||||
|
$("ticketBody").innerHTML = `<div class="tk-load">Pulling fresh quotes…</div>`;
|
||||||
|
refreshTicket();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTicket() {
|
||||||
|
$("ticketModal").hidden = true;
|
||||||
|
TICKET = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTicket() {
|
||||||
|
if (!TICKET) return;
|
||||||
|
const p = new URLSearchParams({ ticker: TICKET.ticker, slug: TICKET.slug });
|
||||||
|
const mx = $("tkMax").value, bf = $("tkBuf").value;
|
||||||
|
if (mx) p.set("max", mx);
|
||||||
|
if (bf !== "") p.set("buffer", bf);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/prepare?" + p.toString());
|
||||||
|
const t = await res.json();
|
||||||
|
$("ticketBody").innerHTML =
|
||||||
|
(!res.ok || t.error) ? ticketError(t) : ticketHTML(t);
|
||||||
|
} catch (e) {
|
||||||
|
$("ticketBody").innerHTML = `<div class="tk-err">Failed: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketError(t) {
|
||||||
|
return `<div class="tk-err">${esc((t && t.error) || "Could not prepare")}
|
||||||
|
${t && t.status ? `<span class="dimv">(status ${esc(t.status)})</span>` : ""}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketHTML(t) {
|
||||||
|
const c = (n, d = 2) => n == null ? "—"
|
||||||
|
: "$" + (+n).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
const ct = (n) => n == null ? "—" : Math.round(n).toLocaleString();
|
||||||
|
const order = (o) => {
|
||||||
|
const dp = o.venue === "Kalshi" ? 2 : 3;
|
||||||
|
return `<div class="tk-order">
|
||||||
|
<span class="tag ${o.venue === "Kalshi" ? "k" : "p"}">${o.venue}</span>
|
||||||
|
<span class="tk-buy">BUY</span>
|
||||||
|
<span class="sidetag ${esc(o.side)}">${esc((o.side || "—").toUpperCase())}</span>
|
||||||
|
<span class="tk-sz">${ct(o.size)}<span class="dimv"> ct</span></span>
|
||||||
|
<span class="tk-px">@ ${o.limit != null ? (+o.limit).toFixed(dp) : "—"}
|
||||||
|
<span class="dimv">(ask ${o.ask != null ? (+o.ask).toFixed(dp) : "—"})</span></span>
|
||||||
|
<span class="tk-cost">${c(o.cost)}</span>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
const wp = t.guaranteed_positive, wct = t.worst_pnl_ct, wt = t.worst_pnl_total;
|
||||||
|
const stat = (l, v, cls) =>
|
||||||
|
`<div class="tk-stat"><span>${l}</span><b class="${cls || ""}">${v}</b></div>`;
|
||||||
|
const warn = (t.warnings || []).map((w) => `<li>${esc(w)}</li>`).join("");
|
||||||
|
return `<div class="tk-head">
|
||||||
|
<span class="achip" style="background:${ac(t.asset)}22;color:${ac(t.asset)}">${esc(t.asset || "?")}</span>
|
||||||
|
<span class="dimv">status ${esc(t.status || "—")} · as of ${esc(t.as_of || "")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tk-orders">${order(t.kalshi)}${order(t.poly)}</div>
|
||||||
|
<div class="tk-stats">
|
||||||
|
${stat("Size", `${ct(t.size)}${t.available != null ? ` <span class="dimv">/ ${ct(t.available)} avail</span>` : ""}`)}
|
||||||
|
${stat("Combined cost", t.combined_cost_ct != null ? "$" + (+t.combined_cost_ct).toFixed(4) + ' <span class="dimv">/ct</span>' : "—")}
|
||||||
|
${stat("Capital needed", c(t.capital_required))}
|
||||||
|
${stat("Worst-case P&L", `${wt != null ? (wt > 0 ? "+" : "") + c(wt) : "—"}${wct != null ? ` <span class="dimv">(${wct > 0 ? "+" : ""}$${(+wct).toFixed(4)}/ct)</span>` : ""}`, wp ? "pos" : "neg")}
|
||||||
|
${stat("Fees (total)", c(t.fees_total))}
|
||||||
|
${stat("Net return", t.net_return != null ? (t.net_return * 100).toFixed(2) + "%" : "—", wp ? "pos" : "")}
|
||||||
|
</div>
|
||||||
|
${warn ? `<ul class="tk-warn">${warn}</ul>` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
function wire() {
|
function wire() {
|
||||||
document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => {
|
document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => {
|
||||||
if (b.classList.contains("on")) return;
|
if (b.classList.contains("on")) return;
|
||||||
@@ -724,6 +806,17 @@ function wire() {
|
|||||||
if (r.ok) { $("settingsModal").hidden = true; toast("Saved — rescanning", "ok"); load(true); }
|
if (r.ok) { $("settingsModal").hidden = true; toast("Saved — rescanning", "ok"); load(true); }
|
||||||
else toast("Save failed", "err");
|
else toast("Save failed", "err");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// prepare-ticket modal controls
|
||||||
|
$("tkClose").onclick = closeTicket;
|
||||||
|
$("ticketModal").addEventListener("click", (e) => {
|
||||||
|
if (e.target === $("ticketModal")) closeTicket();
|
||||||
|
});
|
||||||
|
$("tkMax").addEventListener("change", refreshTicket);
|
||||||
|
$("tkBuf").addEventListener("change", refreshTicket);
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && !$("ticketModal").hidden) closeTicket();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHead();
|
renderHead();
|
||||||
|
|||||||
@@ -107,6 +107,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="ticketModal" class="modal" hidden>
|
||||||
|
<div class="card tk-card">
|
||||||
|
<div class="tk-top">
|
||||||
|
<h2>Prepare trade <span class="tk-badge">PREVIEW ONLY</span></h2>
|
||||||
|
<button id="tkClose" class="ghost tk-x" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="tk-banner">Nothing is sent to Kalshi or Polymarket. This is exactly
|
||||||
|
what the two orders <b>would</b> be — for review only.</div>
|
||||||
|
<div id="ticketBody"></div>
|
||||||
|
<div class="tk-controls">
|
||||||
|
<label>Max size <input type="number" id="tkMax" min="1" step="1" placeholder="all"></label>
|
||||||
|
<label>Slippage buffer (¢) <input type="number" id="tkBuf" min="0" step="1" value="1"></label>
|
||||||
|
</div>
|
||||||
|
<div class="tk-foot">
|
||||||
|
<button class="primary tk-confirm" disabled
|
||||||
|
title="Execution is not wired — this is Step 1 (preview only)">Confirm & Send — not enabled</button>
|
||||||
|
<span class="tk-foot-note">Step 2 would place these with your keys, only on your click.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast" hidden></div>
|
<div id="toast" class="toast" hidden></div>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+52
-1
@@ -267,7 +267,7 @@ tbody tr.r.open { background: rgba(108,140,255,.07); }
|
|||||||
|
|
||||||
/* ---------- detail drawer ---------- */
|
/* ---------- detail drawer ---------- */
|
||||||
tr.detail td { padding: 0; border-bottom: 1px solid var(--line); }
|
tr.detail td { padding: 0; border-bottom: 1px solid var(--line); }
|
||||||
.drawer { display: grid; grid-template-columns: 1fr 1fr 1.1fr; gap: 18px;
|
.drawer { display: grid; grid-template-columns: 1.1fr 1fr 1fr; gap: 18px;
|
||||||
padding: 20px 22px; background: linear-gradient(180deg,
|
padding: 20px 22px; background: linear-gradient(180deg,
|
||||||
rgba(108,140,255,.06), transparent); animation: slide .22s ease; }
|
rgba(108,140,255,.06), transparent); animation: slide .22s ease; }
|
||||||
@keyframes slide { from { opacity: 0; transform: translateY(-6px); } }
|
@keyframes slide { from { opacity: 0; transform: translateY(-6px); } }
|
||||||
@@ -318,6 +318,57 @@ tr.detail td { padding: 0; border-bottom: 1px solid var(--line); }
|
|||||||
.modal input { width: 120px; background: var(--bg2); border: 1px solid var(--line2);
|
.modal input { width: 120px; background: var(--bg2); border: 1px solid var(--line2);
|
||||||
color: var(--txt); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
color: var(--txt); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
||||||
.modal input:focus { outline: none; border-color: var(--accent); }
|
.modal input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- Prepare-trade button + preview ticket (read-only) ---- */
|
||||||
|
.prep-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
||||||
|
.prep-btn { background: linear-gradient(180deg, var(--accent), #4d63d8);
|
||||||
|
color: #fff; border: none; border-radius: 9px; padding: 8px 14px;
|
||||||
|
font: 600 13px/1 "Inter", sans-serif; cursor: pointer; letter-spacing: .01em; }
|
||||||
|
.prep-btn:hover { filter: brightness(1.08); }
|
||||||
|
.prep-hint { color: var(--faint); font-size: 11.5px; }
|
||||||
|
|
||||||
|
.modal .tk-card { width: 472px; max-width: 94vw; }
|
||||||
|
.tk-top { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.tk-top h2 { display: flex; align-items: center; gap: 9px; }
|
||||||
|
.tk-badge { font: 700 10px/1 "Inter", sans-serif; letter-spacing: .08em;
|
||||||
|
color: #e0a93b; background: #e0a93b22; border: 1px solid #e0a93b55;
|
||||||
|
padding: 4px 7px; border-radius: 6px; }
|
||||||
|
.tk-x { padding: 4px 9px; font-size: 14px; }
|
||||||
|
.tk-banner { background: #e0a93b14; border: 1px solid #e0a93b33; color: var(--dim);
|
||||||
|
border-radius: 9px; padding: 9px 11px; font-size: 12px; margin: 12px 0 14px; }
|
||||||
|
.tk-banner b { color: var(--txt); }
|
||||||
|
.tk-head { display: flex; align-items: center; gap: 10px; font-size: 12px;
|
||||||
|
margin-bottom: 10px; }
|
||||||
|
.tk-orders { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.tk-order { display: flex; align-items: center; gap: 9px; padding: 9px 11px;
|
||||||
|
background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.tk-buy { font-weight: 700; font-size: 11px; letter-spacing: .05em; color: var(--dim); }
|
||||||
|
.tk-sz { font-family: "JetBrains Mono", monospace; font-size: 12.5px; }
|
||||||
|
.tk-px { margin-left: auto; font-family: "JetBrains Mono", monospace; font-size: 12px; }
|
||||||
|
.tk-cost { min-width: 78px; text-align: right; font-family: "JetBrains Mono", monospace;
|
||||||
|
font-weight: 600; }
|
||||||
|
.tk-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 18px;
|
||||||
|
margin: 14px 0 4px; }
|
||||||
|
.tk-stat { display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
gap: 10px; font-size: 12.5px; border-bottom: 1px dashed var(--line); padding: 5px 0; }
|
||||||
|
.tk-stat > span { color: var(--dim); }
|
||||||
|
.tk-stat > b { font-family: "JetBrains Mono", monospace; font-weight: 600; }
|
||||||
|
.tk-warn { margin: 12px 0 2px; padding-left: 18px; color: #e0a93b;
|
||||||
|
font-size: 12px; line-height: 1.5; }
|
||||||
|
.tk-controls { display: flex; gap: 16px; margin: 16px 0 6px; }
|
||||||
|
.tk-controls label { flex: 1; display: flex; flex-direction: column; gap: 5px;
|
||||||
|
font-size: 12px; color: var(--dim); }
|
||||||
|
.tk-controls input { width: 100%; background: var(--bg2); border: 1px solid var(--line2);
|
||||||
|
color: var(--txt); border-radius: 8px; padding: 7px 9px; font-size: 13px; }
|
||||||
|
.tk-controls input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.tk-foot { display: flex; flex-direction: column; gap: 7px; margin-top: 14px;
|
||||||
|
padding-top: 14px; border-top: 1px solid var(--line); }
|
||||||
|
.tk-confirm[disabled] { opacity: .5; cursor: not-allowed;
|
||||||
|
background: var(--bg2); color: var(--dim); border: 1px solid var(--line2); }
|
||||||
|
.tk-foot-note { color: var(--faint); font-size: 11px; text-align: center; }
|
||||||
|
.tk-load, .tk-err { padding: 22px 6px; text-align: center; color: var(--dim);
|
||||||
|
font-size: 13px; }
|
||||||
|
.tk-err { color: var(--bad); }
|
||||||
.modal .row { display: flex; gap: 10px; margin-top: 20px; }
|
.modal .row { display: flex; gap: 10px; margin-top: 20px; }
|
||||||
.modal .row button { flex: 1; }
|
.modal .row button { flex: 1; }
|
||||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
|
|||||||
Reference in New Issue
Block a user