Add non-custodial manual trade tickets

This commit is contained in:
Theodore Song
2026-07-15 00:32:50 -04:00
parent 3367daf2f1
commit c391a4741e
5 changed files with 290 additions and 4 deletions
+3 -2
View File
@@ -7,8 +7,9 @@ PRODUCTION_APP_URL=https://polymarket-site-eta.vercel.app
# Keep false until legal/compliance approval, provider credentials, wallet signing, # Keep false until legal/compliance approval, provider credentials, wallet signing,
# payment rails, and audit logging are production-ready. # payment rails, and audit logging are production-ready.
LIVE_TRADING_ENABLED=false LIVE_TRADING_ENABLED=false
# Personal-only feature gate. Keep false until your own wallet/CLOB credentials # Personal-only readiness gate. Keep false until your own wallet/CLOB
# are configured and tiny manual test orders are reviewed. # credentials are configured and tiny manual test orders are reviewed.
# This does not let the AI or server hold private keys.
PERSONAL_TRADING_ENABLED=false PERSONAL_TRADING_ENABLED=false
# Accounts / authentication # Accounts / authentication
+5
View File
@@ -10,6 +10,9 @@ wallet, your own money, manual approval, and audit logging.
- `/api/live` reports which live-money providers are configured or missing. - `/api/live` reports which live-money providers are configured or missing.
- `/api/live` accepts public order intents only as dry-runs and returns an - `/api/live` accepts public order intents only as dry-runs and returns an
audit event. Personal-mode intents are staged for manual review, not executed. audit event. Personal-mode intents are staged for manual review, not executed.
- `/api/trade-tickets` stores non-custodial manual trade tickets. These tickets
contain the market, side, amount, limit, rationale, Polymarket link, and
review instructions, but never a private key or signed order.
- `/api/accounts` creates, logs into, and saves password-backed paper accounts - `/api/accounts` creates, logs into, and saves password-backed paper accounts
through Vercel Blob storage. through Vercel Blob storage.
- `/api/config` exposes safe public provider configuration, including the - `/api/config` exposes safe public provider configuration, including the
@@ -44,6 +47,8 @@ Personal mode is narrower than the public business launch:
- No unattended agent trading. - No unattended agent trading.
- Every live trade starts as a staged manual intent and must be reviewed before - Every live trade starts as a staged manual intent and must be reviewed before
you do anything in Polymarket. you do anything in Polymarket.
- Trade execution happens outside Poly Arena through your own Polymarket wallet
or UI. Poly Arena remains the analyst/ticketing layer.
- Public `LIVE_TRADING_ENABLED` stays `false`. - Public `LIVE_TRADING_ENABLED` stays `false`.
Personal mode still needs: Personal mode still needs:
+82
View File
@@ -77,6 +77,30 @@ export async function ensureSchema() {
) )
`; `;
await db`
create table if not exists trade_tickets (
id bigserial primary key,
user_id text not null,
agent_id text not null,
market_id text not null,
question text,
market_url text,
side text not null,
max_amount numeric not null,
limit_price numeric,
rationale text,
status text not null default 'staged',
instructions jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
`;
await db`
create index if not exists trade_tickets_user_status_idx
on trade_tickets (user_id, status, created_at desc)
`;
schemaReady = true; schemaReady = true;
} }
@@ -194,6 +218,64 @@ export async function latestRiskProfile(userId) {
return rows[0] || null; return rows[0] || null;
} }
export async function createTradeTicket(ticket) {
await ensureSchema();
const db = sql();
const rows = await db`
insert into trade_tickets (
user_id,
agent_id,
market_id,
question,
market_url,
side,
max_amount,
limit_price,
rationale,
status,
instructions
) values (
${ticket.userId},
${ticket.agentId},
${ticket.marketId},
${ticket.question || null},
${ticket.marketUrl || null},
${ticket.side},
${Number(ticket.maxAmount || 0)},
${ticket.limitPrice == null ? null : Number(ticket.limitPrice)},
${ticket.rationale || null},
${ticket.status || "staged"},
${JSON.stringify(ticket.instructions || {})}::jsonb
)
returning *
`;
return rows[0] || null;
}
export async function listTradeTickets(userId, limit = 50) {
await ensureSchema();
const db = sql();
return db`
select *
from trade_tickets
where user_id = ${userId}
order by created_at desc
limit ${Math.max(1, Math.min(Number(limit) || 50, 100))}
`;
}
export async function updateTradeTicketStatus(userId, ticketId, status) {
await ensureSchema();
const db = sql();
const rows = await db`
update trade_tickets
set status = ${status}, updated_at = now()
where user_id = ${userId} and id = ${Number(ticketId)}
returning *
`;
return rows[0] || null;
}
export async function providerEventSummary() { export async function providerEventSummary() {
await ensureSchema(); await ensureSchema();
const db = sql(); const db = sql();
+105
View File
@@ -0,0 +1,105 @@
import { createTradeTicket, listTradeTickets, recordAuditEvent, updateTradeTicketStatus } from "./_db.js";
const VALID_STATUSES = new Set(["staged", "reviewed", "placed_manually", "skipped", "cancelled"]);
function marketSearchUrl(question) {
return `https://polymarket.com/search?query=${encodeURIComponent(String(question || ""))}`;
}
function buildInstructions(ticket) {
const marketUrl = ticket.marketUrl || marketSearchUrl(ticket.question || ticket.marketId);
return {
warning: "Manual review only. The AI/site does not hold a private key and does not submit orders.",
steps: [
"Open the Polymarket market link.",
"Confirm the exact market, side, current price, liquidity, and resolution terms.",
"Keep the order at or below the staged limit price and max amount.",
"Sign or place the order only from your own wallet/account.",
"Return here and mark the ticket as placed manually or skipped.",
],
market_url: marketUrl,
};
}
function normalizeTicket(body) {
return {
userId: String(body.user_id || "local-readiness-user").trim(),
agentId: String(body.agent_id || "").trim(),
marketId: String(body.market_id || "").trim(),
question: String(body.question || "").trim(),
marketUrl: String(body.market_url || "").trim(),
side: String(body.side || "").trim().toUpperCase(),
maxAmount: Number(body.max_amount || 0),
limitPrice: body.limit_price === undefined || body.limit_price === "" ? null : Number(body.limit_price),
rationale: String(body.rationale || "").trim(),
};
}
function validate(ticket) {
const errors = [];
if (!ticket.userId) errors.push("user_id");
if (!ticket.agentId) errors.push("agent_id");
if (!ticket.marketId) errors.push("market_id");
if (!["YES", "NO"].includes(ticket.side)) errors.push("side must be YES or NO");
if (!Number.isFinite(ticket.maxAmount) || ticket.maxAmount <= 0) errors.push("max_amount must be positive");
if (ticket.limitPrice != null && (!Number.isFinite(ticket.limitPrice) || ticket.limitPrice <= 0 || ticket.limitPrice >= 1)) {
errors.push("limit_price must be between 0 and 1");
}
return errors;
}
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
try {
if (req.method === "GET") {
const userId = String(req.query?.user_id || "local-readiness-user").trim();
const tickets = await listTradeTickets(userId, req.query?.limit || 50);
return res.status(200).json({ ok: true, tickets });
}
if (req.method === "POST") {
const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {});
if (body.action === "status") {
const userId = String(body.user_id || "local-readiness-user").trim();
const status = String(body.status || "").trim();
if (!VALID_STATUSES.has(status)) return res.status(400).json({ ok: false, error: "Invalid ticket status" });
const ticket = await updateTradeTicketStatus(userId, body.ticket_id, status);
if (!ticket) return res.status(404).json({ ok: false, error: "Ticket not found" });
await recordAuditEvent("TRADE_TICKET_STATUS_UPDATED", { user_id: userId, ticket_id: ticket.id, status });
return res.status(200).json({ ok: true, ticket });
}
const ticket = normalizeTicket(body);
const errors = validate(ticket);
if (errors.length) return res.status(400).json({ ok: false, error: "Invalid trade ticket", details: errors });
const saved = await createTradeTicket({
...ticket,
status: "staged",
instructions: buildInstructions(ticket),
});
await recordAuditEvent("TRADE_TICKET_STAGED", {
user_id: ticket.userId,
ticket_id: saved?.id,
agent_id: ticket.agentId,
market_id: ticket.marketId,
side: ticket.side,
max_amount: ticket.maxAmount,
});
return res.status(201).json({
ok: true,
ticket: saved,
private_key_used: false,
live_order_placed: false,
manual_review_required: true,
});
}
res.setHeader("Allow", "GET, POST");
return res.status(405).json({ ok: false, error: "Method not allowed" });
} catch (err) {
return res.status(500).json({ ok: false, error: err?.message || "Trade ticket request failed" });
}
}
+95 -2
View File
@@ -583,8 +583,10 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
<div class="account-row"> <div class="account-row">
<select class="select" id="liveIntentAgent"></select> <select class="select" id="liveIntentAgent"></select>
<input class="input" id="liveIntentMarket" placeholder="Market ID" /> <input class="input" id="liveIntentMarket" placeholder="Market ID" />
<input class="input" id="liveIntentQuestion" placeholder="Market question / note" />
<select class="select" id="liveIntentSide"><option>YES</option><option>NO</option></select> <select class="select" id="liveIntentSide"><option>YES</option><option>NO</option></select>
<input class="input" id="liveIntentAmount" type="number" min="1" step="25" placeholder="Max amount" /> <input class="input" id="liveIntentAmount" type="number" min="1" step="25" placeholder="Max amount" />
<input class="input" id="liveIntentPrice" type="number" min="0.01" max="0.99" step="0.01" placeholder="Limit price" />
<button class="btn" id="dryRunOrderBtn">Stage manual intent</button> <button class="btn" id="dryRunOrderBtn">Stage manual intent</button>
</div> </div>
<ol class="steps"> <ol class="steps">
@@ -600,6 +602,10 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
<div id="liveAuditTrail"></div> <div id="liveAuditTrail"></div>
</div> </div>
</div> </div>
<div class="card">
<div class="card-h"><h3>Manual trade ticket queue</h3><span class="small muted">AI suggests, you sign elsewhere</span></div>
<div id="personalTicketQueue"></div>
</div>
<div class="card"> <div class="card">
<div class="card-h"><h3>Provider webhooks</h3><span class="small muted">events are stored in Neon</span></div> <div class="card-h"><h3>Provider webhooks</h3><span class="small muted">events are stored in Neon</span></div>
<div id="liveWebhookStatus" class="small muted">Checking webhook setup...</div> <div id="liveWebhookStatus" class="small muted">Checking webhook setup...</div>
@@ -697,6 +703,7 @@ const PAPER_KEY = "pma_paper_accounts_v1";
const LIVE_KEY = "pma_live_readiness_v1"; const LIVE_KEY = "pma_live_readiness_v1";
const PAPER_SESSION_PREFIX = "pma_cloud_session_"; const PAPER_SESSION_PREFIX = "pma_cloud_session_";
const PERSONAL_MODE = new URLSearchParams(location.search).get("personal") === "1"; const PERSONAL_MODE = new URLSearchParams(location.search).get("personal") === "1";
const PERSONAL_USER_ID = "local-readiness-user";
const CATEGORIES = ["All","Politics","Sports","Crypto","Economy","Pop Culture","Other"]; const CATEGORIES = ["All","Politics","Sports","Crypto","Economy","Pop Culture","Other"];
const SYNC_KEYS=[AGENTS_KEY,SUG_KEY,FOCUS_KEY,VIEW_KEY,PF_SORT_KEY,CHART_RANGE_KEY,INVEST_KEY,PAPER_KEY,LIVE_KEY]; const SYNC_KEYS=[AGENTS_KEY,SUG_KEY,FOCUS_KEY,VIEW_KEY,PF_SORT_KEY,CHART_RANGE_KEY,INVEST_KEY,PAPER_KEY,LIVE_KEY];
const CAT_COLORS = {Politics:"#fb7185",Sports:"#34d399",Crypto:"#fbbf24",Economy:"#7c8cff", const CAT_COLORS = {Politics:"#fb7185",Sports:"#34d399",Crypto:"#fbbf24",Economy:"#7c8cff",
@@ -709,6 +716,7 @@ let PAPER_MARKET_OFFSET = 0;
let PAPER_MARKET_QUERY = ""; let PAPER_MARKET_QUERY = "";
let LIVE_BACKEND_STATUS = null; let LIVE_BACKEND_STATUS = null;
let LIVE_POLICY_STATUS = null; let LIVE_POLICY_STATUS = null;
let PERSONAL_TICKETS = [];
let PROVIDER_CONFIG = null; let PROVIDER_CONFIG = null;
const catColor = (c) => CAT_COLORS[c] || CAT_COLORS.Other; const catColor = (c) => CAT_COLORS[c] || CAT_COLORS.Other;
const getFocus = () => localStorage.getItem(FOCUS_KEY) || "All"; const getFocus = () => localStorage.getItem(FOCUS_KEY) || "All";
@@ -1808,8 +1816,10 @@ function renderSuggestions(){
<div class="metrics"> <div class="metrics">
<span>Vol <b>${fmtUSD(s.volume)}</b></span><span>24h <b>${fmtUSD(s.volume_24hr)}</b></span><span>Resolves <b>${days}</b></span> <span>Vol <b>${fmtUSD(s.volume)}</b></span><span>24h <b>${fmtUSD(s.volume_24hr)}</b></span><span>Resolves <b>${days}</b></span>
${s.url?`<a class="market-link" href="${esc(s.url)}" target="_blank" rel="noopener">Open on Polymarket ↗</a>`:""} ${s.url?`<a class="market-link" href="${esc(s.url)}" target="_blank" rel="noopener">Open on Polymarket ↗</a>`:""}
${PERSONAL_MODE?`<button class="btn ghost" data-stage-suggestion="${esc(s.market_id)}">Stage ticket</button>`:""}
</div></div>`; </div></div>`;
}).join(""); }).join("");
document.querySelectorAll("[data-stage-suggestion]").forEach(btn=>btn.onclick=()=>stageSuggestionTicket(btn.dataset.stageSuggestion));
} }
function renderPortfolioTab(){ function renderPortfolioTab(){
const st=loadState(); const st=loadState();
@@ -2379,6 +2389,7 @@ async function fetchLiveBackendStatus(){
LIVE_POLICY_STATUS={ok:false,note:"Policy status endpoint is unavailable."}; LIVE_POLICY_STATUS={ok:false,note:"Policy status endpoint is unavailable."};
} }
renderLiveBackendStatus(); renderLiveBackendStatus();
if(PERSONAL_MODE)loadTradeTickets();
} }
function renderLiveBackendStatus(){ function renderLiveBackendStatus(){
const root=$("liveBackendStatus");if(!root)return; const root=$("liveBackendStatus");if(!root)return;
@@ -2407,24 +2418,106 @@ function renderLiveBackendStatus(){
async function dryRunLiveOrderIntent(){ async function dryRunLiveOrderIntent(){
const s=loadLiveState(); const s=loadLiveState();
const intent={ const intent={
user_id:"local-readiness-user", user_id:PERSONAL_USER_ID,
wallet_address:s.walletAddress, wallet_address:s.walletAddress,
agent_id:$("liveIntentAgent").value, agent_id:$("liveIntentAgent").value,
market_id:$("liveIntentMarket").value.trim(), market_id:$("liveIntentMarket").value.trim(),
question:($("liveIntentQuestion")&&$("liveIntentQuestion").value||"").trim(),
side:$("liveIntentSide").value, side:$("liveIntentSide").value,
max_amount:Number($("liveIntentAmount").value||0), max_amount:Number($("liveIntentAmount").value||0),
limit_price:Number(($("liveIntentPrice")&&$("liveIntentPrice").value)||0)||null,
execution_mode:"manual_approval", execution_mode:"manual_approval",
account_scope:PERSONAL_MODE?"personal":"public_readiness", account_scope:PERSONAL_MODE?"personal":"public_readiness",
}; };
try{ try{
if(PERSONAL_MODE)await createTradeTicket({
user_id:intent.user_id,
agent_id:intent.agent_id,
market_id:intent.market_id,
question:intent.question,
side:intent.side,
max_amount:intent.max_amount,
limit_price:intent.limit_price,
rationale:"Manually staged from the personal cockpit.",
});
const r=await fetch("/api/live",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({intent})}); const r=await fetch("/api/live",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({intent})});
const d=await r.json(); const d=await r.json();
$("liveOrderDryRun").innerHTML=`<div class="locked-panel"><b>Order intent dry-run</b><div class="small muted">${esc(d.error||"Accepted dry-run.")}</div><pre style="white-space:pre-wrap;font-size:11px;color:#cdd6e8">${esc(JSON.stringify(d.audit_event||d,null,2))}</pre></div>`; $("liveOrderDryRun").innerHTML=`<div class="locked-panel"><b>Order intent dry-run</b><div class="small muted">${esc(d.error||"Accepted dry-run.")}</div><pre style="white-space:pre-wrap;font-size:11px;color:#cdd6e8">${esc(JSON.stringify(d.audit_event||d,null,2))}</pre></div>`;
saveLiveState(s,`${PERSONAL_MODE?"Staged manual":"Dry-ran"} ${intent.side} order intent for ${intent.agent_id} on ${intent.market_id}`); saveLiveState(s,`${PERSONAL_MODE?"Staged manual":"Dry-ran"} ${intent.side} order intent for ${intent.agent_id} on ${intent.market_id}`);
if(PERSONAL_MODE)await loadTradeTickets();
}catch(e){ }catch(e){
toast("Order dry-run failed."); toast(e.message||"Order dry-run failed.");
} }
} }
async function createTradeTicket(ticket){
const r=await fetch("/api/trade-tickets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(ticket)});
const d=await r.json();
if(!r.ok)throw new Error(d.error||"Ticket staging failed");
toast("Manual trade ticket staged.");
return d.ticket;
}
async function stageSuggestionTicket(marketId){
const s=(loadSuggestions().suggestions||[]).find(x=>String(x.market_id)===String(marketId));
if(!s)return toast("Suggestion not found.");
const amount=Number(prompt("Max amount for this manual ticket?", "10")||0);
if(!amount||amount<=0)return;
try{
await createTradeTicket({
user_id:PERSONAL_USER_ID,
agent_id:localStorage.getItem(VIEW_KEY)||"value",
market_id:s.market_id,
question:s.question,
market_url:s.url,
side:s.side,
max_amount:amount,
limit_price:s.entry_price,
rationale:s.rationale,
});
await loadTradeTickets();
showTab("live");
}catch(e){toast(e.message||"Ticket staging failed.");}
}
async function loadTradeTickets(){
if(!PERSONAL_MODE)return;
try{
const r=await fetch(`/api/trade-tickets?user_id=${encodeURIComponent(PERSONAL_USER_ID)}&limit=30`,{cache:"no-store"});
const d=await r.json();
PERSONAL_TICKETS=d.tickets||[];
}catch(e){PERSONAL_TICKETS=[];}
renderTradeTickets();
}
async function updateTicketStatus(ticketId,status){
try{
const r=await fetch("/api/trade-tickets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action:"status",user_id:PERSONAL_USER_ID,ticket_id:ticketId,status})});
const d=await r.json();
if(!r.ok)throw new Error(d.error||"Ticket update failed");
await loadTradeTickets();
toast("Ticket updated.");
}catch(e){toast(e.message||"Ticket update failed.");}
}
function renderTradeTickets(){
const root=$("personalTicketQueue");if(!root)return;
if(!PERSONAL_MODE){root.innerHTML=`<div class="empty">Personal trade tickets are hidden on the public site.</div>`;return;}
if(!PERSONAL_TICKETS.length){root.innerHTML=`<div class="empty">No staged tickets yet. Stage one from Suggestions or the manual console.</div>`;return;}
root.innerHTML=PERSONAL_TICKETS.map(t=>{
const instructions=t.instructions||{},steps=instructions.steps||[];
const url=t.market_url||instructions.market_url||marketSearchUrl(t.question||t.market_id);
return `<div class="trade-card">
<div class="trade-head"><div><div class="trade-title">${esc(t.question||t.market_id)}</div><div class="trade-meta"><span>${esc(t.agent_id)}</span><span>${esc(t.status)}</span><span>${new Date(t.created_at).toLocaleString()}</span></div></div><span class="pill ${esc(t.side)}">BUY ${esc(t.side)}</span></div>
<div class="small muted">Max ${fmtUSD(Number(t.max_amount||0))}${t.limit_price?` · limit ${Math.round(Number(t.limit_price)*100)}¢`:""} · ticket #${t.id}</div>
${t.rationale?`<div class="rationale">${esc(t.rationale)}</div>`:""}
<ol class="steps">${steps.map(step=>`<li>${esc(step)}</li>`).join("")}</ol>
<div class="trade-actions">
<a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open Polymarket</a>
<button class="btn ghost" data-ticket-status="${t.id}:reviewed">Reviewed</button>
<button class="btn ghost" data-ticket-status="${t.id}:placed_manually">Placed manually</button>
<button class="btn ghost" data-ticket-status="${t.id}:skipped">Skipped</button>
<button class="btn ghost" data-ticket-status="${t.id}:cancelled">Cancel</button>
</div>
</div>`;
}).join("");
document.querySelectorAll("[data-ticket-status]").forEach(btn=>btn.onclick=()=>{const [id,status]=btn.dataset.ticketStatus.split(":");updateTicketStatus(id,status);});
}
async function saveRiskProfileToServer(s){ async function saveRiskProfileToServer(s){
const permissions=JSON.parse(JSON.stringify(s.agents||{})); const permissions=JSON.parse(JSON.stringify(s.agents||{}));
Object.values(permissions).forEach(p=>{p.autoTrade=false;p.manualApprove=true;}); Object.values(permissions).forEach(p=>{p.autoTrade=false;p.manualApprove=true;});