Compare commits

...

27 Commits

Author SHA1 Message Date
0f4e9f4c41 Also update golangci-lint.
All checks were successful
Build & Test / Main (push) Successful in 19s
Release / Release (push) Successful in 24s
2024-09-09 23:55:04 -07:00
2c8e198163 Update Go, Goreleaser.
Some checks are pending
Build & Test / Main (push) Waiting to run
2024-09-09 23:48:32 -07:00
eda4200585 Switch to GoLand.
All checks were successful
Build & Test / Main (push) Successful in 1m49s
2024-03-31 01:07:31 -07:00
5c22d85e2b Add custom .golangci.yaml config.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m35s
2024-03-29 19:29:29 -07:00
f67323c5f4 Support hardcoded starting prices.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
The Coindesk API doesn't have data going all the way back. But since history
isn't changing, we can simply put in known prices.

Also, extend the CDPR cells to have four digits instead of just two.
2024-03-29 19:24:10 -07:00
4d5dcc46d2 Misc small improvements.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
2024-03-29 18:40:33 -07:00
9e6abb1112 Naming things is hard.
All checks were successful
Build & Test / Main (push) Successful in 2m26s
2024-03-29 01:15:59 -07:00
2d991880ce Schedule refreshes more consistently.
All checks were successful
Build & Test / Main (push) Successful in 1m1s
Instead of returning the `scheduleRefresh` command only after receiving
an `update` message, do it while handling the `refresh` message. For
this not to cause weird behavior, the refresh deadline should be shorter
than the refresh interval.
2024-03-24 23:01:51 -07:00
7b445a02a2 Refresh indicator goes red on error.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m3s
It will stay flashing red until the next refresh, at which point it goes
back to its normal color. On a successful refresh, it still stops.

Also, add a deadline of 15 seconds to the refresh command.
2024-03-24 02:29:45 -07:00
97f4793ec3 Write errors to log file.
All checks were successful
Build & Test / Main (push) Successful in 1m1s
2024-03-22 22:55:41 -07:00
270534c0d5 Pause spinner ticks when not refreshing.
All checks were successful
Build & Test / Main (push) Successful in 59s
Also, add a quick-and-dirty model for displaying basic performance
stats, currently just the number of calls to the root Update() and
View() methods.
2024-03-22 21:14:34 -07:00
c4dde38d23 Support multiple assets simultaneously.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m1s
2024-03-22 17:43:15 -07:00
e14f0488c5 Add config defaults for more assets.
All checks were successful
Build & Test / Main (push) Successful in 59s
* LTC
* SOL
* XRP
* DOGE
* ADA
2024-03-21 22:07:16 -07:00
7222c4e26b Update screenshot.
All checks were successful
Build & Test / Main (push) Successful in 57s
Release / Release (push) Successful in 1m1s
2024-03-21 17:45:27 -07:00
ba4e803eea Make it easier to use other assets.
All checks were successful
Build & Test / Main (push) Successful in 1m31s
2024-03-21 17:39:10 -07:00
ac1a4bc16c Move Bitcoinity websocket capture, and test it.
All checks were successful
Build & Test / Main (push) Successful in 57s
2024-03-21 12:31:40 -07:00
52508b7d4e 'go mod tidy'
All checks were successful
Build & Test / Main (push) Successful in 1m25s
2024-03-21 12:23:17 -07:00
4a40899653 Spice up the UI.
All checks were successful
Build & Test / Main (push) Successful in 58s
* Full screen ("alt framebuffer").
* Rounded borders.
* Spinner that starts during a refresh.
* Colored table headers.
* Table header have bottom border.
2024-03-21 02:43:38 -07:00
80855a15a9 Minor reformatting.
All checks were successful
Build & Test / Main (push) Successful in 57s
Release / Release (push) Successful in 1m1s
2024-03-21 00:17:29 -07:00
19de412fc5 Add spinner that indicates an ongoing refresh.
All checks were successful
Build & Test / Main (push) Successful in 57s
2024-03-21 00:08:58 -07:00
1c6d5e9917 Simplify projection table generation.
All checks were successful
Build & Test / Main (push) Successful in 1m28s
Instead of pre-allocating the grid and using tricky indexing to fill in
the cells, just fully regenerate it. But do it columnwise first, and
then transpose it.
2024-03-20 23:40:38 -07:00
8b8307cc57 How about actually use the goal name? 2024-03-19 21:55:27 -07:00
36b6504a38 Remarks on configuration in the README.
All checks were successful
Build & Test / Main (push) Successful in 1m33s
Release / Release (push) Successful in 1m2s
2024-03-19 21:47:47 -07:00
60a4574c5a Initial configuration support. 2024-03-19 21:44:11 -07:00
bf50ba4539 Move TUI code into separate package.
All checks were successful
Build & Test / Main (push) Successful in 51s
2024-03-19 18:40:42 -07:00
15a7052389 Improve coindesk package tests. 2024-03-19 18:35:22 -07:00
83fd89446d Fix module name.
All checks were successful
Build & Test / Main (push) Successful in 1m20s
2024-03-19 14:29:37 -07:00
31 changed files with 1341 additions and 288 deletions

View File

@ -17,12 +17,12 @@ jobs:
name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.22.1
go-version: 1.23.1
-
name: Run linter
uses: golangci/golangci-lint-action@v3
with:
version: v1.56
version: v1.61
-
name: Run tests
run: go test ./...
@ -31,7 +31,7 @@ jobs:
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: 1.24.0
version: 2.2.0
args: release --clean --snapshot
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@ -17,13 +17,13 @@ jobs:
name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.22.1
go-version: 1.23.1
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: 1.24.0
version: 2.2.0
args: release --clean
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

15
.golangci.yaml Normal file
View File

@ -0,0 +1,15 @@
linters:
disable-all: true
enable:
- errcheck
- godot
- goimports
- gosimple
- govet
- ineffassign
- nilerr
- nilnil
- staticcheck
- typecheck
- unused
- usestdlibvars

View File

@ -6,7 +6,7 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 1
version: 2
before:
hooks:

1
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
workspace.xml

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/moonmath.iml" filepath="$PROJECT_DIR$/.idea/moonmath.iml" />
</modules>
</component>
</project>

9
.idea/moonmath.iml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

