From 89c1433cdbab20630ccd12c011a3fda8b913e612 Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Sat, 12 Sep 2026 17:26:00 +0200 Subject: [PATCH] Show BTC price to beat --- README.md | 3 ++- index.html | 4 +++ server.go | 73 +++++++++++++++++++++++++++++++++----------------- server_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 121 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e888110..196cc55 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,8 @@ Logs: `polydisplay.log` in the working directory. Rolled at local midnight to Candles and prices: Binance when a USDT pair exists, otherwise CoinGecko. 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. +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. 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 8839d50..fa9b06d 100644 --- a/index.html +++ b/index.html @@ -221,6 +221,7 @@

Unlock pad

function p2(x){ return (x<10?"0":"")+x; } function fmtUsd(n){ if(n==null||isNaN(n))return "-"; var a=Math.abs(n),dp=a>=1000?0:a>=1?2:a>=0.01?4:6; return "$"+Number(n).toLocaleString("en-US",{minimumFractionDigits:dp,maximumFractionDigits:dp}); } +function fmtUsd2(n){ if(n==null||isNaN(n))return "-"; return "$"+Number(n).toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2}); } function fmtSignedUsd(n){ if(n==null||isNaN(n))return "-"; return (n>=0?"+":"-")+fmtUsd(Math.abs(n)); } function fmtSignedUsd2(n){ if(n==null||isNaN(n))return "-"; return (n>=0?"+":"-")+"$"+Math.abs(n).toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2}); } function fmtPct(n){ if(n==null||isNaN(n))return ""; return (n>=0?"+":"")+Number(n).toFixed(2)+"%"; } @@ -542,6 +543,8 @@

Unlock pad

for(var i=0;i0)? + '
price to beat'+fmtUsd2(+p.priceToBeat)+'
':""; html+='
'+esc(p.title)+'
'+ '
'+ ''+esc(p.outcome)+''+ @@ -549,6 +552,7 @@

Unlock pad

