mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-07-27 16:07:46 +00:00
Add manual real fill tracking
This commit is contained in:
+196
@@ -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();
|
||||
|
||||
+80
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user