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
33 changes: 24 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
└─────────────────────────────┘
```
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v21
v22
162 changes: 162 additions & 0 deletions commodities.go
Original file line number Diff line number Diff line change
@@ -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
}
98 changes: 98 additions & 0 deletions commodities_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading