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 @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ <h3>Unlock pad</h3>
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)+"%"; }
Expand Down Expand Up @@ -542,13 +543,16 @@ <h3>Unlock pad</h3>
for(var i=0;i<rows.length;i++){
var p=rows[i]; totalVal+=(+p.currentValue||0); totalPnl+=(+p.cashPnl||0);
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>':"";
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>'+
esc((+p.size).toFixed(1))+' @ <span class="price-move">'+
(p.avgPrice!=null?(+p.avgPrice).toFixed(3):"-")+' &rarr; '+(p.curPrice!=null?(+p.curPrice).toFixed(3):"-")+'</span></span>'+
'<span class="'+cls(+p.cashPnl)+'">'+fmtSignedUsd(+p.cashPnl)+' <span style="opacity:.75">('+fmtPct(+p.percentPnl)+')</span></span>'+
'</div>'+
priceToBeat+
'<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
73 changes: 49 additions & 24 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -577,55 +579,78 @@ 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
}
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
Expand Down
68 changes: 66 additions & 2 deletions server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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{} })
Expand Down Expand Up @@ -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</span><span>'+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))
Expand Down