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