18
.vscode/launch.json vendored
View File

@ -1,18 +0,0 @@
{
// 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
View File

@ -1,15 +0,0 @@
{
"cSpell.words": [
"bitcoinity",
"Bitfinex",
"Bitstamp",
"bubbletea",
"CDPR",
"charmbracelet",
"Coindesk",
"lipgloss",
"moonmath",
"OHLC",
"sourcegraph"
]
}

View File

@ -1,6 +1,6 @@
# moonmath
## Bullshit BTC Price Projections, Now in Your CLI!
## Bullshit Crypto Price Projections, Now in Your CLI!
![screenshot](./screenshot.png)
@ -12,6 +12,28 @@ program. It's written in Go using the [Bubble Tea][tea] library, and uses
[tea]: https://github.com/charmbracelet/bubbletea
[coin]: https://www.coindesk.com
### Installation
Go to the [Releases page](https://code.humancabbage.net/sam/moonmath/releases)
and download the archive for your operating system and architecture. (For the
uninitiated, "Darwin" means macOS.)
### Configuration
By default, the program will use Bitcoin along with various goals and bases of
comparison. With the `--asset` flag, another asset supported by Coindesk can be
chosen. These can even be chained, e.g. `--asset BTC --asset ETH`, to show
projections for multiple assets simultaneously.
The [builtin default config](./config/default.yaml) only has special
goals a handful of the most popular assets. With the `--config-file` flag,
however, one can specify a YAML file that overrides these defaults and adds
goals for other assets.
Check out [coindesk/assets.go](./coindesk/assets.go) for a full list of
supported assets. Keep in mind these have not been exhaustively tested, and
it's likely that many will fail with the default configuration settings.
### "Theory"
Given a pair of quotes taken at the start and end of some period,
@ -39,8 +61,7 @@ $$ x = {{log(p_f) - log(p_e)} \over log(r)} $$
### Future Improvements
* Support other assets available from Coindesk.
* Configurable projection milestones.
* Add more default configurations for various assets.
* 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.

View File

@ -5,7 +5,7 @@ import (
"encoding/json"
"testing"
"code.humancabbage.net/moonmath/bitcoinity"
"code.humancabbage.net/sam/moonmath/bitcoinity"
)
func TestUnmarshalGetTickerResponse(t *testing.T) {
@ -21,3 +21,24 @@ func TestUnmarshalGetTickerResponse(t *testing.T) {
//go:embed get_ticker.json
var getTickerJson []byte
func TestUnmarshalWebhookMessages(t *testing.T) {
var msgs []bitcoinity.Message
err := json.Unmarshal(capturedJson, &msgs)
if err != nil {
t.Errorf("failed to unmarshal webhook messages JSON: %v", err)
}
for _, msg := range msgs {
if msg.Event != "new_msg" {
continue
}
var payload bitcoinity.MarketPayload
err := json.Unmarshal(msg.Payload, &payload)
if err != nil {
t.Errorf("failed to unmarshal market payload JSON: %v", err)
}
}
}
//go:embed captured.json
var capturedJson []byte

View File

@ -0,0 +1 @@
{"statusCode":200,"message":"OK","data":{"BTC":{"iso":"BTC","name":"Bitcoin","slug":"bitcoin","change":{"percent":-6.3,"value":-4187.634882541},"ohlc":{"o":66436.26558132548,"h":66436.26558132548,"l":61526.78744328402,"c":62248.630698784444},"circulatingSupply":19658987,"marketCap":1223745021675.2043,"ts":1710898259000,"src":"tb"}}}

327
coindesk/assets.go Normal file
View File

@ -0,0 +1,327 @@
package coindesk
//goland:noinspection ALL
const (
BTC Asset = "BTC"
ETH Asset = "ETH"
XRP Asset = "XRP"
BCH Asset = "BCH"
EOS Asset = "EOS"
XLM Asset = "XLM"
LTC Asset = "LTC"
ADA Asset = "ADA"
XMR Asset = "XMR"
DASH Asset = "DASH"
IOTA Asset = "IOTA"
TRX Asset = "TRX"
NEO Asset = "NEO"
ETC Asset = "ETC"
XEM Asset = "XEM"
ZEC Asset = "ZEC"
BTG Asset = "BTG"
LSK Asset = "LSK"
QTUM Asset = "QTUM"
BSV Asset = "BSV"
DOGE Asset = "DOGE"
DCR Asset = "DCR"
USDT Asset = "USDT"
USDC Asset = "USDC"
LINK Asset = "LINK"
XTZ Asset = "XTZ"
ZRX Asset = "ZRX"
DAI Asset = "DAI"
BAT Asset = "BAT"
OXT Asset = "OXT"
ALGO Asset = "ALGO"
ATOM Asset = "ATOM"
KNC Asset = "KNC"
OMG Asset = "OMG"
ANT Asset = "ANT"
REP Asset = "REP"
BAND Asset = "BAND"
BTT Asset = "BTT"
MANA Asset = "MANA"
FET Asset = "FET"
ICX Asset = "ICX"
KAVA Asset = "KAVA"
LRC Asset = "LRC"
MKR Asset = "MKR"
MLN Asset = "MLN"
NANO Asset = "NANO"
NMR Asset = "NMR"
PAXG Asset = "PAXG"
USDP Asset = "USDP"
SC Asset = "SC"
STORJ Asset = "STORJ"
WAVES Asset = "WAVES"
FIL Asset = "FIL"
CVC Asset = "CVC"
DNT Asset = "DNT"
REN Asset = "REN"
BNT Asset = "BNT"
WBTC Asset = "WBTC"
GRT Asset = "GRT"
UNI Asset = "UNI"
DOT Asset = "DOT"
YFI Asset = "YFI"
AAVE Asset = "AAVE"
MATIC Asset = "MATIC"
AMP Asset = "AMP"
CELO Asset = "CELO"
COMP Asset = "COMP"
CRV Asset = "CRV"
RLC Asset = "RLC"
KSM Asset = "KSM"
NKN Asset = "NKN"
SHIB Asset = "SHIB"
SKL Asset = "SKL"
SNX Asset = "SNX"
LUNC Asset = "LUNC"
UMA Asset = "UMA"
ICP Asset = "ICP"
SOL Asset = "SOL"
AVAX Asset = "AVAX"
UST Asset = "UST"
ENJ Asset = "ENJ"
IOTX Asset = "IOTX"
AXS Asset = "AXS"
XYO Asset = "XYO"
SUSHI Asset = "SUSHI"
ANKR Asset = "ANKR"
CHZ Asset = "CHZ"
LPT Asset = "LPT"
COTI Asset = "COTI"
KEEP Asset = "KEEP"
SAND Asset = "SAND"
GALA Asset = "GALA"
APE Asset = "APE"
CRO Asset = "CRO"
ACHP Asset = "ACHP"
JASMY Asset = "JASMY"
REQ Asset = "REQ"
SLP Asset = "SLP"
NEAR Asset = "NEAR"
MBOX Asset = "MBOX"
POLIS Asset = "POLIS"
MOVR Asset = "MOVR"
POLS Asset = "POLS"
QUICK Asset = "QUICK"
MINA Asset = "MINA"
IMX Asset = "IMX"
XEC Asset = "XEC"
NEXO Asset = "NEXO"
RUNE Asset = "RUNE"
QNT Asset = "QNT"
VET Asset = "VET"
CAKE Asset = "CAKE"
BNB Asset = "BNB"
THETA Asset = "THETA"
HBAR Asset = "HBAR"
FTM Asset = "FTM"
RVN Asset = "RVN"
ZIL Asset = "ZIL"
DGB Asset = "DGB"
FTT Asset = "FTT"
ENS Asset = "ENS"
WRX Asset = "WRX"
WAXP Asset = "WAXP"
EGLD Asset = "EGLD"
BUSD Asset = "BUSD"
CEL Asset = "CEL"
OP Asset = "OP"
LUNA Asset = "LUNA"
RAY Asset = "RAY"
FLOW Asset = "FLOW"
AUDIO Asset = "AUDIO"
CKB Asset = "CKB"
VGX Asset = "VGX"
YGG Asset = "YGG"
CHR Asset = "CHR"
STMX Asset = "STMX"
SXP Asset = "SXP"
INJ Asset = "INJ"
JOE Asset = "JOE"
POLY Asset = "POLY"
STX Asset = "STX"
SFP Asset = "SFP"
FARM Asset = "FARM"
XVG Asset = "XVG"
CLV Asset = "CLV"
WOO Asset = "WOO"
GLMR Asset = "GLMR"
STEEM Asset = "STEEM"
RARE Asset = "RARE"
IDEX Asset = "IDEX"
SRM Asset = "SRM"
PYR Asset = "PYR"
MIR Asset = "MIR"
SYS Asset = "SYS"
ALPACA Asset = "ALPACA"
QSP Asset = "QSP"
SCRT Asset = "SCRT"
SUN Asset = "SUN"
APT Asset = "APT"
MASK Asset = "MASK"
DYDX Asset = "DYDX"
CVX Asset = "CVX"
GMT Asset = "GMT"
CTSI Asset = "CTSI"
METIS Asset = "METIS"
FORTH Asset = "FORTH"
RBN Asset = "RBN"
SAMO Asset = "SAMO"
SPELL Asset = "SPELL"
LDO Asset = "LDO"
ARB Asset = "ARB"
BLUR Asset = "BLUR"
GAS Asset = "GAS"
RACA Asset = "RACA"
BABYDOGE Asset = "BABYDOGE"
FLOKI Asset = "FLOKI"
HOT Asset = "HOT"
BFC Asset = "BFC"
KISHU Asset = "KISHU"
ELON Asset = "ELON"
SAITAMA Asset = "SAITAMA"
REEF Asset = "REEF"
CEEK Asset = "CEEK"
ATLAS Asset = "ATLAS"
LOOKS Asset = "LOOKS"
WIN Asset = "WIN"
ONE Asset = "ONE"
DENT Asset = "DENT"
GST Asset = "GST"
TWT Asset = "TWT"
HNT Asset = "HNT"
AGLD Asset = "AGLD"
BTRST Asset = "BTRST"
ETHW Asset = "ETHW"
ILV Asset = "ILV"
RARI Asset = "RARI"
STG Asset = "STG"
SYN Asset = "SYN"
TOKE Asset = "TOKE"
BLZ Asset = "BLZ"
FLR Asset = "FLR"
FIS Asset = "FIS"
GNS Asset = "GNS"
ID Asset = "ID"
PEPE Asset = "PEPE"
DIA Asset = "DIA"
TLM Asset = "TLM"
XCN Asset = "XCN"
BIT Asset = "BIT"
RPL Asset = "RPL"
RNDR Asset = "RNDR"
ONEINCH Asset = "1INCH"
BAL Asset = "BAL"
T Asset = "T"
GNO Asset = "GNO"
ASTR Asset = "ASTR"
GLM Asset = "GLM"
OCEAN Asset = "OCEAN"
BICO Asset = "BICO"
CELR Asset = "CELR"
LQTY Asset = "LQTY"
TRAC Asset = "TRAC"
ZEN Asset = "ZEN"
API3 Asset = "API3"
PLA Asset = "PLA"
AXL Asset = "AXL"
HFT Asset = "HFT"
MC Asset = "MC"
C98 Asset = "C98"
GAL Asset = "GAL"
GTC Asset = "GTC"
RAD Asset = "RAD"
POWR Asset = "POWR"
POND Asset = "POND"
ALICE Asset = "ALICE"
TRU Asset = "TRU"
OGN Asset = "OGN"
DAR Asset = "DAR"
BADGER Asset = "BADGER"
GHST Asset = "GHST"
LCX Asset = "LCX"
ARPA Asset = "ARPA"
MXC Asset = "MXC"
PERP Asset = "PERP"
LOKA Asset = "LOKA"
BOBA Asset = "BOBA"
BOND Asset = "BOND"
ALCX Asset = "ALCX"
KP3R Asset = "KP3R"
TON Asset = "TON"
AR Asset = "AR"
AVA Asset = "AVA"
BONE Asset = "BONE"
BONK Asset = "BONK"
CORE Asset = "CORE"
CSPR Asset = "CSPR"
DG Asset = "DG"
ERN Asset = "ERN"
FXS Asset = "FXS"
GMX Asset = "GMX"
GT Asset = "GT"
GUSD Asset = "GUSD"
HMT Asset = "HMT"
HT Asset = "HT"
KCS Asset = "KCS"
KLAY Asset = "KLAY"
LEO Asset = "LEO"
MPL Asset = "MPL"
OKB Asset = "OKB"
PIT Asset = "PIT"
OSMO Asset = "OSMO"
RLY Asset = "RLY"
SANTOS Asset = "SANTOS"
SUI Asset = "SUI"
SWEAT Asset = "SWEAT"
TUSD Asset = "TUSD"
TVK Asset = "TVK"
UNFI Asset = "UNFI"
USDD Asset = "USDD"
VLX Asset = "VLX"
WEMIX Asset = "WEMIX"
XDC Asset = "XDC"
XRD Asset = "XRD"
FB Asset = "FB"
BRISE Asset = "BRISE"
KAS Asset = "KAS"
XEN Asset = "XEN"
HAM Asset = "HAM"
TAMA Asset = "TAMA"
KDA Asset = "KDA"
CFX Asset = "CFX"
VRA Asset = "VRA"
BDX Asset = "BDX"
RDNT Asset = "RDNT"
WLD Asset = "WLD"
AGIX Asset = "AGIX"
PYUSD Asset = "PYUSD"
MOON Asset = "MOON"
SEI Asset = "SEI"
AKT Asset = "AKT"
MAGIC Asset = "MAGIC"
SNT Asset = "SNT"
ALPHA Asset = "ALPHA"
ALI Asset = "ALI"
CQT Asset = "CQT"
HIGH Asset = "HIGH"
AERGO Asset = "AERGO"
GODS Asset = "GODS"
ZBC Asset = "ZBC"
ACA Asset = "ACA"
MDT Asset = "MDT"
LIT Asset = "LIT"
QI Asset = "QI"
AURORA Asset = "AURORA"
TOMI Asset = "TOMI"
XCH Asset = "XCH"
MANTA Asset = "MANTA"
PYTH Asset = "PYTH"
STRK Asset = "STRK"
ETHFI Asset = "ETHFI"
TIA Asset = "TIA"
EETH Asset = "EETH"
)

View File

@ -1,29 +0,0 @@
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()
}

View File

@ -10,13 +10,6 @@ import (
// 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"`

31
coindesk/model_test.go Normal file
View File

@ -0,0 +1,31 @@
package coindesk_test
import (
_ "embed"
"encoding/json"
"testing"
"code.humancabbage.net/sam/moonmath/coindesk"
)
func TestUnmarshalPriceValues(t *testing.T) {
testUnmarshalResponse[coindesk.PriceValues](t, priceValuesJson)
}
func TestUnmarshalAssetTickers(t *testing.T) {
testUnmarshalResponse[coindesk.AssetTickers](t, assetTickersJson)
}
func testUnmarshalResponse[T any](t *testing.T, jsonBytes []byte) {
var resp coindesk.Response[T]
err := json.Unmarshal(jsonBytes, &resp)
if err != nil {
t.Errorf("failed to unmarshal JSON: %v", err)
}
}
//go:embed price_values.json
var priceValuesJson []byte
//go:embed asset_tickers.json
var assetTickersJson []byte

View File

@ -0,0 +1 @@
{"statusCode":200,"message":"OK","data":{"iso":"BTC","name":"Bitcoin","slug":"bitcoin","ingestionStart":"2014-11-03","interval":"15m","src":"tb","entries":[[1710548099000,69784.6599201257],[1710548999000,69907.6435505413],[1710549899000,69735.0244223474],[1710550799000,69608.3777481736],[1710551699000,69643.5872644923],[1710552599000,69389.4883714119],[1710553499000,69350.2996335944],[1710554399000,69241.2719800575],[1710555299000,69110.5930246779],[1710556199000,69046.5706710773],[1710557099000,69209.8770692447],[1710557999000,69069.0204183649],[1710558899000,69317.6327892521],[1710559799000,69175.2057532632],[1710560699000,69237.706076427],[1710561599000,69206.0448006493],[1710562499000,69077.8264056849],[1710563399000,69161.0784263752],[1710564299000,68903.1424009851],[1710565199000,68994.5234182921],[1710566099000,68754.9875193438],[1710566999000,68784.6200740683],[1710567899000,69002.1375650817],[1710568799000,68975.0878307215],[1710569699000,69045.2310776643],[1710570599000,69025.7239355934],[1710571499000,69068.3988090268],[1710572399000,69185.2444741912],[1710573299000,69246.5439171529],[1710574199000,69278.2574941136],[1710575099000,69407.4780576693],[1710575999000,69406.4527711537],[1710576899000,69296.4923727422],[1710577799000,69165.948416751],[1710578699000,69258.7912250956],[1710579599000,69017.1102984366],[1710580499000,69014.4480481396],[1710581399000,69099.3096696255],[1710582299000,69011.246072562],[1710583199000,68942.5343294262],[1710584099000,68697.8235476743],[1710584999000,68597.1119491188],[1710585899000,68532.2931864276],[1710586799000,68420.1712986066],[1710587699000,68354.8847177761],[1710588599000,67949.5219527423],[1710589499000,68327.2101380853],[1710590399000,68260.6576285653],[1710591299000,68192.7968537126],[1710592199000,68302.103987459],[1710593099000,68096.4362930929],[1710593999000,67906.8863092216],[1710594899000,67982.183818933],[1710595799000,68073.3862259585],[1710596699000,67891.8144823227],[1710597599000,67706.923652975],[1710598499000,67942.4174766702],[1710599399000,67848.3672161555],[1710600299000,67975.9404080798],[1710601199000,68177.0804610881],[1710602099000,67994.5903423281],[1710602999000,68024.8507494152],[1710603899000,68201.8594048759],[1710604799000,68358.4188016011],[1710605699000,68239.5960888028],[1710606599000,68224.3438496054],[1710607499000,68293.736596961],[1710608399000,68158.4057208716],[1710609299000,68114.4234280406],[1710610199000,67829.2127954109],[1710611099000,67630.8190396486],[1710611999000,67220.3085172518],[1710612899000,67043.4786652409],[1710613799000,66906.1713676385],[1710614699000,66871.2079622553],[1710615599000,67015.8311895788],[1710616499000,67458.766887969],[1710617399000,67493.9852796663],[1710618299000,67354.2332686561],[1710619199000,66916.4704143146],[1710620099000,66995.7196236024],[1710620999000,67046.4965828003],[1710621899000,67402.0503271179],[1710622799000,67087.3064846683],[1710623699000,66943.270112864],[1710624599000,66268.8656111216],[1710625499000,66290.9133774055],[1710626399000,66189.3483322972],[1710627299000,66073.8513767399],[1710628199000,66433.8329094007],[1710629099000,66549.3543050487],[1710629999000,66331.1108451565],[1710630899000,65376.9600942977],[1710631799000,65280.3933464042],[1710632699000,65598.1187015513],[1710633599000,65261.5180497119],[1710634499000,65780.8283123471]]}}

107
config/config.go Normal file
View File

@ -0,0 +1,107 @@
package config
import (
_ "embed"
"slices"
"time"
"code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/moon"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/rawbytes"
"github.com/knadh/koanf/providers/structs"
"github.com/knadh/koanf/v2"
)
var k = koanf.New(".")
func Load(filePath string) (r Root, err error) {
err = k.Load(structs.Provider(Default, "koanf"), nil)
if err != nil {
return
}
if filePath != "" {
err = k.Load(file.Provider(filePath), yaml.Parser())
if err != nil {
return
}
}
err = k.Unmarshal("", &r)
return
}
var Default Root
type Root struct {
Defaults Asset `koanf:"defaults"`
Assets map[coindesk.Asset]Asset `koanf:"assets"`
}
type Asset struct {
Asset coindesk.Asset `koanf:"asset"`
Goals []moon.Goal `koanf:"goals"`
ConstantBases []moon.ConstantBase `koanf:"constantBases"`
RelativeBases []moon.RelativeBase `koanf:"relativeBases"`
}
func (r Root) ForAsset(a coindesk.Asset) (cfg Asset) {
cfg = merge(r.Assets[a], r.Defaults)
cfg.Asset = a
return
}
func merge(dst, src Asset) Asset {
if len(dst.Goals) == 0 {
dst.Goals = src.Goals
}
if len(dst.ConstantBases) == 0 {
dst.ConstantBases = src.ConstantBases
}
if len(dst.RelativeBases) == 0 {
dst.RelativeBases = src.RelativeBases
}
return dst
}
// GetBases returns the concatenation of the constant and relative bases, sorted
// from most recent to least recent in time.
func GetBases(d *Asset) (bases []moon.Base) {
for _, b := range d.ConstantBases {
bases = append(bases, b)
}
for _, b := range d.RelativeBases {
bases = append(bases, b)
}
now := time.Now()
slices.SortFunc(bases, func(a, b moon.Base) int {
aTime := a.From(now)
bTime := b.From(now)
aFirst := aTime.Before(bTime)
bFirst := bTime.Before(aTime)
switch {
case aFirst:
return 1
case bFirst:
return -1
}
return 0
})
return
}
//go:embed default.yaml
var defaultYamlBytes []byte
func init() {
var k = koanf.New(".")
err := k.Load(rawbytes.Provider(defaultYamlBytes), yaml.Parser())
if err != nil {
panic(err)
}
err = k.Unmarshal("", &Default)
if err != nil {
panic(err)
}
}

10
config/config_test.go Normal file
View File

@ -0,0 +1,10 @@
package config_test
import (
"testing"
)
func TestDefault(t *testing.T) {
// config.Default is parsed in config.init(), which panics on error.
// so just getting this far means that at least it parsed successfully.
}

142
config/default.yaml Normal file
View File

@ -0,0 +1,142 @@
defaults:
goals:
- name: $100k
value: 100000
- name: $150k
value: 150000
- name: $200k
value: 200000
- name: $250k
value: 250000
- name: $300k
value: 300000
- name: $500k
value: 500000
- name: $1m
value: 1000000
relativeBases:
- name: Month
offset: -720h0m0s
- name: Quarter
offset: -2160h0m0s
- name: Half-Year
offset: -4368h0m0s
- name: Year
offset: -8760h0m0s
constantBases:
- name: 2020-
time: 2019-12-31T16:00:00-08:00
- name: 2017-
time: 2016-12-31T16:00:00-08:00
- name: 2013-
time: 2012-12-31T16:00:00-08:00
startingPrice: 13.30
- name: 2011-
time: 2010-12-31T16:00:00-08:00
startingPrice: 0.30
assets:
ETH:
goals:
- name: $5k
value: 5000
- name: $7.5k
value: 7500
- name: $10k
value: 10000
- name: $15k
value: 15000
- name: $20k
value: 20000
- name: $25k
value: 25000
constantBases:
- name: 2020-
time: 2019-12-31T16:00:00-08:00
- name: 2017-
time: 2016-12-31T16:00:00-08:00
LTC:
goals:
- name: $100
value: 100
- name: $150
value: 150
- name: $200
value: 200
- name: $300
value: 300
- name: $500
value: 500
- name: $1k
value: 1000
constantBases:
- name: 2021-
time: 2020-12-31T16:00:00-08:00
SOL:
goals:
- name: $250
value: 250
- name: $375
value: 375
- name: $500
value: 500
- name: $750
value: 750
- name: $1k
value: 1000
- name: $2k
value: 2000
constantBases:
- name: 2022-
time: 2021-12-31T16:00:00-08:00
XRP:
goals:
- name: $1
value: 1
- name: $1.5
value: 1.5
- name: $2
value: 2
- name: $3
value: 3
- name: $5
value: 5
- name: $10
value: 10
constantBases:
- name: 2022-
time: 2021-12-31T16:00:00-08:00
DOGE:
goals:
- name: $1
value: 1
- name: $1.5
value: 1.5
- name: $2
value: 2
- name: $3
value: 3
- name: $5
value: 5
- name: $10
value: 10
constantBases:
- name: 2022-
time: 2021-12-31T16:00:00-08:00
ADA:
goals:
- name: $1
value: 1
- name: $1.5
value: 1.5
- name: $2
value: 2
- name: $3
value: 3
- name: $5
value: 5
- name: $10
value: 10
constantBases:
- name: 2022-
time: 2021-12-31T16:00:00-08:00

19
go.mod
View File

@ -1,12 +1,18 @@
module code.humancabbage.net/moonmath
module code.humancabbage.net/sam/moonmath
go 1.22.1
require (
github.com/alecthomas/kong v0.9.0
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/knadh/koanf/parsers/yaml v0.1.0
github.com/knadh/koanf/providers/file v0.1.0
github.com/knadh/koanf/providers/rawbytes v0.1.0
github.com/knadh/koanf/providers/structs v0.1.0
github.com/knadh/koanf/v2 v2.1.0
github.com/sourcegraph/conc v0.3.0
golang.org/x/net v0.22.0
)
@ -14,19 +20,26 @@ require (
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/containerd/console v1.0.4 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
github.com/knadh/koanf/maps v0.1.1 // indirect
github.com/kr/text v0.2.0 // 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/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // 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
go.uber.org/multierr v1.11.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
gopkg.in/yaml.v3 v3.0.1 // indirect
)

49
go.sum
View File

@ -1,3 +1,9 @@
github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA=
github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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=
@ -10,9 +16,33 @@ github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMt
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c=
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w=
github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY=
github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf3ODrc//Lp+c=
github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA=
github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME=
github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c=
github.com/knadh/koanf/providers/structs v0.1.0 h1:wJRteCNn1qvLtE5h8KQBvLJovidSdntfdyIbbCzEyE0=
github.com/knadh/koanf/providers/structs v0.1.0/go.mod h1:sw2YZ3txUcqA3Z27gPlmmBzWn1h8Nt9O6EP/91MkcWE=
github.com/knadh/koanf/v2 v2.1.0 h1:eh4QmHHBuU8BybfIJ8mB8K8gsGCD/AUQTdwGq/GzId8=
github.com/knadh/koanf/v2 v2.1.0/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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=
@ -22,6 +52,10 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
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/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
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=
@ -36,16 +70,14 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
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=
@ -58,5 +90,8 @@ 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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -2,31 +2,37 @@ package moon
import (
"context"
"errors"
"fmt"
"math"
"time"
"code.humancabbage.net/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/coindesk"
"github.com/sourcegraph/conc/pool"
)
type Math struct {
Asset coindesk.Asset
CurrentPrice coindesk.Price
Columns []Column
Goals []float64
Goals []Goal
Labels []string
}
func NewMath(goals []float64, bases []Base) (m Math) {
if goals == nil {
goals = DefaultGoals
}
if bases == nil {
bases = DefaultBases
func NewMath(asset coindesk.Asset, goals []Goal, bases []Base) (m Math) {
if goals == nil || bases == nil {
panic("goals and bases must be set")
}
m.Asset = asset
m.Goals = goals
m.Labels = []string{"Starting", "CDPR"}
m.Columns = make([]Column, len(bases))
for i := range bases {
m.Columns[i].Base = bases[i]
}
for i := range goals {
m.Labels = append(m.Labels, goals[i].Name)
}
return
}
@ -43,7 +49,7 @@ type Projection struct {
}
func ProjectDates(
from time.Time, currentPrice float64, cdpr float64, goals []float64,
from time.Time, currentPrice float64, cdpr float64, goals []Goal,
) (p Projection) {
if cdpr <= 0 {
return
@ -51,7 +57,7 @@ func ProjectDates(
logP := math.Log(currentPrice)
logR := math.Log(cdpr)
for _, goal := range goals {
daysToGo := (math.Log(goal) - logP) / logR
daysToGo := (math.Log(goal.Value) - logP) / logR
date := from.Add(time.Hour * 24 * time.Duration(daysToGo))
p.Dates = append(p.Dates, date)
}
@ -60,37 +66,20 @@ func ProjectDates(
}
func (m *Math) Refresh(ctx context.Context) (err error) {
resp, err := coindesk.GetAssetTickers(ctx, coindesk.BTC)
resp, err := coindesk.GetAssetTickers(ctx, m.Asset)
if err != nil {
return
}
m.CurrentPrice = resp.Data[coindesk.BTC].OHLC.Closing
m.CurrentPrice = resp.Data[m.Asset].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
return c.project(ctx, m, now)
})
}
err = tasks.Wait()
@ -107,58 +96,96 @@ type Column struct {
Projections Projection
}
var DefaultGoals = []float64{
100000,
150000,
200000,
250000,
300000,
500000,
1000000,
func (c *Column) project(ctx context.Context, m *Math, now time.Time) (err error) {
err = c.fillStartingPrice(ctx, m.Asset, now)
if err != nil {
return
}
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)},
c.Gain = float64(m.CurrentPrice) / float64(c.StartingPrice)
days := now.Sub(c.StartingDate).Hours() / 24
c.CDPR = CDPR(days, c.Gain)
c.Projections = ProjectDates(
now, float64(m.CurrentPrice),
c.CDPR, m.Goals,
)
return
}
func (c *Column) fillStartingPrice(
ctx context.Context, asset coindesk.Asset, now time.Time,
) error {
// if base provides a hardcoded starting price, use it
c.StartingDate = c.Base.From(now)
c.StartingPrice = coindesk.Price(c.Base.GetStartingPrice())
if c.StartingPrice != 0 {
return nil
}
// otherwise, look up the starting price via Coindesk
nextDay := c.StartingDate.Add(time.Hour * 24)
resp, err := coindesk.GetPriceValues(ctx, asset, c.StartingDate, nextDay)
if err != nil {
err = fmt.Errorf("getting price for %s on %v: %w",
asset, c.StartingDate, err)
return err
}
if len(resp.Data.Entries) == 0 {
c.Projections.Dates = nil
return errEmptyPriceEntries
}
c.StartingPrice = resp.Data.Entries[0].Price
return nil
}
var errEmptyPriceEntries = errors.New("price values response has no entries")
type Goal struct {
Name string `koanf:"name"`
Value float64 `koanf:"value"`
}
// Base is a temporal point of comparison used for price projection.
type Base interface {
From(now time.Time) time.Time
Name() string
Label() string
GetStartingPrice() float64
}
// ConstantBase is a base that is a constant time, e.g. 2020-01-01.
type ConstantBase struct {
name string
time time.Time
Name string `koanf:"name"`
Time time.Time `koanf:"time"`
StartingPrice float64 `koanf:"startingPrice"`
}
func (cb ConstantBase) From(_ time.Time) time.Time {
return cb.time
return cb.Time
}
func (cb ConstantBase) Name() string {
return cb.name
func (cb ConstantBase) Label() string {
return cb.Name
}
// RelativeBase is a base that is relative, e.g. "90 days ago."
func (cb ConstantBase) GetStartingPrice() float64 {
return cb.StartingPrice
}
// RelativeBase is a base that is relative, e.g. "90 days ago".
type RelativeBase struct {
name string
offset time.Duration
Name string `koanf:"name"`
Offset time.Duration `koanf:"offset"`
}
func (rb RelativeBase) From(now time.Time) time.Time {
then := now.Add(time.Duration(rb.offset))
then := now.Add(rb.Offset)
return then
}
func (rb RelativeBase) Name() string {
return rb.name
func (rb RelativeBase) Label() string {
return rb.Name
}
func (rb RelativeBase) GetStartingPrice() float64 {
return 0
}

View File

@ -4,20 +4,21 @@ import (
"testing"
"time"
"code.humancabbage.net/moonmath/moon"
"code.humancabbage.net/sam/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 := moon.ProjectDates(time.Now(), 68900, 1.0055, []moon.Goal{
{"$100k", 100000},
{"$150k", 150000},
{"$200k", 200000},
{"$250k", 250000},
{"$300k", 300000},
{"$500k", 500000},
{"$1m", 1000000},
})
_ = p
}

View File

@ -1,147 +1,84 @@
package main
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"time"
"path/filepath"
"strings"
"code.humancabbage.net/moonmath/moon"
"github.com/charmbracelet/bubbles/table"
"code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/tui"
"github.com/alecthomas/kong"
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)+2)
for i := range projectionRows {
projectionRows[i] = make(table.Row, len(projectionCols))
}
projectionRows[0][0] = "Starting"
projectionRows[1][0] = "CDPR"
for i := range math.Goals {
projectionRows[i+2][0] = fmt.Sprintf("$%.0f", math.Goals[i])
}
projections := table.New(
table.WithColumns(projectionCols),
table.WithRows(projectionRows),
table.WithHeight(len(math.Goals)+2),
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].StartingPrice)
rows[1][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+2][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"
var CLI struct {
Asset []string `short:"a" default:"BTC" help:"Asset(s) to project."`
ConfigFile string `short:"c" help:"Path to YAML configuration file."`
Perf bool `help:"Display internal performance stats."`
}
func main() {
p := tea.NewProgram(initialModel())
logFile := setupLogging()
defer func() {
_ = logFile.Close()
}()
ctx := kong.Parse(&CLI)
if ctx.Error != nil {
fail(ctx.Error)
}
allCfg, err := config.Load(CLI.ConfigFile)
if err != nil {
fail(err)
}
var assets []coindesk.Asset
for i := range CLI.Asset {
asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i]))
assets = append(assets, asset)
}
m := tui.New(assets, allCfg, CLI.Perf)
p := tea.NewProgram(m,
tea.WithAltScreen(),
tea.WithFPS(30),
)
if _, err := p.Run(); err != nil {
fail(err)
}
}
func setupLogging() io.Closer {
homePath, err := os.UserHomeDir()
if err != nil {
panic(err)
}
programConfigPath := filepath.Join(homePath, ".moonmath")
err = os.MkdirAll(programConfigPath, 0755)
if err != nil {
panic(err)
}
errLogPath := filepath.Join(programConfigPath, "errors.log")
errLogFile, err := os.OpenFile(
errLogPath,
os.O_CREATE|os.O_APPEND|os.O_WRONLY,
0600,
)
if err != nil {
panic(err)
}
slog.SetDefault(slog.New(
slog.NewTextHandler(errLogFile, &slog.HandlerOptions{
Level: slog.LevelError,
})))
return errLogFile
}
func fail(err error) {
fmt.Printf("program error: %v\n", err)
os.Exit(1)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 38 KiB

268
tui/asset/asset.go Normal file
View File

@ -0,0 +1,268 @@
package asset
import (
"context"
"fmt"
"log/slog"
"time"
"code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/moon"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Model struct {
math moon.Math
refreshing bool
indicator spinner.Model
properties table.Model
projections table.Model
}
type Msg struct {
Asset coindesk.Asset
inner tea.Msg
}
func New(cfg config.Asset) (m Model) {
m.math = moon.NewMath(
cfg.Asset,
cfg.Goals,
config.GetBases(&cfg),
)
tableStyle := table.DefaultStyles()
tableStyle.Selected = tableStyle.Cell.Copy().
Padding(0)
tableStyle.Header = tableStyle.Header.
Bold(true).
Foreground(lipgloss.Color("214")).
Border(baseStyle.GetBorderStyle(), false, false, true, false)
// properties table
m.properties = table.New(
table.WithColumns([]table.Column{
{Title: "Property", Width: 9},
{Title: "Value", Width: 9},
}),
table.WithHeight(2),
table.WithStyles(tableStyle),
)
// projections table
labelsWidth := 0
for _, l := range m.math.Labels {
if len(l) > labelsWidth {
labelsWidth = len(l)
}
}
projectionCols := []table.Column{
{Title: "Labels", Width: labelsWidth},
}
for _, c := range m.math.Columns {
projectionCols = append(projectionCols,
table.Column{
Title: c.Base.Label(),
Width: 10,
})
}
m.projections = table.New(
table.WithColumns(projectionCols),
table.WithHeight(len(m.math.Labels)),
table.WithStyles(tableStyle),
)
// indicator spinner
m.indicator = spinner.New()
m.indicator.Spinner = spinner.Points
m.indicator.Style = indicatorNormalStyle
return
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.indicator.Tick,
func() tea.Msg {
return Msg{m.math.Asset, refresh{}}
},
)
}
func (m Model) Handles(a coindesk.Asset) bool {
return m.math.Asset == a
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case Msg:
switch msg := msg.inner.(type) {
case refresh:
m.refreshing = true
m.indicator.Style = indicatorNormalStyle
return m, tea.Batch(
m.resumeIndicator,
m.refresh,
m.scheduleRefresh(),
)
case update:
var cmd tea.Cmd
if msg.err == nil {
m.math = msg.math
refillProperties(&m)
refillProjections(&m)
cmd = m.stopIndicator()
} else {
m.indicator.Style = indicatorErrorStyle
}
return m, cmd
case stopIndicator:
m.refreshing = false
return m, nil
}
case spinner.TickMsg:
if !m.refreshing {
return m, nil
}
var cmd tea.Cmd
m.indicator, cmd = m.indicator.Update(msg)
return m, cmd
}
return m, nil
}
type refresh struct{}
type update struct {
math moon.Math
err error
}
type stopIndicator struct{}
func (m Model) refresh() tea.Msg {
ctx, cancel := context.WithTimeout(
context.Background(),
refreshTimeout)
defer cancel()
err := m.math.Refresh(ctx)
if err != nil {
slog.Error("refresh",
"asset", m.math.Asset,
"err", err,
)
}
return Msg{m.math.Asset, update{m.math, err}}
}
func (m Model) resumeIndicator() tea.Msg {
return m.indicator.Tick()
}
func (m Model) scheduleRefresh() tea.Cmd {
return tea.Tick(refreshInterval,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, refresh{}}
})
}
func (m Model) stopIndicator() tea.Cmd {
// wait a bit to stop the indicator, so that it's more obvious
// even when the refresh completes quickly.
return tea.Tick(stopIndicatorDelay,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, stopIndicator{}}
})
}
var refreshInterval = time.Second * 30
var refreshTimeout = time.Second * 15
var stopIndicatorDelay = time.Millisecond * 500
func refillProperties(m *Model) {
rows := []table.Row{
{"Asset", string(m.math.Asset)},
{"Price", fmt.Sprintf("$%0.2f", m.math.CurrentPrice)},
}
m.properties.SetRows(rows)
}
func refillProjections(m *Model) {
cols := []table.Row{m.math.Labels}
for i := range m.math.Columns {
entries := renderEntries(m.math.Columns[i])
cols = append(cols, entries)
}
rows := transpose(cols)
m.projections.SetRows(rows)
}
func renderEntries(c moon.Column) (entries []string) {
entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice))
entries = append(entries, fmt.Sprintf("%.4f%%", (c.CDPR-1)*100))
never := c.CDPR <= 1
for i := range c.Projections.Dates {
var cell string
if never {
cell = "NEVER!!!!!"
} else {
cell = c.
Projections.
Dates[i].
Format("2006-01-02")
}
entries = append(entries, cell)
}
return
}
func transpose(slice []table.Row) []table.Row {
xl := len(slice[0])
yl := len(slice)
result := make([]table.Row, xl)
for i := range result {
result[i] = make(table.Row, yl)
}
for i := 0; i < xl; i++ {
for j := 0; j < yl; j++ {
result[i][j] = slice[j][i]
}
}
return result
}
func (m Model) View() string {
var s string
indicator := ""
if m.refreshing {
indicator = m.indicator.View()
}
right := lipgloss.JoinVertical(
lipgloss.Center,
baseStyle.Render(m.properties.View()),
indicator,
)
s += lipgloss.JoinHorizontal(
lipgloss.Center,
right,
baseStyle.Render(m.projections.View()),
)
return s + "\n"
}
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
var indicatorNormalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
var indicatorErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("160"))

