mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-13 15:48:05 +00:00
Compare commits
28
Commits
49888c7b56
...
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 |
+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();
|
||||||
|
|||||||
+160
-17
@@ -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: 900, 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,8 +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,
|
||||||
|
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,
|
conviction: s.conviction, volume: s.volume, volume_24hr: s.volume_24hr, liquidity: s.liquidity,
|
||||||
trade_ready: s.trade_ready, watch_only: s.watch_only,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+555
-462
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user