Initial public commit.
This commit is contained in:
commit
23ed509200
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/moonmath
|
18
.vscode/launch.json
vendored
Normal file
18
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch file",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "debug",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"program": "${file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
15
.vscode/settings.json
vendored
Normal file
15
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"bitcoinity",
|
||||||
|
"Bitfinex",
|
||||||
|
"Bitstamp",
|
||||||
|
"bubbletea",
|
||||||
|
"CDPR",
|
||||||
|
"charmbracelet",
|
||||||
|
"Coindesk",
|
||||||
|
"lipgloss",
|
||||||
|
"moonmath",
|
||||||
|
"OHLC",
|
||||||
|
"sourcegraph"
|
||||||
|
]
|
||||||
|
}
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Samuel Fredrickson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
46
README.md
Normal file
46
README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# moonmath
|
||||||
|
|
||||||
|
## Bullshit BTC Price Projections, Now in Your CLI!
|
||||||
|
|
||||||
|
![screenshot](./screenshot.png)
|
||||||
|
|
||||||
|
This is a re-implementation of [Moon Math][moon] that runs locally as a CLI
|
||||||
|
program. It's written in Go using the [Bubble Tea][tea] library, and uses
|
||||||
|
[Coindesk][coin] to source price data.
|
||||||
|
|
||||||
|
[moon]: https://www.moonmath.win
|
||||||
|
[tea]: https://github.com/charmbracelet/bubbletea
|
||||||
|
[coin]: https://www.coindesk.com
|
||||||
|
|
||||||
|
### "Theory"
|
||||||
|
|
||||||
|
Given a pair of quotes taken at the start and end of some period,
|
||||||
|
|
||||||
|
$$ (t_s, p_s), (t_e, p_e) $$
|
||||||
|
|
||||||
|
we can derive the total gain for that period, and its length in days.
|
||||||
|
|
||||||
|
$$ g = p_e / p_s $$
|
||||||
|
$$ d = t_e - t_s $$
|
||||||
|
|
||||||
|
Combining these, we can calculate the *compounding daily periodic rate* (CDPR).
|
||||||
|
|
||||||
|
$$ r = g^{1/d} $$
|
||||||
|
|
||||||
|
We can use this rate to project the price $ p_f $ at some $ x $ days in the
|
||||||
|
future.
|
||||||
|
|
||||||
|
$$ p_f = p_e r^x $$
|
||||||
|
|
||||||
|
If we instead make $ p_f $ a target price, we can solve this equation for $ x $,
|
||||||
|
telling us how many days it will take to reach that target.
|
||||||
|
|
||||||
|
$$ x = {{log(p_f) - log(p_e)} \over log(r)} $$
|
||||||
|
|
||||||
|
### Future Improvements
|
||||||
|
|
||||||
|
* Support other assets available from Coindesk.
|
||||||
|
* Configurable projection milestones.
|
||||||
|
* Allow projection by date, e.g. use the CDPR to calculate what the price
|
||||||
|
would be on a particular date.
|
||||||
|
* Log errors to a file.
|
44
bitcoinity/client.go
Normal file
44
bitcoinity/client.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package bitcoinity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/carlmjohnson/requests"
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetWebsocket() (Websocket, error) {
|
||||||
|
origin := baseUrl
|
||||||
|
url := "wss://bitcoinity.org/webs_bridge/websocket?vsn=1.0.0"
|
||||||
|
conn, err := websocket.Dial(url, "", origin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ws := newWebsocket(conn)
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTicker(
|
||||||
|
ctx context.Context, req *GetTickerRequest,
|
||||||
|
) (*GetTickerResponse, error) {
|
||||||
|
var resp GetTickerResponse
|
||||||
|
err := requests.New(commonConfig).
|
||||||
|
Path("/markets/get_ticker").
|
||||||
|
Param("currency", string(req.Currency)).
|
||||||
|
Param("exchange", string(req.Exchange)).
|
||||||
|
Param("span", string(req.Span)).
|
||||||
|
ToJSON(&resp).
|
||||||
|
Fetch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = "https://bitcoinity.org"
|
||||||
|
|
||||||
|
func commonConfig(rb *requests.Builder) {
|
||||||
|
rb.
|
||||||
|
BaseURL(baseUrl).
|
||||||
|
Accept("application/json;charset=utf-8")
|
||||||
|
}
|
1
bitcoinity/get_ticker.json
Normal file
1
bitcoinity/get_ticker.json
Normal file
File diff suppressed because one or more lines are too long
101
bitcoinity/model.go
Normal file
101
bitcoinity/model.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package bitcoinity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var EmptyPayload json.RawMessage = []byte("{}")
|
||||||
|
|
||||||
|
type MessageId string
|
||||||
|
|
||||||
|
type Topic string
|
||||||
|
|
||||||
|
const TopicAll Topic = "all"
|
||||||
|
|
||||||
|
func MarketTopic(e Exchange, c Currency) Topic {
|
||||||
|
topic := fmt.Sprintf("webs:markets_%s_%s", e, c)
|
||||||
|
return Topic(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventPhxJoin Event = "phx_join"
|
||||||
|
EventPhxReply Event = "phx_reply"
|
||||||
|
EventNewMsg Event = "new_msg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetTickerRequest struct {
|
||||||
|
Currency Currency
|
||||||
|
Exchange Exchange
|
||||||
|
Span Span
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetTickerResponse struct {
|
||||||
|
TickerLife int `json:"ticker_life"`
|
||||||
|
VolumeResolution int `json:"volume_resolution"`
|
||||||
|
PriceChange string `json:"price_change"`
|
||||||
|
PriceHigh string `json:"price_high"`
|
||||||
|
PriceLow string `json:"price_low"`
|
||||||
|
Buys []Trade `json:"buys"`
|
||||||
|
Sells []Trade `json:"sells"`
|
||||||
|
Lasts []Trade `json:"lasts"`
|
||||||
|
Volume []Volume `json:"volume"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Currency string
|
||||||
|
|
||||||
|
const (
|
||||||
|
USD Currency = "USD"
|
||||||
|
EUR Currency = "EUR"
|
||||||
|
GBP Currency = "GBP"
|
||||||
|
AUD Currency = "AUD"
|
||||||
|
JPY Currency = "JPY"
|
||||||
|
CAD Currency = "CAD"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Exchange string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Coinbase Exchange = "coinbase"
|
||||||
|
Bitfinex Exchange = "bitfinex"
|
||||||
|
Bitstamp Exchange = "bitstamp"
|
||||||
|
Kraken Exchange = "kraken"
|
||||||
|
Gemini Exchange = "gemini"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Span string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Span10Minutes Span = "10m"
|
||||||
|
Span1Hour Span = "1h"
|
||||||
|
Span12Hours Span = "12h"
|
||||||
|
Span24Hours Span = "24h"
|
||||||
|
Span3Days Span = "3d"
|
||||||
|
Span7Days Span = "7d"
|
||||||
|
Span30Days Span = "30d"
|
||||||
|
Span6Months Span = "6m"
|
||||||
|
Span2Years Span = "2y"
|
||||||
|
SpanAll Span = "all"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Trade struct {
|
||||||
|
Timestamp int64
|
||||||
|
Price float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Trade) UnmarshalJSON(b []byte) error {
|
||||||
|
a := []interface{}{&t.Timestamp, &t.Price}
|
||||||
|
return json.Unmarshal(b, &a)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Volume struct {
|
||||||
|
Timestamp int64
|
||||||
|
Size float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Volume) UnmarshalJSON(b []byte) error {
|
||||||
|
a := []interface{}{&v.Timestamp, &v.Size}
|
||||||
|
return json.Unmarshal(b, &a)
|
||||||
|
}
|
23
bitcoinity/model_test.go
Normal file
23
bitcoinity/model_test.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package bitcoinity_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.humancabbage.net/moonmath/bitcoinity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnmarshalGetTickerResponse(t *testing.T) {
|
||||||
|
var resp bitcoinity.GetTickerResponse
|
||||||
|
err := json.Unmarshal(getTickerJson, &resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to unmarshal get_ticker JSON: %v", err)
|
||||||
|
}
|
||||||
|
if resp.TickerLife != 604800 {
|
||||||
|
t.Errorf("expected TickerLife == 604800, not %v", resp.TickerLife)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed get_ticker.json
|
||||||
|
var getTickerJson []byte
|
196
bitcoinity/websocket.go
Normal file
196
bitcoinity/websocket.go
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
package bitcoinity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Websocket interface {
|
||||||
|
Join(Topic, Handler) error
|
||||||
|
Shutdown() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler func(*Message)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Event Event `json:"event"`
|
||||||
|
Topic Topic `json:"topic"`
|
||||||
|
Ref *MessageId `json:"ref"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarketPayload struct {
|
||||||
|
Data MarketData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarketData struct {
|
||||||
|
Currency Currency `json:"currency"`
|
||||||
|
Exchange Exchange `json:"exchange_name"`
|
||||||
|
Trade MarketTrade `json:"trade"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarketTrade struct {
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Date float64 `json:"date"`
|
||||||
|
Exchange Exchange `json:"exchange_name"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type realWebsocket struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
inbox chan *Message
|
||||||
|
outbox chan outgoing
|
||||||
|
|
||||||
|
pending map[MessageId]Handler
|
||||||
|
subscriptions map[Topic]Handler
|
||||||
|
nextMsgId int64
|
||||||
|
|
||||||
|
running atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type outgoing struct {
|
||||||
|
Request *Message
|
||||||
|
ResponseHandler Handler
|
||||||
|
TopicHandler Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWebsocket(conn *websocket.Conn) *realWebsocket {
|
||||||
|
ws := realWebsocket{
|
||||||
|
conn: conn,
|
||||||
|
inbox: make(chan *Message),
|
||||||
|
outbox: make(chan outgoing),
|
||||||
|
pending: map[MessageId]Handler{},
|
||||||
|
subscriptions: map[Topic]Handler{},
|
||||||
|
}
|
||||||
|
ws.running.Store(true)
|
||||||
|
|
||||||
|
go ws.heartbeat()
|
||||||
|
go ws.reader()
|
||||||
|
go ws.mailman()
|
||||||
|
return &ws
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *realWebsocket) heartbeat() {
|
||||||
|
for ws.running.Load() {
|
||||||
|
ws.outbox <- outgoing{
|
||||||
|
Request: &Message{
|
||||||
|
Topic: "phoenix",
|
||||||
|
Event: "heartbeat",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second * 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *realWebsocket) reader() {
|
||||||
|
var partial []byte
|
||||||
|
failures := 0
|
||||||
|
|
||||||
|
for ws.running.Load() {
|
||||||
|
buf := make([]byte, 16*1024)
|
||||||
|
n, err := ws.conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
ws.running.Store(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("websocket read: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
useful := buf[0:n]
|
||||||
|
partial = append(partial, useful...)
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
err = json.Unmarshal(partial, &msg)
|
||||||
|
if err != nil {
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = 0
|
||||||
|
partial = nil
|
||||||
|
ws.inbox <- &msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *realWebsocket) mailman() {
|
||||||
|
for ws.running.Load() {
|
||||||
|
select {
|
||||||
|
case i := <-ws.inbox:
|
||||||
|
ws.dispatch(i)
|
||||||
|
case o := <-ws.outbox:
|
||||||
|
_ = ws.send(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *realWebsocket) dispatch(m *Message) {
|
||||||
|
var handler Handler
|
||||||
|
if m.Ref != nil {
|
||||||
|
handler = ws.pending[*m.Ref]
|
||||||
|
delete(ws.pending, *m.Ref)
|
||||||
|
} else if m.Topic != "" {
|
||||||
|
handler = ws.subscriptions[m.Topic]
|
||||||
|
}
|
||||||
|
if handler != nil {
|
||||||
|
handler(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *realWebsocket) send(o outgoing) error {
|
||||||
|
if o.Request.Ref == nil {
|
||||||
|
msgId := ws.nextMsgId + 1
|
||||||
|
ws.nextMsgId += 1
|
||||||
|
msgIdStr := fmt.Sprintf("%d", msgId)
|
||||||
|
o.Request.Ref = (*MessageId)(&msgIdStr)
|
||||||
|
}
|
||||||
|
req, err := json.Marshal(o.Request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = ws.conn.Write(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if o.ResponseHandler != nil {
|
||||||
|
ws.pending[*o.Request.Ref] = o.ResponseHandler
|
||||||
|
}
|
||||||
|
if o.TopicHandler != nil {
|
||||||
|
ws.subscriptions[o.Request.Topic] = o.TopicHandler
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *realWebsocket) Join(topic Topic, topicHandler Handler) error {
|
||||||
|
respChan, respHandler := oneShot()
|
||||||
|
ws.outbox <- outgoing{
|
||||||
|
Request: &Message{
|
||||||
|
Event: EventPhxJoin,
|
||||||
|
Topic: topic,
|
||||||
|
Payload: EmptyPayload,
|
||||||
|
},
|
||||||
|
ResponseHandler: respHandler,
|
||||||
|
TopicHandler: topicHandler,
|
||||||
|
}
|
||||||
|
<-respChan
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *realWebsocket) Shutdown() error {
|
||||||
|
ws.running.Store(false)
|
||||||
|
err := ws.conn.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneShot() (<-chan *Message, Handler) {
|
||||||
|
pipe := make(chan *Message)
|
||||||
|
return pipe, func(msg *Message) {
|
||||||
|
pipe <- msg
|
||||||
|
}
|
||||||
|
}
|
29
coindesk/lib_test.go
Normal file
29
coindesk/lib_test.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package coindesk_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.humancabbage.net/moonmath/coindesk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestXxx(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
then := now.Add(time.Duration(-24) * time.Hour)
|
||||||
|
|
||||||
|
values, err := coindesk.GetPriceValues(context.Background(), coindesk.BTC, then, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("test failure: %v", err)
|
||||||
|
}
|
||||||
|
_ = values
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
tickers, err := coindesk.GetAssetTickers(context.Background(), coindesk.BTC, coindesk.ETH)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("test failure: %v", err)
|
||||||
|
}
|
||||||
|
_ = tickers
|
||||||
|
fmt.Println()
|
||||||
|
}
|
112
coindesk/model.go
Normal file
112
coindesk/model.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package coindesk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Asset is a cryptocurrency, like Bitcoin, Ethereum, etc.
|
||||||
|
type Asset string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BTC is the Bitcoin asset.
|
||||||
|
BTC Asset = "BTC"
|
||||||
|
// ETH is the Ethereum asset.
|
||||||
|
ETH Asset = "ETH"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response represents the general top-level format of Coindesk API responses.
|
||||||
|
type Response[T any] struct {
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data T `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriceValues contains a series of timestamped prices for a particular asset.
|
||||||
|
type PriceValues struct {
|
||||||
|
ISO Asset `json:"iso"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
IngestionStart Date `json:"ingestionStart"`
|
||||||
|
Entries []TimestampPrice `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetTickers is a map from an asset to its ticker data.
|
||||||
|
type AssetTickers map[Asset]AssetTicker
|
||||||
|
|
||||||
|
// AssetTicker is a snapshot of pricing data for an asset.
|
||||||
|
type AssetTicker struct {
|
||||||
|
ISO Asset `json:"iso"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Change struct {
|
||||||
|
Percent float64 `json:"percent"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
} `json:"change"`
|
||||||
|
OHLC struct {
|
||||||
|
Opening Price `json:"o"`
|
||||||
|
High Price `json:"h"`
|
||||||
|
Low Price `json:"l"`
|
||||||
|
Closing Price `json:"c"`
|
||||||
|
} `json:"ohlc"`
|
||||||
|
CirculatingSupply float64 `json:"circulatingSupply"`
|
||||||
|
MarketCap Price `json:"marketCap"`
|
||||||
|
Timestamp Timestamp `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimestampPrice represents a JSON array with two elements: an integer Unix
|
||||||
|
// timestamp expressed in milliseconds, and a floating-point USD price.
|
||||||
|
type TimestampPrice struct {
|
||||||
|
Timestamp Timestamp
|
||||||
|
Price Price
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TimestampPrice) UnmarshalJSON(b []byte) error {
|
||||||
|
a := []interface{}{&t.Timestamp, &t.Price}
|
||||||
|
return json.Unmarshal(b, &a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp represents an integer Unix timestamp expressed in milliseconds
|
||||||
|
// which has been converted into a Golang time.Time object.
|
||||||
|
type Timestamp time.Time
|
||||||
|
|
||||||
|
func (t *Timestamp) UnmarshalJSON(b []byte) error {
|
||||||
|
s := string(b)
|
||||||
|
n := len(s)
|
||||||
|
secsStr := s[0 : n-3]
|
||||||
|
millisStr := s[n-3:]
|
||||||
|
secs, err := strconv.ParseInt(secsStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
millis, err := strconv.ParseInt(millisStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
converted := time.Unix(secs, millis*1e6)
|
||||||
|
*t = Timestamp(converted)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price represents the USD price of an asset.
|
||||||
|
type Price float64
|
||||||
|
|
||||||
|
// Date represents a date-only string which has been converted into a Golang
|
||||||
|
// time.Time object.
|
||||||
|
type Date time.Time
|
||||||
|
|
||||||
|
func (d *Date) UnmarshalJSON(b []byte) error {
|
||||||
|
s := string(b)
|
||||||
|
s, _ = strings.CutPrefix(s, "\"")
|
||||||
|
s, _ = strings.CutSuffix(s, "\"")
|
||||||
|
t, err := time.Parse(time.DateOnly, s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*d = Date(t)
|
||||||
|
return nil
|
||||||
|
}
|
48
coindesk/requests.go
Normal file
48
coindesk/requests.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package coindesk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/carlmjohnson/requests"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPriceValues gets timestamped prices for a particular asset.
|
||||||
|
func GetPriceValues(
|
||||||
|
ctx context.Context, asset Asset, startDate, endDate time.Time,
|
||||||
|
) (resp Response[PriceValues], err error) {
|
||||||
|
const basePath = "v2/tb/price/values"
|
||||||
|
const timeFormat = "2006-01-02T15:04"
|
||||||
|
err = requests.New(commonConfig).
|
||||||
|
Path(fmt.Sprintf("%s/%s", basePath, asset)).
|
||||||
|
Param("start_date", startDate.Format(timeFormat)).
|
||||||
|
Param("end_date", endDate.Format(timeFormat)).
|
||||||
|
ToJSON(&resp).
|
||||||
|
Fetch(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAssetTickers gets tickers for a set of assets.
|
||||||
|
func GetAssetTickers(ctx context.Context, assets ...Asset) (resp Response[AssetTickers], err error) {
|
||||||
|
const basePath = "v2/tb/price/ticker"
|
||||||
|
var strAssets []string
|
||||||
|
for _, asset := range assets {
|
||||||
|
strAssets = append(strAssets, string(asset))
|
||||||
|
}
|
||||||
|
err = requests.New(commonConfig).
|
||||||
|
Path(basePath).
|
||||||
|
Param("assets", strings.Join(strAssets, ",")).
|
||||||
|
ToJSON(&resp).
|
||||||
|
Fetch(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = "https://production.api.coindesk.com"
|
||||||
|
|
||||||
|
func commonConfig(rb *requests.Builder) {
|
||||||
|
rb.
|
||||||
|
BaseURL(baseUrl).
|
||||||
|
Accept("application/json;charset=utf-8")
|
||||||
|
}
|
217
example.json
Normal file
217
example.json
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "phx_join",
|
||||||
|
"payload": {},
|
||||||
|
"ref": "1",
|
||||||
|
"topic": "all"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "phx_join",
|
||||||
|
"payload": {},
|
||||||
|
"ref": "2",
|
||||||
|
"topic": "webs:markets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "phx_join",
|
||||||
|
"payload": {},
|
||||||
|
"ref": "3",
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "phx_reply",
|
||||||
|
"payload": {
|
||||||
|
"response": {},
|
||||||
|
"status": "ok"
|
||||||
|
},
|
||||||
|
"ref": "1",
|
||||||
|
"topic": "all"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "phx_reply",
|
||||||
|
"payload": {
|
||||||
|
"response": {},
|
||||||
|
"status": "ok"
|
||||||
|
},
|
||||||
|
"ref": "2",
|
||||||
|
"topic": "webs:markets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "phx_reply",
|
||||||
|
"payload": {
|
||||||
|
"response": {},
|
||||||
|
"status": "ok"
|
||||||
|
},
|
||||||
|
"ref": "3",
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 7.626e-5,
|
||||||
|
"date": 1710454598.412,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71296.64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 2.1277e-4,
|
||||||
|
"date": 1710454598.529,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71296.51
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 9.298e-5,
|
||||||
|
"date": 1710454598.571,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71290.31
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 0.0280536,
|
||||||
|
"date": 1710454599.14,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71282.41
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 0.00179797,
|
||||||
|
"date": 1710454599.739,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71279.04
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 6.6664e-4,
|
||||||
|
"date": 1710454599.969,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71279.03
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 0.00269708,
|
||||||
|
"date": 1710454600.174,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71278.99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 0.00707958,
|
||||||
|
"date": 1710454600.722,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71268.25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 1.6783e-4,
|
||||||
|
"date": 1710454600.955,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71273.11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "new_msg",
|
||||||
|
"payload": {
|
||||||
|
"data": {
|
||||||
|
"currency": "USD",
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"trade": {
|
||||||
|
"amount": 0.00134701,
|
||||||
|
"date": 1710454601.033,
|
||||||
|
"exchange_name": "coinbase",
|
||||||
|
"price": 71274.89
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ref": null,
|
||||||
|
"topic": "webs:markets_coinbase_USD"
|
||||||
|
}
|
||||||
|
]
|
32
go.mod
Normal file
32
go.mod
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
module code.humancabbage.net/moonmath
|
||||||
|
|
||||||
|
go 1.22.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/carlmjohnson/requests v0.23.5
|
||||||
|
github.com/charmbracelet/bubbles v0.18.0
|
||||||
|
github.com/charmbracelet/bubbletea v0.25.0
|
||||||
|
github.com/charmbracelet/lipgloss v0.10.0
|
||||||
|
github.com/sourcegraph/conc v0.3.0
|
||||||
|
golang.org/x/net v0.22.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/containerd/console v1.0.4 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
|
github.com/muesli/termenv v0.15.2 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
golang.org/x/sync v0.6.0 // indirect
|
||||||
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
|
golang.org/x/term v0.18.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
)
|
62
go.sum
Normal file
62
go.sum
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/carlmjohnson/requests v0.23.5 h1:NPANcAofwwSuC6SIMwlgmHry2V3pLrSqRiSBKYbNHHA=
|
||||||
|
github.com/carlmjohnson/requests v0.23.5/go.mod h1:zG9P28thdRnN61aD7iECFhH5iGGKX2jIjKQD9kqYH+o=
|
||||||
|
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
||||||
|
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||||
|
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
||||||
|
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
||||||
|
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
|
||||||
|
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
|
||||||
|
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
|
||||||
|
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||||
|
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||||
|
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||||
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||||
|
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
164
moon/moon.go
Normal file
164
moon/moon.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package moon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.humancabbage.net/moonmath/coindesk"
|
||||||
|
"github.com/sourcegraph/conc/pool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Math struct {
|
||||||
|
CurrentPrice coindesk.Price
|
||||||
|
Columns []Column
|
||||||
|
Goals []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMath(goals []float64, bases []Base) (m Math) {
|
||||||
|
if goals == nil {
|
||||||
|
goals = DefaultGoals
|
||||||
|
}
|
||||||
|
if bases == nil {
|
||||||
|
bases = DefaultBases
|
||||||
|
}
|
||||||
|
m.Goals = goals
|
||||||
|
m.Columns = make([]Column, len(bases))
|
||||||
|
for i := range bases {
|
||||||
|
m.Columns[i].Base = bases[i]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func CDPR(days, gain float64) float64 {
|
||||||
|
if gain <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
cdpr := math.Pow(gain, 1/days)
|
||||||
|
return cdpr
|
||||||
|
}
|
||||||
|
|
||||||
|
type Projection struct {
|
||||||
|
Dates []time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectDates(
|
||||||
|
from time.Time, currentPrice float64, cdpr float64, goals []float64,
|
||||||
|
) (p Projection) {
|
||||||
|
if cdpr <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logP := math.Log(currentPrice)
|
||||||
|
logR := math.Log(cdpr)
|
||||||
|
for _, goal := range goals {
|
||||||
|
daysToGo := (math.Log(goal) - logP) / logR
|
||||||
|
date := from.Add(time.Hour * 24 * time.Duration(daysToGo))
|
||||||
|
p.Dates = append(p.Dates, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Math) Refresh(ctx context.Context) (err error) {
|
||||||
|
resp, err := coindesk.GetAssetTickers(ctx, coindesk.BTC)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.CurrentPrice = resp.Data[coindesk.BTC].OHLC.Closing
|
||||||
|
|
||||||
|
tasks := pool.New().WithErrors()
|
||||||
|
tasks.WithMaxGoroutines(len(m.Columns))
|
||||||
|
//tasks.WithMaxGoroutines(1)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for i := range m.Columns {
|
||||||
|
c := &m.Columns[i]
|
||||||
|
tasks.Go(func() error {
|
||||||
|
c.StartingDate = c.Base.From(now)
|
||||||
|
nextDay := c.StartingDate.Add(time.Hour * 24)
|
||||||
|
resp, err := coindesk.GetPriceValues(ctx,
|
||||||
|
coindesk.BTC, c.StartingDate, nextDay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.StartingPrice = resp.Data.Entries[0].Price
|
||||||
|
c.Gain = float64(m.CurrentPrice) / float64(c.StartingPrice)
|
||||||
|
days := now.Sub(c.StartingDate).Hours() / 24
|
||||||
|
c.CDPR = CDPR(days, c.Gain)
|
||||||
|
if c.CDPR > 1 {
|
||||||
|
c.Projections = ProjectDates(now, float64(m.CurrentPrice), c.CDPR, m.Goals)
|
||||||
|
} else {
|
||||||
|
c.Projections.Dates = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
err = tasks.Wait()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type Column struct {
|
||||||
|
Base Base
|
||||||
|
StartingDate time.Time
|
||||||
|
StartingPrice coindesk.Price
|
||||||
|
Gain float64
|
||||||
|
CDPR float64
|
||||||
|
Projections Projection
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultGoals = []float64{
|
||||||
|
100000,
|
||||||
|
150000,
|
||||||
|
200000,
|
||||||
|
250000,
|
||||||
|
300000,
|
||||||
|
500000,
|
||||||
|
1000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultBases = []Base{
|
||||||
|
RelativeBase{"Month", time.Duration(-30) * time.Hour * 24},
|
||||||
|
RelativeBase{"Quarter", time.Duration(-90) * time.Hour * 24},
|
||||||
|
RelativeBase{"Half-Year", time.Duration(-182) * time.Hour * 24},
|
||||||
|
RelativeBase{"Year", time.Duration(-365) * time.Hour * 24},
|
||||||
|
ConstantBase{"2020-", time.Unix(1577836800, 0)},
|
||||||
|
ConstantBase{"2019-", time.Unix(1546300800, 0)},
|
||||||
|
ConstantBase{"2018-", time.Unix(1514764800, 0)},
|
||||||
|
ConstantBase{"2017-", time.Unix(1483228800, 0)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base is a temporal point of comparison used for price projection.
|
||||||
|
type Base interface {
|
||||||
|
From(now time.Time) time.Time
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConstantBase is a base that is a constant time, e.g. 2020-01-01.
|
||||||
|
type ConstantBase struct {
|
||||||
|
name string
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb ConstantBase) From(_ time.Time) time.Time {
|
||||||
|
return cb.time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb ConstantBase) Name() string {
|
||||||
|
return cb.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelativeBase is a base that is relative, e.g. "90 days ago."
|
||||||
|
type RelativeBase struct {
|
||||||
|
name string
|
||||||
|
offset time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb RelativeBase) From(now time.Time) time.Time {
|
||||||
|
then := now.Add(time.Duration(rb.offset))
|
||||||
|
return then
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb RelativeBase) Name() string {
|
||||||
|
return rb.name
|
||||||
|
}
|
23
moon/moon_test.go
Normal file
23
moon/moon_test.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package moon_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.humancabbage.net/moonmath/moon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCDPR(t *testing.T) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjection(t *testing.T) {
|
||||||
|
p := moon.ProjectDates(time.Now(), 68900, 1.0055, []float64{
|
||||||
|
100000,
|
||||||
|
150000,
|
||||||
|
200000,
|
||||||
|
250000,
|
||||||
|
300000,
|
||||||
|
350000,
|
||||||
|
})
|
||||||
|
_ = p
|
||||||
|
}
|
145
moonmath.go
Normal file
145
moonmath.go
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.humancabbage.net/moonmath/moon"
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
var baseStyle = lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("240"))
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
math moon.Math
|
||||||
|
|
||||||
|
prices table.Model
|
||||||
|
projections table.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
_ = m.math.Refresh(context.TODO())
|
||||||
|
return m.math
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialModel() model {
|
||||||
|
math := moon.NewMath(nil, nil)
|
||||||
|
|
||||||
|
tableStyle := table.DefaultStyles()
|
||||||
|
tableStyle.Selected = lipgloss.NewStyle()
|
||||||
|
prices := table.New(
|
||||||
|
table.WithColumns([]table.Column{
|
||||||
|
{Title: "Price", Width: 9},
|
||||||
|
}),
|
||||||
|
table.WithHeight(1),
|
||||||
|
table.WithStyles(tableStyle),
|
||||||
|
)
|
||||||
|
|
||||||
|
projectionCols := []table.Column{
|
||||||
|
{Title: "Labels", Width: 8},
|
||||||
|
}
|
||||||
|
for i := range math.Columns {
|
||||||
|
projectionCols = append(projectionCols, table.Column{
|
||||||
|
Title: math.Columns[i].Base.Name(),
|
||||||
|
Width: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
projectionRows := make([]table.Row, len(math.Goals)+1)
|
||||||
|
for i := range projectionRows {
|
||||||
|
projectionRows[i] = make(table.Row, len(projectionCols))
|
||||||
|
}
|
||||||
|
projectionRows[0][0] = "CDPR"
|
||||||
|
for i := range math.Goals {
|
||||||
|
projectionRows[i+1][0] = fmt.Sprintf("$%.0f", math.Goals[i])
|
||||||
|
}
|
||||||
|
projections := table.New(
|
||||||
|
table.WithColumns(projectionCols),
|
||||||
|
table.WithRows(projectionRows),
|
||||||
|
table.WithHeight(len(math.Goals)+1),
|
||||||
|
table.WithStyles(tableStyle),
|
||||||
|
)
|
||||||
|
|
||||||
|
return model{
|
||||||
|
math: math,
|
||||||
|
prices: prices,
|
||||||
|
projections: projections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case moon.Math:
|
||||||
|
m.math = msg
|
||||||
|
refillPrice(&m)
|
||||||
|
refillProjections(&m)
|
||||||
|
return m, tea.Tick(time.Second*30, func(t time.Time) tea.Msg {
|
||||||
|
_ = m.math.Refresh(context.TODO())
|
||||||
|
return m.math
|
||||||
|
})
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "q", "esc":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refillPrice(m *model) {
|
||||||
|
rows := []table.Row{
|
||||||
|
[]string{fmt.Sprintf("$%0.2f", m.math.CurrentPrice)},
|
||||||
|
}
|
||||||
|
m.prices.SetRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refillProjections(m *model) {
|
||||||
|
rows := m.projections.Rows()
|
||||||
|
|
||||||
|
for col := range m.math.Columns {
|
||||||
|
_ = col
|
||||||
|
never := false
|
||||||
|
if m.math.Columns[col].CDPR <= 1 {
|
||||||
|
never = true
|
||||||
|
}
|
||||||
|
|
||||||
|
rows[0][col+1] = fmt.Sprintf("%.2f%%", (m.math.Columns[col].CDPR-1)*100)
|
||||||
|
for row := 0; row < len(m.math.Goals); row++ {
|
||||||
|
var cell string
|
||||||
|
if never {
|
||||||
|
cell = "NEVER!!!!!"
|
||||||
|
} else {
|
||||||
|
cell = m.math.Columns[col].
|
||||||
|
Projections.Dates[row].
|
||||||
|
Format("2006-01-02")
|
||||||
|
}
|
||||||
|
rows[row+1][col+1] = cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.projections.SetRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
var s string
|
||||||
|
s += lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Top,
|
||||||
|
baseStyle.Render(m.prices.View()),
|
||||||
|
baseStyle.Render(m.projections.View()),
|
||||||
|
)
|
||||||
|
return s + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
p := tea.NewProgram(initialModel())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Printf("program error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
Loading…
Reference in New Issue
Block a user