mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-07-27 16:07:46 +00:00
948 lines
40 KiB
JavaScript
948 lines
40 KiB
JavaScript
import {
|
|
createTradeTicket,
|
|
listPersonalCapital,
|
|
listRealPortfolio,
|
|
listTradeTickets,
|
|
recordAuditEvent,
|
|
recordPersonalCapitalAction,
|
|
recordRealFill,
|
|
updateRealPositionMark,
|
|
updateTradeTicketStatus,
|
|
} from "./_db.js";
|
|
import nodemailer from "nodemailer";
|
|
|
|
const REQUIRED_ENV = [
|
|
["PRODUCTION_APP_URL", "Production app URL"],
|
|
["AUTH_PROVIDER", "Real account authentication provider"],
|
|
["DATABASE_URL", "Production account database"],
|
|
["SESSION_SECRET", "Session signing secret"],
|
|
["ENCRYPTION_KEY", "Encryption key for sensitive user settings"],
|
|
["KYC_PROVIDER", "KYC / eligibility provider"],
|
|
["KYC_API_KEY", "KYC provider API key"],
|
|
["KYC_WEBHOOK_SECRET", "KYC webhook secret"],
|
|
["SANCTIONS_PROVIDER", "Sanctions / watchlist screening provider"],
|
|
["SANCTIONS_API_KEY", "Sanctions provider API key"],
|
|
["GEOIP_PROVIDER", "Geo-IP / geofencing provider"],
|
|
["GEOIP_API_KEY", "Geo-IP provider API key"],
|
|
["RESTRICTED_JURISDICTIONS", "Restricted jurisdiction rules"],
|
|
["TERMS_VERSION", "Approved terms version"],
|
|
["PRIVACY_VERSION", "Approved privacy policy version"],
|
|
["RISK_DISCLOSURE_VERSION", "Approved risk disclosure version"],
|
|
["MARKET_POLICY_STORE", "Market eligibility and category policy store"],
|
|
["CONSENT_STORE", "Versioned user consent store"],
|
|
["PAYMENTS_PROVIDER", "Deposit and withdrawal provider"],
|
|
["PAYMENTS_API_KEY", "Payments provider API key"],
|
|
["PAYMENTS_WEBHOOK_SECRET", "Payments webhook secret"],
|
|
["WEBHOOK_BASE_URL", "Public webhook base URL"],
|
|
["WALLET_PROVIDER", "Wallet or deposit-wallet provider"],
|
|
["WALLET_PROJECT_ID", "Wallet provider project ID"],
|
|
["WALLET_API_KEY", "Wallet provider API key"],
|
|
["DEPOSIT_WALLET_ADDRESS", "Polymarket deposit-wallet address"],
|
|
["POLYMARKET_SIGNATURE_TYPE", "Polymarket signature type"],
|
|
["POLYMARKET_CLOB_API_KEY", "Polymarket CLOB API key"],
|
|
["POLYMARKET_CLOB_SECRET", "Polymarket CLOB API secret"],
|
|
["POLYMARKET_CLOB_PASSPHRASE", "Polymarket CLOB passphrase"],
|
|
["SETTLEMENT_ASSET", "Settlement asset"],
|
|
["SETTLEMENT_CHAIN", "Settlement chain"],
|
|
["RECONCILIATION_STORE", "Balance, position, and fill reconciliation store"],
|
|
["ACCOUNTING_EXPORT_STORE", "Statements, tax, and accounting export store"],
|
|
["RATE_LIMIT_STORE", "Rate limit and abuse prevention store"],
|
|
["AUDIT_LOG_STORE", "Durable audit log store"],
|
|
["MONITORING_DSN", "Error and performance monitoring"],
|
|
["INCIDENT_WEBHOOK_URL", "Incident alert webhook"],
|
|
["RISK_ADMIN_TOKEN", "Risk admin incident-control token"],
|
|
["ADMIN_ALERT_EMAIL", "Admin alert destination"],
|
|
["CUSTOMER_SUPPORT_EMAIL", "Customer support contact"],
|
|
];
|
|
|
|
const PERSONAL_REQUIRED_ENV = [
|
|
["DATABASE_URL", "Audit database"],
|
|
["DEPOSIT_WALLET_ADDRESS", "Personal Polymarket deposit wallet address"],
|
|
["POLYMARKET_SIGNATURE_TYPE", "Polymarket signature type"],
|
|
["POLYMARKET_CLOB_API_KEY", "Personal Polymarket CLOB API key"],
|
|
["POLYMARKET_CLOB_SECRET", "Personal Polymarket CLOB API secret"],
|
|
["POLYMARKET_CLOB_PASSPHRASE", "Personal Polymarket CLOB passphrase"],
|
|
["RISK_ADMIN_TOKEN", "Personal admin token"],
|
|
];
|
|
|
|
const ENV_ALIASES = {
|
|
DATABASE_URL: ["NEON_DATABASE_URL"],
|
|
KYC_API_KEY: ["VERIFF_API_KEY"],
|
|
KYC_WEBHOOK_SECRET: ["VERIFF_WEBHOOK_SECRET", "VERIFF_SECRET_KEY"],
|
|
SANCTIONS_API_KEY: ["VERIFF_API_KEY"],
|
|
GEOIP_API_KEY: ["VERIFF_API_KEY"],
|
|
PAYMENTS_API_KEY: ["CIRCLE_API_KEY"],
|
|
PAYMENTS_WEBHOOK_SECRET: ["CIRCLE_WEBHOOK_SECRET"],
|
|
WALLET_PROJECT_ID: ["CIRCLE_WALLET_SET_ID"],
|
|
WALLET_API_KEY: ["CIRCLE_API_KEY"],
|
|
RECONCILIATION_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
|
ACCOUNTING_EXPORT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
|
RATE_LIMIT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
|
AUDIT_LOG_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
|
CONSENT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
|
MONITORING_DSN: ["SENTRY_DSN"],
|
|
INCIDENT_WEBHOOK_URL: ["SENTRY_WEBHOOK_URL"],
|
|
};
|
|
|
|
const PROVIDER_STACK = [
|
|
{
|
|
provider: "Clerk",
|
|
role: "User auth, sessions, sign-in/sign-out, account identity",
|
|
env: ["AUTH_PROVIDER", "CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY", "CLERK_WEBHOOK_SIGNING_SECRET"],
|
|
dashboard: "https://dashboard.clerk.com/",
|
|
},
|
|
{
|
|
provider: "Neon",
|
|
role: "Postgres database for users, portfolios, audit metadata, reconciliation, and history",
|
|
env: ["DATABASE_URL"],
|
|
dashboard: "https://console.neon.tech/",
|
|
},
|
|
{
|
|
provider: "Veriff",
|
|
role: "KYC/KYB, identity verification, age checks, AML/sanctions workflow",
|
|
env: ["KYC_PROVIDER", "VERIFF_API_KEY", "VERIFF_SECRET_KEY", "VERIFF_WEBHOOK_SECRET"],
|
|
dashboard: "https://station.veriff.com/",
|
|
},
|
|
{
|
|
provider: "Circle",
|
|
role: "USDC, embedded wallets, payments/deposits/withdrawals, webhooks",
|
|
env: ["PAYMENTS_PROVIDER", "WALLET_PROVIDER", "CIRCLE_API_KEY", "CIRCLE_WEBHOOK_SECRET", "CIRCLE_WALLET_SET_ID"],
|
|
dashboard: "https://console.circle.com/",
|
|
},
|
|
{
|
|
provider: "Sentry",
|
|
role: "Error monitoring, browser/backend issue tracking, incident visibility",
|
|
env: ["MONITORING_DSN", "SENTRY_DSN"],
|
|
dashboard: "https://sentry.io/",
|
|
},
|
|
];
|
|
|
|
const LAUNCH_REQUIREMENTS = [
|
|
["legal", "Terms, privacy policy, risk disclosures, and jurisdiction policy approved"],
|
|
["eligibility", "KYC/KYB, age, sanctions, watchlist, and location screening active"],
|
|
["market_policy", "Allowed market categories, restricted events, and manipulation rules defined"],
|
|
["auth", "Production accounts, sessions, password reset, MFA path, and sign-out implemented"],
|
|
["wallets", "User-controlled wallet/deposit-wallet signing and permission revocation implemented"],
|
|
["payments", "Deposits, withdrawals, webhooks, failed-payment handling, and support flows implemented"],
|
|
["execution", "Signed order creation, CLOB submission, cancellation, retry, and fill tracking implemented"],
|
|
["reconciliation", "Cash, open orders, positions, fills, fees, and withdrawals reconciled continuously"],
|
|
["risk", "Agent allocation caps, market caps, stop rules, manual approval, and emergency pause enforced server-side"],
|
|
["security", "Secret management, encryption, rate limits, abuse controls, and security review completed"],
|
|
["records", "Append-only audit logs, customer statements, exports, and retention policy active"],
|
|
["operations", "Monitoring, incident response, customer support, and rollback plan ready"],
|
|
["testing", "Paper-to-live parity, dry-runs, test wallets, webhook tests, and edge-case QA completed"],
|
|
];
|
|
|
|
const WEBHOOK_ROUTES = [
|
|
["clerk", "Clerk user and session events", "/api/webhooks/clerk"],
|
|
["veriff", "Veriff verification and review events", "/api/webhooks/veriff"],
|
|
["circle", "Circle payment, wallet, and transfer events", "/api/webhooks/circle"],
|
|
];
|
|
|
|
function envValue(key) {
|
|
if (process.env[key]) return process.env[key];
|
|
return (ENV_ALIASES[key] || []).map((alias) => process.env[alias]).find(Boolean);
|
|
}
|
|
|
|
function providerStatus() {
|
|
return REQUIRED_ENV.map(([key, label]) => ({
|
|
key,
|
|
label,
|
|
configured: Boolean(envValue(key)),
|
|
aliases: ENV_ALIASES[key] || [],
|
|
expected: key === "POLYMARKET_SIGNATURE_TYPE" ? "3 for new deposit-wallet API users" : undefined,
|
|
}));
|
|
}
|
|
|
|
function personalStatus() {
|
|
return PERSONAL_REQUIRED_ENV.map(([key, label]) => ({
|
|
key,
|
|
label,
|
|
configured: Boolean(envValue(key)),
|
|
expected: key === "POLYMARKET_SIGNATURE_TYPE" ? "3 for deposit-wallet users" : undefined,
|
|
}));
|
|
}
|
|
|
|
function stackStatus() {
|
|
return PROVIDER_STACK.map((provider) => {
|
|
const checks = provider.env.map((key) => ({ key, configured: Boolean(envValue(key)) }));
|
|
return {
|
|
...provider,
|
|
configured: checks.every((x) => x.configured),
|
|
checks,
|
|
};
|
|
});
|
|
}
|
|
|
|
function tradeEmailStatus() {
|
|
const resend = Boolean(process.env.RESEND_API_KEY);
|
|
const webhook = Boolean(process.env.TRADE_EMAIL_WEBHOOK_URL);
|
|
const smtpMissing = ["SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASS"].filter((key) => !process.env[key]);
|
|
const smtp = smtpMissing.length === 0;
|
|
const provider = resend ? "resend" : (smtp ? "smtp" : (webhook ? "webhook" : null));
|
|
return {
|
|
configured: Boolean(provider),
|
|
provider,
|
|
options: {
|
|
resend: { configured: resend, missing: resend ? [] : ["RESEND_API_KEY"] },
|
|
smtp: { configured: smtp, missing: smtpMissing },
|
|
webhook: { configured: webhook, missing: webhook ? [] : ["TRADE_EMAIL_WEBHOOK_URL"] },
|
|
},
|
|
missing_any_one_of: provider ? [] : [
|
|
"RESEND_API_KEY",
|
|
"or SMTP_HOST + SMTP_PORT + SMTP_USER + SMTP_PASS",
|
|
"or TRADE_EMAIL_WEBHOOK_URL",
|
|
],
|
|
};
|
|
}
|
|
|
|
function liveTradingReady() {
|
|
const requiredConfigured = providerStatus().every((x) => x.configured);
|
|
const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true";
|
|
const signatureTypeOk = String(process.env.POLYMARKET_SIGNATURE_TYPE || "") === "3";
|
|
const chainOk = String(process.env.POLYMARKET_CHAIN_ID || "137") === "137";
|
|
return requiredConfigured && liveFlagEnabled && signatureTypeOk && chainOk;
|
|
}
|
|
|
|
function personalTradingReady() {
|
|
return personalStatus().every((x) => x.configured)
|
|
&& process.env.PERSONAL_TRADING_ENABLED === "true"
|
|
&& String(process.env.POLYMARKET_SIGNATURE_TYPE || "") === "3"
|
|
&& String(process.env.POLYMARKET_CHAIN_ID || "137") === "137";
|
|
}
|
|
|
|
function baseStatus() {
|
|
const providers = providerStatus();
|
|
const origin = (envValue("WEBHOOK_BASE_URL") || envValue("PRODUCTION_APP_URL") || "").replace(/\/api\/?$/, "").replace(/\/$/, "");
|
|
const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true";
|
|
const signatureTypeOk = String(process.env.POLYMARKET_SIGNATURE_TYPE || "") === "3";
|
|
const chainOk = String(process.env.POLYMARKET_CHAIN_ID || "137") === "137";
|
|
const missing = providers.filter((x) => !x.configured).map((x) => x.label);
|
|
const personal = personalStatus();
|
|
const personalMissing = personal.filter((x) => !x.configured).map((x) => x.label);
|
|
if (process.env.PERSONAL_TRADING_ENABLED !== "true") personalMissing.push("PERSONAL_TRADING_ENABLED=true after your own wallet/CLOB test");
|
|
if (!liveFlagEnabled) missing.push("LIVE_TRADING_ENABLED=true after final approval");
|
|
if (!signatureTypeOk) missing.push("POLYMARKET_SIGNATURE_TYPE=3 for deposit-wallet users");
|
|
if (!chainOk) missing.push("POLYMARKET_CHAIN_ID=137");
|
|
return {
|
|
ok: true,
|
|
live_trading_enabled: liveTradingReady(),
|
|
personal_trading_enabled: personalTradingReady(),
|
|
live_flag_enabled: liveFlagEnabled,
|
|
personal_flag_enabled: process.env.PERSONAL_TRADING_ENABLED === "true",
|
|
signature_type_ok: signatureTypeOk,
|
|
chain_ok: chainOk,
|
|
locked_reason: liveTradingReady()
|
|
? null
|
|
: "Live trading is locked until eligibility, payments, wallet/deposit-wallet signing, Polymarket CLOB credentials, audit storage, monitoring, and LIVE_TRADING_ENABLED=true are configured.",
|
|
providers,
|
|
provider_stack: stackStatus(),
|
|
trade_email: tradeEmailStatus(),
|
|
personal_requirements: personal,
|
|
launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })),
|
|
webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({
|
|
provider,
|
|
label,
|
|
path,
|
|
url: origin ? `${origin}${path}` : path,
|
|
})),
|
|
next_required: missing,
|
|
personal_next_required: personalMissing,
|
|
docs: {
|
|
polymarket_overview: "https://docs.polymarket.com/trading/overview",
|
|
polymarket_quickstart: "https://docs.polymarket.com/trading/quickstart",
|
|
deposit_wallets: "https://docs.polymarket.com/trading/deposit-wallets",
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateIntent(intent) {
|
|
const missing = [];
|
|
["user_id", "wallet_address", "agent_id", "market_id", "side", "max_amount"].forEach((key) => {
|
|
if (!intent || intent[key] === undefined || intent[key] === null || intent[key] === "") missing.push(key);
|
|
});
|
|
if (intent && !["YES", "NO"].includes(String(intent.side).toUpperCase())) missing.push("side must be YES or NO");
|
|
if (intent && Number(intent.max_amount) <= 0) missing.push("max_amount must be positive");
|
|
return missing;
|
|
}
|
|
|
|
const VALID_TICKET_STATUSES = new Set(["staged", "reviewed", "placed_manually", "skipped", "cancelled"]);
|
|
const VALID_FILL_ACTIONS = new Set(["BUY", "SELL"]);
|
|
const VALID_CAPITAL_ACTIONS = new Set(["DEPOSIT", "WITHDRAW", "BUY_AGENT", "SELL_AGENT"]);
|
|
const VALID_TRADE_EMAIL_ACTIONS = new Set(["OPEN", "CLOSE", "EXIT", "STOP", "GAIN"]);
|
|
const AGENT_CHAT_MAX_HISTORY = 12;
|
|
const AGENT_CHAT_MAX_POSITIONS = 10;
|
|
const AGENT_CHAT_MAX_SUGGESTIONS = 8;
|
|
|
|
function cleanAgentText(value, max = 1200) {
|
|
return String(value || "").replace(/\s+/g, " ").trim().slice(0, max);
|
|
}
|
|
|
|
function cleanTradeEmailText(value, max = 500) {
|
|
return String(value || "").replace(/\s+/g, " ").trim().slice(0, max);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "").replace(/[&<>"]/g, (c) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
}[c]));
|
|
}
|
|
|
|
function normalizeTradeEmailEvents(events) {
|
|
return (Array.isArray(events) ? events : []).slice(0, 100).map((event) => {
|
|
const action = cleanTradeEmailText(event?.action, 12).toUpperCase();
|
|
if (!VALID_TRADE_EMAIL_ACTIONS.has(action)) return null;
|
|
return {
|
|
agent_name: cleanTradeEmailText(event?.agent_name, 80),
|
|
agent_emoji: cleanTradeEmailText(event?.agent_emoji, 8),
|
|
agent_id: cleanTradeEmailText(event?.agent_id, 60),
|
|
action,
|
|
date: cleanTradeEmailText(event?.date, 40),
|
|
question: cleanTradeEmailText(event?.question, 220),
|
|
side: cleanTradeEmailText(event?.side, 12).toUpperCase(),
|
|
detail: cleanTradeEmailText(event?.detail, 320),
|
|
equity: Number.isFinite(Number(event?.equity)) ? Number(event.equity) : null,
|
|
return_pct: Number.isFinite(Number(event?.return_pct)) ? Number(event.return_pct) : null,
|
|
};
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function tradeEmailBody({ events, appUrl, test }) {
|
|
const title = test ? "Poly Arena test trade alert" : `Poly Arena trade alerts: ${events.length} new action${events.length === 1 ? "" : "s"}`;
|
|
const rows = events.map((event) => {
|
|
const perf = event.return_pct === null ? "" : ` · ${event.return_pct >= 0 ? "+" : ""}${event.return_pct.toFixed(2)}%`;
|
|
return `${event.agent_emoji || ""} ${event.agent_name || event.agent_id} ${event.action} ${event.side || ""}: ${event.question}${event.detail ? `\n ${event.detail}` : ""}${event.equity === null ? "" : `\n Equity: $${event.equity.toLocaleString("en-US", { maximumFractionDigits: 2 })}${perf}`}`;
|
|
}).join("\n\n");
|
|
const text = `${title}\n\n${rows}\n\nPaper-trading alert only. Poly Arena did not place a live order or manage private keys.${appUrl ? `\n\nOpen Poly Arena: ${appUrl}` : ""}`;
|
|
const htmlRows = events.map((event) => {
|
|
const perf = event.return_pct === null ? "" : ` · ${event.return_pct >= 0 ? "+" : ""}${event.return_pct.toFixed(2)}%`;
|
|
return `<tr>
|
|
<td style="padding:10px;border-bottom:1px solid #e5e7eb"><b>${escapeHtml(event.agent_emoji || "")} ${escapeHtml(event.agent_name || event.agent_id)}</b><br><span style="color:#6b7280">${escapeHtml(event.date)}</span></td>
|
|
<td style="padding:10px;border-bottom:1px solid #e5e7eb"><b>${escapeHtml(event.action)} ${escapeHtml(event.side || "")}</b><br>${escapeHtml(event.question)}${event.detail ? `<br><span style="color:#6b7280">${escapeHtml(event.detail)}</span>` : ""}</td>
|
|
<td style="padding:10px;border-bottom:1px solid #e5e7eb;text-align:right">${event.equity === null ? "" : `$${event.equity.toLocaleString("en-US", { maximumFractionDigits: 2 })}<br><span style="color:${event.return_pct >= 0 ? "#047857" : "#dc2626"}">${perf}</span>`}</td>
|
|
</tr>`;
|
|
}).join("");
|
|
const html = `<div style="font-family:Inter,Arial,sans-serif;color:#111827">
|
|
<h2>${escapeHtml(title)}</h2>
|
|
<table style="border-collapse:collapse;width:100%;font-size:14px">${htmlRows}</table>
|
|
<p style="color:#6b7280;font-size:12px;margin-top:16px">Paper-trading alert only. Poly Arena did not place a live order or manage private keys.</p>
|
|
${appUrl ? `<p><a href="${escapeHtml(appUrl)}">Open Poly Arena</a></p>` : ""}
|
|
</div>`;
|
|
return { subject: title, text, html };
|
|
}
|
|
|
|
async function sendTradeEmail({ to, events, appUrl, test }) {
|
|
const email = cleanTradeEmailText(to, 180);
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
return { status: 400, body: { ok: false, error: "Enter a valid email address." } };
|
|
}
|
|
if (!events.length) {
|
|
return { status: 400, body: { ok: false, error: "No trade events to send." } };
|
|
}
|
|
const payload = tradeEmailBody({ events, appUrl: cleanTradeEmailText(appUrl, 240), test });
|
|
|
|
if (process.env.RESEND_API_KEY) {
|
|
const from = process.env.TRADE_EMAIL_FROM || process.env.ADMIN_ALERT_EMAIL || "Poly Arena <onboarding@resend.dev>";
|
|
const replyTo = process.env.TRADE_EMAIL_REPLY_TO || process.env.CUSTOMER_SUPPORT_EMAIL || undefined;
|
|
const response = await fetch("https://api.resend.com/emails", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ from, to: email, reply_to: replyTo, subject: payload.subject, text: payload.text, html: payload.html }),
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) return { status: response.status, body: { ok: false, error: data?.message || data?.error || "Email provider rejected the alert." } };
|
|
return { status: 200, body: { ok: true, provider: "resend", id: data?.id || null, sent: events.length } };
|
|
}
|
|
|
|
if (process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS) {
|
|
const port = Number(process.env.SMTP_PORT);
|
|
const secure = String(process.env.SMTP_SECURE || "").toLowerCase() === "true" || port === 465;
|
|
const from = process.env.TRADE_EMAIL_FROM || process.env.SMTP_FROM || process.env.SMTP_USER;
|
|
const replyTo = process.env.TRADE_EMAIL_REPLY_TO || process.env.CUSTOMER_SUPPORT_EMAIL || undefined;
|
|
const transporter = nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST,
|
|
port,
|
|
secure,
|
|
auth: {
|
|
user: process.env.SMTP_USER,
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
});
|
|
const info = await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
replyTo,
|
|
subject: payload.subject,
|
|
text: payload.text,
|
|
html: payload.html,
|
|
});
|
|
return { status: 200, body: { ok: true, provider: "smtp", id: info?.messageId || null, sent: events.length } };
|
|
}
|
|
|
|
if (process.env.TRADE_EMAIL_WEBHOOK_URL) {
|
|
const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ to: email, ...payload, events }),
|
|
});
|
|
if (!response.ok) return { status: response.status, body: { ok: false, error: "Trade email webhook rejected the alert." } };
|
|
return { status: 200, body: { ok: true, provider: "webhook", sent: events.length } };
|
|
}
|
|
|
|
return {
|
|
status: 501,
|
|
body: {
|
|
ok: false,
|
|
error: "Email alerts need a delivery provider in Vercel: RESEND_API_KEY, or SMTP_HOST + SMTP_PORT + SMTP_USER + SMTP_PASS, or TRADE_EMAIL_WEBHOOK_URL.",
|
|
trade_email: tradeEmailStatus(),
|
|
},
|
|
};
|
|
}
|
|
|
|
function safeAgentNumber(value, fallback = 0) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
function compactAgentPosition(pos) {
|
|
return {
|
|
question: cleanAgentText(pos.question, 180),
|
|
side: cleanAgentText(pos.side, 8),
|
|
entry_price: safeAgentNumber(pos.entry_price),
|
|
current_price: safeAgentNumber(pos.current_price),
|
|
value: safeAgentNumber(pos.value),
|
|
unrealized_pnl: safeAgentNumber(pos.unrealized_pnl),
|
|
conviction: safeAgentNumber(pos.conviction),
|
|
category: cleanAgentText(pos.category, 60),
|
|
opened_at: cleanAgentText(pos.opened_at, 40),
|
|
url: cleanAgentText(pos.url, 240),
|
|
};
|
|
}
|
|
|
|
function compactAgentSuggestion(sug) {
|
|
return {
|
|
question: cleanAgentText(sug.question, 180),
|
|
side: cleanAgentText(sug.side, 8),
|
|
entry_price: safeAgentNumber(sug.entry_price),
|
|
conviction: safeAgentNumber(sug.conviction),
|
|
edge: safeAgentNumber(sug.edge),
|
|
category: cleanAgentText(sug.category, 60),
|
|
rationale: cleanAgentText(sug.rationale, 240),
|
|
url: cleanAgentText(sug.url, 240),
|
|
};
|
|
}
|
|
|
|
function compactAgentHistory(history) {
|
|
return (Array.isArray(history) ? history : []).slice(-AGENT_CHAT_MAX_HISTORY).map((m) => ({
|
|
role: m && m.role === "user" ? "user" : "assistant",
|
|
text: cleanAgentText(m && m.text, 900),
|
|
})).filter((m) => m.text);
|
|
}
|
|
|
|
function openAIOutputText(data) {
|
|
if (data && typeof data.output_text === "string") return data.output_text.trim();
|
|
const chunks = [];
|
|
for (const item of data && Array.isArray(data.output) ? data.output : []) {
|
|
for (const content of Array.isArray(item.content) ? item.content : []) {
|
|
if (content.type === "output_text" && content.text) chunks.push(content.text);
|
|
if (content.type === "text" && content.text) chunks.push(content.text);
|
|
}
|
|
}
|
|
return chunks.join("\n").trim();
|
|
}
|
|
|
|
async function handleAgentChat(body, res) {
|
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
if (!apiKey) {
|
|
return res.status(501).json({
|
|
ok: false,
|
|
code: "missing_openai_key",
|
|
error: "Agent AI chat needs OPENAI_API_KEY in Vercel environment variables.",
|
|
});
|
|
}
|
|
|
|
const question = cleanAgentText(body.question, 1000);
|
|
if (!question) return res.status(400).json({ ok: false, error: "Question is required." });
|
|
|
|
const agent = body.agent || {};
|
|
const portfolio = body.portfolio || {};
|
|
const context = {
|
|
agent: {
|
|
id: cleanAgentText(agent.id, 40),
|
|
name: cleanAgentText(agent.name, 80),
|
|
kind: cleanAgentText(agent.kind, 40),
|
|
style: cleanAgentText(agent.style || agent.blurb, 800),
|
|
voice: cleanAgentText(agent.voice, 200),
|
|
},
|
|
portfolio: {
|
|
equity: safeAgentNumber(portfolio.equity),
|
|
cash: safeAgentNumber(portfolio.cash),
|
|
return_pct: safeAgentNumber(portfolio.return_pct),
|
|
pnl: safeAgentNumber(portfolio.pnl),
|
|
rank: safeAgentNumber(portfolio.rank),
|
|
open_positions: safeAgentNumber(portfolio.open_positions),
|
|
last_decision: cleanAgentText(portfolio.last_decision, 700),
|
|
recent_actions: (Array.isArray(portfolio.recent_actions) ? portfolio.recent_actions : []).slice(-8).map((x) => cleanAgentText(x, 260)),
|
|
positions: (Array.isArray(portfolio.positions) ? portfolio.positions : []).slice(0, AGENT_CHAT_MAX_POSITIONS).map(compactAgentPosition),
|
|
snapshots: (Array.isArray(portfolio.snapshots) ? portfolio.snapshots : []).slice(-8).map((s) => ({
|
|
date: cleanAgentText(s.date || s.timestamp, 40),
|
|
equity: safeAgentNumber(s.equity),
|
|
return_pct: safeAgentNumber(s.return_pct),
|
|
open_positions: safeAgentNumber(s.open_positions),
|
|
})),
|
|
},
|
|
leaderboard: (Array.isArray(body.leaderboard) ? body.leaderboard : []).slice(0, 10).map((r) => ({
|
|
name: cleanAgentText(r.name, 80),
|
|
return_pct: safeAgentNumber(r.return_pct),
|
|
equity: safeAgentNumber(r.equity),
|
|
rank: safeAgentNumber(r.rank),
|
|
})),
|
|
suggestions: (Array.isArray(body.suggestions) ? body.suggestions : []).slice(0, AGENT_CHAT_MAX_SUGGESTIONS).map(compactAgentSuggestion),
|
|
history: compactAgentHistory(body.history),
|
|
};
|
|
|
|
const instructions = [
|
|
`You are ${context.agent.name || "a Polymarket paper-trading agent"} inside Poly Arena.`,
|
|
"Speak in first person as the selected agent, like a real chat partner.",
|
|
"Answer the user's exact question instead of repeating a generic performance summary.",
|
|
"Use the supplied portfolio, positions, leaderboard, suggestions, and chat history as your facts.",
|
|
"Be specific about trades, risk, performance, and uncertainty when the data is available.",
|
|
"Do not claim you can guarantee profits or force agents to make money.",
|
|
"Do not give personalized financial advice. Keep it framed as paper trading, research, or manual review.",
|
|
"If asked what you will do next, describe likely decision rules, not a guaranteed action.",
|
|
"Keep responses concise: 2 to 5 short paragraphs or a tight bullet list.",
|
|
].join("\n");
|
|
|
|
try {
|
|
const response = await fetch("https://api.openai.com/v1/responses", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
model: process.env.OPENAI_MODEL || "gpt-5.6-luna",
|
|
instructions,
|
|
input: [{
|
|
role: "user",
|
|
content: [{
|
|
type: "input_text",
|
|
text: `Context JSON:\n${JSON.stringify(context)}\n\nUser message:\n${question}`,
|
|
}],
|
|
}],
|
|
max_output_tokens: 550,
|
|
}),
|
|
});
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
return res.status(response.status).json({
|
|
ok: false,
|
|
error: data && data.error && data.error.message ? data.error.message : "OpenAI chat request failed.",
|
|
});
|
|
}
|
|
|
|
const text = openAIOutputText(data);
|
|
return res.status(200).json({ ok: true, text: text || "I am here, but I could not form a useful answer from the current context." });
|
|
} catch (err) {
|
|
return res.status(500).json({ ok: false, error: err && err.message ? err.message : "Agent chat failed." });
|
|
}
|
|
}
|
|
|
|
function marketSearchUrl(question) {
|
|
return `https://polymarket.com/search?query=${encodeURIComponent(String(question || ""))}`;
|
|
}
|
|
|
|
function buildTicketInstructions(ticket) {
|
|
const marketUrl = ticket.market_url || ticket.marketUrl || marketSearchUrl(ticket.question || ticket.market_id || 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 validateTicket(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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function normalizeCapitalAction(body) {
|
|
return {
|
|
userId: String(body.user_id || "local-readiness-user").trim(),
|
|
action: String(body.capital_action || body.capitalAction || "").trim().toUpperCase(),
|
|
agentId: String(body.agent_id || "").trim(),
|
|
amount: Number(body.amount || 0),
|
|
note: String(body.note || "").trim(),
|
|
};
|
|
}
|
|
|
|
function validateCapitalAction(action) {
|
|
const errors = [];
|
|
if (!action.userId) errors.push("user_id");
|
|
if (!VALID_CAPITAL_ACTIONS.has(action.action)) errors.push("capital_action must be DEPOSIT, WITHDRAW, BUY_AGENT, or SELL_AGENT");
|
|
if ((action.action === "BUY_AGENT" || action.action === "SELL_AGENT") && !action.agentId) errors.push("agent_id");
|
|
if (!Number.isFinite(action.amount) || action.amount <= 0) errors.push("amount must be positive");
|
|
return errors;
|
|
}
|
|
|
|
function stripTags(value) {
|
|
return String(value || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function decodeXml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, "\"")
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
function cleanMarketContextText(value, max = 220) {
|
|
return String(value || "").replace(/\s+/g, " ").trim().slice(0, max);
|
|
}
|
|
|
|
function contextQuery(market) {
|
|
const q = cleanMarketContextText(market?.question, 140)
|
|
.replace(/\bwill\b/ig, "")
|
|
.replace(/\?/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const cat = cleanMarketContextText(market?.category, 40);
|
|
if (cat === "Sports") return `${q} injury lineup odds`;
|
|
if (cat === "Politics") return `${q} poll election news`;
|
|
if (cat === "Crypto") return `${q} crypto market news`;
|
|
if (cat === "Economy") return `${q} economy fed inflation news`;
|
|
if (cat === "Pop Culture") return `${q} entertainment latest`;
|
|
return `${q} latest news`;
|
|
}
|
|
|
|
async function fetchNewsContext(market) {
|
|
const query = contextQuery(market);
|
|
if (!query || query.length < 8) return { source_count: 0, latest_title: "", query };
|
|
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`;
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 900);
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { "User-Agent": "PolyArenaContext/1.0" },
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) return { source_count: 0, latest_title: "", query };
|
|
const xml = await response.text();
|
|
const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].slice(0, 5);
|
|
const titles = items.map((item) => {
|
|
const match = item[1].match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>|<title>([\s\S]*?)<\/title>/);
|
|
return cleanMarketContextText(decodeXml(stripTags(match ? (match[1] || match[2]) : "")), 160);
|
|
}).filter(Boolean);
|
|
return { source_count: titles.length, latest_title: titles[0] || "", query };
|
|
} catch (err) {
|
|
return { source_count: 0, latest_title: "", query, error: err?.name === "AbortError" ? "timeout" : "unavailable" };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function handleMarketContext(body, res) {
|
|
const markets = (Array.isArray(body.markets) ? body.markets : []).slice(0, 120).map((market) => ({
|
|
id: cleanMarketContextText(market?.id, 80),
|
|
question: cleanMarketContextText(market?.question, 220),
|
|
category: cleanMarketContextText(market?.category, 60),
|
|
tags: Array.isArray(market?.tags) ? market.tags.slice(0, 6).map((tag) => cleanMarketContextText(tag, 40)) : [],
|
|
})).filter((market) => market.id && market.question);
|
|
const signals = {};
|
|
const batchSize = 24;
|
|
for (let i = 0; i < markets.length; i += batchSize) {
|
|
const batch = markets.slice(i, i + batchSize);
|
|
const results = await Promise.all(batch.map(fetchNewsContext));
|
|
results.forEach((signal, idx) => {
|
|
signals[batch[idx].id] = signal;
|
|
});
|
|
}
|
|
return res.status(200).json({ ok: true, count: Object.keys(signals).length, signals });
|
|
}
|
|
|
|
export default async function handler(req, res) {
|
|
res.setHeader("Cache-Control", "no-store");
|
|
|
|
if (req.method === "GET") {
|
|
if (req.query?.action === "tickets") {
|
|
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.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 });
|
|
}
|
|
if (req.query?.action === "personal_capital") {
|
|
const userId = String(req.query?.user_id || "local-readiness-user").trim();
|
|
const capital = await listPersonalCapital(userId);
|
|
return res.status(200).json({ ok: true, ...capital });
|
|
}
|
|
return res.status(200).json(baseStatus());
|
|
}
|
|
|
|
if (req.method === "POST") {
|
|
const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {});
|
|
|
|
if (body.action === "trade_email") {
|
|
const events = normalizeTradeEmailEvents(body.events);
|
|
let result;
|
|
try {
|
|
result = await sendTradeEmail({
|
|
to: body.to,
|
|
events,
|
|
appUrl: body.app_url,
|
|
test: Boolean(body.test),
|
|
});
|
|
} catch (err) {
|
|
return res.status(502).json({
|
|
ok: false,
|
|
error: err?.response || err?.message || "Email provider failed to send the alert.",
|
|
trade_email: tradeEmailStatus(),
|
|
});
|
|
}
|
|
if (result.body?.ok) {
|
|
await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", {
|
|
to_domain: String(body.to || "").split("@")[1] || null,
|
|
event_count: events.length,
|
|
test: Boolean(body.test),
|
|
provider: result.body.provider,
|
|
});
|
|
}
|
|
return res.status(result.status).json(result.body);
|
|
}
|
|
|
|
if (body.action === "agent_chat") {
|
|
return handleAgentChat(body, res);
|
|
}
|
|
|
|
if (body.action === "market_context") {
|
|
return handleMarketContext(body, res);
|
|
}
|
|
|
|
if (body.action === "ticket") {
|
|
const ticket = normalizeTicket(body);
|
|
const ticketErrors = validateTicket(ticket);
|
|
if (ticketErrors.length) return res.status(400).json({ ok: false, error: "Invalid trade ticket", details: ticketErrors });
|
|
const saved = await createTradeTicket({
|
|
...ticket,
|
|
status: "staged",
|
|
instructions: buildTicketInstructions(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,
|
|
});
|
|
}
|
|
|
|
if (body.action === "ticket_status") {
|
|
const userId = String(body.user_id || "local-readiness-user").trim();
|
|
const status = String(body.status || "").trim();
|
|
if (!VALID_TICKET_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 });
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
if (body.action === "capital_action") {
|
|
const capitalAction = normalizeCapitalAction(body);
|
|
const capitalErrors = validateCapitalAction(capitalAction);
|
|
if (capitalErrors.length) return res.status(400).json({ ok: false, error: "Invalid personal capital action", details: capitalErrors });
|
|
let capital;
|
|
try {
|
|
capital = await recordPersonalCapitalAction(capitalAction);
|
|
} catch (err) {
|
|
return res.status(400).json({ ok: false, error: err?.message || "Personal capital tracker update failed" });
|
|
}
|
|
await recordAuditEvent("PERSONAL_CAPITAL_ACTION", {
|
|
user_id: capitalAction.userId,
|
|
action: capitalAction.action,
|
|
agent_id: capitalAction.agentId,
|
|
amount: capitalAction.amount,
|
|
custody_by_site: false,
|
|
real_order_placed_by_site: false,
|
|
});
|
|
return res.status(200).json({
|
|
ok: true,
|
|
...capital,
|
|
custody_by_site: false,
|
|
real_order_placed_by_site: false,
|
|
note: "Personal capital tracker updated for bookkeeping only. Poly Arena did not receive funds or place an order.",
|
|
});
|
|
}
|
|
|
|
const intent = body.intent || body;
|
|
const errors = validateIntent(intent);
|
|
if (errors.length) {
|
|
return res.status(400).json({ ok: false, error: "Invalid order intent", details: errors });
|
|
}
|
|
|
|
const status = baseStatus();
|
|
const auditEvent = {
|
|
at: new Date().toISOString(),
|
|
type: "ORDER_INTENT_DRY_RUN",
|
|
user_id: String(intent.user_id),
|
|
wallet_address: String(intent.wallet_address),
|
|
agent_id: String(intent.agent_id),
|
|
market_id: String(intent.market_id),
|
|
side: String(intent.side).toUpperCase(),
|
|
max_amount: Number(intent.max_amount),
|
|
execution_mode: intent.execution_mode || "manual_approval",
|
|
account_scope: intent.account_scope || body.account_scope || "public_readiness",
|
|
};
|
|
|
|
if (auditEvent.account_scope === "personal") {
|
|
await recordAuditEvent("PERSONAL_ORDER_INTENT_STAGED", auditEvent);
|
|
return res.status(202).json({
|
|
ok: true,
|
|
dry_run: true,
|
|
manual_review_required: true,
|
|
live_order_placed: false,
|
|
message: personalTradingReady()
|
|
? "Personal prerequisites are configured, but this endpoint still stages manual review only."
|
|
: "Personal order intent staged. Configure your own deposit wallet/CLOB credentials before any manual live execution.",
|
|
audit_event: auditEvent,
|
|
status,
|
|
});
|
|
}
|
|
|
|
if (!status.live_trading_enabled) {
|
|
await recordAuditEvent("ORDER_INTENT_BLOCKED", auditEvent);
|
|
return res.status(423).json({
|
|
ok: false,
|
|
dry_run: true,
|
|
error: status.locked_reason,
|
|
audit_event: auditEvent,
|
|
status,
|
|
});
|
|
}
|
|
|
|
await recordAuditEvent("ORDER_INTENT_NOT_IMPLEMENTED", auditEvent);
|
|
return res.status(501).json({
|
|
ok: false,
|
|
dry_run: true,
|
|
error: "Live trading credentials are configured, but signed order placement is intentionally not implemented yet.",
|
|
audit_event: auditEvent,
|
|
status,
|
|
});
|
|
}
|
|
|
|
res.setHeader("Allow", "GET, POST");
|
|
return res.status(405).json({ ok: false, error: "Method not allowed" });
|
|
}
|