(p.avgPrice!=null?(+p.avgPrice).toFixed(3):"-")+' → '+(p.curPrice!=null?(+p.curPrice).toFixed(3):"-")+'
'+ ''+fmtSignedUsd(+p.cashPnl)+' ('+fmtPct(+p.percentPnl)+')'+ '
'+ + priceToBeat+ '
value'+fmtUsd(+p.currentValue)+'
'; } $("pmSummary").innerHTML=fmtUsd(totalVal)+' '+fmtSignedUsd(totalPnl)+''; diff --git a/server.go b/server.go index ae5cf67..0a19f0d 100644 --- a/server.go +++ b/server.go @@ -219,18 +219,20 @@ type CoinState struct { } type Position struct { - Title string `json:"title"` - Outcome string `json:"outcome"` - Asset string `json:"asset"` - Size float64 `json:"size"` - AvgPrice float64 `json:"avgPrice"` - CurPrice float64 `json:"curPrice"` - Redeemable bool `json:"redeemable"` - CashPnl float64 `json:"cashPnl"` - PercentPnl float64 `json:"percentPnl"` - CurrentValue float64 `json:"currentValue"` - ConditionID string `json:"conditionId"` - EndDate string `json:"endDate"` + Title string `json:"title"` + Outcome string `json:"outcome"` + Asset string `json:"asset"` + Size float64 `json:"size"` + AvgPrice float64 `json:"avgPrice"` + CurPrice float64 `json:"curPrice"` + Redeemable bool `json:"redeemable"` + CashPnl float64 `json:"cashPnl"` + PercentPnl float64 `json:"percentPnl"` + CurrentValue float64 `json:"currentValue"` + ConditionID string `json:"conditionId"` + EndDate string `json:"endDate"` + EventSlug string `json:"eventSlug,omitempty"` + PriceToBeat *float64 `json:"priceToBeat,omitempty"` } type Act struct { @@ -562,7 +564,7 @@ func fetchPositions(wallet string) ([]Position, error) { open = append(open, p) } out = open - fillEndTimes(out) + fillMarketMetadata(out) // Soonest resolution first; undated last. ISO timestamps sort as strings. sort.SliceStable(out, func(i, j int) bool { a, b := out[i].EndDate, out[j].EndDate @@ -577,37 +579,48 @@ func fetchPositions(wallet string) ([]Position, error) { // gamma host; a var so tests can point it at a stub var gammaBase = "https://gamma-api.polymarket.com" -// end times never move once a market exists, so one lookup per market is enough -var endTimes = map[string]string{} +type marketMeta struct { + EndDate string + PriceToBeat *float64 +} + +// End times never move once a market exists. BTC Up/Down reference prices can +// appear after the market metadata is first published, so retry those until set. +var marketMetadata = map[string]marketMeta{} // /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 fillEndTimes(pos []Position) { +func fillMarketMetadata(pos []Position) { var missing []string for _, p := range pos { if p.ConditionID == "" { continue } - if _, ok := endTimes[p.ConditionID]; !ok { + meta, ok := marketMetadata[p.ConditionID] + needsPrice := strings.HasPrefix(p.EventSlug, "btc-updown-") && meta.PriceToBeat == nil + if !ok || needsPrice { missing = append(missing, p.ConditionID) } } for len(missing) > 0 { n := min(len(missing), 20) - if err := loadEndTimes(missing[:n]); err != nil { + if err := loadMarketMetadata(missing[:n]); err != nil { log.Printf("gamma: end times unavailable, sorting by date: %v", err) break } missing = missing[n:] } for i, p := range pos { - if t := endTimes[p.ConditionID]; t != "" { - pos[i].EndDate = t + if meta, ok := marketMetadata[p.ConditionID]; ok { + if meta.EndDate != "" { + pos[i].EndDate = meta.EndDate + } + pos[i].PriceToBeat = meta.PriceToBeat } } } -func loadEndTimes(ids []string) error { +func loadMarketMetadata(ids []string) error { url := gammaBase + "/markets?limit=" + strconv.Itoa(len(ids)) for _, id := range ids { url += "&condition_ids=" + id @@ -615,17 +628,29 @@ func loadEndTimes(ids []string) error { var raw []struct { ConditionID string `json:"conditionId"` EndDate string `json:"endDate"` + Events []struct { + EventMetadata struct { + PriceToBeat *float64 `json:"priceToBeat"` + } `json:"eventMetadata"` + } `json:"events"` } if err := getJSON(url, &raw, nil); err != nil { return err } for _, m := range raw { - endTimes[m.ConditionID] = m.EndDate + meta := marketMeta{EndDate: m.EndDate} + for _, event := range m.Events { + if event.EventMetadata.PriceToBeat != nil && *event.EventMetadata.PriceToBeat > 0 { + meta.PriceToBeat = event.EventMetadata.PriceToBeat + break + } + } + marketMetadata[m.ConditionID] = meta } // Cache the misses too, so an unknown market isn't re-queried every cycle. for _, id := range ids { - if _, ok := endTimes[id]; !ok { - endTimes[id] = "" + if _, ok := marketMetadata[id]; !ok { + marketMetadata[id] = marketMeta{} } } return nil diff --git a/server_test.go b/server_test.go index eca3b3e..ffc4033 100644 --- a/server_test.go +++ b/server_test.go @@ -220,10 +220,10 @@ func TestFetchPositionsSortsByEndTime(t *testing.T) { origPoly, origGamma := polyBase, gammaBase polyBase, gammaBase = poly.URL, gamma.URL - endTimes = map[string]string{} + marketMetadata = map[string]marketMeta{} t.Cleanup(func() { polyBase, gammaBase = origPoly, origGamma - endTimes = map[string]string{} + marketMetadata = map[string]marketMeta{} }) got, err := fetchPositions("0xtest") @@ -247,6 +247,56 @@ func TestFetchPositionsSortsByEndTime(t *testing.T) { } } +func TestFetchPositionsAddsBTCPriceToBeat(t *testing.T) { + poly := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`[{"title":"Bitcoin Up or Down - September 12, 8:00AM-12:00PM ET","eventSlug":"btc-updown-4h-1789214400","conditionId":"0xbtc"}]`)) + })) + defer poly.Close() + + var gammaCalls int + gamma := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gammaCalls++ + if gammaCalls == 1 { + w.Write([]byte(`[{"conditionId":"0xbtc","endDate":"2026-09-12T16:00:00Z","events":[{"eventMetadata":{}}]}]`)) + return + } + w.Write([]byte(`[{"conditionId":"0xbtc","endDate":"2026-09-12T16:00:00Z","events":[{"eventMetadata":{"priceToBeat":77338.86223947103}}]}]`)) + })) + 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 first[0].PriceToBeat != nil { + t.Fatalf("price to beat unexpectedly set before Gamma publishes it: %v", *first[0].PriceToBeat) + } + got, err := fetchPositions("0xtest") + if err != nil { + t.Fatalf("second fetchPositions: %v", err) + } + if len(got) != 1 || got[0].PriceToBeat == nil { + t.Fatalf("price to beat missing from position: %+v", got) + } + if want := 77338.86223947103; *got[0].PriceToBeat != want { + t.Errorf("price to beat = %v, want %v", *got[0].PriceToBeat, want) + } + if got[0].EndDate != "2026-09-12T16:00:00Z" { + t.Errorf("end date = %q", got[0].EndDate) + } + if gammaCalls != 2 { + t.Errorf("gamma calls = %d, want retry after missing price", gammaCalls) + } +} + func TestBinanceDue(t *testing.T) { bnProbe = map[string]time.Time{} t.Cleanup(func() { bnProbe = map[string]time.Time{} }) @@ -601,6 +651,20 @@ func TestPnlTodayUsesTwoDecimals(t *testing.T) { } } +func TestIndexShowsPriceToBeatWhenAvailable(t *testing.T) { + b, err := os.ReadFile("index.html") + if err != nil { + t.Fatal(err) + } + s := string(b) + if !strings.Contains(s, `p.priceToBeat!=null&&+p.priceToBeat>0`) { + t.Error("price to beat must only render for a positive API value") + } + if !strings.Contains(s, `price to beat'+fmtUsd2(+p.priceToBeat)`) { + t.Error("position card missing formatted price to beat") + } +} + func hourly(vals ...float64) [][2]float64 { now := float64(time.Now().Unix()) out := make([][2]float64, len(vals))