mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-24 13:08:10 +00:00
Audit maker rewards before shadow quoting
This commit is contained in:
@@ -15,7 +15,7 @@ https://polymarket-site-eta.vercel.app/personal.html
|
||||
|
||||
The site fetches live Polymarket markets, generates agent suggestions, lets you
|
||||
run frequent paper cycles, and syncs the shared arena state through Neon or
|
||||
Vercel Blob. Build 65 also installs an offline app shell and caches timestamped
|
||||
Vercel Blob. Build 66 also installs an offline app shell and caches timestamped
|
||||
market snapshots. During an outage, cycles continue locally; cached entries are
|
||||
allowed for 90 minutes, older snapshots become mark-only, and all cached data
|
||||
expires after 24 hours.
|
||||
@@ -26,7 +26,7 @@ team names, Over/Under, or another pair are rejected instead of being silently
|
||||
reinterpreted as Yes/No. The same semantic check applies to complete event
|
||||
bundles and the offline evaluators.
|
||||
|
||||
Build 65 ranks the competition by each agent's return since Strategy 54 began.
|
||||
Build 66 ranks the competition by each agent's return since Strategy 55 began.
|
||||
Historical replay equity remains visible for context, but it no longer makes an
|
||||
agent look like the current leader when the live adaptive strategy is losing.
|
||||
|
||||
@@ -37,7 +37,7 @@ The learner shrinks small samples toward neutral, caps sizing changes to
|
||||
15% of candidates for deterministic exploration so a stale regime cannot become
|
||||
permanent.
|
||||
|
||||
Strategy 54 treats each binary stake as capable of falling to zero even when the
|
||||
Strategy 55 treats each binary stake as capable of falling to zero even when the
|
||||
18% stop cannot fill. New core positions are capped at 2.5%-4% of equity and
|
||||
aggressive positions at 3%-5%, with lower limits for near-term, extreme-price,
|
||||
reversal, and fast-moving setups. Oversized positions inherited from older
|
||||
@@ -74,13 +74,23 @@ Run `npm run evaluate:adaptive` for a stricter chronological search across 1,920
|
||||
predefined price-action rules. It uses a 60/20/20 train, validation, and holdout
|
||||
split and never selects a rule from the holdout period. The August 19 run loaded
|
||||
all 300 requested histories and found no directional rule that passed both train
|
||||
and validation at either 24 or 72 hours. Strategy 54 therefore keeps directional
|
||||
and validation at either 24 or 72 hours. Strategy 55 therefore keeps directional
|
||||
signals in the walk-forward observation ledger until current, independent-event
|
||||
evidence proves an edge.
|
||||
|
||||
Run `npm run evaluate:liquidity` to inspect live reward-scoring markets for
|
||||
paired resting YES and NO bids whose combined cost is below the $1 settlement
|
||||
payout. Run `npm run evaluate:maker` for the chronological fill-path audit; set
|
||||
Run `npm run evaluate:sports-favorites` for the separate pregame favorite audit.
|
||||
It anchors decisions to the published game start, rejects stale prices, charges a
|
||||
modeled five-cent cost, forms equal-dollar event baskets, and uses a chronological
|
||||
60/20/20 split. The clean 3,000-market run produced nine train-pass rules but zero
|
||||
validation selections, so Strategy 55 does not promote the apparent sports-favorite
|
||||
edge or use the holdout set to rescue it.
|
||||
|
||||
Run `npm run evaluate:liquidity` to inspect live reward-scoring markets using
|
||||
both public outcome books. It recomputes the minimum-size-adjusted midpoint,
|
||||
upper-bounds competing maker score from visible qualifying depth, enforces the
|
||||
$1 payout minimum, and reports one-leg loss beside the estimated reward share.
|
||||
The estimate is a single snapshot, not earned income. Run `npm run evaluate:maker`
|
||||
for the chronological fill-path audit; set
|
||||
`MAKER_MARKETS`, `MAKER_HISTORY_DAYS`, `MAKER_CONCURRENCY`, or
|
||||
`MAKER_EXIT_COST_CENTS` to change it and `MAKER_SUMMARY=1` for compact output.
|
||||
|
||||
@@ -90,17 +100,18 @@ and 24-hour horizons. Zero rules passed training, validation, or untouched
|
||||
holdout. At three hours, the broad 0.5-cent quote-gap rule still lost 0.63% per
|
||||
event in holdout; only 0.61% of observations completed both legs while 23.33%
|
||||
produced adverse one-leg inventory. Wider quotes traded less but remained
|
||||
negative. Strategy 54 therefore does not risk paper capital on an unproven
|
||||
negative. Strategy 55 therefore does not risk paper capital on an unproven
|
||||
maker rule.
|
||||
|
||||
Value Hunter now tracks up to six zero-capital shadow quote pairs. A later live
|
||||
Value Hunter now tracks up to six zero-capital shadow quote pairs selected by
|
||||
the shared reward-book audit. A later live
|
||||
cycle must still verify each touch from public CLOB price history or a current
|
||||
book cross. A three-hour executable exit grades one-sided touches after a
|
||||
conservative half-cent cost, while paired touches and unfilled attempts are also
|
||||
retained. Results are clustered by Polymarket event. Capital exposure can only
|
||||
resume after at least 20 current-strategy event clusters in both the matching
|
||||
category and spread cohort have positive 90% confidence bounds and at least
|
||||
three completed pairs, with any mature losing cohort acting as a veto. There is
|
||||
resume after at least 20 current-strategy event clusters in the matching
|
||||
category, spread, and estimated reward-yield cohorts have positive 90% confidence
|
||||
bounds and at least three completed pairs, with any mature losing cohort acting as a veto. There is
|
||||
no capital-backed exploration lane. Existing paper inventory from prior builds
|
||||
is still reconciled honestly. The engine does not credit hypothetical rewards.
|
||||
See Polymarket's official [fees](https://docs.polymarket.com/trading/fees),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { auditLiquidity } from "../lib/liquidity-audit.js";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== "GET") {
|
||||
res.setHeader("Allow", "GET");
|
||||
return res.status(405).json({ error: "method_not_allowed" });
|
||||
}
|
||||
try {
|
||||
const report = await auditLiquidity({
|
||||
marketLimit: Number(req.query.limit || 60),
|
||||
minHoursToEnd: Number(req.query.min_hours || 48),
|
||||
});
|
||||
res.setHeader("Cache-Control", "s-maxage=60, stale-while-revalidate=120");
|
||||
return res.status(200).json({ ok: true, ...report });
|
||||
} catch (error) {
|
||||
return res.status(502).json({ ok: false, error: "liquidity_audit_unavailable", detail: String(error && error.message || error) });
|
||||
}
|
||||
}
|
||||
+57
-18
@@ -341,7 +341,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
|
||||
<nav class="topnav">
|
||||
<div class="brand">
|
||||
<div class="logo">🏆</div>
|
||||
<div><div class="brand-name">Polymarket Arena</div><div class="brand-sub">10 agents · 5 core + 5 aggressive</div><div class="build-badge">Adaptive strategy 54 · build 65</div></div>
|
||||
<div><div class="brand-name">Polymarket Arena</div><div class="brand-sub">10 agents · 5 core + 5 aggressive</div><div class="build-badge">Adaptive strategy 55 · build 66</div></div>
|
||||
</div>
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab" data-tab="overview">Overview</button>
|
||||
@@ -363,7 +363,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
|
||||
<div class="personal-banner" id="personalBanner">
|
||||
<b>Personal research mode.</b> This copy is for your own analysis, paper tracking, and manual trade research only. It does not pool money, onboard investors, custody funds, bypass eligibility rules, or place orders without your manual approval.
|
||||
</div>
|
||||
<div class="live-build-banner"><b>Build 65 active:</b> a 300-market chronological audit rejected all 3,024 tested paired-maker rules. New maker candidates now run as zero-capital shadow observations; real paper exposure stays disabled until 20 current-strategy event clusters in both the matching category and spread cohort show a positive confidence bound. Existing inventory is still reconciled honestly. Offline snapshots can value positions but cannot invent fills. This remains paper trading; profits are not guaranteed.</div>
|
||||
<div class="live-build-banner"><b>Build 66 active:</b> directional and sports-favorite rules failed clean holdout tests. The maker learner now measures both public order books, qualifying depth, competition, and the $1 reward payout floor before starting a zero-capital observation. Estimated rewards remain separate from P&L, and capital stays disabled until current category, spread, and reward-yield cohorts independently pass. Offline snapshots can value positions but cannot invent fills. This remains paper trading; profits are not guaranteed.</div>
|
||||
|
||||
<!-- ============ OVERVIEW ============ -->
|
||||
<section class="tabpanel" data-tab="overview">
|
||||
@@ -745,7 +745,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Build 65 · Adaptive strategy 54 · Paper trading only · Live prices from Polymarket's public Gamma and CLOB APIs · Not financial advice ·
|
||||
Build 66 · Adaptive strategy 55 · Paper trading only · Live prices from Polymarket's public Gamma and CLOB APIs · Not financial advice ·
|
||||
<a class="market-link" href="https://github.com/theodore-song/polymarket-analyst" target="_blank" rel="noopener">Source on GitHub</a>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -774,9 +774,9 @@ const POLITICS_TREND_MIN_HOLD_HOURS = 72;
|
||||
const EXIT_CONFIRM_HOURS = 6;
|
||||
const AGENTS_KEY = "pma_agents_v2";
|
||||
const SUG_KEY = "pma_suggestions_v5";
|
||||
const BUILD_VERSION = 65;
|
||||
const SUGGESTION_ENGINE_VERSION = 54;
|
||||
const PREVIOUS_STRATEGY_VERSION = 53;
|
||||
const BUILD_VERSION = 66;
|
||||
const SUGGESTION_ENGINE_VERSION = 55;
|
||||
const PREVIOUS_STRATEGY_VERSION = 54;
|
||||
const LEGACY_BUILD_STRATEGY_LINEAGE = Object.freeze({40:40,41:40});
|
||||
function normalizedStrategyVersion(value){
|
||||
const version=Number(value||0);
|
||||
@@ -1113,6 +1113,13 @@ async function fetchNegativeRiskBundles(limit=NEG_RISK_EVENT_SCAN_LIMIT){
|
||||
}
|
||||
return events.map(negativeRiskBundleSuggestion).filter(Boolean).sort((a,b)=>b.net_edge-a.net_edge).slice(0,20);
|
||||
}
|
||||
async function fetchRewardMakerCandidates(limit=60){
|
||||
const params=new URLSearchParams({limit:String(limit),min_hours:"48"});
|
||||
const response=await fetchWithTimeout(`/api/liquidity?${params}`,{},NETWORK_REQUEST_TIMEOUT_MS*2);
|
||||
const payload=await response.json().catch(()=>({}));
|
||||
if(!response.ok||!payload.ok)throw new Error(payload.error||`Liquidity audit ${response.status}`);
|
||||
return Array.isArray(payload.candidates)?payload.candidates:[];
|
||||
}
|
||||
|
||||
/* ---------- Analysis engine ---------- */
|
||||
const W={LIQ:0.24,MOM:0.18,SIGNAL:0.38,TIME:0.20};
|
||||
@@ -2345,6 +2352,12 @@ function markToMarket(p,priceMap,cfg=null,{policyExits=false,executeTrades=true}
|
||||
p.positions=stillOpen;
|
||||
}
|
||||
function makerSpreadBand(spread){return Number(spread)<=0.03?"tight":Number(spread)<=0.05?"medium":"wide";}
|
||||
function makerRewardBand(row){
|
||||
const daily=Number(row&&row.estimated_reward_floor_daily),capital=Number(row&&(row.required_capital||row.reserved_capital));
|
||||
if(!(daily>0&&capital>0))return "unmodeled";
|
||||
const dailyYield=daily/capital;
|
||||
return dailyYield<0.02?"low":dailyYield<0.10?"medium":"high";
|
||||
}
|
||||
function summarizeMakerOutcomes(rows){
|
||||
const weighted=[];
|
||||
(rows||[]).forEach(row=>{
|
||||
@@ -2372,20 +2385,23 @@ function summarizeMakerOutcomes(rows){
|
||||
function buildMakerProfile(p){
|
||||
const outcomes=(p&&p.maker_outcomes||[]).slice(-MAKER_OUTCOME_LIMIT),buckets={};
|
||||
const add=(key,row)=>{if(!buckets[key])buckets[key]=[];buckets[key].push(row);};
|
||||
outcomes.forEach(row=>{add(`category:${row.category||"Other"}`,row);add(`spread:${row.spread_band||makerSpreadBand(row.spread)}`,row);});
|
||||
outcomes.forEach(row=>{add(`category:${row.category||"Other"}`,row);add(`spread:${row.spread_band||makerSpreadBand(row.spread)}`,row);
|
||||
add(`reward:${row.reward_yield_band||makerRewardBand(row)}`,row);});
|
||||
return {version:SUGGESTION_ENGINE_VERSION,outcomes:outcomes.length,global:summarizeMakerOutcomes(outcomes),
|
||||
buckets:Object.fromEntries(Object.entries(buckets).map(([key,rows])=>[key,summarizeMakerOutcomes(rows)]))};
|
||||
}
|
||||
function makerCandidateLearning(candidate,profile){
|
||||
const category=profile.buckets[`category:${candidate.category}`],spread=profile.buckets[`spread:${makerSpreadBand(candidate.spread)}`];
|
||||
const matching=[profile.global,category,spread].filter(Boolean),mature=matching.filter(stats=>stats.current_events>=MAKER_MIN_COHORT_ATTEMPTS);
|
||||
const rewardBand=candidate.reward_yield_band||makerRewardBand(candidate),reward=profile.buckets[`reward:${rewardBand}`];
|
||||
const matching=[profile.global,category,spread,reward].filter(Boolean),mature=matching.filter(stats=>stats.current_events>=MAKER_MIN_COHORT_ATTEMPTS);
|
||||
const losing=mature.some(stats=>stats.upper<0),promoted=!losing&&category&&spread&&[category,spread].every(stats=>
|
||||
stats.current_events>=MAKER_MIN_COHORT_ATTEMPTS&&stats.current_locked>=MAKER_MIN_PROMOTION_LOCKS&&stats.lower>0);
|
||||
const positive=[category,spread].filter(Boolean).sort((a,b)=>b.lower-a.lower)[0];
|
||||
stats.current_events>=MAKER_MIN_COHORT_ATTEMPTS&&stats.current_locked>=MAKER_MIN_PROMOTION_LOCKS&&stats.lower>0)
|
||||
&&reward&&reward.current_events>=MAKER_MIN_COHORT_ATTEMPTS&&reward.current_locked>=MAKER_MIN_PROMOTION_LOCKS&&reward.lower>0;
|
||||
const positive=[category,spread,reward].filter(Boolean).sort((a,b)=>b.lower-a.lower)[0];
|
||||
const multiplier=promoted?clamp(1+Math.max(0,positive.lower)*6*positive.confidence,1,1.25):1;
|
||||
const units=+(candidate.units*multiplier).toFixed(2),requiredCapital=+(units*candidate.paired_cost).toFixed(2),lockedProfit=+(units*(1-candidate.paired_cost)).toFixed(2);
|
||||
const learned=mature.length?mature.reduce((sum,stats)=>sum+stats.mean,0)/mature.length:0;
|
||||
return Object.assign({},candidate,{units,required_capital:requiredCapital,locked_profit:lockedProfit,
|
||||
return Object.assign({},candidate,{units,required_capital:requiredCapital,locked_profit:lockedProfit,reward_yield_band:rewardBand,
|
||||
maker_learning_multiplier:+multiplier.toFixed(3),maker_learning_state:promoted?"promoted":losing?"shadow-rejected":"shadow-observing",
|
||||
maker_capital_enabled:promoted,maker_expected_return:+learned.toFixed(5),score:+(candidate.score+learned*60+(promoted?positive.lower*100:0)).toFixed(4)});
|
||||
}
|
||||
@@ -2395,7 +2411,8 @@ function recordMakerOutcome(p,quote,status,pnl,deployedCapital){
|
||||
const reserved=Math.max(0.01,Number(quote.required_capital||deployedCapital||0.01));
|
||||
p.maker_outcomes.push({quote_id:quote.id,market_id:quote.market_id,condition_id:quote.condition_id||"",question:quote.question,
|
||||
event_key:String(quote.event_key||quote.url||quote.event||quote.market_id),category:quote.category||"Other",spread:Number(quote.spread||0),spread_band:makerSpreadBand(quote.spread),
|
||||
status,pnl:+Number(pnl||0).toFixed(2),shadow_only:Boolean(quote.shadow_only),
|
||||
reward_yield_band:quote.reward_yield_band||makerRewardBand(quote),estimated_reward_floor_daily:+Number(quote.estimated_reward_floor_daily||0).toFixed(4),
|
||||
reward_share_floor:+Number(quote.reward_share_floor||0).toFixed(6),status,pnl:+Number(pnl||0).toFixed(2),shadow_only:Boolean(quote.shadow_only),
|
||||
deployed_capital:+Number(deployedCapital||0).toFixed(2),reserved_capital:+reserved.toFixed(2),return_on_reserved:+(Number(pnl||0)/reserved).toFixed(5),
|
||||
created_at:quote.created_at,completed_at:cycleIso(),hours_to_outcome:+hoursSince(quote.created_at).toFixed(2),
|
||||
strategy_version:Number(quote.strategy_version||SUGGESTION_ENGINE_VERSION),build_version:Number(quote.build_version||BUILD_VERSION)});
|
||||
@@ -2404,6 +2421,17 @@ function recordMakerOutcome(p,quote,status,pnl,deployedCapital){
|
||||
}
|
||||
function makerPairCandidate(m){
|
||||
if(!m||m.closed||m.accepting_orders===false)return null;
|
||||
if(Number(m.audit_version)===1){
|
||||
const pairedCost=Number(m.paired_cost),units=Number(m.reward_min_size),requiredCapital=Number(m.required_capital);
|
||||
const estimatedReward=Number(m.estimated_reward_floor_daily),rewardShare=Number(m.reward_share_floor),hoursToEnd=Number(m.hours_to_end);
|
||||
if(!m.shadow_qualified||!(units>0&&requiredCapital>0&&estimatedReward>=1&&rewardShare>=0.0025&&hoursToEnd>=48)
|
||||
||pairedCost>1-MAKER_MIN_LOCK_MARGIN||!Array.isArray(m.clob_token_ids)||m.clob_token_ids.length!==2)return null;
|
||||
const rewardYield=estimatedReward/requiredCapital;
|
||||
return Object.assign({},m,{units:+units.toFixed(2),required_capital:+requiredCapital.toFixed(2),locked_profit:+Number(m.locked_profit||0).toFixed(2),
|
||||
reward_daily_rate:+Number(m.reward_daily_rate||0).toFixed(3),reward_share_floor:+rewardShare.toFixed(6),estimated_reward_floor_daily:+estimatedReward.toFixed(4),
|
||||
maximum_one_leg_loss:+Number(m.maximum_one_leg_loss||0).toFixed(2),competitive:+Math.max(0,1-rewardShare).toFixed(4),day_move:0,
|
||||
reward_yield_band:makerRewardBand(m),score:+(Math.log10(estimatedReward+1)*4+rewardYield*30+(1-pairedCost)*20).toFixed(4)});
|
||||
}
|
||||
const bid=Number(m.best_bid),ask=Number(m.best_ask),spread=ask-bid,tick=Math.max(0.001,Number(m.tick_size||0.01));
|
||||
const reward=Number(m.rewards_daily_rate||0),minSize=Math.max(5,Number(m.rewards_min_size||0),Number(m.order_min_size||0));
|
||||
const maxSpread=Number(m.rewards_max_spread||0)/100,dayMove=Math.abs(Number(m.price_change_1d||0));
|
||||
@@ -2440,7 +2468,7 @@ function stageMakerQuotes(p,markets){
|
||||
p.history.push({date:logDay(),action:capitalEnabled?"QUOTE":"SHADOW_QUOTE",question:candidate.question,side:"PAIR",
|
||||
detail:capitalEnabled
|
||||
?`Staged ${candidate.units} capital-backed resting YES @ ${pct(candidate.yes_quote)} and NO @ ${pct(candidate.no_quote)} · paired cost ${pct(candidate.paired_cost)} · ${fmtUSD(candidate.locked_profit)} locked settlement margin only if both legs fill · no hypothetical reward credited`
|
||||
:`Watching resting YES @ ${pct(candidate.yes_quote)} and NO @ ${pct(candidate.no_quote)} for ${MAKER_SHADOW_HORIZON_HOURS}h with zero capital · outcome will train the event-clustered maker gate`});
|
||||
:`Watching resting YES @ ${pct(candidate.yes_quote)} and NO @ ${pct(candidate.no_quote)} for ${MAKER_SHADOW_HORIZON_HOURS}h with zero capital · ${candidate.estimated_reward_floor_daily?`${fmtUSD(candidate.estimated_reward_floor_daily)}/day snapshot reward estimate versus ${fmtUSD(candidate.maximum_one_leg_loss)} maximum one-leg loss; estimate is not P&L · `:""}outcome will train the category, spread, and reward-yield gate`});
|
||||
}
|
||||
return {staged,shadowStaged,capitalStaged,active:p.maker_quotes.length,shadowActive:p.maker_quotes.filter(quote=>quote.shadow_only).length,
|
||||
capitalActive:p.maker_quotes.filter(quote=>!quote.shadow_only).length,candidates:candidates.length,reserved:+reserved.toFixed(2),profile:makerProfile};
|
||||
@@ -2976,7 +3004,7 @@ async function runDailyCycle(){
|
||||
return {suggestions:(loadSuggestions().suggestions||[]).length,skipped:true,hour};
|
||||
}
|
||||
SNAP_TS=nowIso();
|
||||
let markets=[],analysisMarkets=[],sugs=[],bundleSugs=[],cache=loadMarketCache(),runMode="live",cacheAgeMs=0;
|
||||
let markets=[],analysisMarkets=[],sugs=[],bundleSugs=[],makerAuditCandidates=[],cache=loadMarketCache(),runMode="live",cacheAgeMs=0;
|
||||
try{
|
||||
setStatus("loading the 500 most active eligible markets…",true);
|
||||
markets=await fetchMarkets(20,100,count=>setStatus(`loaded ${Math.min(count,ACTIVE_MARKET_FETCH_LIMIT).toLocaleString()} of 500 eligible active markets…`,true),ACTIVE_MARKET_FETCH_LIMIT);
|
||||
@@ -2985,6 +3013,8 @@ async function runDailyCycle(){
|
||||
setStatus(`analyzing ${analysisMarkets.length.toLocaleString()} most-active markets…`,true);
|
||||
setStatus("checking complete live-priced event bundles…",true);
|
||||
try{bundleSugs=await fetchNegativeRiskBundles();}catch(e){bundleSugs=[];}
|
||||
setStatus("measuring reward-book competition…",true);
|
||||
try{makerAuditCandidates=await fetchRewardMakerCandidates();}catch(e){makerAuditCandidates=[];}
|
||||
setStatus("checking real-world context…",true);
|
||||
const realWorldSignals=await fetchRealWorldSignals(analysisMarkets);
|
||||
setStatus("analyzing expected value…",true);
|
||||
@@ -3063,14 +3093,14 @@ async function runDailyCycle(){
|
||||
claimedMarkets.forEach(id=>occupied.add(id));
|
||||
openPositions(p,cfg,cfg.rank(cycleSuggestions),focus,decision,occupied,peerMarketStats(st,cfg.id)).forEach(id=>claimedMarkets.add(id));
|
||||
if(cfg.id==="value"&&runMode==="live"&&entriesAllowed){
|
||||
const staged=stageMakerQuotes(p,analysisMarkets);
|
||||
const staged=stageMakerQuotes(p,[...makerAuditCandidates,...analysisMarkets]);
|
||||
p.lastDecision=Object.assign({},p.lastDecision,{makerQuotes:p.maker_quotes.length,makerCandidates:staged.candidates,makerStaged:staged.staged,
|
||||
makerShadowStaged:staged.shadowStaged,makerCapitalStaged:staged.capitalStaged,makerShadowActive:staged.shadowActive,makerCapitalActive:staged.capitalActive,
|
||||
makerFills:makerActivity.fills,makerLocked:makerActivity.locked,makerExpired:makerActivity.expired,makerHistoryTouches:makerActivity.historyTouches,
|
||||
makerShadowCompleted:makerActivity.shadowCompleted,
|
||||
makerReserved:staged.reserved,makerProfile:makerActivity.profile});
|
||||
if(staged.staged||makerActivity.fills||makerActivity.locked||makerActivity.expired){
|
||||
p.lastDecision.allocationStatus=`${p.lastDecision.allocationStatus||""} Maker learner: ${staged.shadowActive} zero-capital shadow observation${staged.shadowActive===1?"":"s"}, ${staged.capitalActive} evidence-promoted capital quote${staged.capitalActive===1?"":"s"}, ${makerActivity.shadowCompleted||0} shadow outcome${makerActivity.shadowCompleted===1?"":"s"} graded this cycle; no hypothetical rewards credited.`.trim();
|
||||
p.lastDecision.allocationStatus=`${p.lastDecision.allocationStatus||""} Reward-book learner: ${staged.shadowActive} zero-capital shadow observation${staged.shadowActive===1?"":"s"}, ${staged.capitalActive} evidence-promoted capital quote${staged.capitalActive===1?"":"s"}, ${makerActivity.shadowCompleted||0} shadow outcome${makerActivity.shadowCompleted===1?"":"s"} graded this cycle; displayed reward estimates are never credited as P&L.`.trim();
|
||||
}
|
||||
}
|
||||
recordSnapshot(p);
|
||||
@@ -4881,10 +4911,11 @@ window.PMA_ENGINE_DIAGNOSTICS=Object.freeze({
|
||||
onePendingObservationPerMarketSide:true,oldestPendingEvidenceFirst:true,coverageAwareObservationSampling:true,uncertaintyGatedCalibration:true,
|
||||
directionalSignalsRequirePromotion:true,liveOnlyCompleteBundles:true,negativeRiskBundleSides:["YES","NO"],negativeRiskEventScanLimit:NEG_RISK_EVENT_SCAN_LIMIT,
|
||||
negativeRiskMinimumNetReturnPct:NEG_RISK_MIN_NET_RETURN*100,
|
||||
pairedMakerQuotes:"shadow-until-promoted",makerShadowHorizonHours:MAKER_SHADOW_HORIZON_HOURS,makerLegacyQuoteExpiryHours:MAKER_QUOTE_EXPIRY_HOURS,makerMinimumLockMarginPct:MAKER_MIN_LOCK_MARGIN*100,
|
||||
pairedMakerQuotes:"reward-book-audited-shadow-until-promoted",makerShadowHorizonHours:MAKER_SHADOW_HORIZON_HOURS,makerLegacyQuoteExpiryHours:MAKER_QUOTE_EXPIRY_HOURS,makerMinimumLockMarginPct:MAKER_MIN_LOCK_MARGIN*100,
|
||||
makerTouchSource:"Polymarket CLOB batch price history",makerOutcomeLimit:MAKER_OUTCOME_LIMIT,makerMinimumCohortAttempts:MAKER_MIN_COHORT_ATTEMPTS,
|
||||
makerMinimumPromotionLocks:MAKER_MIN_PROMOTION_LOCKS,makerMaximumLearningMultiplier:1.25,makerCapitalExplorationPct:0,hypotheticalRewardsCredited:false,
|
||||
historicalPrior:"All directional trends and reversals require positive independent cohort promotion; Sports and Crypto trends remain excluded; exact ranges and path-dependent barriers are excluded; live-priced complete negative-risk bundles may trade; paired maker quotes remain zero-capital shadow observations until their current event-clustered cohorts promote"}),
|
||||
makerCompetitionSource:"shared public CLOB order-book audit",makerPromotionCohorts:["category","spread","reward-yield"],makerRewardPayoutMinimum:1,
|
||||
historicalPrior:"All tested directional and sports-favorite rules failed clean validation and remain observation-only; live-priced complete negative-risk bundles may trade; paired maker quotes require a public-book reward audit and remain zero-capital observations until current category, spread, and reward-yield cohorts independently promote"}),
|
||||
});
|
||||
function runEngineSelfTest(){
|
||||
const market=(overrides={})=>Object.assign({
|
||||
@@ -5053,6 +5084,12 @@ function runEngineSelfTest(){
|
||||
const nonBinaryMarketRejected=normalizeMarket({id:"over-under-test",question:"Over/Under 2.5",outcomes:'["Over","Under"]',outcomePrices:'["0.5","0.5"]',acceptingOrders:true})===null;
|
||||
const makerMarket=market({id:"maker-test",question:"Will the paired quote test pass?",yes_price:0.42,no_price:0.58,best_bid:0.40,best_ask:0.44,spread:0.04,
|
||||
tick_size:0.01,order_min_size:5,rewards_daily_rate:10,rewards_min_size:50,rewards_max_spread:4.5,competitive:0.8,price_change_1d:0.01});
|
||||
const auditedMaker={audit_version:1,market_id:"audited-maker",condition_id:"audit-condition",event_key:"audit-event",question:"Will audited maker pass?",event:"Audit event",url:"",
|
||||
category:"Politics",clob_token_ids:["audit-yes","audit-no"],reward_daily_rate:100,reward_min_size:20,reward_max_spread:5.5,hours_to_end:240,
|
||||
yes_quote:0.44,no_quote:0.52,paired_cost:0.96,required_capital:19.2,locked_profit:0.8,maximum_one_leg_loss:10.4,
|
||||
reward_share_floor:0.02,estimated_reward_floor_daily:2,spread:0.04,tick_size:0.01,payout_eligible:true,shadow_qualified:true};
|
||||
const auditedMakerCandidate=makerPairCandidate(auditedMaker);
|
||||
const belowPayoutMakerCandidate=makerPairCandidate(Object.assign({},auditedMaker,{estimated_reward_floor_daily:0.99}));
|
||||
const makerBook=defaultPortfolio(),makerStage=stageMakerQuotes(makerBook,[makerMarket]),makerQuote=makerBook.maker_quotes[0];
|
||||
makerQuote.shadow_only=false;
|
||||
makerQuote.created_at=hoursAgo(1);
|
||||
@@ -5290,6 +5327,8 @@ function runEngineSelfTest(){
|
||||
adaptiveReturnSetsLeader:adaptiveRankFixture[0].c.id==="adaptive-leader",
|
||||
},
|
||||
makerLiquidity:{
|
||||
acceptsSharedRewardBookAudit:auditedMakerCandidate&&auditedMakerCandidate.reward_yield_band==="high"&&auditedMakerCandidate.required_capital===19.2,
|
||||
enforcesRewardPayoutMinimum:belowPayoutMakerCandidate===null,
|
||||
stagesEligiblePairedQuoteAsShadow:makerStage.staged===1&&makerStage.shadowStaged===1&&makerStage.capitalStaged===0&&makerStage.active===1&&makerQuote.paired_cost===0.96,
|
||||
unprovenQuoteDoesNotReserveCapital:makerStage.reserved===0,
|
||||
untouchedLegacyQuotesAreCanceled:legacyUntouched.expired===1&&legacyUntouchedBook.maker_quotes.length===0&&legacyUntouchedBook.positions.length===0&&legacyUntouchedBook.cash===STARTING_BALANCE,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
const CLOB = "https://clob.polymarket.com";
|
||||
const BOOK_BATCH_SIZE = 200;
|
||||
|
||||
async function fetchJson(url, options = {}, attempts = 3) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
headers: { accept: "application/json", ...(options.headers || {}) },
|
||||
});
|
||||
if (response.ok) return response.json();
|
||||
lastError = new Error(`${response.status} ${response.statusText}`);
|
||||
if (response.status !== 429 && response.status < 500) break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 600 * (attempt + 1)));
|
||||
}
|
||||
throw lastError || new Error("request failed");
|
||||
}
|
||||
|
||||
function activeRate(market) {
|
||||
return (market.rewards_config || []).reduce((sum, row) => sum + Number(row.rate_per_day || 0), 0);
|
||||
}
|
||||
|
||||
function categoryFor(question) {
|
||||
const text = String(question || "").toLowerCase();
|
||||
if (/election|president|senate|governor|mayor|minister|parliament|ceasefire|war|trump|congress/.test(text)) return "Politics";
|
||||
if (/bitcoin|ethereum|crypto|btc|eth|solana|xrp|doge|token/.test(text)) return "Crypto";
|
||||
if (/nba|nfl|mlb|nhl|soccer|football|tennis|ufc|boxing|tournament|championship|match|game/.test(text)) return "Sports";
|
||||
if (/gdp|inflation|interest rate|fed|stock|nasdaq|s&p|oil|gold|gross margin|unemployment/.test(text)) return "Economy";
|
||||
if (/movie|album|music|tv|views|award|ai lab|model release|code arena/.test(text)) return "Pop Culture";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
async function fetchRewardMarkets(marketLimit, minHoursToEnd) {
|
||||
const params = new URLSearchParams({ order_by: "rate_per_day", position: "DESC", page_size: "500" });
|
||||
const response = await fetchJson(`${CLOB}/rewards/markets/multi?${params}`);
|
||||
const cutoff = Date.now() + minHoursToEnd * 60 * 60 * 1000;
|
||||
return (response.data || [])
|
||||
.filter((market) => {
|
||||
const tokens = market.tokens || [];
|
||||
const labels = tokens.map((token) => String(token.outcome || "").trim().toLowerCase());
|
||||
const endTime = Date.parse(market.end_date || "");
|
||||
return labels[0] === "yes" && labels[1] === "no" && tokens.every((token) => token.token_id)
|
||||
&& activeRate(market) >= 1 && Number(market.rewards_min_size) > 0
|
||||
&& Number(market.rewards_max_spread) > 0 && Number.isFinite(endTime) && endTime >= cutoff;
|
||||
})
|
||||
.slice(0, marketLimit);
|
||||
}
|
||||
|
||||
async function fetchBooks(tokenIds) {
|
||||
const result = new Map();
|
||||
for (let index = 0; index < tokenIds.length; index += BOOK_BATCH_SIZE) {
|
||||
const batch = tokenIds.slice(index, index + BOOK_BATCH_SIZE);
|
||||
const books = await fetchJson(`${CLOB}/books`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(batch.map((token_id) => ({ token_id }))),
|
||||
});
|
||||
for (const book of books || []) result.set(String(book.asset_id), book);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function levels(raw, side) {
|
||||
return (raw || [])
|
||||
.map((row) => ({ price: Number(row.price), size: Number(row.size) }))
|
||||
.filter((row) => Number.isFinite(row.price) && Number.isFinite(row.size) && row.price > 0 && row.price < 1 && row.size > 0)
|
||||
.sort((a, b) => side === "bid" ? b.price - a.price : a.price - b.price);
|
||||
}
|
||||
|
||||
function adjustedLevel(rows, minimumSize) {
|
||||
let cumulative = 0;
|
||||
for (const row of rows) {
|
||||
cumulative += row.size;
|
||||
if (cumulative + 1e-9 >= minimumSize) return row.price;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function midpoint(book, minimumSize, ownBid) {
|
||||
const bids = levels(book.bids, "bid");
|
||||
if (ownBid) bids.push(ownBid);
|
||||
bids.sort((a, b) => b.price - a.price);
|
||||
const asks = levels(book.asks, "ask");
|
||||
const bid = adjustedLevel(bids, minimumSize);
|
||||
const ask = adjustedLevel(asks, minimumSize);
|
||||
return Number.isFinite(bid) && Number.isFinite(ask) && bid < ask ? (bid + ask) / 2 : null;
|
||||
}
|
||||
|
||||
function utility(maxDistance, distance) {
|
||||
if (!(maxDistance > 0) || distance > maxDistance) return 0;
|
||||
return ((maxDistance - distance) / maxDistance) ** 2;
|
||||
}
|
||||
|
||||
function publicUtility(book, mid, maxDistance) {
|
||||
return [...levels(book.bids, "bid"), ...levels(book.asks, "ask")]
|
||||
.reduce((sum, row) => sum + utility(maxDistance, Math.abs(row.price - mid)) * row.size, 0);
|
||||
}
|
||||
|
||||
function proposedBid(book) {
|
||||
const bids = levels(book.bids, "bid");
|
||||
const asks = levels(book.asks, "ask");
|
||||
if (!bids.length || !asks.length) return null;
|
||||
const tick = Number(book.tick_size || 0.01);
|
||||
const bestBid = bids[0].price;
|
||||
const bestAsk = asks[0].price;
|
||||
const improved = bestAsk - bestBid >= tick * 3 ? bestBid + tick : bestBid;
|
||||
return { price: Math.min(bestAsk - tick, improved), bestBid, bestAsk, tick };
|
||||
}
|
||||
|
||||
function evaluateMarket(market, books) {
|
||||
const [yesToken, noToken] = market.tokens;
|
||||
const yesBook = books.get(String(yesToken.token_id));
|
||||
const noBook = books.get(String(noToken.token_id));
|
||||
if (!yesBook || !noBook) return null;
|
||||
const yes = proposedBid(yesBook), no = proposedBid(noBook);
|
||||
if (!yes || !no || yes.price <= 0 || no.price <= 0) return null;
|
||||
|
||||
const size = Math.max(Number(market.rewards_min_size), Number(yesBook.min_order_size || 0), Number(noBook.min_order_size || 0));
|
||||
const maxDistance = Number(market.rewards_max_spread) / 100;
|
||||
const yesMid = midpoint(yesBook, size, { price: yes.price, size });
|
||||
const noMid = midpoint(noBook, size, { price: no.price, size });
|
||||
if (!Number.isFinite(yesMid) || !Number.isFinite(noMid)) return null;
|
||||
|
||||
const ownQMin = Math.min(
|
||||
utility(maxDistance, Math.abs(yes.price - yesMid)) * size,
|
||||
utility(maxDistance, Math.abs(no.price - noMid)) * size,
|
||||
);
|
||||
if (!(ownQMin > 0)) return null;
|
||||
|
||||
const competitorUpperScore = publicUtility(yesBook, yesMid, maxDistance)
|
||||
+ publicUtility(noBook, noMid, maxDistance);
|
||||
const conservativeShareAtB1 = ownQMin / (ownQMin + competitorUpperScore);
|
||||
const dailyRate = activeRate(market);
|
||||
const estimatedDailyRewardFloor = dailyRate * conservativeShareAtB1;
|
||||
const pairedCost = yes.price + no.price;
|
||||
const capital = pairedCost * size;
|
||||
const pairedFillProfit = (1 - pairedCost) * size;
|
||||
const maximumOneLegLoss = Math.max(yes.price, no.price) * size;
|
||||
const hoursToEnd = (Date.parse(market.end_date) - Date.now()) / 3600000;
|
||||
const payoutEligible = estimatedDailyRewardFloor >= 1;
|
||||
const shadowQualified = payoutEligible && pairedCost <= 1 && conservativeShareAtB1 >= 0.0025
|
||||
&& estimatedDailyRewardFloor / Math.max(capital, 1) >= 0.001;
|
||||
|
||||
return {
|
||||
audit_version: 1,
|
||||
market_id: String(market.market_id),
|
||||
condition_id: market.condition_id,
|
||||
event_key: String(market.event_slug || market.market_slug || market.market_id),
|
||||
question: market.question,
|
||||
event: market.event_slug || "",
|
||||
url: `https://polymarket.com/event/${market.event_slug || market.market_slug}`,
|
||||
category: categoryFor(market.question),
|
||||
clob_token_ids: [String(yesToken.token_id), String(noToken.token_id)],
|
||||
reward_daily_rate: dailyRate,
|
||||
reward_min_size: size,
|
||||
reward_max_spread: Number(market.rewards_max_spread),
|
||||
hours_to_end: hoursToEnd,
|
||||
yes_quote: yes.price,
|
||||
no_quote: no.price,
|
||||
yes_adjusted_mid: yesMid,
|
||||
no_adjusted_mid: noMid,
|
||||
paired_cost: pairedCost,
|
||||
required_capital: capital,
|
||||
locked_profit: pairedFillProfit,
|
||||
maximum_one_leg_loss: maximumOneLegLoss,
|
||||
own_q_min: ownQMin,
|
||||
competitor_upper_score: competitorUpperScore,
|
||||
reward_share_floor: conservativeShareAtB1,
|
||||
estimated_reward_floor_daily: estimatedDailyRewardFloor,
|
||||
payout_eligible: payoutEligible,
|
||||
shadow_qualified: shadowQualified,
|
||||
spread: Math.max(yes.bestAsk - yes.bestBid, no.bestAsk - no.bestBid),
|
||||
tick_size: Math.min(yes.tick, no.tick),
|
||||
};
|
||||
}
|
||||
|
||||
function roundRow(row) {
|
||||
const rounded = { ...row };
|
||||
for (const key of ["reward_daily_rate", "reward_min_size", "reward_max_spread", "hours_to_end", "yes_quote", "no_quote",
|
||||
"yes_adjusted_mid", "no_adjusted_mid", "paired_cost", "required_capital", "locked_profit", "maximum_one_leg_loss",
|
||||
"own_q_min", "competitor_upper_score", "reward_share_floor", "estimated_reward_floor_daily", "spread", "tick_size"]) {
|
||||
rounded[key] = +Number(row[key]).toFixed(6);
|
||||
}
|
||||
return rounded;
|
||||
}
|
||||
|
||||
export async function auditLiquidity({ marketLimit = 100, minHoursToEnd = 48 } = {}) {
|
||||
const safeLimit = Math.max(10, Math.min(250, Number(marketLimit) || 100));
|
||||
const safeHours = Math.max(1, Number(minHoursToEnd) || 48);
|
||||
const markets = await fetchRewardMarkets(safeLimit, safeHours);
|
||||
const books = await fetchBooks(markets.flatMap((market) => market.tokens.map((token) => String(token.token_id))));
|
||||
const evaluated = markets.map((market) => evaluateMarket(market, books)).filter(Boolean);
|
||||
const qualified = evaluated.filter((row) => row.shadow_qualified)
|
||||
.sort((a, b) => (b.estimated_reward_floor_daily / Math.max(b.required_capital, 1))
|
||||
- (a.estimated_reward_floor_daily / Math.max(a.required_capital, 1))
|
||||
|| b.estimated_reward_floor_daily - a.estimated_reward_floor_daily);
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
requested_markets: safeLimit,
|
||||
minimum_hours_to_end: safeHours,
|
||||
reward_markets: markets.length,
|
||||
complete_book_pairs: evaluated.length,
|
||||
shadow_qualified: qualified.length,
|
||||
methodology: {
|
||||
execution: "two resting BUY quotes, one on each complementary outcome; no fill or reward is credited",
|
||||
midpoint: "minimum-qualifying-size adjusted midpoint recomputed with the proposed quote",
|
||||
competition: "sum of public order utility upper-bounds competitors' aggregate Q_min at a uniform b=1",
|
||||
reward_estimate: "single-snapshot lower-share estimate, not a guaranteed payout; $1 daily payout minimum enforced",
|
||||
unresolved_risk: "in-game multipliers, future competition, queue priority, fills, and adverse selection require shadow evidence",
|
||||
},
|
||||
candidates: qualified.slice(0, 50).map(roundRow),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"evaluate:maker": "node scripts/evaluate-maker.mjs",
|
||||
"evaluate:signals": "node scripts/evaluate-signals.mjs",
|
||||
"evaluate:settlements": "node scripts/evaluate-settlements.mjs",
|
||||
"evaluate:sports-favorites": "node scripts/evaluate-sports-favorites.mjs",
|
||||
"test:server-state": "node scripts/test-server-state.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,75 +1,8 @@
|
||||
const GAMMA = "https://gamma-api.polymarket.com";
|
||||
const LIMIT = Math.max(100, Math.min(500, Number(process.env.LIQUIDITY_MARKETS || 500)));
|
||||
import { auditLiquidity } from "../lib/liquidity-audit.js";
|
||||
|
||||
function parseJson(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try { return JSON.parse(value || "[]"); } catch { return []; }
|
||||
}
|
||||
const report = await auditLiquidity({
|
||||
marketLimit: Number(process.env.LIQUIDITY_MARKETS || 100),
|
||||
minHoursToEnd: Number(process.env.LIQUIDITY_MIN_HOURS || 48),
|
||||
});
|
||||
|
||||
async function fetchJson(url, attempts = 3) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(20000), headers: { accept: "application/json" } });
|
||||
if (response.ok) return response.json();
|
||||
lastError = new Error(`${response.status} ${response.statusText}`);
|
||||
if (response.status !== 429 && response.status < 500) break;
|
||||
} catch (error) { lastError = error; }
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
|
||||
}
|
||||
throw lastError || new Error("request failed");
|
||||
}
|
||||
|
||||
async function fetchMarkets(limit) {
|
||||
const markets = [];
|
||||
for (let offset = 0; markets.length < limit && offset < limit * 4; offset += 100) {
|
||||
const params = new URLSearchParams({ active: "true", closed: "false", archived: "false", include_tag: "true",
|
||||
limit: "100", offset: String(offset), order: "volume24hr", ascending: "false" });
|
||||
const page = await fetchJson(`${GAMMA}/markets?${params}`);
|
||||
if (!Array.isArray(page) || !page.length) break;
|
||||
for (const raw of page) {
|
||||
const labels = parseJson(raw.outcomes).map((outcome) => String(outcome).trim().toLowerCase());
|
||||
if (labels[0] !== "yes" || labels[1] !== "no") continue;
|
||||
markets.push(raw);
|
||||
if (markets.length >= limit) break;
|
||||
}
|
||||
if (page.length < 100) break;
|
||||
}
|
||||
return markets;
|
||||
}
|
||||
|
||||
function candidate(raw) {
|
||||
const bestBid = Number(raw.bestBid), bestAsk = Number(raw.bestAsk), spread = bestAsk - bestBid;
|
||||
const dailyRate = (raw.clobRewards || []).reduce((sum, reward) => sum + Number(reward.rewardsDailyRate || 0), 0);
|
||||
const minSize = Math.max(Number(raw.rewardsMinSize || 0), Number(raw.orderMinSize || 0), 5);
|
||||
const maxSpread = Number(raw.rewardsMaxSpread || 0) / 100;
|
||||
const tick = Number(raw.orderPriceMinTickSize || 0.01), dayMove = Math.abs(Number(raw.oneDayPriceChange || 0));
|
||||
const yesQuote = bestBid, noQuote = 1 - bestAsk, pairedCost = yesQuote + noQuote;
|
||||
const requiredCapital = minSize * pairedCost, lockedProfit = minSize * (1 - pairedCost);
|
||||
const scoring = dailyRate > 0 && spread > 0 && minSize > 0 && maxSpread > 0 && spread / 2 <= maxSpread
|
||||
&& Number.isFinite(yesQuote) && Number.isFinite(noQuote) && yesQuote >= tick && noQuote >= tick;
|
||||
return { marketId: String(raw.id || ""), conditionId: raw.conditionId || "", question: raw.question || "",
|
||||
url: raw.events?.[0]?.slug ? `https://polymarket.com/event/${raw.events[0].slug}` : "",
|
||||
dailyRate, minSize, maxSpread, spread, tick, bestBid, bestAsk, yesQuote, noQuote, pairedCost,
|
||||
lockedProfit, requiredCapital, maximumRewardYield: requiredCapital > 0 ? dailyRate / requiredCapital : 0,
|
||||
competitiveness: Number(raw.competitive || raw.events?.[0]?.competitive || 0), dayMove,
|
||||
volume24hr: Number(raw.volume24hr || 0), liquidity: Number(raw.liquidityNum || raw.liquidity || 0), scoring };
|
||||
}
|
||||
|
||||
const markets = await fetchMarkets(LIMIT), candidates = markets.map(candidate).filter((row) => row.scoring);
|
||||
const balanced = candidates.filter((row) => row.spread >= 0.02 && row.dayMove <= Math.max(0.02, row.spread)
|
||||
&& row.liquidity >= 5000 && row.volume24hr >= 2000 && row.requiredCapital <= 5000)
|
||||
.sort((a, b) => (b.dailyRate / Math.max(1, b.requiredCapital)) - (a.dailyRate / Math.max(1, a.requiredCapital)) || b.spread - a.spread);
|
||||
const compact = (row) => ({ ...row, dailyRate: +row.dailyRate.toFixed(3), maxSpread: +row.maxSpread.toFixed(4),
|
||||
spread: +row.spread.toFixed(4), bestBid: +row.bestBid.toFixed(4), bestAsk: +row.bestAsk.toFixed(4),
|
||||
yesQuote: +row.yesQuote.toFixed(4), noQuote: +row.noQuote.toFixed(4), pairedCost: +row.pairedCost.toFixed(4),
|
||||
lockedProfit: +row.lockedProfit.toFixed(2), requiredCapital: +row.requiredCapital.toFixed(2),
|
||||
maximumRewardYield: +row.maximumRewardYield.toFixed(4), competitiveness: +row.competitiveness.toFixed(4),
|
||||
dayMove: +row.dayMove.toFixed(4), volume24hr: +row.volume24hr.toFixed(2), liquidity: +row.liquidity.toFixed(2) });
|
||||
|
||||
console.log(JSON.stringify({ generatedAt: new Date().toISOString(), requestedMarkets: LIMIT, fetchedEligibleMarkets: markets.length,
|
||||
rewardScoringMarkets: candidates.length, balancedPairedQuoteCandidates: balanced.length,
|
||||
methodology: { fillCredit: "none; scanner identifies resting-quote candidates only",
|
||||
rewardCredit: "none; maximumRewardYield assumes an impossible 100% reward share and is ranking context only",
|
||||
pairedPayout: "$1 if both complementary bids eventually fill", singleFillRisk: "directional until the complementary quote fills or inventory exits" },
|
||||
candidates: balanced.slice(0, 50).map(compact) }, null, 2));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const GAMMA = "https://gamma-api.polymarket.com";
|
||||
const CLOB = "https://clob.polymarket.com";
|
||||
const MARKET_LIMIT = Math.max(20, Math.min(500, Number(process.env.SETTLEMENT_MARKETS || 200)));
|
||||
const MARKET_LIMIT = Math.max(20, Math.min(3000, Number(process.env.SETTLEMENT_MARKETS || 200)));
|
||||
const CONCURRENCY = Math.max(1, Math.min(12, Number(process.env.SETTLEMENT_CONCURRENCY || 6)));
|
||||
const HORIZON_DAYS = [...new Set(String(process.env.SETTLEMENT_HORIZONS || "1,3,7,14,30,90").split(",")
|
||||
.map(Number).filter((value) => Number.isFinite(value) && value >= 1 && value <= 365))].sort((a, b) => a - b);
|
||||
const COST_CENTS = Math.max(0, Math.min(5, Number(process.env.SETTLEMENT_COST_CENTS || 0.5)));
|
||||
const SELECTION_ORDER = ["volumeNum", "closedTime", "createdAt", "id"].includes(process.env.SETTLEMENT_ORDER)
|
||||
? process.env.SETTLEMENT_ORDER : "volumeNum";
|
||||
const SELECTION_ASCENDING = String(process.env.SETTLEMENT_ASCENDING || "false").toLowerCase() === "true";
|
||||
const DAY = 86400;
|
||||
|
||||
function parseJson(value) {
|
||||
@@ -89,10 +92,12 @@ function confirmedTrendAt(points, target, current) {
|
||||
|
||||
async function fetchResolvedMarkets(limit) {
|
||||
const raw = [], seen = new Set(), pageSize = 100;
|
||||
for (let offset = 0; raw.length < limit && offset < limit * 3; offset += pageSize) {
|
||||
const params = new URLSearchParams({ closed: "true", order: "volumeNum", ascending: "false",
|
||||
limit: String(pageSize), offset: String(offset) });
|
||||
const page = await fetchJson(`${GAMMA}/markets?${params}`);
|
||||
let cursor = "";
|
||||
while (raw.length < limit) {
|
||||
const params = new URLSearchParams({ closed: "true", order: SELECTION_ORDER, ascending: String(SELECTION_ASCENDING),
|
||||
limit: String(pageSize) });
|
||||
if (cursor) params.set("after_cursor", cursor);
|
||||
const payload = await fetchJson(`${GAMMA}/markets/keyset?${params}`), page = payload?.markets;
|
||||
if (!Array.isArray(page) || !page.length) break;
|
||||
for (const market of page) {
|
||||
const id = String(market.id || ""), labels = parseJson(market.outcomes).map((outcome) => String(outcome).trim().toLowerCase());
|
||||
@@ -107,7 +112,8 @@ async function fetchResolvedMarkets(limit) {
|
||||
volume: Number(market.volumeNum || market.volume || 0) });
|
||||
if (raw.length >= limit) break;
|
||||
}
|
||||
if (page.length < pageSize) break;
|
||||
if (page.length < pageSize || !payload.next_cursor || payload.next_cursor === cursor) break;
|
||||
cursor = payload.next_cursor;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -226,6 +232,7 @@ const report = {
|
||||
marketsWithHistory: successful.length, failures: histories.filter((result) => result?.error).length,
|
||||
methodology: { horizonDays: HORIZON_DAYS, estimatedRoundTripCostCents: COST_CENTS,
|
||||
historyFidelityMinutes: 1440,
|
||||
marketSelection: `${SELECTION_ORDER} ${SELECTION_ASCENDING ? "ascending" : "descending"}`,
|
||||
clusterUnit: "event",
|
||||
note: "Each rule uses only daily prices available at or before the decision horizon and a subsequently published binary settlement. Trend replays require aligned one-day and one-week direction under the production move bounds. Confidence bounds cluster related markets by event. Markets are selected by resolved volume, so results still carry historical-selection and execution-model limitations." },
|
||||
horizons: Object.fromEntries(HORIZON_DAYS.map((horizon) => {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
const GAMMA = "https://gamma-api.polymarket.com";
|
||||
const CLOB = "https://clob.polymarket.com";
|
||||
const MARKET_LIMIT = Math.max(500, Math.min(5000, Number(process.env.SPORTS_FAVORITE_MARKETS || 3000)));
|
||||
const CONCURRENCY = Math.max(1, Math.min(12, Number(process.env.SPORTS_FAVORITE_CONCURRENCY || 10)));
|
||||
const COST = Math.max(0, Math.min(0.10, Number(process.env.SPORTS_FAVORITE_COST_CENTS || 5) / 100));
|
||||
const MAX_STALENESS_HOURS = Math.max(1, Math.min(12, Number(process.env.SPORTS_FAVORITE_MAX_STALENESS_HOURS || 3)));
|
||||
const HOUR = 3600;
|
||||
|
||||
function parseJson(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try { return JSON.parse(value || "[]"); } catch { return []; }
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
const parsed = Date.parse(String(value || "").replace(" ", "T").replace(/\+00$/, "Z"));
|
||||
return Number.isFinite(parsed) ? parsed / 1000 : null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, attempts = 4) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(30000), headers: { accept: "application/json" } });
|
||||
if (response.ok) return response.json();
|
||||
lastError = new Error(`${response.status} ${response.statusText}`);
|
||||
if (response.status !== 429 && response.status < 500) break;
|
||||
} catch (error) { lastError = error; }
|
||||
await new Promise((resolve) => setTimeout(resolve, 700 * (attempt + 1)));
|
||||
}
|
||||
throw lastError || new Error("request failed");
|
||||
}
|
||||
|
||||
async function mapLimit(items, limit, task) {
|
||||
const output = new Array(items.length);
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
try { output[index] = await task(items[index]); }
|
||||
catch (error) { output[index] = { error: error.message }; }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return output;
|
||||
}
|
||||
|
||||
function sportsMarket(raw) {
|
||||
const tags = [...(raw.tags || []), ...(raw.events || []).flatMap((event) => event.tags || [])]
|
||||
.map((tag) => `${tag.slug || ""} ${tag.label || ""}`).join(" ");
|
||||
const text = `${raw.question || ""} ${raw.sportsMarketType || ""} ${tags}`.toLowerCase();
|
||||
return /\b(sports?|soccer|football|basketball|baseball|tennis|hockey|cricket|golf|boxing|ufc|nba|nfl|nhl|mlb|fifa|epl|match|game|tournament)\b/.test(text);
|
||||
}
|
||||
|
||||
function gameStart(raw) {
|
||||
const event = raw.events?.[0] || {};
|
||||
return timestamp(raw.gameStartTime || raw.eventStartTime || event.startTime || event.eventDate);
|
||||
}
|
||||
|
||||
async function fetchMarkets(limit) {
|
||||
const markets = [], seen = new Set();
|
||||
let cursor = "";
|
||||
while (markets.length < limit) {
|
||||
const params = new URLSearchParams({ closed: "true", order: "closedTime", ascending: "false", limit: "100", include_tag: "true" });
|
||||
if (cursor) params.set("after_cursor", cursor);
|
||||
const payload = await fetchJson(`${GAMMA}/markets/keyset?${params}`), page = payload?.markets;
|
||||
if (!Array.isArray(page) || !page.length) break;
|
||||
for (const raw of page) {
|
||||
const id = String(raw.id || ""), labels = parseJson(raw.outcomes).map((value) => String(value).trim().toLowerCase());
|
||||
const outcomes = parseJson(raw.outcomePrices).map(Number), tokens = parseJson(raw.clobTokenIds).map(String);
|
||||
const finalYes = outcomes[0] >= 0.99 && outcomes[1] <= 0.01 ? 1 : outcomes[1] >= 0.99 && outcomes[0] <= 0.01 ? 0 : null;
|
||||
const startsAt = gameStart(raw), closedAt = timestamp(raw.closedTime || raw.endDate);
|
||||
if (!id || seen.has(id) || labels[0] !== "yes" || labels[1] !== "no" || tokens.length !== 2 || finalYes == null
|
||||
|| !startsAt || !closedAt || !sportsMarket(raw)) continue;
|
||||
seen.add(id);
|
||||
markets.push({ id, question: raw.question || "", eventKey: String(raw.events?.[0]?.id || id), event: raw.events?.[0]?.title || "",
|
||||
tokenId: tokens[0], finalYes, startsAt, closedAt, marketType: String(raw.sportsMarketType || "unknown") });
|
||||
if (markets.length >= limit) break;
|
||||
}
|
||||
if (page.length < 100 || !payload.next_cursor || payload.next_cursor === cursor) break;
|
||||
cursor = payload.next_cursor;
|
||||
}
|
||||
return markets;
|
||||
}
|
||||
|
||||
function atOrBefore(points, target) {
|
||||
let lo = 0, hi = points.length - 1, answer = null;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (points[mid].t <= target) { answer = points[mid]; lo = mid + 1; }
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
function observation(market, points, leadHours) {
|
||||
const target = market.startsAt - leadHours * HOUR, point = atOrBefore(points, target);
|
||||
if (!point || target - point.t > MAX_STALENESS_HOURS * HOUR || point.p <= 0.03 || point.p >= 0.97) return null;
|
||||
const side = point.p >= 0.5 ? "YES" : "NO", entry = side === "YES" ? point.p : 1 - point.p;
|
||||
const won = side === (market.finalYes ? "YES" : "NO"), netReturn = (won ? 1 : 0) / entry - 1 - COST / entry;
|
||||
return { marketId: market.id, eventKey: market.eventKey, question: market.question, event: market.event,
|
||||
marketType: market.marketType, startsAt: market.startsAt, closedAt: market.closedAt, leadHours, side, entry, won, netReturn };
|
||||
}
|
||||
|
||||
function summarize(rows) {
|
||||
if (!rows.length) return { trades: 0, events: 0, mean: 0, eventMean: 0, lower: 0, upper: 0, winRate: 0 };
|
||||
const events = new Map();
|
||||
rows.forEach((row) => { const bucket = events.get(row.eventKey) || []; bucket.push(row.netReturn); events.set(row.eventKey, bucket); });
|
||||
const eventReturns = [...events.values()].map((values) => values.reduce((sum, value) => sum + value, 0) / values.length);
|
||||
const eventMean = eventReturns.reduce((sum, value) => sum + value, 0) / eventReturns.length;
|
||||
const variance = eventReturns.length > 1 ? eventReturns.reduce((sum, value) => sum + (value - eventMean) ** 2, 0) / (eventReturns.length - 1) : 0;
|
||||
const margin = 1.645 * Math.sqrt(variance / Math.max(1, eventReturns.length));
|
||||
return { trades: rows.length, events: eventReturns.length, mean: rows.reduce((sum, row) => sum + row.netReturn, 0) / rows.length,
|
||||
eventMean, lower: eventMean - margin, upper: eventMean + margin, winRate: rows.filter((row) => row.won).length / rows.length };
|
||||
}
|
||||
|
||||
const rules = [];
|
||||
for (const leadHours of [12, 18, 24, 30, 36]) {
|
||||
for (const minEntry of [0.55, 0.60, 0.65, 0.70]) {
|
||||
for (const maxEntry of [0.75, 0.85, 0.95]) {
|
||||
if (minEntry >= maxEntry) continue;
|
||||
rules.push({ id: `lead${leadHours}_${minEntry}-${maxEntry}`, leadHours, minEntry, maxEntry });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const markets = await fetchMarkets(MARKET_LIMIT);
|
||||
const histories = await mapLimit(markets, CONCURRENCY, async (market) => {
|
||||
const data = await fetchJson(`${CLOB}/prices-history?market=${encodeURIComponent(market.tokenId)}&interval=max&fidelity=60`);
|
||||
const points = (data.history || []).map((point) => ({ t: Number(point.t), p: Number(point.p) }))
|
||||
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.p)).sort((a, b) => a.t - b.t);
|
||||
return { market, points };
|
||||
});
|
||||
const usable = histories.filter((row) => row && !row.error && row.points.length);
|
||||
const times = usable.map((row) => row.market.startsAt).sort((a, b) => a - b);
|
||||
const trainCut = times[Math.floor(times.length * 0.6)] || 0, validationCut = times[Math.floor(times.length * 0.8)] || 0;
|
||||
const partitions = {
|
||||
train: usable.filter((row) => row.market.startsAt < trainCut),
|
||||
validation: usable.filter((row) => row.market.startsAt >= trainCut && row.market.startsAt < validationCut),
|
||||
holdout: usable.filter((row) => row.market.startsAt >= validationCut),
|
||||
};
|
||||
|
||||
function tradesFor(partition, rule) {
|
||||
const eligible = partition.map(({ market, points }) => observation(market, points, rule.leadHours)).filter(Boolean)
|
||||
.filter((row) => row.entry >= rule.minEntry && row.entry < rule.maxEntry);
|
||||
const events = new Map();
|
||||
eligible.forEach((row) => {
|
||||
const rows = events.get(row.eventKey) || [];
|
||||
rows.push(row);events.set(row.eventKey, rows);
|
||||
});
|
||||
return [...events.values()].flatMap((rows) => rows.sort((a, b) => b.entry - a.entry || a.marketId.localeCompare(b.marketId)).slice(0, 4));
|
||||
}
|
||||
|
||||
const evaluated = rules.map((rule) => {
|
||||
const trainRows = tradesFor(partitions.train, rule), validationRows = tradesFor(partitions.validation, rule), holdoutRows = tradesFor(partitions.holdout, rule);
|
||||
const train = summarize(trainRows), validation = summarize(validationRows), holdout = summarize(holdoutRows);
|
||||
const trainPassed = train.trades >= 60 && train.events >= 25 && train.lower > 0;
|
||||
const validationPassed = trainPassed && validation.trades >= 25 && validation.events >= 10 && validation.lower > 0;
|
||||
const passesHoldout = validationPassed && holdout.trades >= 25 && holdout.events >= 10 && holdout.lower > 0;
|
||||
return { rule, train, validation, holdout, trainPassed, validationPassed, passesHoldout, holdoutRows };
|
||||
});
|
||||
const selected = evaluated.filter((row) => row.validationPassed).sort((a, b) => Number(b.passesHoldout) - Number(a.passesHoldout) || b.holdout.lower - a.holdout.lower);
|
||||
const baseline = evaluated.find((row) => row.rule.leadHours === 24 && row.rule.minEntry === 0.55 && row.rule.maxEntry === 0.95);
|
||||
const compact = (stats) => Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, Number.isFinite(value) ? +value.toFixed(5) : value]));
|
||||
const candidate = (row) => ({ rule: row.rule, train: compact(row.train), validation: compact(row.validation), holdout: compact(row.holdout), passesHoldout: row.passesHoldout });
|
||||
|
||||
console.log(JSON.stringify({ generatedAt: new Date().toISOString(), requestedMarkets: MARKET_LIMIT, sportsMarkets: markets.length,
|
||||
historiesWithData: usable.length, failures: histories.filter((row) => row?.error).length,
|
||||
methodology: { selection: "most recently closed eligible Yes/No sports markets", decisionAnchor: "published game start",
|
||||
historyFidelityMinutes: 60, maxPriceStalenessHours: MAX_STALENESS_HOURS, modeledCostCents: COST * 100,
|
||||
split: "60% train / 20% validation / 20% untouched holdout", clusterUnit: "equal-dollar event baskets split across at most four highest-priced eligible favorites", testedRules: rules.length },
|
||||
partitionMarkets: Object.fromEntries(Object.entries(partitions).map(([key, value]) => [key, value.length])),
|
||||
trainPassed: evaluated.filter((row) => row.trainPassed).length, validationSelected: selected.length,
|
||||
holdoutPassed: selected.filter((row) => row.passesHoldout).length, baseline: baseline ? candidate(baseline) : null,
|
||||
candidates: selected.slice(0, 20).map(candidate),
|
||||
holdoutExamples: (selected.find((row) => row.passesHoldout)?.holdoutRows || []).slice(0, 12)
|
||||
.map((row) => ({ question: row.question, event: row.event, side: row.side, entry: +row.entry.toFixed(4), won: row.won, netReturn: +row.netReturn.toFixed(4) }))
|
||||
}, null, 2));
|
||||
Reference in New Issue
Block a user