mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-13 23:58:05 +00:00
Compare commits
37
Commits
86fa65f8f1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab333dd045 | ||
|
|
0ef05ea734 | ||
|
|
cfecd2e78b | ||
|
|
6233efc098 | ||
|
|
ecbdbf4cbf | ||
|
|
a4402134ff | ||
|
|
e57d84af1d | ||
|
|
c7c8359f09 | ||
|
|
8dc7c9506c | ||
|
|
313c4f9326 | ||
|
|
1effdd8373 | ||
|
|
4630bd482b | ||
|
|
91a6c5678c | ||
|
|
8a897b9a6b | ||
|
|
2a2f8af765 | ||
|
|
a352e86a2b | ||
|
|
21f89a9fea | ||
|
|
f8e3799f21 | ||
|
|
5f57608ee7 | ||
|
|
697830a782 | ||
|
|
cb4a539256 | ||
|
|
09b4eac736 | ||
|
|
c7ce683db9 | ||
|
|
d04ed59088 | ||
|
|
82a1e0c831 | ||
|
|
67beab0144 | ||
|
|
3c39cfe9f3 | ||
|
|
744387199e | ||
|
|
49888c7b56 | ||
|
|
e36ef12e6a | ||
|
|
29f9dcf29b | ||
|
|
1f1e0c39b8 | ||
|
|
b5fed7980f | ||
|
|
4b3e03a353 | ||
|
|
734b989072 | ||
|
|
8d69619dad | ||
|
|
018d1d9294 |
+7
-1
@@ -85,11 +85,17 @@ CUSTOMER_SUPPORT_EMAIL=
|
|||||||
RISK_ADMIN_WALLET=
|
RISK_ADMIN_WALLET=
|
||||||
|
|
||||||
# Trade email alerts
|
# Trade email alerts
|
||||||
# Use either Resend directly or a webhook from Zapier/Make/another email automation.
|
# Use Resend, SMTP, or a webhook from Zapier/Make/another email automation.
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
TRADE_EMAIL_FROM=Poly Arena <alerts@yourdomain.com>
|
TRADE_EMAIL_FROM=Poly Arena <alerts@yourdomain.com>
|
||||||
TRADE_EMAIL_REPLY_TO=
|
TRADE_EMAIL_REPLY_TO=
|
||||||
TRADE_EMAIL_WEBHOOK_URL=
|
TRADE_EMAIL_WEBHOOK_URL=
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=
|
||||||
|
SMTP_SECURE=true
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=
|
||||||
|
|
||||||
# Agent chat
|
# Agent chat
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|||||||
+40
@@ -2,6 +2,7 @@ import { neon } from "@neondatabase/serverless";
|
|||||||
|
|
||||||
let sqlClient;
|
let sqlClient;
|
||||||
let schemaReady;
|
let schemaReady;
|
||||||
|
let sharedStateSchemaReady;
|
||||||
|
|
||||||
export function databaseUrl() {
|
export function databaseUrl() {
|
||||||
return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || "";
|
return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || "";
|
||||||
@@ -20,6 +21,45 @@ export function sql() {
|
|||||||
return sqlClient;
|
return sqlClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureSharedStateSchema() {
|
||||||
|
if (sharedStateSchemaReady) return;
|
||||||
|
const db = sql();
|
||||||
|
await db`
|
||||||
|
create table if not exists shared_app_state (
|
||||||
|
state_key text primary key,
|
||||||
|
payload jsonb not null,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
sharedStateSchemaReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSharedAppState(stateKey) {
|
||||||
|
await ensureSharedStateSchema();
|
||||||
|
const db = sql();
|
||||||
|
const rows = await db`
|
||||||
|
select payload, updated_at
|
||||||
|
from shared_app_state
|
||||||
|
where state_key = ${stateKey}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSharedAppState(stateKey, payload) {
|
||||||
|
await ensureSharedStateSchema();
|
||||||
|
const db = sql();
|
||||||
|
const rows = await db`
|
||||||
|
insert into shared_app_state (state_key, payload, updated_at)
|
||||||
|
values (${stateKey}, ${JSON.stringify(payload)}::jsonb, now())
|
||||||
|
on conflict (state_key) do update set
|
||||||
|
payload = excluded.payload,
|
||||||
|
updated_at = now()
|
||||||
|
returning updated_at
|
||||||
|
`;
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureSchema() {
|
export async function ensureSchema() {
|
||||||
if (schemaReady) return;
|
if (schemaReady) return;
|
||||||
const db = sql();
|
const db = sql();
|
||||||
|
|||||||
+147
-7
@@ -9,6 +9,7 @@ import {
|
|||||||
updateRealPositionMark,
|
updateRealPositionMark,
|
||||||
updateTradeTicketStatus,
|
updateTradeTicketStatus,
|
||||||
} from "./_db.js";
|
} from "./_db.js";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
const REQUIRED_ENV = [
|
const REQUIRED_ENV = [
|
||||||
["PRODUCTION_APP_URL", "Production app URL"],
|
["PRODUCTION_APP_URL", "Production app URL"],
|
||||||
@@ -173,6 +174,28 @@ function stackStatus() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function liveTradingReady() {
|
||||||
const requiredConfigured = providerStatus().every((x) => x.configured);
|
const requiredConfigured = providerStatus().every((x) => x.configured);
|
||||||
const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true";
|
const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true";
|
||||||
@@ -214,6 +237,7 @@ function baseStatus() {
|
|||||||
: "Live trading is locked until eligibility, payments, wallet/deposit-wallet signing, Polymarket CLOB credentials, audit storage, monitoring, and LIVE_TRADING_ENABLED=true are configured.",
|
: "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,
|
providers,
|
||||||
provider_stack: stackStatus(),
|
provider_stack: stackStatus(),
|
||||||
|
trade_email: tradeEmailStatus(),
|
||||||
personal_requirements: personal,
|
personal_requirements: personal,
|
||||||
launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })),
|
launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })),
|
||||||
webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({
|
webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({
|
||||||
@@ -336,6 +360,31 @@ async function sendTradeEmail({ to, events, appUrl, test }) {
|
|||||||
return { status: 200, body: { ok: true, provider: "resend", id: data?.id || null, sent: events.length } };
|
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) {
|
if (process.env.TRADE_EMAIL_WEBHOOK_URL) {
|
||||||
const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, {
|
const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -350,7 +399,8 @@ async function sendTradeEmail({ to, events, appUrl, test }) {
|
|||||||
status: 501,
|
status: 501,
|
||||||
body: {
|
body: {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "Email alerts need RESEND_API_KEY plus TRADE_EMAIL_FROM, or TRADE_EMAIL_WEBHOOK_URL, in Vercel environment variables.",
|
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(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -600,6 +650,83 @@ function validateCapitalAction(action) {
|
|||||||
return errors;
|
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) {
|
export default async function handler(req, res) {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
|
||||||
@@ -627,12 +754,21 @@ export default async function handler(req, res) {
|
|||||||
|
|
||||||
if (body.action === "trade_email") {
|
if (body.action === "trade_email") {
|
||||||
const events = normalizeTradeEmailEvents(body.events);
|
const events = normalizeTradeEmailEvents(body.events);
|
||||||
const result = await sendTradeEmail({
|
let result;
|
||||||
to: body.to,
|
try {
|
||||||
events,
|
result = await sendTradeEmail({
|
||||||
appUrl: body.app_url,
|
to: body.to,
|
||||||
test: Boolean(body.test),
|
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) {
|
if (result.body?.ok) {
|
||||||
await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", {
|
await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", {
|
||||||
to_domain: String(body.to || "").split("@")[1] || null,
|
to_domain: String(body.to || "").split("@")[1] || null,
|
||||||
@@ -648,6 +784,10 @@ export default async function handler(req, res) {
|
|||||||
return handleAgentChat(body, res);
|
return handleAgentChat(body, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.action === "market_context") {
|
||||||
|
return handleMarketContext(body, res);
|
||||||
|
}
|
||||||
|
|
||||||
if (body.action === "ticket") {
|
if (body.action === "ticket") {
|
||||||
const ticket = normalizeTicket(body);
|
const ticket = normalizeTicket(body);
|
||||||
const ticketErrors = validateTicket(ticket);
|
const ticketErrors = validateTicket(ticket);
|
||||||
|
|||||||
+162
-18
@@ -1,20 +1,130 @@
|
|||||||
import { get, put } from "@vercel/blob";
|
import { get, list, put } from "@vercel/blob";
|
||||||
|
import { hasDatabase, readSharedAppState, writeSharedAppState } from "./_db.js";
|
||||||
|
|
||||||
const STATE_PATH = process.env.PMA_STATE_PATH || "shared/state.json";
|
const STATE_PATH = process.env.PMA_STATE_PATH || "shared/state.json";
|
||||||
|
const STATE_VERSION_PREFIX = process.env.PMA_STATE_VERSION_PREFIX || "shared/state-versions/";
|
||||||
|
const DATABASE_STATE_KEY = process.env.PMA_DATABASE_STATE_KEY || "polymarket-arena";
|
||||||
const AGENTS_KEY = "pma_agents_v2";
|
const AGENTS_KEY = "pma_agents_v2";
|
||||||
const SUG_KEY = "pma_suggestions_v5";
|
const SUG_KEY = "pma_suggestions_v5";
|
||||||
const PAPER_KEY = "pma_paper_accounts_v1";
|
const PAPER_KEY = "pma_paper_accounts_v1";
|
||||||
const LIVE_KEY = "pma_live_readiness_v1";
|
const LIVE_KEY = "pma_live_readiness_v1";
|
||||||
const AGENT_IDS = ["value", "momentum", "favorite", "longshot", "diversifier", "copycat", "whale1", "whale2", "whale3", "whale4"];
|
const AGENT_IDS = ["value", "momentum", "favorite", "longshot", "diversifier", "catalyst", "reversal", "breakout", "tailalpha", "conviction"];
|
||||||
const LIMITS = { closed: 80, history: 160, snapshots: 240, suggestions: 120, paperHistory: 120, paperSnapshots: 120, audit: 120 };
|
const LIMITS = { closed: 80, history: 160, snapshots: 240, suggestions: 900, paperHistory: 120, paperSnapshots: 120, audit: 120 };
|
||||||
|
|
||||||
|
function withBlobAuth(options = {}) {
|
||||||
|
const token = process.env.BLOB_READ_WRITE_TOKEN;
|
||||||
|
return token ? { ...options, token } : options;
|
||||||
|
}
|
||||||
|
|
||||||
async function readJsonBlob() {
|
async function readJsonBlob() {
|
||||||
const blob = await get(STATE_PATH, { access: "private" });
|
let databaseAvailable = false;
|
||||||
|
if (hasDatabase()) {
|
||||||
|
try {
|
||||||
|
const row = await readSharedAppState(DATABASE_STATE_KEY);
|
||||||
|
databaseAvailable = true;
|
||||||
|
if (row && row.payload) return row.payload;
|
||||||
|
} catch {
|
||||||
|
databaseAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let primaryError = null;
|
||||||
|
try {
|
||||||
|
const primary = await readBlobJson(STATE_PATH);
|
||||||
|
if (primary) return primary;
|
||||||
|
} catch (err) {
|
||||||
|
primaryError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latest = await latestVersionedStateBlob();
|
||||||
|
if (latest) return latest;
|
||||||
|
} catch (err) {
|
||||||
|
if (!primaryError) primaryError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryError && !databaseAvailable) throw primaryError;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistState(state) {
|
||||||
|
let databaseSaved = false;
|
||||||
|
let databaseError = null;
|
||||||
|
if (hasDatabase()) {
|
||||||
|
try {
|
||||||
|
await writeSharedAppState(DATABASE_STATE_KEY, state);
|
||||||
|
databaseSaved = true;
|
||||||
|
} catch (err) {
|
||||||
|
databaseError = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let blobSaved = false;
|
||||||
|
let blobError = null;
|
||||||
|
try {
|
||||||
|
await put(STATE_PATH, JSON.stringify(state), withBlobAuth({
|
||||||
|
access: "private",
|
||||||
|
allowOverwrite: true,
|
||||||
|
contentType: "application/json",
|
||||||
|
cacheControlMaxAge: 0,
|
||||||
|
}));
|
||||||
|
blobSaved = true;
|
||||||
|
} catch (err) {
|
||||||
|
blobError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!databaseSaved && !blobSaved) throw databaseError || blobError || new Error("No state provider is available");
|
||||||
|
return { databaseSaved, blobSaved };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBlobJson(pathname) {
|
||||||
|
const blob = await get(pathname, withBlobAuth({
|
||||||
|
access: "private",
|
||||||
|
headers: { "cache-control": "no-cache" },
|
||||||
|
}));
|
||||||
if (!blob || blob.statusCode !== 200 || !blob.stream) return null;
|
if (!blob || blob.statusCode !== 200 || !blob.stream) return null;
|
||||||
const text = await new Response(blob.stream).text();
|
const text = await new Response(blob.stream).text();
|
||||||
return text ? JSON.parse(text) : null;
|
return text ? JSON.parse(text) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function latestVersionedStateBlob() {
|
||||||
|
let cursor;
|
||||||
|
let newest = null;
|
||||||
|
do {
|
||||||
|
const page = await list(withBlobAuth({ prefix: STATE_VERSION_PREFIX, limit: 1000, cursor }));
|
||||||
|
for (const blob of page.blobs || []) {
|
||||||
|
if (!newest || new Date(blob.uploadedAt).getTime() > new Date(newest.uploadedAt).getTime()) newest = blob;
|
||||||
|
}
|
||||||
|
cursor = page.cursor;
|
||||||
|
} while (cursor);
|
||||||
|
if (!newest) return null;
|
||||||
|
return readBlobJson(newest.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleVersion(cycle = "") {
|
||||||
|
const m = String(cycle).match(/\|v(\d+)$/);
|
||||||
|
return m ? Number(m[1]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPositionCount(st) {
|
||||||
|
if (!st || !st.agents) return 0;
|
||||||
|
return AGENT_IDS.reduce((sum, id) => sum + (Array.isArray(st.agents[id]?.positions) ? st.agents[id].positions.length : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRejectStaleAgentWrite(currentAgents, incomingAgents) {
|
||||||
|
if (!currentAgents || !incomingAgents) return false;
|
||||||
|
const currentCycle = currentAgents.last_cycle_hour || "";
|
||||||
|
const incomingCycle = incomingAgents.last_cycle_hour || "";
|
||||||
|
if (!currentCycle || !incomingCycle) return false;
|
||||||
|
const currentVersion = cycleVersion(currentCycle);
|
||||||
|
const incomingVersion = cycleVersion(incomingCycle);
|
||||||
|
if (incomingVersion < currentVersion) return true;
|
||||||
|
if (incomingVersion === currentVersion && incomingCycle < currentCycle) return true;
|
||||||
|
const currentOpen = openPositionCount(currentAgents);
|
||||||
|
const incomingOpen = openPositionCount(incomingAgents);
|
||||||
|
return incomingVersion === currentVersion && incomingCycle === currentCycle && currentOpen > 0 && incomingOpen === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function agentStateFromItems(items) {
|
function agentStateFromItems(items) {
|
||||||
if (!items || !items[AGENTS_KEY]) return null;
|
if (!items || !items[AGENTS_KEY]) return null;
|
||||||
try {
|
try {
|
||||||
@@ -24,6 +134,15 @@ function agentStateFromItems(items) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function suggestionStateFromItems(items) {
|
||||||
|
if (!items || !items[SUG_KEY]) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(items[SUG_KEY]);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function compactPortfolio(p) {
|
function compactPortfolio(p) {
|
||||||
if (!p || typeof p !== "object") return p;
|
if (!p || typeof p !== "object") return p;
|
||||||
const out = { ...p };
|
const out = { ...p };
|
||||||
@@ -41,7 +160,8 @@ function compactAgentState(st) {
|
|||||||
for (const id of AGENT_IDS) {
|
for (const id of AGENT_IDS) {
|
||||||
out.agents[id] = compactPortfolio(st.agents && st.agents[id]);
|
out.agents[id] = compactPortfolio(st.agents && st.agents[id]);
|
||||||
}
|
}
|
||||||
out.whales = st.whales || {};
|
delete out.whales;
|
||||||
|
delete out.copycatLeader;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +171,11 @@ function compactSuggestion(s) {
|
|||||||
market_id: s.market_id, question: s.question, event: s.event, url: s.url, category: s.category,
|
market_id: s.market_id, question: s.question, event: s.event, url: s.url, category: s.category,
|
||||||
clob_yes: s.clob_yes, clob_no: s.clob_no, yes_price: s.yes_price, no_price: s.no_price,
|
clob_yes: s.clob_yes, clob_no: s.clob_no, yes_price: s.yes_price, no_price: s.no_price,
|
||||||
fair_value: s.fair_value, edge: s.edge, side: s.side, entry_price: s.entry_price,
|
fair_value: s.fair_value, edge: s.edge, side: s.side, entry_price: s.entry_price,
|
||||||
conviction: s.conviction, volume: s.volume, volume_24hr: s.volume_24hr,
|
net_edge: s.net_edge, friction: s.friction, chase_penalty: s.chase_penalty,
|
||||||
|
evidence_score: s.evidence_score, evidence_source_count: s.evidence_source_count, quality: s.quality,
|
||||||
|
conviction: s.conviction, volume: s.volume, volume_24hr: s.volume_24hr, liquidity: s.liquidity,
|
||||||
|
spread: s.spread, price_change_1h: s.price_change_1h, price_change_1d: s.price_change_1d, price_change_1w: s.price_change_1w,
|
||||||
|
momentum_strength: s.momentum_strength, trade_ready: s.trade_ready, watch_only: s.watch_only, jump_risk: s.jump_risk,
|
||||||
days_to_resolution: s.days_to_resolution, drivers: s.drivers, rationale: s.rationale,
|
days_to_resolution: s.days_to_resolution, drivers: s.drivers, rationale: s.rationale,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -96,7 +220,7 @@ function conflictResponse(res, error, current) {
|
|||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
try {
|
try {
|
||||||
if (!process.env.BLOB_READ_WRITE_TOKEN && !process.env.VERCEL_OIDC_TOKEN) {
|
if (!hasDatabase() && !process.env.BLOB_READ_WRITE_TOKEN && !process.env.VERCEL_OIDC_TOKEN) {
|
||||||
return res.status(503).json({ ok: false, error: "Cloud state is not configured" });
|
return res.status(503).json({ ok: false, error: "Cloud state is not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,30 +236,50 @@ export default async function handler(req, res) {
|
|||||||
return res.status(400).json({ ok: false, error: "Invalid state payload" });
|
return res.status(400).json({ ok: false, error: "Invalid state payload" });
|
||||||
}
|
}
|
||||||
const current = await readJsonBlob();
|
const current = await readJsonBlob();
|
||||||
|
const incomingItems = { ...body.items };
|
||||||
const currentAgents = agentStateFromItems(current && current.items);
|
const currentAgents = agentStateFromItems(current && current.items);
|
||||||
const incomingAgents = agentStateFromItems(body.items);
|
let incomingAgents = agentStateFromItems(incomingItems);
|
||||||
|
const staleAgentWrite = shouldRejectStaleAgentWrite(currentAgents, incomingAgents);
|
||||||
|
if (body.force && staleAgentWrite && current?.items?.[AGENTS_KEY]) {
|
||||||
|
incomingItems[AGENTS_KEY] = current.items[AGENTS_KEY];
|
||||||
|
incomingAgents = currentAgents;
|
||||||
|
}
|
||||||
|
const currentSuggestions = suggestionStateFromItems(current && current.items);
|
||||||
|
const incomingSuggestions = suggestionStateFromItems(incomingItems);
|
||||||
|
if (currentSuggestions && incomingSuggestions
|
||||||
|
&& Number(incomingSuggestions.engine_version || 0) < Number(currentSuggestions.engine_version || 0)) {
|
||||||
|
incomingItems[SUG_KEY] = current.items[SUG_KEY];
|
||||||
|
}
|
||||||
if (!body.force && currentAgents) {
|
if (!body.force && currentAgents) {
|
||||||
const currentCycle = currentAgents.last_cycle_hour || "";
|
const currentCycle = currentAgents.last_cycle_hour || "";
|
||||||
const incomingCycle = incomingAgents && incomingAgents.last_cycle_hour ? incomingAgents.last_cycle_hour : "";
|
const incomingCycle = incomingAgents && incomingAgents.last_cycle_hour ? incomingAgents.last_cycle_hour : "";
|
||||||
if (currentCycle && (!incomingAgents || !incomingCycle)) {
|
if (currentCycle && (!incomingAgents || !incomingCycle)) {
|
||||||
return conflictResponse(res, "Cloud already has a cycle result; refusing unscheduled local state", current);
|
return conflictResponse(res, "Cloud already has a cycle result; refusing unscheduled local state", current);
|
||||||
}
|
}
|
||||||
if (currentCycle && incomingCycle && incomingCycle < currentCycle) {
|
|
||||||
return conflictResponse(res, "Incoming state is older than the shared cloud result", current);
|
|
||||||
}
|
|
||||||
const sameCycle = currentCycle && currentCycle === incomingCycle;
|
const sameCycle = currentCycle && currentCycle === incomingCycle;
|
||||||
const differentRun = currentAgents.last_run && incomingAgents.last_run && currentAgents.last_run !== incomingAgents.last_run;
|
const differentRun = currentAgents.last_run && incomingAgents.last_run && currentAgents.last_run !== incomingAgents.last_run;
|
||||||
if (sameCycle && differentRun) {
|
if (sameCycle && differentRun) {
|
||||||
return conflictResponse(res, "This cycle already has a cloud result", current);
|
return conflictResponse(res, "This cycle already has a cloud result", current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const state = { version: 1, updated_at: new Date().toISOString(), items: compactItems(body.items) };
|
if (!body.force && staleAgentWrite) {
|
||||||
await put(STATE_PATH, JSON.stringify(state), {
|
return conflictResponse(res, "Incoming state would replace newer active positions with stale cash-only data", current);
|
||||||
access: "private",
|
}
|
||||||
allowOverwrite: true,
|
const state = { version: 1, updated_at: new Date().toISOString(), items: compactItems(incomingItems) };
|
||||||
contentType: "application/json",
|
const saved = await persistState(state);
|
||||||
cacheControlMaxAge: 60,
|
if (saved.blobSaved && process.env.PMA_ENABLE_STATE_HISTORY === "true") {
|
||||||
});
|
const versionedPath = `${STATE_VERSION_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
||||||
|
try {
|
||||||
|
await put(versionedPath, JSON.stringify(state), withBlobAuth({
|
||||||
|
access: "private",
|
||||||
|
allowOverwrite: false,
|
||||||
|
contentType: "application/json",
|
||||||
|
cacheControlMaxAge: 0,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// The shared state is authoritative; backup retention must not block a cycle.
|
||||||
|
}
|
||||||
|
}
|
||||||
return res.status(200).json({ ok: true, state });
|
return res.status(200).json({ ok: true, state });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+786
-485
File diff suppressed because it is too large
Load Diff
Generated
+10
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@vercel/blob": "2.5.0",
|
"@vercel/blob": "2.5.0",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"svix": "^1.96.1"
|
"svix": "^1.96.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -222,6 +223,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/npm-run-path": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@vercel/blob": "2.5.0",
|
"@vercel/blob": "2.5.0",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"svix": "^1.96.1"
|
"svix": "^1.96.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user