diff --git a/README.md b/README.md index 4692154..bc7d231 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,8 @@ fetched in one request; OHLC requests are paced to its public API guidance. Positions and activity: Polymarket data-api, polled every 30s. Account P/L: Polymarket user-pnl-api, 720 hourly points over 30 days, polled every 2 min and thinned to 120 points for the sparkline. Gamma market metadata supplies -exact end times and the price to beat for BTC Up/Down positions. +exact end times, time to resolution in the device's timezone, and the price to beat +for Up/Down positions. The P/L windows are anchored by sample, not by clock, which is how polymarket.com anchors them: its 1D series is 24 hourly points spanning 23h, diff --git a/index.html b/index.html index cc7083b..5a37989 100644 --- a/index.html +++ b/index.html @@ -230,6 +230,75 @@

Unlock pad

function setDot(id,ok){ $(id).className="dot "+(ok?"ok":"err"); } function showErr(msg){ var b=$("errBanner"); if(msg){ b.textContent=msg; b.className="err-banner show"; } else { b.className="err-banner"; } } +var MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; +function fmtResolution(endStr){ + if(!endStr) return ""; + var hasTime = endStr.indexOf("T") >= 0; + var dt; + if(hasTime){ + dt = new Date(endStr); + } else { + var parts = endStr.split("-"); + if(parts.length < 3) return ""; + dt = new Date(+parts[0], +parts[1]-1, +parts[2]); + } + if(isNaN(dt.getTime())) return ""; + + var now = new Date(); + var diffMs = dt.getTime() - now.getTime(); + + var isToday = dt.getFullYear() === now.getFullYear() && + dt.getMonth() === now.getMonth() && + dt.getDate() === now.getDate(); + + var tom = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + var isTomorrow = dt.getFullYear() === tom.getFullYear() && + dt.getMonth() === tom.getMonth() && + dt.getDate() === tom.getDate(); + + var timeStr = ""; + if(hasTime){ + var hm = p2(dt.getHours()) + ":" + p2(dt.getMinutes()); + if(isToday){ + timeStr = hm; + } else if(isTomorrow){ + timeStr = "Tomorrow " + hm; + } else { + timeStr = MONTHS[dt.getMonth()] + " " + dt.getDate() + + (dt.getFullYear() !== now.getFullYear() ? ", " + dt.getFullYear() : "") + " " + hm; + } + } else { + if(isToday){ + timeStr = "Today"; + } else if(isTomorrow){ + timeStr = "Tomorrow"; + } else { + timeStr = MONTHS[dt.getMonth()] + " " + dt.getDate() + + (dt.getFullYear() !== now.getFullYear() ? ", " + dt.getFullYear() : ""); + } + } + + var relStr = ""; + if(diffMs <= 0){ + relStr = (!hasTime && isToday) ? "today" : "ended"; + } else { + var totalMin = Math.round(diffMs / 60000); + if(diffMs < 45000){ + relStr = "<1m left"; + } else if(totalMin < 60){ + relStr = totalMin + "m left"; + } else if(totalMin < 1440){ + var h = Math.floor(totalMin / 60), m = totalMin % 60; + relStr = h + "h" + (m > 0 ? " " + m + "m" : "") + " left"; + } else { + var d = Math.floor(totalMin / 1440), remH = Math.floor((totalMin % 1440) / 60); + relStr = d + "d" + (remH > 0 ? " " + remH + "h" : "") + " left"; + } + } + + return timeStr + " (" + relStr + ")"; +} + function tick(){ var d=new Date(); $("clock").textContent=p2(d.getHours())+":"+p2(d.getMinutes())+":"+p2(d.getSeconds()); } setInterval(tick,1000); tick(); @@ -545,6 +614,9 @@

Unlock pad

var isYes=(p.outcome||"").toLowerCase()==="yes"; var priceToBeat=(p.priceToBeat!=null&&+p.priceToBeat>0)? '
price to beat'+fmtUsd2(+p.priceToBeat)+'
':""; + var timeToRes=(p.endDate?fmtResolution(p.endDate):""); + var timeToResRow=timeToRes? + '
time to resolution'+esc(timeToRes)+'
':""; html+='
'+esc(p.title)+'
'+ '
'+ ''+esc(p.outcome)+''+ @@ -553,6 +625,7 @@

Unlock pad