42
tui/perf/perf.go Normal file
View File

@ -0,0 +1,42 @@
package perf
import (
"fmt"
"sync/atomic"
tea "github.com/charmbracelet/bubbletea"
)
type Model struct {
updates *atomic.Int64
views *atomic.Int64
}
func New() (m Model) {
m.updates = new(atomic.Int64)
m.views = new(atomic.Int64)
return
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(_ tea.Msg) (Model, tea.Cmd) {
return m, nil
}
func (m Model) View() string {
updates := m.updates.Load()
views := m.views.Load()
s := fmt.Sprintf("updates: %d\tviews: %d", updates, views)
return s
}
func (m Model) AddUpdate() {
m.updates.Add(1)
}
func (m Model) AddView() {
m.views.Add(1)
}

99
tui/tui.go Normal file
View File

@ -0,0 +1,99 @@
package tui
import (
"fmt"
"code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/tui/asset"
"code.humancabbage.net/sam/moonmath/tui/perf"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Model struct {
assets []asset.Model
stats perf.Model
displayStats bool
}
func New(assets []coindesk.Asset, cfg config.Root, displayStats bool) (m Model) {
m.stats = perf.New()
m.displayStats = displayStats
// construct models for each asset, but remove dupes
seen := map[coindesk.Asset]struct{}{}
for _, a := range assets {
if _, ok := seen[a]; ok {
continue
}
assetCfg := cfg.ForAsset(a)
assetModel := asset.New(assetCfg)
m.assets = append(m.assets, assetModel)
seen[a] = struct{}{}
}
return
}
func (m Model) Init() tea.Cmd {
// initialize child models, collecting their commands,
// then return them all in a batch
var inits []tea.Cmd
for i := range m.assets {
cmd := m.assets[i].Init()
inits = append(inits, cmd)
}
return tea.Batch(inits...)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stats.AddUpdate()
switch msg := msg.(type) {
// handle keys for quitting
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
}
// forward asset messages to the appropriate model
case asset.Msg:
cmd := m.forward(msg.Asset, msg)
return m, cmd
// forward any other message to each child model.
// typically, this is for animation.
default:
var commands []tea.Cmd
for i := range m.assets {
var cmd tea.Cmd
m.assets[i], cmd = m.assets[i].Update(msg)
commands = append(commands, cmd)
}
return m, tea.Batch(commands...)
}
return m, nil
}
func (m Model) View() string {
m.stats.AddView()
var ss []string
for i := range m.assets {
s := m.assets[i].View()
ss = append(ss, s)
}
if m.displayStats {
ss = append(ss, m.stats.View())
}
r := lipgloss.JoinVertical(lipgloss.Center, ss...)
return r
}
func (m Model) forward(a coindesk.Asset, msg tea.Msg) (cmd tea.Cmd) {
// O(n) is fine when n is small
for i := range m.assets {
if !m.assets[i].Handles(a) {
continue
}
m.assets[i], cmd = m.assets[i].Update(msg)
return
}
panic(fmt.Errorf("rogue message: %v", msg))
}