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
+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;
}
@@ -194,6 +218,64 @@ export async function latestRiskProfile(userId) {
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() {
await ensureSchema();
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" });
}
}