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