diff --git a/REAL_MONEY_ROADMAP.md b/REAL_MONEY_ROADMAP.md index 5832f38..1e63b00 100644 --- a/REAL_MONEY_ROADMAP.md +++ b/REAL_MONEY_ROADMAP.md @@ -14,6 +14,9 @@ wallet, your own money, manual approval, and audit logging. 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/live` manual-fill actions record trades that you placed yourself on + Polymarket, then update tracked real positions and P&L. These records are for + reconciliation only: Poly Arena still does not sign or submit the trade. - `/api/accounts` creates, logs into, and saves password-backed paper accounts through Vercel Blob storage. - `/api/config` exposes safe public provider configuration, including the @@ -50,6 +53,8 @@ Personal mode is narrower than the public business launch: 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. +- After you manually trade, record the actual shares, price, fees, and note in + Poly Arena so the personal cockpit can track real position value and P&L. - Public `LIVE_TRADING_ENABLED` stays `false`. Personal mode still needs: diff --git a/api/_db.js b/api/_db.js index 6cdf720..7349a8f 100644 --- a/api/_db.js +++ b/api/_db.js @@ -101,6 +101,50 @@ export async function ensureSchema() { on trade_tickets (user_id, status, created_at desc) `; + await db` + create table if not exists real_fills ( + id bigserial primary key, + user_id text not null, + ticket_id bigint, + agent_id text, + market_id text not null, + question text, + market_url text, + side text not null, + action text not null, + shares numeric not null, + price numeric not null, + fees numeric not null default 0, + tx_note text, + filled_at timestamptz not null default now(), + created_at timestamptz not null default now() + ) + `; + + await db` + create index if not exists real_fills_user_market_idx + on real_fills (user_id, market_id, side, filled_at desc) + `; + + await db` + create table if not exists real_positions ( + id bigserial primary key, + user_id text not null, + agent_id text, + market_id text not null, + question text, + market_url text, + side text not null, + shares numeric not null default 0, + avg_price numeric not null default 0, + cost_basis numeric not null default 0, + realized_pnl numeric not null default 0, + current_price numeric, + updated_at timestamptz not null default now(), + unique (user_id, market_id, side) + ) + `; + schemaReady = true; } @@ -276,6 +320,158 @@ export async function updateTradeTicketStatus(userId, ticketId, status) { return rows[0] || null; } +export async function recordRealFill(fill) { + await ensureSchema(); + const db = sql(); + const action = String(fill.action || "BUY").toUpperCase(); + const shares = Number(fill.shares || 0); + const price = Number(fill.price || 0); + const fees = Number(fill.fees || 0); + + const rows = await db` + insert into real_fills ( + user_id, + ticket_id, + agent_id, + market_id, + question, + market_url, + side, + action, + shares, + price, + fees, + tx_note, + filled_at + ) values ( + ${fill.userId}, + ${fill.ticketId ? Number(fill.ticketId) : null}, + ${fill.agentId || null}, + ${fill.marketId}, + ${fill.question || null}, + ${fill.marketUrl || null}, + ${fill.side}, + ${action}, + ${shares}, + ${price}, + ${fees}, + ${fill.txNote || null}, + ${fill.filledAt ? new Date(fill.filledAt).toISOString() : new Date().toISOString()} + ) + returning * + `; + + const existing = await db` + select * + from real_positions + where user_id = ${fill.userId} + and market_id = ${fill.marketId} + and side = ${fill.side} + limit 1 + `; + const pos = existing[0]; + + if (action === "SELL" && pos) { + const sellShares = Math.min(shares, Number(pos.shares || 0)); + const avgPrice = Number(pos.avg_price || 0); + const proceeds = sellShares * price - fees; + const costRemoved = sellShares * avgPrice; + const nextShares = Number(pos.shares || 0) - sellShares; + const nextCost = Math.max(0, Number(pos.cost_basis || 0) - costRemoved); + const realized = Number(pos.realized_pnl || 0) + proceeds - costRemoved; + await db` + update real_positions + set shares = ${nextShares}, + cost_basis = ${nextCost}, + realized_pnl = ${realized}, + current_price = ${price}, + updated_at = now() + where id = ${pos.id} + `; + } else { + const currentShares = pos ? Number(pos.shares || 0) : 0; + const currentCost = pos ? Number(pos.cost_basis || 0) : 0; + const nextShares = currentShares + shares; + const nextCost = currentCost + shares * price + fees; + const avgPrice = nextShares > 0 ? nextCost / nextShares : 0; + await db` + insert into real_positions ( + user_id, + agent_id, + market_id, + question, + market_url, + side, + shares, + avg_price, + cost_basis, + realized_pnl, + current_price, + updated_at + ) values ( + ${fill.userId}, + ${fill.agentId || null}, + ${fill.marketId}, + ${fill.question || null}, + ${fill.marketUrl || null}, + ${fill.side}, + ${nextShares}, + ${avgPrice}, + ${nextCost}, + ${pos ? Number(pos.realized_pnl || 0) : 0}, + ${price}, + now() + ) + on conflict (user_id, market_id, side) do update set + agent_id = coalesce(excluded.agent_id, real_positions.agent_id), + question = coalesce(excluded.question, real_positions.question), + market_url = coalesce(excluded.market_url, real_positions.market_url), + shares = excluded.shares, + avg_price = excluded.avg_price, + cost_basis = excluded.cost_basis, + current_price = excluded.current_price, + updated_at = now() + `; + } + + if (fill.ticketId) { + await updateTradeTicketStatus(fill.userId, fill.ticketId, "placed_manually"); + } + + return rows[0] || null; +} + +export async function updateRealPositionMark(userId, positionId, currentPrice) { + await ensureSchema(); + const db = sql(); + const rows = await db` + update real_positions + set current_price = ${Number(currentPrice)}, updated_at = now() + where user_id = ${userId} and id = ${Number(positionId)} + returning * + `; + return rows[0] || null; +} + +export async function listRealPortfolio(userId) { + await ensureSchema(); + const db = sql(); + const positions = await db` + select * + from real_positions + where user_id = ${userId} + order by updated_at desc + `; + const fills = await db` + select * + from real_fills + where user_id = ${userId} + order by filled_at desc + limit 100 + `; + return { positions, fills }; +} + export async function providerEventSummary() { await ensureSchema(); const db = sql(); diff --git a/api/live.js b/api/live.js index 6f0fdec..bfdd03c 100644 --- a/api/live.js +++ b/api/live.js @@ -1,4 +1,12 @@ -import { createTradeTicket, listTradeTickets, recordAuditEvent, updateTradeTicketStatus } from "./_db.js"; +import { + createTradeTicket, + listRealPortfolio, + listTradeTickets, + recordAuditEvent, + recordRealFill, + updateRealPositionMark, + updateTradeTicketStatus, +} from "./_db.js"; const REQUIRED_ENV = [ ["PRODUCTION_APP_URL", "Production app URL"], @@ -233,6 +241,7 @@ function validateIntent(intent) { } const VALID_TICKET_STATUSES = new Set(["staged", "reviewed", "placed_manually", "skipped", "cancelled"]); +const VALID_FILL_ACTIONS = new Set(["BUY", "SELL"]); function marketSearchUrl(question) { return `https://polymarket.com/search?query=${encodeURIComponent(String(question || ""))}`; @@ -280,6 +289,36 @@ function validateTicket(ticket) { return errors; } +function normalizeFill(body) { + return { + userId: String(body.user_id || "local-readiness-user").trim(), + ticketId: body.ticket_id || null, + 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(), + action: String(body.fill_action || body.trade_action || "BUY").trim().toUpperCase(), + shares: Number(body.shares || 0), + price: Number(body.price || 0), + fees: Number(body.fees || 0), + txNote: String(body.tx_note || "").trim(), + filledAt: body.filled_at || null, + }; +} + +function validateFill(fill) { + const errors = []; + if (!fill.userId) errors.push("user_id"); + if (!fill.marketId) errors.push("market_id"); + if (!["YES", "NO"].includes(fill.side)) errors.push("side must be YES or NO"); + if (!VALID_FILL_ACTIONS.has(fill.action)) errors.push("fill_action must be BUY or SELL"); + if (!Number.isFinite(fill.shares) || fill.shares <= 0) errors.push("shares must be positive"); + if (!Number.isFinite(fill.price) || fill.price <= 0 || fill.price >= 1) errors.push("price must be between 0 and 1"); + if (!Number.isFinite(fill.fees) || fill.fees < 0) errors.push("fees must be 0 or greater"); + return errors; +} + export default async function handler(req, res) { res.setHeader("Cache-Control", "no-store"); @@ -289,6 +328,11 @@ export default async function handler(req, res) { const tickets = await listTradeTickets(userId, req.query?.limit || 50); return res.status(200).json({ ok: true, tickets }); } + if (req.query?.action === "real_portfolio") { + const userId = String(req.query?.user_id || "local-readiness-user").trim(); + const portfolio = await listRealPortfolio(userId); + return res.status(200).json({ ok: true, ...portfolio }); + } return res.status(200).json(baseStatus()); } @@ -331,6 +375,41 @@ export default async function handler(req, res) { return res.status(200).json({ ok: true, ticket }); } + if (body.action === "record_fill") { + const fill = normalizeFill(body); + const fillErrors = validateFill(fill); + if (fillErrors.length) return res.status(400).json({ ok: false, error: "Invalid manual fill", details: fillErrors }); + const saved = await recordRealFill(fill); + await recordAuditEvent("REAL_FILL_RECORDED", { + user_id: fill.userId, + ticket_id: fill.ticketId, + market_id: fill.marketId, + side: fill.side, + action: fill.action, + shares: fill.shares, + price: fill.price, + private_key_used: false, + live_order_placed_by_site: false, + }); + return res.status(201).json({ + ok: true, + fill: saved, + private_key_used: false, + live_order_placed_by_site: false, + note: "Manual fill recorded for tracking only. Poly Arena did not sign or place this trade.", + }); + } + + if (body.action === "mark_position") { + const userId = String(body.user_id || "local-readiness-user").trim(); + const price = Number(body.current_price || 0); + if (!Number.isFinite(price) || price <= 0 || price >= 1) return res.status(400).json({ ok: false, error: "current_price must be between 0 and 1" }); + const position = await updateRealPositionMark(userId, body.position_id, price); + if (!position) return res.status(404).json({ ok: false, error: "Position not found" }); + await recordAuditEvent("REAL_POSITION_MARK_UPDATED", { user_id: userId, position_id: position.id, current_price: price }); + return res.status(200).json({ ok: true, position }); + } + const intent = body.intent || body; const errors = validateIntent(intent); if (errors.length) { diff --git a/index.html b/index.html index 8463dfa..311fdaf 100644 --- a/index.html +++ b/index.html @@ -606,6 +606,17 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color