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