diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed3684a..77ca387 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,6 +14,10 @@ jobs:
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - run: node --test tests/chart.test.cjs
- name: gofmt
run: test -z "$(gofmt -l .)"
- run: go vet ./...
diff --git a/README.md b/README.md
index aee69d3..23a3913 100644
--- a/README.md
+++ b/README.md
@@ -129,7 +129,21 @@ Logs: `polydisplay.log` in the working directory. Rolled at local midnight to
## Data
-Candles and prices: Kraken, with Coinbase fallback. Kraken ticker prices are
+Candles and prices: choose Auto, Kraken, Binance, or Coinbase in Settings.
+Auto uses Kraken with Coinbase fallback; explicit selections use only that
+exchange. Kraken and Coinbase use USD; Binance uses USDT pairs and may be
+unavailable in some regions or for some assets. All sources use the same candle
+intervals: 15 minutes (1 day), 1 hour (7 days), and 4 hours (14/30 days). Coinbase
+hourly candles are paginated and combined into complete UTC-aligned 4-hour buckets.
+
+The trend badge follows the selected period, as do the swing/channel overlays.
+Both analyze closed candles only; the current candle and live spot remain visible.
+Incomplete, gapped, invalid, or stale history shows an unavailable verdict and
+suppresses channel overlays. Trend direction compares high/low extremes across
+thirds of the period with a deadband of 3.5 median candle ranges; channel lines
+fit the confirmed swing highs and lows.
+
+Kraken ticker prices are
fetched in one request; OHLC requests are paced to its public API guidance.
Positions and activity: Polymarket data-api, independently polled every 30s
and 1 min without request bursts. Account P/L: Polymarket user-pnl-api, 720
diff --git a/index.html b/index.html
index 235569d..3cd9444 100644
--- a/index.html
+++ b/index.html
@@ -180,6 +180,13 @@
Settings
1 day 7 days
14 days 30 days
+ Market data source
+
+ Auto (Kraken → Coinbase)
+ Kraken (USD)
+ Binance (USDT)
+ Coinbase (USD)
+
Sort watchlist
Traded assets first
@@ -196,7 +203,7 @@ Settings
Save & reload
Reload app · clear cache
- Saved on the server (config.json). Data source: Kraken → Coinbase fallback.
+ Applies to the whole watchlist. Auto uses Kraken with Coinbase fallback. Binance uses USDT pairs; availability depends on your connection and the asset.
@@ -314,18 +321,21 @@ Unlock pad
var sp=(spot!=null && isFinite(spot) && spot>0) ? +spot : last;
/* plot range covers spot so a breakout past the window stays on canvas */
var pLo=Math.min(lo,sp), pHi=Math.max(hi,sp);
- return {lo:lo,hi:hi,iHi:iHi,iLo:iLo,spot:sp,n:data.length,
+ return {lo:lo,hi:hi,iHi:iHi,iLo:iLo,spot:sp,n:data.length,times:data.map(function(c){return c[0];}),
plotLo:pLo,plotRange:(pHi-pLo)||1};
}
function chartY(m,v,h){ return h-2-((v-m.plotLo)/m.plotRange)*(h-4); }
-function chartX(m,i,w){ var cw=w/m.n; return i*cw+cw/2; }
+function chartX(m,i,w){
+ var cw=w/m.n, span=m.times[m.n-1]-m.times[0];
+ return span>0 ? cw/2+(w-cw)*(m.times[i]-m.times[0])/span : i*cw+cw/2;
+}
/* ---------- market structure (higher highs / lower lows) ---------- */
/* a high or low must move this many typical candle ranges to score a step.
Measured against candle size rather than window range: a trend inflates
its own window range, which would raise the bar the stronger it gets. */
-var TREND_DEADBAND_ATR=3.5;
+var TREND_DEADBAND_RANGE=3.5;
function segExtremes(data,from,to){
var hi=-Infinity,lo=Infinity;
@@ -349,7 +359,7 @@ Unlock pad
if(!data||data.length<9) return null;
var n=data.length, a=Math.floor(n/3), b=Math.floor(2*n/3);
var s1=segExtremes(data,0,a), s2=segExtremes(data,a,b), s3=segExtremes(data,b,n);
- var dead=medianCandleRange(data)*TREND_DEADBAND_ATR;
+ var dead=medianCandleRange(data)*TREND_DEADBAND_RANGE;
var H=stepSign(s1.hi,s2.hi,dead)+stepSign(s2.hi,s3.hi,dead)+stepSign(s1.hi,s3.hi,dead);
var L=stepSign(s1.lo,s2.lo,dead)+stepSign(s2.lo,s3.lo,dead)+stepSign(s1.lo,s3.lo,dead);
if(H>0&&L<0) return {label:"Expanding",cls:"flat",why:"higher highs · lower lows"};
@@ -384,19 +394,39 @@ Unlock pad
}
function pivotK(n){ return Math.max(2,Math.round(n/20)); }
+function periodStructure(data,days,now,updated){
+ var interval=(days<=1 ? 15 : days<=7 ? 60 : 240)*60000;
+ var closed=[], reason="", expected=days*86400000/interval;
+ for(var i=0;iMath.min(c[1],c[4]) ||
+ c[0]%interval!==0 || (i && c[0]-data[i-1][0]!==interval)){
+ reason="invalid or missing candles"; break;
+ }
+ if(c[0]+interval<=now) closed.push(c);
+ }
+ if(!reason && updated && isFinite(updated) && now-updated>900000) reason="stale candles";
+ if(!reason && closed.lengthinterval+600000) reason="stale candles";
+ if(reason) return {data:[],trend:{label:"Unavailable",cls:"flat",why:reason}};
+ return {data:closed,trend:trendVerdict(closed)};
+}
+
/* candlestick chart (tokens only) */
-function candleChart(data,w,h,m){
+function candleChart(data,w,h,m,structure){
var n=m.n, cw=w/n, bw=Math.max(1.2,cw*0.62);
function Y(v){ return chartY(m,v,h); }
var s='';
for(var j=0;j=o, col=up?"var(--green)":"var(--red)";
+ var x=chartX(m,j,w), up=c>=o, col=up?"var(--green)":"var(--red)";
s+=' ';
var yo=Y(o),yc=Y(c),top=Math.min(yo,yc),bh=Math.max(1,Math.abs(yo-yc));
s+=' ';
}
- var pv=swingPivots(data,pivotK(n)), dr=Math.max(1,cw*0.3);
+ var analyzed=structure || data;
+ var pv=swingPivots(analyzed,pivotK(analyzed.length)), dr=Math.max(1,cw*0.3);
for(var q=0;qUnlock pad
/* keep a label anchored to its candle without letting it run off either edge */
function labelLeft(m,i,w){ return Math.max(8,Math.min(92,(chartX(m,i,w)/w)*100)); }
-function chartWithAxis(data,w,h,days,spot,trendData){
+function chartWithAxis(data,w,h,days,spot,updated){
if(!data||data.length<2) return ' ';
var m=chartMetrics(data,spot), labels="";
var n=m.n; days=days||7;
- for(var f=0;f<=3;f++){ var idx=Math.round((n-1)*f/3); labels+=''+axisTime(data[idx][0],days)+' '; }
+ for(var f=0;f<=3;f++){
+ var time=data[0][0]+(data[n-1][0]-data[0][0])*f/3;
+ labels+=''+axisTime(time,days)+' ';
+ }
var spotTop=Math.max(0,Math.min(h-11,chartY(m,m.spot,h)-6));
var hiTop=Math.max(0,chartY(m,m.hi,h)-11);
var loTop=Math.min(h-11,chartY(m,m.lo,h)+2);
- /* the chip reads 24h; the drawn pattern stays on the displayed period */
- var tr=trendVerdict(trendData&&trendData.length>=9 ? trendData : data);
- var trWin=(trendData&&trendData.length>=9) ? "24h" : days+"d";
+ /* Badge and channel pivots share the selected period's closed candles. */
+ var structure=periodStructure(data,days,Date.now(),updated), tr=structure.trend;
+ var trWin=days===1 ? "24h" : days+"d";
var trHtml=tr?(''+tr.label+' '+
''+esc(tr.why)+' · '+trWin+'
'):"";
return ''+
'
▲ '+fmtUsd(m.hi)+'
'+
'
▼ '+fmtUsd(m.lo)+'
'+
'
'+fmtUsd(m.spot)+'
'+
- candleChart(data,w,h,m)+
+ candleChart(data,w,h,m,structure.data)+
'
'+labels+'
'+trHtml+
'
';
}
@@ -638,12 +671,14 @@ Unlock pad
if(!coins.length){ cl='No assets. Tap ⚙ to add.
'; }
for(var k=0;k'+(c.source==="kraken"?"Kraken":"Coinbase")+''):"";
+ var sourceName={kraken:"Kraken · USD",coinbase:"Coinbase · USD",binance:"Binance · USDT"};
+ var tag=c.source?(''+esc(sourceName[c.source]||c.source)+' '):"";
cl+=''+
'
'+esc(c.sym)+' '+esc(c.name)+'
'+
- '
'+fmtUsd(c.price)+' '+
+ '
'+(c.price>0?fmtUsd(c.price):"—")+' '+
''+(c.chg24h!=null?fmtPct(c.chg24h)+" 24h":"")+'
'+
- chartWithAxis(c.candles,320,72,s.candleDays,c.price,c.cand24)+tag+'
';
+ chartWithAxis(c.candles,320,72,s.candleDays,c.price,c.candlesUpdated)+tag+
+ (c.marketError?'
Market data unavailable: '+esc(c.marketError)+'
':"")+'
';
}
$("cxList").innerHTML=cl;
@@ -662,6 +697,7 @@ Unlock pad
draftCfg=c; draftCoins=(c.coins||[]).slice();
$("walletInput").value=c.wallet||"";
$("daysSel").value=String(c.candleDays||7);
+ $("providerSel").value=c.marketProvider||"auto";
$("sortSel").value=c.sort||"trades";
renderChips(); $("searchInput").value=""; $("results").innerHTML="";
$("modal").className="modal show";
@@ -707,7 +743,7 @@ Unlock pad
},350);
}
function saveSettings(){
- var body={ wallet:$("walletInput").value.trim(), candleDays:+$("daysSel").value||7, sort:$("sortSel").value||"trades", coins:draftCoins||[] };
+ var body={ wallet:$("walletInput").value.trim(), candleDays:+$("daysSel").value||7, marketProvider:$("providerSel").value||"auto", sort:$("sortSel").value||"trades", coins:draftCoins||[] };
api("/api/config",{method:"POST",json:true,body:JSON.stringify(body)}, function(res){
if(!res||res.status===401){ showPin(""); return; }
if(!res.ok){ showErr("Save failed"); return; }
diff --git a/server.go b/server.go
index 1cc98b2..979953b 100644
--- a/server.go
+++ b/server.go
@@ -45,11 +45,12 @@ type Coin struct {
}
type Config struct {
- Wallet string `json:"wallet"`
- CandleDays int `json:"candleDays"`
- Port int `json:"port"`
- Sort string `json:"sort"` // "az" (symbol A-Z) | "config" (as added)
- Coins []Coin `json:"coins"`
+ MarketProvider string `json:"marketProvider"`
+ Wallet string `json:"wallet"`
+ CandleDays int `json:"candleDays"`
+ Port int `json:"port"`
+ Sort string `json:"sort"` // "az" (symbol A-Z) | "config" (as added)
+ Coins []Coin `json:"coins"`
}
const configPath = "config.json"
@@ -57,9 +58,10 @@ const envPath = ".env"
func defaultConfig() Config {
return Config{
- Wallet: "",
- CandleDays: 1,
- Sort: "trades",
+ MarketProvider: "auto",
+ Wallet: "",
+ CandleDays: 1,
+ Sort: "trades",
}
}
@@ -150,6 +152,9 @@ func loadConfig() Config {
c = defaultConfig()
}
c.Port = listenPort(c.Port)
+ if !validMarketProvider(c.MarketProvider) {
+ c.MarketProvider = "auto"
+ }
if c.CandleDays == 0 {
c.CandleDays = 7
}
@@ -213,15 +218,17 @@ func saveConfig(c Config) {
type Candle [5]float64 // [openTimeMs, open, high, low, close]
type CoinState struct {
- Sym string `json:"sym"`
- Name string `json:"name"`
- ID string `json:"id"`
- Price float64 `json:"price"`
- Chg24h float64 `json:"chg24h"`
- Source string `json:"source"` // "kraken" | "coinbase" | ""
- Active bool `json:"active"` // referenced by a current Polymarket position
- Candles []Candle `json:"candles"`
- Cand24 []Candle `json:"cand24"` // last 24h, whatever the display period
+ 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"`
+ Source string `json:"source"` // "kraken" | "coinbase" | "binance" | ""
+ Active bool `json:"active"` // referenced by a current Polymarket position
+ Candles []Candle `json:"candles"`
+ Cand24 []Candle `json:"cand24"` // last 24h, whatever the display period
}
type Position struct {
@@ -277,14 +284,17 @@ type State struct {
}
var (
- mu sync.RWMutex
- cfg Config
- state State
- candles = map[string][]Candle{} // id -> candles (refreshed slowly)
- cand24 = map[string][]Candle{} // id -> last 24h, for the trend read
- csource = map[string]string{} // id -> candle source
- marketPrice = map[string]float64{} // id -> last good spot price
- slowMu sync.Mutex
+ mu sync.RWMutex
+ configRevision uint64
+ cfg Config
+ state State
+ candles = map[string][]Candle{} // id -> candles (refreshed slowly)
+ cand24 = map[string][]Candle{} // id -> last 24h, for daily price change
+ candleErrors = map[string]string{}
+ candleUpdated = map[string]int64{}
+ csource = map[string]string{} // id -> candle source
+ marketPrice = map[string]float64{} // id -> last good spot price
+ slowMu sync.Mutex
// Fresh connection per request: a VPN's short idle timeout was dropping the
// pooled keep-alive connections during the 20s gap between cycles, so the
// first couple of requests each cycle failed (BTC/ETH showed price 0).
@@ -478,8 +488,80 @@ func cgHeaders() map[string]string {
var (
krakenBase = "https://api.kraken.com"
coinbaseBase = "https://api.exchange.coinbase.com"
+ binanceBase = "https://api.binance.com"
)
+func validMarketProvider(provider string) bool {
+ return provider == "auto" || provider == "kraken" || provider == "coinbase" || provider == "binance"
+}
+
+func binanceSymbol(c Coin) string { return marketSymbol(c) + "USDT" }
+
+func fetchBinanceCandles(c Coin, days int) ([]Candle, error) {
+ interval, limit := candleParams(days, "kraken")
+ intervalName := "15m"
+ if interval == 60 {
+ intervalName = "1h"
+ }
+ if interval == 240 {
+ intervalName = "4h"
+ }
+ q := url.Values{"symbol": {binanceSymbol(c)}, "interval": {intervalName}, "limit": {strconv.Itoa(limit)}}
+ var rows [][]interface{}
+ if err := getJSON(binanceBase+"/api/v3/klines?"+q.Encode(), &rows, nil); err != nil {
+ return nil, err
+ }
+ var out []Candle
+ for _, row := range rows {
+ if len(row) < 5 {
+ return nil, fmt.Errorf("invalid Binance candle")
+ }
+ var candle Candle
+ for i := 0; i < 5; i++ {
+ v, err := strconv.ParseFloat(fmt.Sprint(row[i]), 64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Binance candle: %w", err)
+ }
+ candle[i] = v
+ }
+ out = append(out, candle)
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("empty")
+ }
+ return trimCandles(out, limit), nil
+}
+
+func fetchSelectedCandles(c Coin, days int, provider string) ([]Candle, string, error) {
+ var candles []Candle
+ var err error
+ switch provider {
+ case "kraken":
+ candles, err = fetchKrakenCandles(c, days)
+ case "coinbase":
+ candles, err = fetchCoinbaseCandles(c, days)
+ case "binance":
+ candles, err = fetchBinanceCandles(c, days)
+ default:
+ return fetchMarketCandles(c, days)
+ }
+ return candles, provider, err
+}
+
+func fetchBinancePrice(c Coin) (float64, error) {
+ var raw struct {
+ Price string `json:"price"`
+ }
+ if err := getJSON(binanceBase+"/api/v3/ticker/price?symbol="+url.QueryEscape(binanceSymbol(c)), &raw, nil); err != nil {
+ return 0, err
+ }
+ p, err := strconv.ParseFloat(raw.Price, 64)
+ if err != nil || p <= 0 {
+ return 0, fmt.Errorf("no price")
+ }
+ return p, nil
+}
+
func marketSymbol(c Coin) string {
s := strings.ToUpper(strings.TrimSpace(c.Sym))
if c.Bn != "" { // accept old fourth-field values such as WIFUSDT
@@ -506,9 +588,9 @@ func candleParams(days int, provider string) (interval, limit int) {
if provider == "kraken" {
switch {
case days <= 1:
- return 30, 48
+ return 15, 96
case days <= 7:
- return 240, 42
+ return 60, 168
case days <= 14:
return 240, 84
default:
@@ -521,9 +603,9 @@ func candleParams(days int, provider string) (interval, limit int) {
case days <= 7:
return 3600, 168
case days <= 14:
- return 21600, 56
+ return 3600, 336
default:
- return 21600, 120
+ return 3600, 720
}
}
@@ -578,29 +660,83 @@ func fetchKrakenCandles(c Coin, days int) ([]Candle, error) {
}
func fetchCoinbaseCandles(c Coin, days int) ([]Candle, error) {
- granularity, limit := candleParams(days, "coinbase")
+ granularity, _ := candleParams(days, "coinbase")
+ interval, limit := candleParams(days, "kraken")
+ bucketSeconds := int64(interval * 60)
end := time.Now().UTC()
- q := url.Values{}
- q.Set("granularity", strconv.Itoa(granularity))
- q.Set("start", end.Add(-time.Duration(days)*24*time.Hour).Format(time.RFC3339))
- q.Set("end", end.Format(time.RFC3339))
- u := coinbaseBase + "/products/" + url.PathEscape(coinbaseProduct(c)) + "/candles?" + q.Encode()
- var raw [][]float64
- if err := getJSON(u, &raw, nil); err != nil {
- return nil, err
- }
- out := make([]Candle, 0, len(raw))
- for _, k := range raw {
- if len(k) >= 5 {
- out = append(out, Candle{k[0] * 1000, k[3], k[2], k[1], k[4]})
+ start := time.Unix(end.Unix()/bucketSeconds*bucketSeconds-int64(days)*86400, 0).UTC()
+ var out []Candle
+ for from := start; from.Before(end); {
+ // Leave room for an inclusive endpoint within Coinbase's 300-candle cap.
+ to := from.Add(time.Duration(299*granularity) * time.Second)
+ if to.After(end) {
+ to = end
+ }
+ q := url.Values{}
+ q.Set("granularity", strconv.Itoa(granularity))
+ q.Set("start", from.Format(time.RFC3339))
+ q.Set("end", to.Format(time.RFC3339))
+ u := coinbaseBase + "/products/" + url.PathEscape(coinbaseProduct(c)) + "/candles?" + q.Encode()
+ var raw [][]float64
+ if err := getJSON(u, &raw, nil); err != nil {
+ return nil, err
+ }
+ for _, k := range raw {
+ if len(k) >= 5 && k[0] >= float64(start.Unix()) && k[0] <= float64(end.Unix()) {
+ out = append(out, Candle{k[0] * 1000, k[3], k[2], k[1], k[4]})
+ }
+ }
+ from = to
+ if from.Before(end) {
+ time.Sleep(1100 * time.Millisecond)
}
}
+ out = aggregateCandles(out, granularity, interval*60, end)
if len(out) == 0 {
return nil, fmt.Errorf("empty")
}
return trimCandles(out, limit), nil
}
+// Rebucket Coinbase into the same UTC-aligned intervals used by Kraken.
+// Deduplicate overlapping pages and reject incomplete historical buckets.
+func aggregateCandles(in []Candle, sourceSeconds, targetSeconds int, now time.Time) []Candle {
+ trimCandles(in, len(in))
+ var out []Candle
+ var bucket Candle
+ count := 0
+ previous := float64(-1)
+ flush := func() {
+ if count == targetSeconds/sourceSeconds || (count > 0 && bucket[0]+float64(targetSeconds)*1000 > float64(now.UnixMilli())) {
+ out = append(out, bucket)
+ }
+ }
+ for _, c := range in {
+ if c[0] == previous {
+ continue
+ }
+ previous = c[0]
+ t := float64(int64(c[0])/int64(targetSeconds*1000)) * float64(targetSeconds*1000)
+ if count == 0 || t != bucket[0] {
+ flush()
+ bucket = c
+ bucket[0] = t
+ count = 1
+ } else {
+ if c[2] > bucket[2] {
+ bucket[2] = c[2]
+ }
+ if c[3] < bucket[3] {
+ bucket[3] = c[3]
+ }
+ bucket[4] = c[4]
+ count++
+ }
+ }
+ flush()
+ return out
+}
+
func fetchMarketCandles(c Coin, days int) ([]Candle, string, error) {
cs, kerr := fetchKrakenCandles(c, days)
if kerr == nil {
@@ -925,6 +1061,7 @@ func refreshPnl(wallet string, now time.Time) {
func refreshFast() {
mu.RLock()
c := cfg
+ revision := configRevision
mu.RUnlock()
now := time.Now()
@@ -992,33 +1129,49 @@ func refreshFast() {
refreshPnl(wallet, time.Now())
}
- // Kraken returns all requested tickers in one call. Only missing pairs fall
- // back to Coinbase, and the last good price survives a provider outage.
- prices, _ := fetchKrakenPrices(c.Coins)
+ // 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)
+ }
if prices == nil {
prices = map[string]float64{}
}
var priceMu sync.Mutex
var priceWG sync.WaitGroup
+ var missing []Coin
for _, cn := range c.Coins {
- if prices[cn.ID] == 0 {
- cn := cn
- priceWG.Add(1)
- go func() {
- defer priceWG.Done()
- if p, err := fetchCoinbasePrice(cn); err == nil {
- priceMu.Lock()
- prices[cn.ID] = p
- priceMu.Unlock()
- }
- }()
+ if prices[cn.ID] == 0 && c.MarketProvider != "kraken" {
+ missing = append(missing, cn)
}
}
+ for _, cn := range missing {
+ priceWG.Add(1)
+ go func() {
+ defer priceWG.Done()
+ var p float64
+ var err error
+ if c.MarketProvider == "binance" {
+ p, err = fetchBinancePrice(cn)
+ } else {
+ p, err = fetchCoinbasePrice(cn)
+ }
+ if err == nil {
+ priceMu.Lock()
+ prices[cn.ID] = p
+ priceMu.Unlock()
+ }
+ }()
+ }
priceWG.Wait()
active := activeCoins(positions, c.Coins)
mu.Lock()
+ if revision != configRevision {
+ mu.Unlock()
+ return
+ }
for id, price := range prices {
marketPrice[id] = price
}
@@ -1028,7 +1181,7 @@ func refreshFast() {
coinStates = append(coinStates, CoinState{
Sym: cn.Sym, Name: cn.Name, ID: cn.ID,
Price: p, Chg24h: candleChange24(p, cand24[cn.ID]),
- Source: csource[cn.ID], Active: active[cn.ID], Candles: candles[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],
})
}
@@ -1053,28 +1206,39 @@ func refreshSlow() {
mu.RLock()
c := cfg
+ revision := configRevision
mu.RUnlock()
for _, cn := range c.Coins {
- cs, src, err := fetchMarketCandles(cn, c.CandleDays)
+ cs, src, err := fetchSelectedCandles(cn, c.CandleDays, c.MarketProvider)
if err != nil {
log.Printf("market candles %s: %v", cn.Sym, err)
}
- // the trend read always wants 24h; a 7d/4h window only spans 6 candles
+ // Keep a separate 24h series only for the daily price change.
var c24 []Candle
if cs != nil && c.CandleDays == 1 {
c24 = cs
} else {
time.Sleep(1100 * time.Millisecond)
- if trend, _, trendErr := fetchMarketCandles(cn, 1); trendErr == nil {
+ if trend, _, trendErr := fetchSelectedCandles(cn, 1, c.MarketProvider); trendErr == nil {
c24 = trend
} else {
log.Printf("market trend %s: %v", cn.Sym, trendErr)
}
}
mu.Lock()
+ if revision != configRevision {
+ mu.Unlock()
+ return
+ }
+ if err != nil {
+ candleErrors[cn.ID] = err.Error()
+ } else {
+ delete(candleErrors, cn.ID)
+ }
if cs != nil {
candles[cn.ID] = cs
+ candleUpdated[cn.ID] = time.Now().UnixMilli()
csource[cn.ID] = src
}
if c24 != nil {
@@ -1120,7 +1284,20 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad json", 400)
return
}
+
+ if nc.MarketProvider != "" && !validMarketProvider(nc.MarketProvider) {
+ http.Error(w, "unsupported market provider", http.StatusBadRequest)
+ return
+ }
+ if nc.CandleDays != 0 && nc.CandleDays != 1 && nc.CandleDays != 7 && nc.CandleDays != 14 && nc.CandleDays != 30 {
+ http.Error(w, "unsupported candle period", http.StatusBadRequest)
+ return
+ }
mu.Lock()
+ configRevision++
+ if nc.MarketProvider != "" {
+ cfg.MarketProvider = nc.MarketProvider
+ }
if nc.Wallet != "" {
cfg.Wallet = strings.TrimSpace(nc.Wallet)
}
@@ -1134,9 +1311,13 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
cfg.Coins = nc.Coins
}
saved := cfg
+ state.Coins = nil
+ state.CandleDays = cfg.CandleDays
candles = map[string][]Candle{}
cand24 = map[string][]Candle{}
csource = map[string]string{}
+ candleErrors = map[string]string{}
+ candleUpdated = map[string]int64{}
marketPrice = map[string]float64{}
mu.Unlock()
saveConfig(saved)
diff --git a/server_test.go b/server_test.go
index 87a199e..8911474 100644
--- a/server_test.go
+++ b/server_test.go
@@ -3,6 +3,8 @@
package main
import (
+ "encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"os"
@@ -518,13 +520,14 @@ func TestMarketPairsPreserveLegacyOverride(t *testing.T) {
}
func TestFetchMarketCandlesFallsBackToCoinbase(t *testing.T) {
+ base := time.Now().UTC().Truncate(15 * time.Minute).Add(-2 * time.Hour).Unix()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/0/public/OHLC":
w.WriteHeader(http.StatusUnavailableForLegalReasons)
case r.URL.Path == "/products/BTC-USD/candles":
// Coinbase is newest-first: [time, low, high, open, close, volume].
- w.Write([]byte(`[[200,2,4,3,3.5,10],[100,1,3,2,2.5,8]]`))
+ fmt.Fprintf(w, `[[%d,2,4,3,3.5,10],[%d,1,3,2,2.5,8]]`, base+900, base)
default:
http.NotFound(w, r)
}
@@ -541,7 +544,7 @@ func TestFetchMarketCandlesFallsBackToCoinbase(t *testing.T) {
if source != "coinbase" {
t.Errorf("source = %q, want coinbase", source)
}
- if len(got) != 2 || got[0] != (Candle{100000, 2, 3, 1, 2.5}) || got[1][0] != 200000 {
+ if len(got) != 2 || got[0] != (Candle{float64(base) * 1000, 2, 3, 1, 2.5}) || got[1][0] != float64(base+900)*1000 {
t.Errorf("normalized Coinbase candles = %#v", got)
}
}
@@ -1108,3 +1111,209 @@ func TestBuildPnlUsesSeriesEnd(t *testing.T) {
t.Error("buildPnl must return nil without a series")
}
}
+
+func TestCoinbasePagesAndMatchesKrakenIntervals(t *testing.T) {
+ for _, days := range []int{1, 7, 14, 30} {
+ t.Run(fmt.Sprint(days), func(t *testing.T) {
+ calls := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ from, err := time.Parse(time.RFC3339, r.URL.Query().Get("start"))
+ if err != nil {
+ t.Error(err)
+ }
+ to, err := time.Parse(time.RFC3339, r.URL.Query().Get("end"))
+ if err != nil {
+ t.Error(err)
+ }
+ gran, _ := candleParams(days, "coinbase")
+ if to.Sub(from) > 299*time.Duration(gran)*time.Second {
+ t.Error("page exceeds inclusive candle cap")
+ }
+ var rows [][]float64
+ for ts := from.Unix(); ts <= to.Unix(); ts += int64(gran) {
+ rows = append(rows, []float64{float64(ts), 1, 4, 2, 3, 10})
+ }
+ json.NewEncoder(w).Encode(rows)
+ }))
+ defer srv.Close()
+ orig := coinbaseBase
+ coinbaseBase = srv.URL
+ defer func() { coinbaseBase = orig }()
+ got, err := fetchCoinbaseCandles(Coin{Sym: "BTC"}, days)
+ if err != nil {
+ t.Fatal(err)
+ }
+ interval, limit := candleParams(days, "kraken")
+ if len(got) != limit {
+ t.Fatalf("got %d candles, want %d", len(got), limit)
+ }
+ for i, c := range got {
+ if int64(c[0])%int64(interval*60000) != 0 {
+ t.Error("unaligned bucket")
+ }
+ if i > 0 && c[0]-got[i-1][0] != float64(interval*60000) {
+ t.Error("gap or duplicate across pages")
+ }
+ if c[1] != 2 || c[2] != 4 || c[3] != 1 || c[4] != 3 {
+ t.Errorf("OHLC changed: %v", c)
+ }
+ }
+ wantCalls := 1
+ if days == 14 {
+ wantCalls = 2
+ }
+ if days == 30 {
+ wantCalls = 3
+ }
+ if calls != wantCalls {
+ t.Errorf("calls=%d want %d", calls, wantCalls)
+ }
+ })
+ }
+}
+
+func TestAggregateCandlesPreservesOHLCAndRejectsMissingHour(t *testing.T) {
+ in := []Candle{{10800000, 13, 16, 12, 15}, {0, 10, 12, 9, 11}, {3600000, 11, 14, 10, 13}, {7200000, 13, 15, 11, 13},
+ {3600000, 11, 14, 10, 13}, // overlapping page
+ {14400000, 15, 18, 14, 17}, {21600000, 17, 20, 16, 19}, {25200000, 19, 21, 18, 20}}
+ got := aggregateCandles(in, 3600, 14400, time.Unix(28800, 0))
+ if len(got) != 1 || got[0] != (Candle{0, 10, 16, 9, 15}) {
+ t.Fatalf("aggregate=%v", got)
+ }
+ // Keep an unfinished bucket for drawing; the frontend excludes it from analysis.
+ got = aggregateCandles([]Candle{{28800000, 20, 22, 19, 21}}, 3600, 14400, time.Unix(30000, 0))
+ if len(got) != 1 {
+ t.Fatal("current bucket lost")
+ }
+}
+
+func TestSelectedProvidersDoNotFallBack(t *testing.T) {
+ for _, provider := range []string{"kraken", "coinbase", "binance"} {
+ t.Run(provider, func(t *testing.T) {
+ calls := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ expected := map[string]string{"kraken": "/0/public/OHLC", "coinbase": "/products/BTC-USD/candles", "binance": "/api/v3/klines"}[provider]
+ if r.URL.Path != expected {
+ t.Errorf("unexpected fallback: %s", r.URL.Path)
+ }
+ w.WriteHeader(http.StatusUnavailableForLegalReasons)
+ }))
+ defer srv.Close()
+ k, c, b := krakenBase, coinbaseBase, binanceBase
+ krakenBase, coinbaseBase, binanceBase = srv.URL, srv.URL, srv.URL
+ defer func() { krakenBase, coinbaseBase, binanceBase = k, c, b }()
+ _, source, err := fetchSelectedCandles(Coin{Sym: "BTC"}, 30, provider)
+ if err == nil || source != provider || calls != 1 {
+ t.Fatalf("source=%s error=%v calls=%d", source, err, calls)
+ }
+ })
+ }
+}
+
+func TestBinanceCandleIntervalsAndNormalization(t *testing.T) {
+ for _, days := range []int{1, 7, 14, 30} {
+ t.Run(fmt.Sprint(days), func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ expected := map[int]string{1: "15m", 7: "1h", 14: "4h", 30: "4h"}[days]
+ _, limit := candleParams(days, "kraken")
+ if r.URL.Query().Get("symbol") != "BTCUSDT" || r.URL.Query().Get("interval") != expected || r.URL.Query().Get("limit") != fmt.Sprint(limit) {
+ t.Errorf("query=%s", r.URL.RawQuery)
+ }
+ w.Write([]byte(`[[2000,"3","4","2","3.5"],[1000,"2","3","1","2.5"]]`))
+ }))
+ defer srv.Close()
+ orig := binanceBase
+ binanceBase = srv.URL
+ defer func() { binanceBase = orig }()
+ got, source, err := fetchSelectedCandles(Coin{Sym: "BTC"}, days, "binance")
+ if err != nil || source != "binance" || len(got) != 2 || got[0] != (Candle{1000, 2, 3, 1, 2.5}) {
+ t.Fatalf("%s %v %v", source, got, err)
+ }
+ })
+ }
+}
+
+func TestRefreshFastHonorsSelectedPriceProvider(t *testing.T) {
+ for _, provider := range []string{"kraken", "coinbase", "binance"} {
+ t.Run(provider, func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ expected := map[string]string{"kraken": "/0/public/Ticker", "coinbase": "/products/BTC-USD/ticker", "binance": "/api/v3/ticker/price"}[provider]
+ if r.URL.Path != expected {
+ t.Errorf("wrong provider: %s", r.URL.Path)
+ }
+ if provider == "kraken" {
+ w.Write([]byte(`{"error":[],"result":{"BTC/USD":{"c":["123"]}}}`))
+ } else {
+ w.Write([]byte(`{"price":"123"}`))
+ }
+ }))
+ defer srv.Close()
+ k, c, b := krakenBase, coinbaseBase, binanceBase
+ origCfg, origState, origPrices := cfg, state, marketPrice
+ krakenBase, coinbaseBase, binanceBase = srv.URL, srv.URL, srv.URL
+ cfg = Config{MarketProvider: provider, CandleDays: 1, Coins: []Coin{{Sym: "BTC", ID: "bitcoin"}}}
+ marketPrice = map[string]float64{}
+ defer func() {
+ krakenBase, coinbaseBase, binanceBase = k, c, b
+ cfg, state, marketPrice = origCfg, origState, origPrices
+ }()
+ refreshFast()
+ if len(state.Coins) != 1 || state.Coins[0].Price != 123 {
+ t.Fatalf("state=%v", state.Coins)
+ }
+ })
+ }
+}
+
+func TestProviderConfigValidatesPersistsAndClearsCache(t *testing.T) {
+ t.Chdir(t.TempDir())
+ origCfg, origState, rev := cfg, state, configRevision
+ origCandles, orig24, origSource, origPrices, origErrors, origUpdated := candles, cand24, csource, marketPrice, candleErrors, candleUpdated
+ defer func() {
+ cfg, state, configRevision = origCfg, origState, rev
+ candles, cand24, csource, marketPrice, candleErrors, candleUpdated = origCandles, orig24, origSource, origPrices, origErrors, origUpdated
+ }()
+ cfg = defaultConfig()
+ for _, body := range []string{`{"marketProvider":"invalid"}`, `{"candleDays":2}`} {
+ w := httptest.NewRecorder()
+ handleConfig(w, httptest.NewRequest("POST", "/api/config", strings.NewReader(body)))
+ if w.Code != 400 || configRevision != rev {
+ t.Fatalf("invalid config changed state: %d", w.Code)
+ }
+ }
+ for _, provider := range []string{"kraken", "binance", "coinbase", "auto"} {
+ state.Coins = []CoinState{{Sym: "BTC"}}
+ candles = map[string][]Candle{"bitcoin": {{0, 1, 2, 1, 1}}}
+ w := httptest.NewRecorder()
+ handleConfig(w, httptest.NewRequest("POST", "/api/config", strings.NewReader(fmt.Sprintf(`{"marketProvider":%q,"candleDays":30}`, provider))))
+ if w.Code != 200 || cfg.MarketProvider != provider || len(candles) != 0 || len(state.Coins) != 0 {
+ t.Fatalf("provider switch failed: %d %v", w.Code, cfg)
+ }
+ saved := loadConfig()
+ if saved.MarketProvider != provider || saved.CandleDays != 30 {
+ t.Errorf("persisted=%v", saved)
+ }
+ }
+}
+
+func TestRefreshSlowDiscardsResultsAfterProviderChange(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ cfg.MarketProvider = "coinbase"
+ configRevision++
+ mu.Unlock()
+ w.Write([]byte(`{"error":[],"result":{"BTC/USD":[[100,"2","3","1","2.5"]],"last":100}}`))
+ }))
+ defer srv.Close()
+ origK, origCfg, rev, origCandles := krakenBase, cfg, configRevision, candles
+ krakenBase = srv.URL
+ cfg = Config{MarketProvider: "kraken", CandleDays: 1, Coins: []Coin{{Sym: "BTC", ID: "bitcoin"}}}
+ candles = map[string][]Candle{}
+ defer func() { krakenBase, cfg, configRevision, candles = origK, origCfg, rev, origCandles }()
+ refreshSlow()
+ if len(candles) != 0 {
+ t.Fatal("previous provider results repopulated cleared cache")
+ }
+}
diff --git a/tests/chart.test.cjs b/tests/chart.test.cjs
new file mode 100644
index 0000000..bb7bd5b
--- /dev/null
+++ b/tests/chart.test.cjs
@@ -0,0 +1,77 @@
+// Copyright 2026ff novatechflow (Alexander Alten)
+// SPDX-License-Identifier: PolyForm-Shield-1.0.0
+const {test} = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const html = fs.readFileSync('index.html', 'utf8');
+const now = 1800000000000;
+class FixedDate extends Date { static now() { return now; } }
+const ctx = {Date: FixedDate, isFinite, fmtUsd: String, esc: String,
+ p2: v => String(v).padStart(2, '0')};
+// Run the real renderer and analysis helpers without booting the page's DOM.
+vm.createContext(ctx);
+vm.runInContext(html.slice(html.indexOf('function chartMetrics('), html.indexOf('/* ---------- auth:')), ctx);
+function series(days, direction = 1) {
+ const interval = (days === 1 ? 15 : days === 7 ? 60 : 240) * 60000;
+ const n = days * 86400000 / interval;
+ const end = Math.floor(now / interval) * interval;
+ return Array.from({length: n}, (_, i) => {
+ const p = 1000 + direction * i * 2;
+ return [end - (n - i) * interval, p, p + 1, p - 1, p];
+ });
+}
+for (const days of [1, 7, 14, 30]) {
+ test(`${days}d badge follows displayed period despite opposite 24h data`, () => {
+ const up = series(days), down24 = series(1, -1);
+ const result = ctx.chartWithAxis(up, 320, 72, days, up.at(-1)[4], down24);
+ assert.match(result, /class="trend up">Uptrend/);
+ assert.match(result, new RegExp(`· ${days === 1 ? '24h' : days + 'd'}`));
+ assert.equal(ctx.periodStructure(series(days, -1), days, now).trend.label, 'Downtrend');
+ assert.equal(ctx.periodStructure(series(days, 0), days, now).trend.label, 'Neutral');
+ });
+ test(`${days}d unfinished candle cannot change the analysis`, () => {
+ const d = series(days), interval = d[1][0] - d[0][0];
+ const current = [d.at(-1)[0] + interval, 1000, 100000, 1, 2];
+ // Real feed limits include the current candle, replacing the oldest candle.
+ const actual = ctx.periodStructure([...d.slice(1), current], days, now);
+ assert.equal(actual.data.length, d.length - 1);
+ assert.equal(actual.trend.label, 'Uptrend');
+ assert.ok(actual.data.every(c => c[0] !== current[0]));
+ });
+ test(`${days}d missing, stale, short or invalid candles fail closed`, () => {
+ assert.equal(ctx.periodStructure(series(days), days, now, now - 900001).trend.why, 'stale candles');
+ const d = series(days), missing = d.filter((_, i) => i !== 10);
+ assert.equal(ctx.periodStructure(missing, days, now).trend.label, 'Unavailable');
+ assert.equal(ctx.periodStructure(d.slice(-10), days, now).trend.label, 'Unavailable');
+ const stale = d.map(c => [c[0] - 86400000, ...c.slice(1)]);
+ assert.equal(ctx.periodStructure(stale, days, now).trend.why, 'stale candles');
+ const malformed = d.map(c => [...c]); malformed[5][2] = NaN;
+ assert.equal(ctx.periodStructure(malformed, days, now).trend.label, 'Unavailable');
+ const result = ctx.chartWithAxis(missing, 320, 72, days, 1000);
+ assert.match(result, /Unavailable/);
+ assert.doesNotMatch(result, /stroke-dasharray="3,3"/); // no misleading channels
+ });
+}
+test('structure formula handles expanding, coiling and a round trip', () => {
+ const fixture = (highs, lows) => Array.from({length: 30}, (_, i) => {
+ const block = Math.floor(i / 10), h = highs[block], l = lows[block];
+ // One extreme per third, narrow typical ranges elsewhere.
+ const p = (h + l) / 2;
+ return [i, p, i % 10 === 5 ? h : p + 0.1, i % 10 === 5 ? l : p - 0.1, p];
+ });
+ assert.equal(ctx.trendVerdict(fixture([110, 120, 130], [90, 80, 70])).label, 'Expanding');
+ assert.equal(ctx.trendVerdict(fixture([130, 120, 110], [70, 80, 90])).label, 'Coiling');
+ assert.equal(ctx.trendVerdict(fixture([110, 130, 110], [90, 110, 90])).label, 'Neutral');
+});
+test('swing ties keep the first plateau and require both sides', () => {
+ const d = [1, 2, 5, 5, 2, 1, 2].map((p, i) => [i, p, p + 1, p - 1, p]);
+ assert.deepEqual(Array.from(ctx.swingPivots(d, 2).highs), [2]);
+ assert.deepEqual(Array.from(ctx.swingPivots(series(7), 2).highs), []);
+});
+test('candle geometry preserves elapsed time across missing intervals', () => {
+ const d = [[0, 2, 3, 1, 2], [1000, 2, 3, 1, 2], [4000, 2, 3, 1, 2]];
+ const m = ctx.chartMetrics(d, 2);
+ 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);
+});