''+fmtSignedUsd(+p.cashPnl)+' ('+fmtPct(+p.percentPnl)+')'+ '
'+ priceToBeat+ + timeToResRow+ '
value'+fmtUsd(+p.currentValue)+'
'; } $("pmSummary").innerHTML=fmtUsd(totalVal)+' '+fmtSignedUsd(totalPnl)+''; diff --git a/server.go b/server.go index a416f09..bad7fa6 100644 --- a/server.go +++ b/server.go @@ -232,6 +232,7 @@ type Position struct { CurrentValue float64 `json:"currentValue"` ConditionID string `json:"conditionId"` EndDate string `json:"endDate"` + Slug string `json:"slug,omitempty"` EventSlug string `json:"eventSlug,omitempty"` PriceToBeat *float64 `json:"priceToBeat,omitempty"` } @@ -691,21 +692,40 @@ type marketMeta struct { PriceToBeat *float64 } -// End times never move once a market exists. BTC Up/Down reference prices can +// End times never move once a market exists. Up/Down reference prices can // appear after the market metadata is first published, so retry those until set. var marketMetadata = map[string]marketMeta{} +func normID(id string) string { + return strings.ToLower(strings.TrimSpace(id)) +} + +func isUpDown(p Position) bool { + if strings.EqualFold(p.Outcome, "up") || strings.EqualFold(p.Outcome, "down") { + return true + } + s := strings.ToLower(p.EventSlug + " " + p.Slug + " " + p.Title) + return strings.Contains(s, "updown") || + strings.Contains(s, "up-down") || + strings.Contains(s, "up/down") || + strings.Contains(s, "up or down") || + strings.Contains(s, "up-or-down") +} + // /positions only carries a date ("2026-08-12"), which can't separate a market // closing at noon from one closing at 18:00. gamma has the full timestamp. func fillMarketMetadata(pos []Position) { var missing []string + seen := map[string]bool{} for _, p := range pos { - if p.ConditionID == "" { + cid := normID(p.ConditionID) + if cid == "" { continue } - meta, ok := marketMetadata[p.ConditionID] - needsPrice := strings.HasPrefix(p.EventSlug, "btc-updown-") && meta.PriceToBeat == nil - if !ok || needsPrice { + meta, ok := marketMetadata[cid] + needsPrice := isUpDown(p) && meta.PriceToBeat == nil + if (!ok || needsPrice) && !seen[cid] { + seen[cid] = true missing = append(missing, p.ConditionID) } } @@ -718,7 +738,7 @@ func fillMarketMetadata(pos []Position) { missing = missing[n:] } for i, p := range pos { - if meta, ok := marketMetadata[p.ConditionID]; ok { + if meta, ok := marketMetadata[normID(p.ConditionID)]; ok { if meta.EndDate != "" { pos[i].EndDate = meta.EndDate } @@ -733,9 +753,13 @@ func loadMarketMetadata(ids []string) error { url += "&condition_ids=" + id } var raw []struct { - ConditionID string `json:"conditionId"` - EndDate string `json:"endDate"` - Events []struct { + ConditionID string `json:"conditionId"` + EndDate string `json:"endDate"` + PriceToBeat *float64 `json:"priceToBeat"` + EventMetadata struct { + PriceToBeat *float64 `json:"priceToBeat"` + } `json:"eventMetadata"` + Events []struct { EventMetadata struct { PriceToBeat *float64 `json:"priceToBeat"` } `json:"eventMetadata"` @@ -745,19 +769,29 @@ func loadMarketMetadata(ids []string) error { return err } for _, m := range raw { + cid := normID(m.ConditionID) meta := marketMeta{EndDate: m.EndDate} + if prev, ok := marketMetadata[cid]; ok && prev.PriceToBeat != nil { + meta.PriceToBeat = prev.PriceToBeat + } + if m.PriceToBeat != nil && *m.PriceToBeat > 0 { + meta.PriceToBeat = m.PriceToBeat + } else if m.EventMetadata.PriceToBeat != nil && *m.EventMetadata.PriceToBeat > 0 { + meta.PriceToBeat = m.EventMetadata.PriceToBeat + } for _, event := range m.Events { if event.EventMetadata.PriceToBeat != nil && *event.EventMetadata.PriceToBeat > 0 { meta.PriceToBeat = event.EventMetadata.PriceToBeat break } } - marketMetadata[m.ConditionID] = meta + marketMetadata[cid] = meta } // Cache the misses too, so an unknown market isn't re-queried every cycle. for _, id := range ids { - if _, ok := marketMetadata[id]; !ok { - marketMetadata[id] = marketMeta{} + cid := normID(id) + if _, ok := marketMetadata[cid]; !ok { + marketMetadata[cid] = marketMeta{} } } return nil diff --git a/server_test.go b/server_test.go index ac6570d..1d741d5 100644 --- a/server_test.go +++ b/server_test.go @@ -297,6 +297,104 @@ func TestFetchPositionsAddsBTCPriceToBeat(t *testing.T) { } } +func TestFetchPositionsAddsAllUpDownMarketsPriceToBeat(t *testing.T) { + poly := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`[ + {"title":"Bitcoin Up or Down - September 19, 5AM ET","eventSlug":"bitcoin-up-or-down-september-19-2026-5am-et","conditionId":"0xbtc1h"}, + {"title":"Ethereum Up or Down - September 19, 5:05AM-5:10AM ET","eventSlug":"eth-updown-5m-1789808700","conditionId":"0xeth5m"}, + {"title":"Solana Market","slug":"sol-updown-15m-1789807500","conditionId":"0xsol15m"}, + {"title":"XRP Contract","outcome":"Up","conditionId":"0xXrp"}, + {"title":"Kraken IPO by June 30, 2026?","eventSlug":"kraken-ipo-by-june-30-2026","outcome":"Yes","conditionId":"0xkraken"} + ]`)) + })) + defer poly.Close() + + var gammaCalls int + gamma := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gammaCalls++ + if gammaCalls == 1 { + // First call: metadata published without priceToBeat yet + w.Write([]byte(`[ + {"conditionId":"0xbtc1h","endDate":"2026-09-19T10:00:00Z","events":[{"eventMetadata":{}}]}, + {"conditionId":"0xeth5m","endDate":"2026-09-19T09:10:00Z","events":[{"eventMetadata":{}}]}, + {"conditionId":"0xsol15m","endDate":"2026-09-19T09:15:00Z","events":[{"eventMetadata":{}}]}, + {"conditionId":"0xxrp","endDate":"2026-09-19T09:10:00Z","events":[{"eventMetadata":{}}]}, + {"conditionId":"0xkraken","endDate":"2026-06-30T04:00:00Z","events":[{"eventMetadata":{}}]} + ]`)) + return + } + // Second call: only the 4 up/down markets should be queried, NOT kraken-ipo + w.Write([]byte(`[ + {"conditionId":"0xbtc1h","endDate":"2026-09-19T10:00:00Z","events":[{"eventMetadata":{"priceToBeat":81312.01}}]}, + {"conditionId":"0xeth5m","endDate":"2026-09-19T09:10:00Z","events":[{"eventMetadata":{"priceToBeat":2645.23}}]}, + {"conditionId":"0xsol15m","endDate":"2026-09-19T09:15:00Z","events":[{"eventMetadata":{"priceToBeat":112.27}}]}, + {"conditionId":"0xxrp","endDate":"2026-09-19T09:10:00Z","events":[{"eventMetadata":{"priceToBeat":1.415}}]} + ]`)) + })) + defer gamma.Close() + + origPoly, origGamma := polyBase, gammaBase + polyBase, gammaBase = poly.URL, gamma.URL + marketMetadata = map[string]marketMeta{} + t.Cleanup(func() { + polyBase, gammaBase = origPoly, origGamma + marketMetadata = map[string]marketMeta{} + }) + + first, err := fetchPositions("0xtest") + if err != nil { + t.Fatalf("fetchPositions: %v", err) + } + if len(first) != 5 { + t.Fatalf("first positions count = %d, want 5", len(first)) + } + for i, p := range first { + if p.PriceToBeat != nil { + t.Fatalf("first[%d] unexpectedly has priceToBeat: %v", i, *p.PriceToBeat) + } + } + + got, err := fetchPositions("0xtest") + if err != nil { + t.Fatalf("second fetchPositions: %v", err) + } + if len(got) != 5 { + t.Fatalf("second positions count = %d, want 5", len(got)) + } + + expected := map[string]float64{ + "0xbtc1h": 81312.01, + "0xeth5m": 2645.23, + "0xsol15m": 112.27, + "0xXrp": 1.415, + } + for _, p := range got { + if want, ok := expected[p.ConditionID]; ok { + if p.PriceToBeat == nil { + t.Errorf("%s: price to beat missing", p.ConditionID) + } else if *p.PriceToBeat != want { + t.Errorf("%s: price to beat = %v, want %v", p.ConditionID, *p.PriceToBeat, want) + } + } else if p.ConditionID == "0xkraken" { + if p.PriceToBeat != nil { + t.Errorf("kraken position should not have price to beat: %v", *p.PriceToBeat) + } + } + } + + if gammaCalls != 2 { + t.Errorf("gamma calls = %d, want 2 (initial fetch + retry for up/down)", gammaCalls) + } + + // Third fetch should not call Gamma again because all up/down prices are resolved + if _, err := fetchPositions("0xtest"); err != nil { + t.Fatalf("third fetchPositions: %v", err) + } + if gammaCalls != 2 { + t.Errorf("gamma calls after 3rd fetch = %d, want still 2", gammaCalls) + } +} + func TestMarketPairsPreserveLegacyOverride(t *testing.T) { if got := krakenPair(Coin{Sym: "BTC"}); got != "XBTUSD" { t.Errorf("kraken BTC pair = %q, want XBTUSD", got) @@ -793,6 +891,23 @@ func TestIndexShowsPriceToBeatWhenAvailable(t *testing.T) { } } +func TestIndexShowsTimeToResolutionWhenAvailable(t *testing.T) { + b, err := os.ReadFile("index.html") + if err != nil { + t.Fatal(err) + } + s := string(b) + if !strings.Contains(s, `time to resolution`) { + t.Error("position card missing time to resolution row") + } + if !strings.Contains(s, `fmtResolution(p.endDate)`) { + t.Error("position card missing fmtResolution call for endDate") + } + if !strings.Contains(s, `function fmtResolution(endStr)`) { + t.Error("index.html missing fmtResolution function") + } +} + func hourly(vals ...float64) [][2]float64 { now := float64(time.Now().Unix()) out := make([][2]float64, len(vals))