diff --git a/README.md b/README.md index 23a3913..c23b8ba 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,34 @@ Copyright 2026ff [novatechflow](https://www.novatechflow.com) (Alexander Alten). See `LICENSE`. -LAN dashboard for Polymarket binary trading. Left column is the live book: -a P/L card (the day's profit as polymarket.com reports it, over a 30-day -sparkline, with the 7d, 30d, and all-time figures), then the open positions -and recent activity. Right column is charts for a mix of trading and -monitoring assets. A Go process on the host serves the page and talks to Polymarket, -Kraken, and Coinbase. CoinGecko is used only for asset search. Browsers only talk -to that process. +LAN dashboard for crypto and commodities, with optional Polymarket positions. +Without a wallet, the watchlist fills the display with responsive asset tiles. +Add a Polymarket public address in Settings to show positions, P/L and activity +on the left and your watchlist on the right. On phones the panels stack. +Clear the address to return to the full-width watchlist. + +Crypto prices and candles use Kraken, Coinbase or Binance. CoinGecko supplies +crypto search. In Settings, choose **Commodities** under Add asset to search +Gold, Silver, Copper, WTI, Brent and Natural Gas futures. Yahoo supplies these +quotes and chart history without an API key. Tiles identify the contract, +USD unit, delayed status and last quote time. Commodity changes use Yahoo's +previous-session close when available; crypto changes use the last 24 hours. +Commodity charts preserve session gaps and do not apply the continuous-crypto +trend analysis. Quotes and history refresh every five minutes; old quotes remain +labelled with their original timestamp when a market is idle or a fetch fails. + +Yahoo futures quotes are delayed, and the active contract can roll. They are +not spot commodity prices. This optional integration uses Yahoo's unofficial +chart endpoint for personal dashboards; availability can change. For shared +or commercial deployments, use a provider with appropriate data rights. + +The Go server polls and caches all data; browsers only contact the local server. ``` ┌────────────┐ http ┌─────────────────────────────┐ https │ pad / │ ────────────► │ polydisplayd (Go) │ ──► Polymarket │ browser │ /api/state │ polls + caches │ ──► Kraken - │ │ ◄──────────── │ serves the page │ ──► Coinbase + │ │ ◄──────────── │ serves the page │ ──► Coinbase / Yahoo └────────────┘ │ /api/config, /api/search │ └─────────────────────────────┘ ``` @@ -34,7 +49,7 @@ Default listen port is 8080 (`POLYDISPLAY_PORT`). Dark/light follows the device. ./install.sh ``` -Downloads Go if needed, asks for a Polymarket public address (`0x...`), writes +Downloads Go if needed, asks for an optional Polymarket public address (`0x...`; leave empty for assets only), writes `config.json`, builds `polydisplayd`, and installs a login service: LaunchAgent on macOS, systemd user unit on Linux. At the prompt you can add extra tickers; those are looked up on CoinGecko. Kraken supplies runtime prices and candles, diff --git a/VERSION b/VERSION index 1689437..53d1c14 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v21 +v22 diff --git a/commodities.go b/commodities.go new file mode 100644 index 0000000..c90795c --- /dev/null +++ b/commodities.go @@ -0,0 +1,162 @@ +// Copyright 2026ff novatechflow (Alexander Alten) +// SPDX-License-Identifier: PolyForm-Shield-1.0.0 +package main + +import ( + "fmt" + "math" + "net/url" + "strings" + "time" +) + +// Yahoo futures symbols are curated so display units are explicit and stable. +// Quotes identify the current contract; this is not a spot commodity feed. +type commodity struct { + Coin + Ticker, Unit string +} + +var commodityCatalog = []commodity{ + {Coin{Sym: "GOLD", Name: "Gold futures", ID: "yahoo:GC=F", Kind: "commodity"}, "GC=F", "troy oz"}, + {Coin{Sym: "SILVER", Name: "Silver futures", ID: "yahoo:SI=F", Kind: "commodity"}, "SI=F", "troy oz"}, + {Coin{Sym: "COPPER", Name: "Copper futures", ID: "yahoo:HG=F", Kind: "commodity"}, "HG=F", "lb"}, + {Coin{Sym: "WTI", Name: "WTI crude oil futures", ID: "yahoo:CL=F", Kind: "commodity"}, "CL=F", "barrel"}, + {Coin{Sym: "BRENT", Name: "Brent crude oil futures", ID: "yahoo:BZ=F", Kind: "commodity"}, "BZ=F", "barrel"}, + {Coin{Sym: "NATGAS", Name: "Natural gas futures", ID: "yahoo:NG=F", Kind: "commodity"}, "NG=F", "MMBtu"}, +} +var yahooBase = "https://query1.finance.yahoo.com" +var commodityQuotes = map[string]commodityQuote{} // guarded by mu, updated on slow cadence + +type commodityQuote struct { + Price float64 + Change *float64 + Time int64 + Contract string +} + +func findCommodity(id string) (commodity, bool) { + for _, c := range commodityCatalog { + if c.ID == id { + return c, true + } + } + return commodity{}, false +} + +func cryptoCoins(coins []Coin) []Coin { + out := []Coin{} + for _, c := range coins { + if c.Kind != "commodity" { + out = append(out, c) + } + } + return out +} + +func cryptoChange(price float64, data []Candle) *float64 { + if price <= 0 || len(data) == 0 || data[0][1] <= 0 { + return nil + } + v := candleChange24(price, data) + return &v +} + +func finitePositive(v float64) bool { return v > 0 && !math.IsNaN(v) && !math.IsInf(v, 0) } + +func fetchCommodity(c Coin, days int) ([]Candle, commodityQuote, error) { + asset, ok := findCommodity(c.ID) + if !ok { + return nil, commodityQuote{}, fmt.Errorf("unsupported commodity") + } + interval := "1h" + if days == 1 { + interval = "15m" + } + now := time.Now() + params := url.Values{"interval": {interval}, "period1": {fmt.Sprint(now.Add(-time.Duration(days) * 24 * time.Hour).Unix())}, "period2": {fmt.Sprint(now.Unix())}} + var raw struct { + Chart struct { + Error *struct { + Description string `json:"description"` + } `json:"error"` + Result []struct { + Meta struct { + Price float64 `json:"regularMarketPrice"` + Previous *float64 `json:"previousClose"` + Time int64 `json:"regularMarketTime"` + Name string `json:"shortName"` + Currency string `json:"currency"` + } `json:"meta"` + Times []int64 `json:"timestamp"` + Indicators struct { + Quote []struct { + Open []*float64 `json:"open"` + High []*float64 `json:"high"` + Low []*float64 `json:"low"` + Close []*float64 `json:"close"` + } `json:"quote"` + } `json:"indicators"` + } `json:"result"` + } `json:"chart"` + } + if err := getJSON(yahooBase+"/v8/finance/chart/"+url.PathEscape(asset.Ticker)+"?"+params.Encode(), &raw, map[string]string{"User-Agent": "Mozilla/5.0"}); err != nil { + return nil, commodityQuote{}, err + } + if raw.Chart.Error != nil || len(raw.Chart.Result) == 0 { + return nil, commodityQuote{}, fmt.Errorf("Yahoo quote unavailable") + } + r := raw.Chart.Result[0] + if r.Meta.Currency != "USD" || !finitePositive(r.Meta.Price) || r.Meta.Time <= 0 { + return nil, commodityQuote{}, fmt.Errorf("invalid Yahoo quote") + } + quote := commodityQuote{Price: r.Meta.Price, Time: r.Meta.Time, Contract: r.Meta.Name} + // previousClose is the previous session reference, independent of chart range. + // chartPreviousClose is deliberately not used: it can refer to the range start. + if r.Meta.Previous != nil && finitePositive(*r.Meta.Previous) { + v := (quote.Price / *r.Meta.Previous - 1) * 100 + quote.Change = &v + } + out := []Candle{} + if len(r.Indicators.Quote) > 0 { + q := r.Indicators.Quote[0] + for i, ts := range r.Times { + if i >= len(q.Open) || i >= len(q.High) || i >= len(q.Low) || i >= len(q.Close) { + continue + } + if q.Open[i] == nil || q.High[i] == nil || q.Low[i] == nil || q.Close[i] == nil { + continue + } + o, h, l, c := *q.Open[i], *q.High[i], *q.Low[i], *q.Close[i] + if !finitePositive(o) || !finitePositive(h) || !finitePositive(l) || !finitePositive(c) || l > h || o < l || o > h || c < l || c > h { + continue + } + out = append(out, Candle{float64(ts) * 1000, o, h, l, c}) + } + } + // Session breaks remain gaps; no continuous-market trend badge is applied. + return trimCandles(out, len(out)), quote, nil +} + +func searchCommodities(q string) []struct{ Sym, Name, ID, Kind string } { + out := []struct{ Sym, Name, ID, Kind string }{} + q = strings.ToLower(strings.TrimSpace(q)) + for _, c := range commodityCatalog { + if strings.Contains(strings.ToLower(c.Sym+" "+c.Name+" "+c.Ticker), q) { + out = append(out, struct{ Sym, Name, ID, Kind string }{c.Sym, c.Name, c.ID, "commodity"}) + } + } + return out +} + +func validWallet(wallet string) bool { + if len(wallet) != 42 || !strings.HasPrefix(wallet, "0x") { + return false + } + for _, r := range wallet[2:] { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') { + return false + } + } + return true +} diff --git a/commodities_test.go b/commodities_test.go new file mode 100644 index 0000000..37db9d0 --- /dev/null +++ b/commodities_test.go @@ -0,0 +1,98 @@ +// Copyright 2026ff novatechflow (Alexander Alten) +// SPDX-License-Identifier: PolyForm-Shield-1.0.0 +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCommodityQuoteAndSessionHistory(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v8/finance/chart/GC=F" || r.URL.Query().Get("interval") != "15m" { + t.Errorf("unexpected request %s", r.URL) + } + w.Write([]byte(`{"chart":{"error":null,"result":[{"meta":{"currency":"USD","regularMarketPrice":2100,"regularMarketTime":1700000000,"previousClose":2000,"chartPreviousClose":1800,"shortName":"Gold Dec 26"},"timestamp":[100,200,300,400],"indicators":{"quote":[{"open":[2000,null,2010,2010],"high":[2020,null,2020,2000],"low":[1990,null,2000,2020],"close":[2010,null,2015,2010]}]}}]}}`)) + })) + defer srv.Close() + old := yahooBase + yahooBase = srv.URL + defer func() { yahooBase = old }() + cs, q, err := fetchCommodity(commodityCatalog[0].Coin, 1) + if err != nil { + t.Fatal(err) + } + if len(cs) != 2 || cs[1][0] != 300000 { + t.Fatalf("null and malformed bars not filtered: %v", cs) + } + if q.Change == nil || *q.Change < 4.99 || *q.Change > 5.01 || q.Contract != "Gold Dec 26" || q.Time != 1700000000 { + t.Fatalf("wrong session reference or quote: %+v", q) + } +} + +func TestCommodityRejectsUpstreamFailuresAndWrongCurrency(t *testing.T) { + for _, body := range []string{ + `{"chart":{"error":{"description":"not found"},"result":null}}`, + `{"chart":{"result":[{"meta":{"currency":"EUR","regularMarketPrice":2000,"regularMarketTime":100}}]}}`, + } { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) })) + old := yahooBase + yahooBase = srv.URL + _, _, err := fetchCommodity(commodityCatalog[0].Coin, 7) + yahooBase = old + srv.Close() + if err == nil { + t.Fatal("bad quote accepted") + } + } +} + +func TestCommoditySearchAndCryptoIsolation(t *testing.T) { + w := httptest.NewRecorder() + handleSearch(w, httptest.NewRequest("GET", "/api/search?type=commodity&q=gold", nil)) + var found []struct{ ID, Kind string } + if err := json.Unmarshal(w.Body.Bytes(), &found); err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].ID != "yahoo:GC=F" || found[0].Kind != "commodity" { + t.Fatalf("search=%s", w.Body.String()) + } + mixed := append([]Coin{{Sym: "BTC", ID: "bitcoin"}}, commodityCatalog[0].Coin) + cs := cryptoCoins(mixed) + if len(cs) != 1 || cs[0].ID != "bitcoin" { + t.Fatal("commodity reached crypto feed") + } +} + +func TestWalletCanBeClearedWithoutChangingWatchlist(t *testing.T) { + t.Chdir(t.TempDir()) + oldCfg, oldState, oldRev := cfg, state, configRevision + defer func() { cfg, state, configRevision = oldCfg, oldState, oldRev }() + wallet := "0x" + strings.Repeat("1", 40) + cfg = Config{Wallet: wallet, Coins: []Coin{{ID: "bitcoin", Sym: "BTC"}}} + state = State{Wallet: wallet, Positions: []Position{{Title: "old"}}, Pnl: &PnL{}} + w := httptest.NewRecorder() + handleConfig(w, httptest.NewRequest("POST", "/api/config", strings.NewReader(`{"wallet":""}`))) + if w.Code != 200 || cfg.Wallet != "" || state.Wallet != "" || len(state.Positions) != 0 || state.Pnl != nil || len(cfg.Coins) != 1 { + t.Fatalf("wallet clear failed: %d", w.Code) + } + if loadConfig().Wallet != "" { + t.Fatal("wallet persisted") + } + cfg.Wallet = wallet + w = httptest.NewRecorder() + handleConfig(w, httptest.NewRequest("POST", "/api/config", strings.NewReader(`{"candleDays":7}`))) + if cfg.Wallet != wallet { + t.Fatal("omitted wallet unexpectedly cleared") + } + for _, body := range []string{`{"wallet":"bad"}`, `{"coins":[{"id":"yahoo:GC=F"}]}`, `{"coins":[{"id":"yahoo:FAKE","kind":"commodity"}]}`} { + w = httptest.NewRecorder() + handleConfig(w, httptest.NewRequest("POST", "/api/config", strings.NewReader(body))) + if w.Code != 400 { + t.Fatalf("bad config accepted %s", body) + } + } +} diff --git a/index.html b/index.html index 3cd9444..8837d5f 100644 --- a/index.html +++ b/index.html @@ -18,7 +18,7 @@ Copyright 2026ff novatechflow (Alexander Alten) SPDX-License-Identifier: PolyForm-Shield-1.0.0 - polyDisplay - LAN web app: Polymarket positions + crypto watchlist. + polyDisplay - LAN web app: Crypto and commodity watchlist with optional Polymarket. The browser talks ONLY to the local Go server (/api/*): it aggregates Polymarket + crypto (Kraken-first, Coinbase fallback) so the display never hits an external API, rate limit, or geoblock. @@ -51,6 +51,26 @@ .gear{font-size:22px;color:var(--muted);background:none;border:none;cursor:pointer;padding:4px 6px;} main{display:flex;flex:1 1 auto;min-height:0;} .col{flex:1 1 50%;min-width:0;display:flex;flex-direction:column;} + main.assets-only .col.left{display:none;} + main.assets-only .col.right{flex-basis:100%;} + #cxList{display:grid;grid-template-columns:1fr;align-content:start;gap:12px;padding:12px 16px 20px;} + #cxList .coin{margin:0;border-radius:16px;padding:16px;overflow:hidden;} + #cxList .empty{grid-column:1/-1;} + main.assets-only #cxList{grid-template-columns:repeat(auto-fit,minmax(300px,1fr));} + .coin .top{flex-wrap:wrap;gap:10px;} + .coin .quote{display:flex;align-items:baseline;gap:8px;} + .asset-meta{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-top:12px;font-size:11px;color:var(--muted);} + .asset-kind{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--accent);margin-bottom:8px;} + @media(min-width:1500px){main.assets-only #cxList{grid-template-columns:repeat(3,minmax(0,1fr));}} + @media(max-width:650px){ + main{display:block;overflow-y:auto;} + .col{min-height:240px;} + .col.left{border-right:0;border-bottom:1px solid var(--line);max-height:48vh;} + .list{overflow-y:visible;flex:none;} + .col.left .list{overflow-y:auto;flex:1;} + main.assets-only #cxList{grid-template-columns:1fr;} + .coin .val{font-size:26px;} + } .col.left{border-right:1px solid var(--line);} .col-head{display:flex;align-items:center;justify-content:space-between;padding:10px 16px 6px;flex:0 0 auto;} .col-head h2{margin:0;font-size:15px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:1px;} @@ -157,7 +177,7 @@
-
+

Polymarket

—
Connecting to server…
@@ -173,14 +193,15 @@ @@ -531,6 +553,15 @@

Unlock pad

'
'; } +function commodityChart(data,w,h,days){ + if(!data||data.length<2) return '
Waiting for commodity history
'; + var m=chartMetrics(data), points=[]; + for(var i=0;i'+ + ''+ + '
'+axisTime(data[0][0],days)+''+axisTime(data[data.length-1][0],days)+'
'; +} + /* ---------- auth: PIN once, then bearer in localStorage ---------- */ var LS_A="pd_access", LS_R="pd_refresh"; function getAccess(){ try{ return localStorage.getItem(LS_A)||""; }catch(e){ return ""; } } @@ -635,6 +666,7 @@

Unlock pad

} function render(s){ + $("dashboard").className=(s.wallet||"").trim()?"":"assets-only"; if(s.version) $("appVer").textContent=s.version+" · server"; var rows=s.positions||[], html=""; if(!(s.wallet||"").trim()){ @@ -668,17 +700,23 @@

Unlock pad

$("pmList").innerHTML=html; var coins=s.coins||[], cl=""; - if(!coins.length){ cl='
No assets. Tap ⚙ to add.
'; } + if(!coins.length){ cl='
'+(s.loading?"Refreshing watchlist…":"No assets. Tap ⚙ to add.")+'
'; } for(var k=0;k'+esc(sourceName[c.source]||c.source)+''):""; - cl+='
'+ + var commodity=c.kind==="commodity"; + var sourceName={kraken:"Kraken · USD",coinbase:"Coinbase · USD",binance:"Binance · USDT",yahoo:"Yahoo · USD"}; + var quoteTime=c.quoteTime?new Date(c.quoteTime*1000).toLocaleString():""; + var freshness=c.marketError?"Unavailable":(!c.quoteTime?"Waiting for quote":(Date.now()/1000-c.quoteTime>1800?"Delayed · last quote":"Delayed quote")); + cl+='
'+ + '
'+(commodity?'Commodity · futures':'Crypto')+'
'+ '
'+esc(c.sym)+''+esc(c.name)+'
'+ - '
'+(c.price>0?fmtUsd(c.price):"—")+''+ - ''+(c.chg24h!=null?fmtPct(c.chg24h)+" 24h":"")+'
'+ - chartWithAxis(c.candles,320,72,s.candleDays,c.price,c.candlesUpdated)+tag+ - (c.marketError?'
Market data unavailable: '+esc(c.marketError)+'
':"")+'
'; + '
'+(c.price>0?fmtUsd(c.price):"—")+''+ + ''+(c.chg24h!=null?fmtPct(c.chg24h)+(commodity?" session":" 24h"):"")+'
'+ + (commodity?commodityChart(c.candles,320,96,s.candleDays):chartWithAxis(c.candles,320,96,s.candleDays,c.price,c.candlesUpdated))+ + '
'+esc(sourceName[c.source]||c.source||"Connecting")+(c.unit?' / '+esc(c.unit):'')+''+ + (commodity?''+esc(freshness)+(quoteTime?' · '+esc(quoteTime):'')+'':'')+'
'+ + (c.contract?'
'+esc(c.contract)+'
':"")+ + (c.marketError?'
Market data unavailable: '+esc(c.marketError)+'
':"")+'
'; } $("cxList").innerHTML=cl; @@ -720,14 +758,17 @@

Unlock pad

function onSearch(){ var q=$("searchInput").value.trim(); if(searchTimer)clearTimeout(searchTimer); - if(!q){ $("results").innerHTML=""; return; } + $("results").innerHTML=""; + if(!q && $("assetType").value!=="commodity") return; searchTimer=setTimeout(function(){ - api("/api/search?q="+encodeURIComponent(q), {}, function(res){ + var searchType=$("assetType").value; + api("/api/search?type="+searchType+"&q="+encodeURIComponent(q), {}, function(res){ if(!res||!res.ok){ $("results").innerHTML='
Search failed.
'; return; } res.json().then(function(list){ + if(!draftCoins || searchType!==$("assetType").value || q!==$("searchInput").value.trim()) return; list=list||[]; var h=""; for(var i=0;i'+ + h+='
'+ ''+esc((list[i].Sym||"").toUpperCase())+' · '+esc(list[i].Name)+'
'; } $("results").innerHTML=h||'
No matches.
'; @@ -735,7 +776,7 @@

Unlock pad

for(var k=0;kUnlock pad $("saveBtn").onclick=saveSettings; $("reloadBtn").onclick=purgeAndReload; $("searchInput").oninput=onSearch; +$("assetType").onchange=function(){ + $("searchInput").placeholder=this.value==="commodity"?"e.g. gold, oil, silver":"e.g. bitcoin, sol, chainlink"; + onSearch(); +}; $("modal").onclick=function(e){ if(e.target===this) closeSettings(); }; /* ---------- pull-to-refresh (drag a column down at the top to reload) ---------- */ diff --git a/install.sh b/install.sh index 278c2a8..615d561 100755 --- a/install.sh +++ b/install.sh @@ -109,12 +109,12 @@ ask_wallet() { read -r -p "Polymarket public address (0x..., empty to keep): " w [ -n "$w" ] || w="$current" else - read -r -p "Polymarket public address (0x...): " w + read -r -p "Polymarket public address (0x..., optional; empty for assets only): " w fi else w="$current" fi - is_wallet "$w" || die "need a Polymarket public address 0x + 40 hex (or set POLYMARKET_WALLET)" + [ -z "$w" ] || is_wallet "$w" || die "invalid Polymarket public address 0x + 40 hex (or set POLYMARKET_WALLET)" WALLET="$w" } diff --git a/media/polyDisplay-live.png b/media/polyDisplay-live.png index e68125a..bbd03a4 100644 Binary files a/media/polyDisplay-live.png and b/media/polyDisplay-live.png differ diff --git a/server.go b/server.go index 979953b..b33050b 100644 --- a/server.go +++ b/server.go @@ -38,6 +38,7 @@ var serverVersion string /* ------------------------- config ------------------------- */ type Coin struct { + Kind string `json:"kind,omitempty"` // empty remains crypto for existing configs Sym string `json:"sym"` Name string `json:"name"` ID string `json:"id"` // CoinGecko id @@ -218,13 +219,17 @@ func saveConfig(c Config) { type Candle [5]float64 // [openTimeMs, open, high, low, close] type CoinState struct { + Kind string `json:"kind,omitempty"` + Unit string `json:"unit,omitempty"` + Contract string `json:"contract,omitempty"` + QuoteTime int64 `json:"quoteTime,omitempty"` CandlesUpdated int64 `json:"candlesUpdated"` MarketError string `json:"marketError,omitempty"` Sym string `json:"sym"` Name string `json:"name"` ID string `json:"id"` Price float64 `json:"price"` - Chg24h float64 `json:"chg24h"` + Chg24h *float64 `json:"chg24h"` Source string `json:"source"` // "kraken" | "coinbase" | "binance" | "" Active bool `json:"active"` // referenced by a current Polymarket position Candles []Candle `json:"candles"` @@ -272,6 +277,7 @@ type PnL struct { } type State struct { + Loading bool `json:"loading,omitempty"` Version string `json:"version"` Updated int64 `json:"updated"` Wallet string `json:"wallet"` @@ -1132,7 +1138,7 @@ func refreshFast() { // Explicit selection never silently mixes providers. Auto retains fallback. prices := map[string]float64{} if c.MarketProvider == "" || c.MarketProvider == "auto" || c.MarketProvider == "kraken" { - prices, _ = fetchKrakenPrices(c.Coins) + prices, _ = fetchKrakenPrices(cryptoCoins(c.Coins)) } if prices == nil { prices = map[string]float64{} @@ -1141,7 +1147,7 @@ func refreshFast() { var priceWG sync.WaitGroup var missing []Coin for _, cn := range c.Coins { - if prices[cn.ID] == 0 && c.MarketProvider != "kraken" { + if cn.Kind != "commodity" && prices[cn.ID] == 0 && c.MarketProvider != "kraken" { missing = append(missing, cn) } } @@ -1178,12 +1184,24 @@ func refreshFast() { coinStates := make([]CoinState, 0, len(c.Coins)) for _, cn := range sortedCoins(c.Coins, c.Sort, active) { p := marketPrice[cn.ID] - coinStates = append(coinStates, CoinState{ + coinState := CoinState{ Sym: cn.Sym, Name: cn.Name, ID: cn.ID, - Price: p, Chg24h: candleChange24(p, cand24[cn.ID]), + Price: p, Chg24h: cryptoChange(p, cand24[cn.ID]), CandlesUpdated: candleUpdated[cn.ID], MarketError: candleErrors[cn.ID], Source: csource[cn.ID], Active: active[cn.ID], Candles: candles[cn.ID], Cand24: cand24[cn.ID], - }) + } + if cn.Kind == "commodity" { + asset, _ := findCommodity(cn.ID) + quote := commodityQuotes[cn.ID] + coinState.Kind = "commodity" + coinState.Source = "yahoo" + coinState.Unit = asset.Unit + coinState.Contract = quote.Contract + coinState.QuoteTime = quote.Time + coinState.Price = quote.Price + coinState.Chg24h = quote.Change + } + coinStates = append(coinStates, coinState) } state = State{ Version: strings.TrimSpace(serverVersion), @@ -1210,6 +1228,25 @@ func refreshSlow() { mu.RUnlock() for _, cn := range c.Coins { + if cn.Kind == "commodity" { + cs, quote, err := fetchCommodity(cn, c.CandleDays) + mu.Lock() + if revision != configRevision { + mu.Unlock() + return + } + if err != nil { + candleErrors[cn.ID] = err.Error() + } else { + delete(candleErrors, cn.ID) + commodityQuotes[cn.ID] = quote + candles[cn.ID] = cs + candleUpdated[cn.ID] = time.Now().UnixMilli() + } + mu.Unlock() + time.Sleep(1100 * time.Millisecond) + continue + } cs, src, err := fetchSelectedCandles(cn, c.CandleDays, c.MarketProvider) if err != nil { log.Printf("market candles %s: %v", cn.Sym, err) @@ -1279,7 +1316,10 @@ func handleState(w http.ResponseWriter, r *http.Request) { func handleConfig(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { - var nc Config + var nc struct { + Config + Wallet *string `json:"wallet"` + } if err := json.NewDecoder(r.Body).Decode(&nc); err != nil { http.Error(w, "bad json", 400) return @@ -1293,13 +1333,40 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, "unsupported candle period", http.StatusBadRequest) return } + seen := map[string]bool{} + for i, asset := range nc.Coins { + if asset.ID == "" || seen[asset.ID] { + http.Error(w, "invalid or duplicate asset", 400) + return + } + seen[asset.ID] = true + if asset.Kind == "commodity" { + c, ok := findCommodity(asset.ID) + if !ok { + http.Error(w, "unsupported commodity", 400) + return + } + nc.Coins[i] = c.Coin + } else if (asset.Kind != "" && asset.Kind != "crypto") || strings.HasPrefix(asset.ID, "yahoo:") { + http.Error(w, "unsupported asset kind", 400) + return + } + } + if nc.Wallet != nil { + wallet := strings.TrimSpace(*nc.Wallet) + if wallet != "" && !validWallet(wallet) { + http.Error(w, "invalid wallet address", 400) + return + } + } + mu.Lock() configRevision++ if nc.MarketProvider != "" { cfg.MarketProvider = nc.MarketProvider } - if nc.Wallet != "" { - cfg.Wallet = strings.TrimSpace(nc.Wallet) + if nc.Wallet != nil { + cfg.Wallet = strings.TrimSpace(*nc.Wallet) } if nc.CandleDays != 0 { cfg.CandleDays = nc.CandleDays @@ -1311,7 +1378,12 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { cfg.Coins = nc.Coins } saved := cfg + state.Wallet = cfg.Wallet + state.Positions = nil + state.Activity = nil + state.Pnl = nil state.Coins = nil + state.Loading = len(cfg.Coins) > 0 state.CandleDays = cfg.CandleDays candles = map[string][]Candle{} cand24 = map[string][]Candle{} @@ -1319,6 +1391,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { candleErrors = map[string]string{} candleUpdated = map[string]int64{} marketPrice = map[string]float64{} + commodityQuotes = map[string]commodityQuote{} mu.Unlock() saveConfig(saved) select { @@ -1336,6 +1409,11 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { // proxy CoinGecko search so the browser never calls out directly func handleSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") + if r.URL.Query().Get("type") == "commodity" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(searchCommodities(q)) + return + } if q == "" { w.Write([]byte("[]")) return diff --git a/server_test.go b/server_test.go index 8911474..778b4e8 100644 --- a/server_test.go +++ b/server_test.go @@ -662,15 +662,19 @@ func TestDefaultConfigHasNoWallet(t *testing.T) { } func TestEmbeddedServerVersion(t *testing.T) { - if got := strings.TrimSpace(serverVersion); got != "v21" { - t.Fatalf("server version = %q, want v21", got) + version, err := os.ReadFile("VERSION") + if err != nil { + t.Fatal(err) + } + if got, want := strings.TrimSpace(serverVersion), strings.TrimSpace(string(version)); got == "" || got != want { + t.Fatalf("server version = %q, want %q", got, want) } b, err := os.ReadFile("index.html") if err != nil { t.Fatal(err) } html := string(b) - if strings.Contains(html, `id="appVer">v21`) { + if strings.Contains(html, `id="appVer">v`) { t.Error("client version is hard-coded instead of coming from server state") } if !strings.Contains(html, `$("appVer").textContent=s.version+" · server"`) { @@ -809,8 +813,8 @@ func TestRefreshFastSkipsPolymarketWithoutWallet(t *testing.T) { if state.Wallet != "" || state.Note != "" { t.Errorf("wallet=%q note=%q, want both empty", state.Wallet, state.Note) } - if state.Version != "v21" { - t.Errorf("state version=%q, want v21", state.Version) + if want := strings.TrimSpace(serverVersion); state.Version != want { + t.Errorf("state version=%q, want %q", state.Version, want) } } diff --git a/tests/chart.test.cjs b/tests/chart.test.cjs index bb7bd5b..b233aa9 100644 --- a/tests/chart.test.cjs +++ b/tests/chart.test.cjs @@ -75,3 +75,11 @@ test('candle geometry preserves elapsed time across missing intervals', () => { const x0 = ctx.chartX(m, 0, 300), x1 = ctx.chartX(m, 1, 300), x2 = ctx.chartX(m, 2, 300); assert.ok(Math.abs((x2 - x1) / (x1 - x0) - 3) < 1e-10); }); +test('commodity chart preserves session gaps without crypto trend signals', () => { + const data = [[1000,10,12,9,11],[2000,11,13,10,12],[10000,12,14,11,13]]; + const chart = ctx.commodityChart(data,320,96,1); + assert.match(chart,/polyline/); + assert.doesNotMatch(chart,/trend|Unavailable|stroke-dasharray/); + const points = chart.match(/points="([^"]+)"/)[1].split(' ').map(p => p.split(',').map(Number)); + assert.ok(points[2][0]-points[1][0] > 7*(points[1][0]-points[0][0]), 'session gap must occupy elapsed time'); +});