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))