|
|
<!doctype html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="utf-8" /> |
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
|
|
<title>MPTrading Admin</title> |
|
|
<style> |
|
|
:root{ |
|
|
--bg:#0b1020; |
|
|
--panel:#111a33; |
|
|
--panel2:#0f1730; |
|
|
--text:#e7eaf3; |
|
|
--muted:#aab2d5; |
|
|
--line:#233055; |
|
|
--green:#22c55e; |
|
|
--red:#ef4444; |
|
|
--blue:#60a5fa; |
|
|
} |
|
|
*{ box-sizing:border-box; } |
|
|
body{ |
|
|
margin:0; |
|
|
font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif; |
|
|
background:var(--bg); |
|
|
color:var(--text); |
|
|
} |
|
|
header{ |
|
|
height:52px; |
|
|
display:flex; |
|
|
align-items:center; |
|
|
justify-content:space-between; |
|
|
padding:0 14px; |
|
|
border-bottom:1px solid var(--line); |
|
|
background:rgba(0,0,0,0.15); |
|
|
gap:12px; |
|
|
} |
|
|
header .title{ |
|
|
font-weight:700; |
|
|
letter-spacing:0.3px; |
|
|
white-space:nowrap; |
|
|
} |
|
|
.small{ font-size:12px; color:var(--muted); } |
|
|
code{ color:#c7d2fe; } |
|
|
main{ |
|
|
padding:12px; |
|
|
display:grid; |
|
|
grid-template-columns: 1.2fr 1fr; |
|
|
gap:12px; |
|
|
height: calc(100vh - 52px); |
|
|
} |
|
|
@media (max-width: 1000px){ |
|
|
main{ grid-template-columns: 1fr; height:auto; } |
|
|
} |
|
|
.card{ |
|
|
background:var(--panel); |
|
|
border:1px solid var(--line); |
|
|
border-radius:10px; |
|
|
overflow:hidden; |
|
|
display:flex; |
|
|
flex-direction:column; |
|
|
min-height:0; |
|
|
} |
|
|
.card h2{ |
|
|
margin:0; |
|
|
padding:10px 12px; |
|
|
font-size:13px; |
|
|
color:var(--muted); |
|
|
text-transform:uppercase; |
|
|
letter-spacing:0.08em; |
|
|
border-bottom:1px solid var(--line); |
|
|
background:var(--panel2); |
|
|
} |
|
|
.content{ |
|
|
padding:12px; |
|
|
overflow:auto; |
|
|
min-height:0; |
|
|
} |
|
|
label{ |
|
|
display:block; |
|
|
font-size:12px; |
|
|
color:var(--muted); |
|
|
margin:10px 0 6px; |
|
|
} |
|
|
input, textarea, button{ |
|
|
font:inherit; |
|
|
border-radius:10px; |
|
|
border:1px solid var(--line); |
|
|
background:#0c1430; |
|
|
color:var(--text); |
|
|
padding:10px 12px; |
|
|
} |
|
|
textarea{ |
|
|
width:100%; |
|
|
min-height:260px; |
|
|
resize:vertical; |
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; |
|
|
font-size:12px; |
|
|
line-height:1.35; |
|
|
} |
|
|
input{ width:100%; } |
|
|
.row{ display:grid; grid-template-columns: 1fr 1fr; gap:10px; } |
|
|
.row3{ display:grid; grid-template-columns: 1fr 1fr 1fr; gap:10px; } |
|
|
.actions{ |
|
|
display:flex; |
|
|
gap:10px; |
|
|
flex-wrap:wrap; |
|
|
margin-top:10px; |
|
|
align-items:center; |
|
|
} |
|
|
button{ cursor:pointer; } |
|
|
button.primary:hover{ background:rgba(96,165,250,0.12); } |
|
|
button.danger{ border-color:rgba(239,68,68,0.5); } |
|
|
button.danger:hover{ background:rgba(239,68,68,0.12); } |
|
|
table{ |
|
|
width:100%; |
|
|
border-collapse:collapse; |
|
|
font-size:13px; |
|
|
} |
|
|
th, td{ |
|
|
text-align:left; |
|
|
padding:8px 6px; |
|
|
border-bottom:1px solid rgba(35,48,85,0.6); |
|
|
vertical-align:top; |
|
|
} |
|
|
th{ color:var(--muted); font-weight:600; } |
|
|
td.mono{ |
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; |
|
|
} |
|
|
.ok{ color:var(--green); } |
|
|
.err{ color:var(--red); } |
|
|
.hint{ |
|
|
font-size:12px; |
|
|
color:var(--muted); |
|
|
margin-top:6px; |
|
|
line-height:1.35; |
|
|
} |
|
|
.pill{ |
|
|
display:inline-block; |
|
|
padding:2px 8px; |
|
|
border-radius:999px; |
|
|
border:1px solid var(--line); |
|
|
font-size:12px; |
|
|
color:var(--muted); |
|
|
background:rgba(255,255,255,0.02); |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<header> |
|
|
<div class="title">MPTrading Admin</div> |
|
|
<div class="small"> |
|
|
Endpoints: |
|
|
<code>/admin/state</code>, <code>/admin/load_scenario</code>, <code>/admin/add_event</code>, <code>/admin/clear_events</code> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<main> |
|
|
|
|
|
<section class="card"> |
|
|
<h2>Scenario loader</h2> |
|
|
<div class="content"> |
|
|
<label>Admin token (sent as <code>X-ADMIN-TOKEN</code>)</label> |
|
|
<input id="token" type="password" placeholder="Enter ADMIN_TOKEN" autocomplete="off" /> |
|
|
|
|
|
<div class="row"> |
|
|
<div> |
|
|
<label>Market length override (optional)</label> |
|
|
<input id="marketLength" type="number" min="600" step="1" placeholder="Leave blank = auto" /> |
|
|
<div class="hint"> |
|
|
Blank = auto-size to at least <span class="pill">600</span> and at least the highest scenario day + 1. |
|
|
Set a number to force a specific length (minimum 600). |
|
|
</div> |
|
|
</div> |
|
|
<div> |
|
|
<label>Server state</label> |
|
|
<div class="small"> |
|
|
Day: <span class="mono" id="curDay">--</span><br/> |
|
|
Vol: <span class="mono" id="curVol">--</span><br/> |
|
|
Market length: <span class="mono" id="curMktLen">--</span><br/> |
|
|
Tick rate: <span class="mono" id="curTickRate">--</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<label>Scenario JSON</label> |
|
|
<textarea id="scenario" spellcheck="false"></textarea> |
|
|
|
|
|
<div class="actions"> |
|
|
<button class="primary" id="loadBtn">Load scenario</button> |
|
|
<button class="danger" id="clearBtn">Clear events</button> |
|
|
<button id="refreshBtn">Refresh state</button> |
|
|
<span id="msg" class="small"></span> |
|
|
</div> |
|
|
|
|
|
<div class="small" style="margin-top:12px;"> |
|
|
Scenario format: |
|
|
<pre class="small" style="white-space:pre-wrap;margin:8px 0 0;color:var(--muted);"> |
|
|
{ |
|
|
"name": "FOMC week", |
|
|
"startDay": 0, |
|
|
"basePrice": 100.0, |
|
|
"defaultVolatility": 0.8, |
|
|
"events": [ |
|
|
{"day": 20, "shockPct": 5.0, "volatility": 1.4, "news": "Rumor of rate cut"} |
|
|
] |
|
|
} |
|
|
</pre> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
|
|
|
<section class="card"> |
|
|
<h2>Add event schedule</h2> |
|
|
<div class="content"> |
|
|
<div class="row3"> |
|
|
<div> |
|
|
<label>Offset ticks ahead</label> |
|
|
<input id="offset" type="number" value="10" min="0" step="1" /> |
|
|
</div> |
|
|
<div> |
|
|
<label>Shock % (e.g. 5 or -3)</label> |
|
|
<input id="shockPct" type="number" value="5" step="0.1" /> |
|
|
</div> |
|
|
<div> |
|
|
<label>Volatility (optional)</label> |
|
|
<input id="vol" type="number" placeholder="leave blank" step="0.1" /> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<label>News (optional)</label> |
|
|
<input id="news" type="text" placeholder="Headline to broadcast at that tick" /> |
|
|
|
|
|
<div class="actions"> |
|
|
<button class="primary" id="addBtn">Add event</button> |
|
|
</div> |
|
|
|
|
|
<div style="height:12px;"></div> |
|
|
|
|
|
<table> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>Day</th> |
|
|
<th>Shock</th> |
|
|
<th>Vol</th> |
|
|
<th>News</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="eventsBody"> |
|
|
<tr><td colspan="4" class="small">No events loaded.</td></tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</section> |
|
|
</main> |
|
|
|
|
|
<script> |
|
|
const id = (x) => document.getElementById(x); |
|
|
|
|
|
const token = id("token"); |
|
|
const scenario = id("scenario"); |
|
|
const marketLength = id("marketLength"); |
|
|
|
|
|
const curDay = id("curDay"); |
|
|
const curVol = id("curVol"); |
|
|
const curMktLen = id("curMktLen"); |
|
|
const curTickRate = id("curTickRate"); |
|
|
|
|
|
const msg = id("msg"); |
|
|
const eventsBody = id("eventsBody"); |
|
|
|
|
|
const loadBtn = id("loadBtn"); |
|
|
const clearBtn = id("clearBtn"); |
|
|
const refreshBtn = id("refreshBtn"); |
|
|
const addBtn = id("addBtn"); |
|
|
|
|
|
const offset = id("offset"); |
|
|
const shockPct = id("shockPct"); |
|
|
const vol = id("vol"); |
|
|
const news = id("news"); |
|
|
|
|
|
function setMsg(text, ok = true){ |
|
|
msg.textContent = text; |
|
|
msg.className = ok ? "small ok" : "small err"; |
|
|
} |
|
|
|
|
|
function headers(){ |
|
|
const t = token.value; |
|
|
return { |
|
|
"Content-Type": "application/json", |
|
|
"X-ADMIN-TOKEN": t |
|
|
}; |
|
|
} |
|
|
|
|
|
async function apiGet(path){ |
|
|
const r = await fetch(path, { method: "GET", headers: headers() }); |
|
|
const txt = await r.text(); |
|
|
let data = null; |
|
|
try { data = JSON.parse(txt); } catch { data = { raw: txt }; } |
|
|
if (!r.ok) throw new Error((data && data.detail) ? data.detail : ("HTTP " + r.status)); |
|
|
return data; |
|
|
} |
|
|
|
|
|
async function apiPost(path, body){ |
|
|
const r = await fetch(path, { method: "POST", headers: headers(), body: JSON.stringify(body) }); |
|
|
const txt = await r.text(); |
|
|
let data = null; |
|
|
try { data = JSON.parse(txt); } catch { data = { raw: txt }; } |
|
|
if (!r.ok) throw new Error((data && data.detail) ? data.detail : ("HTTP " + r.status)); |
|
|
return data; |
|
|
} |
|
|
|
|
|
function escapeHtml(s){ |
|
|
return String(s).replace(/[&<>"']/g, (c) => ({ |
|
|
"&":"&","<":"<",">":">",'"':""","'":"'" |
|
|
}[c])); |
|
|
} |
|
|
|
|
|
function renderEvents(events){ |
|
|
const tb = eventsBody; |
|
|
tb.innerHTML = ""; |
|
|
if (!events || !events.length){ |
|
|
tb.innerHTML = '<tr><td colspan="4" class="small">No events loaded.</td></tr>'; |
|
|
return; |
|
|
} |
|
|
for (const e of events){ |
|
|
const tr = document.createElement("tr"); |
|
|
const shock = Number(e.shockPct || 0).toFixed(2); |
|
|
const v = (e.volatility === null || e.volatility === undefined) ? "" : Number(e.volatility).toFixed(2); |
|
|
tr.innerHTML = ` |
|
|
<td class="mono">${Number(e.day)}</td> |
|
|
<td class="mono">${shock}</td> |
|
|
<td class="mono">${v}</td> |
|
|
<td>${escapeHtml(e.news || "")}</td> |
|
|
`; |
|
|
tb.appendChild(tr); |
|
|
} |
|
|
} |
|
|
|
|
|
async function refresh(){ |
|
|
const st = await apiGet("/admin/state"); |
|
|
curDay.textContent = st.day; |
|
|
curVol.textContent = st.currentVolatility; |
|
|
curMktLen.textContent = st.marketLength; |
|
|
curTickRate.textContent = st.tickRate; |
|
|
renderEvents(st.events); |
|
|
} |
|
|
|
|
|
|
|
|
scenario.value = JSON.stringify({ |
|
|
name: "Example scenario", |
|
|
startDay: 0, |
|
|
basePrice: 100.0, |
|
|
defaultVolatility: 0.8, |
|
|
events: [ |
|
|
{ day: 20, shockPct: 5.0, volatility: 1.4, news: "Rumor of rate cut" }, |
|
|
{ day: 30, shockPct: -3.0, volatility: 2.0, news: "Unexpected hike" } |
|
|
] |
|
|
}, null, 2); |
|
|
|
|
|
refreshBtn.addEventListener("click", async () => { |
|
|
try{ |
|
|
await refresh(); |
|
|
setMsg("State refreshed."); |
|
|
}catch(e){ |
|
|
setMsg(e.message, false); |
|
|
} |
|
|
}); |
|
|
|
|
|
clearBtn.addEventListener("click", async () => { |
|
|
try{ |
|
|
await apiPost("/admin/clear_events", {}); |
|
|
await refresh(); |
|
|
setMsg("Events cleared."); |
|
|
}catch(e){ |
|
|
setMsg(e.message, false); |
|
|
} |
|
|
}); |
|
|
|
|
|
loadBtn.addEventListener("click", async () => { |
|
|
try{ |
|
|
const obj = JSON.parse(scenario.value); |
|
|
|
|
|
const ml = marketLength.value.trim(); |
|
|
if (ml){ |
|
|
obj.marketLength = Number(ml); |
|
|
} else { |
|
|
delete obj.marketLength; |
|
|
} |
|
|
|
|
|
const res = await apiPost("/admin/load_scenario", obj); |
|
|
await refresh(); |
|
|
setMsg(`Scenario loaded. startDay=${res.startDay}, eventsLoaded=${res.eventsLoaded}, marketLength=${res.marketLength} (${res.marketLengthMode})`); |
|
|
}catch(e){ |
|
|
setMsg(e.message, false); |
|
|
} |
|
|
}); |
|
|
|
|
|
addBtn.addEventListener("click", async () => { |
|
|
try{ |
|
|
const off = Number(offset.value); |
|
|
const sh = Number(shockPct.value); |
|
|
|
|
|
const volRaw = vol.value.trim(); |
|
|
const volatility = volRaw ? Number(volRaw) : null; |
|
|
|
|
|
const n = news.value.trim(); |
|
|
|
|
|
const body = { offset: off, shockPct: sh }; |
|
|
if (volatility !== null && Number.isFinite(volatility)) body.volatility = volatility; |
|
|
if (n) body.news = n; |
|
|
|
|
|
await apiPost("/admin/add_event", body); |
|
|
await refresh(); |
|
|
setMsg("Event added."); |
|
|
}catch(e){ |
|
|
setMsg(e.message, false); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
refresh().catch(() => {}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|