Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,75 @@ <h3>Unlock pad</h3>
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();

Expand Down Expand Up @@ -545,6 +614,9 @@ <h3>Unlock pad</h3>
var isYes=(p.outcome||"").toLowerCase()==="yes";
var priceToBeat=(p.priceToBeat!=null&&+p.priceToBeat>0)?
'<div class="row" style="margin-top:4px"><span class="meta">price to beat</span><span>'+fmtUsd2(+p.priceToBeat)+'</span></div>':"";
var timeToRes=(p.endDate?fmtResolution(p.endDate):"");
var timeToResRow=timeToRes?
'<div class="row" style="margin-top:4px"><span class="meta">time to resolution</span><span>'+esc(timeToRes)+'</span></div>':"";
html+='<div class="pos"><div class="title">'+esc(p.title)+'</div>'+
'<div class="row"><span class="meta">'+
'<span class="badge '+(isYes?"yes":"no")+'">'+esc(p.outcome)+'</span>'+
Expand All @@ -553,6 +625,7 @@ <h3>Unlock pad</h3>
'<span class="'+cls(+p.cashPnl)+'">'+fmtSignedUsd(+p.cashPnl)+' <span style="opacity:.75">('+fmtPct(+p.percentPnl)+')</span></span>'+
'</div>'+
priceToBeat+
timeToResRow+
'<div class="row" style="margin-top:4px"><span class="meta">value</span><span>'+fmtUsd(+p.currentValue)+'</span></div></div>';
}
$("pmSummary").innerHTML=fmtUsd(totalVal)+' <span class="'+cls(totalPnl)+'">'+fmtSignedUsd(totalPnl)+'</span>';
Expand Down
58 changes: 46 additions & 12 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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
}
Expand All @@ -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"`
Expand All @@ -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
Expand Down
115 changes: 115 additions & 0 deletions server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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</span>`) {
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))
Expand Down
Loading