commit 23ed509200e2c5ae19740924957cd16bf821820c Author: Sam Fredrickson Date: Sun Mar 17 02:10:05 2024 -0700 Initial public commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00253cc --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/moonmath diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..af57857 --- /dev/null +++ b/.vscode/launch.json @@ -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}" + } + + + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b1fd0e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "cSpell.words": [ + "bitcoinity", + "Bitfinex", + "Bitstamp", + "bubbletea", + "CDPR", + "charmbracelet", + "Coindesk", + "lipgloss", + "moonmath", + "OHLC", + "sourcegraph" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f501a82 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa26ce9 --- /dev/null +++ b/README.md @@ -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. diff --git a/bitcoinity/client.go b/bitcoinity/client.go new file mode 100644 index 0000000..a67c62a --- /dev/null +++ b/bitcoinity/client.go @@ -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") +} diff --git a/bitcoinity/get_ticker.json b/bitcoinity/get_ticker.json new file mode 100644 index 0000000..44bdbed --- /dev/null +++ b/bitcoinity/get_ticker.json @@ -0,0 +1 @@ +{"ticker_life":604800,"volume_resolution":16800,"lasts":[[1709964000000,68475.175456638],[1709967600000,68626.4197800843],[1709971200000,68499.9257360174],[1709974800000,68421.009926165],[1709978400000,68294.0262620086],[1709982000000,68407.5464117156],[1709985600000,68472.031319471],[1709989200000,68572.9757857644],[1709992800000,68438.1716470681],[1709996400000,68426.1125233728],[1710000000000,68308.9215891684],[1710003600000,68285.7647690601],[1710007200000,68408.7159320073],[1710010800000,68397.6048978898],[1710014400000,68487.9638212605],[1710018000000,68496.5038475367],[1710021600000,68540.4920150493],[1710025200000,68506.8734373123],[1710028800000,68481.6942999111],[1710032400000,69040.7820535749],[1710036000000,69171.7318773701],[1710039600000,69086.1129943591],[1710043200000,69575.32472086],[1710046800000,69516.1427381054],[1710050400000,69398.9234834021],[1710054000000,69466.353745482],[1710057600000,69616.6701906639],[1710061200000,69686.1609871012],[1710064800000,69906.5009484715],[1710068400000,69783.6490516607],[1710072000000,69836.9576301872],[1710075600000,69718.7369308144],[1710079200000,69303.9592128747],[1710082800000,69371.2205952118],[1710086400000,69634.3811792792],[1710090000000,69585.0275914514],[1710093600000,69504.8172303926],[1710097200000,69435.8344990212],[1710100800000,69449.5420157399],[1710104400000,69353.4319500268],[1710108000000,68814.6601063395],[1710111600000,68774.335417703],[1710115200000,68234.883079493],[1710118800000,68090.5031206901],[1710122400000,68454.4265317288],[1710126000000,68510.4754354591],[1710129600000,68639.5654746363],[1710133200000,68672.389028808],[1710136800000,68787.9403844506],[1710140400000,70553.2960143432],[1710144000000,71366.3641423778],[1710147600000,71575.2726869142],[1710151200000,71693.5468379788],[1710154800000,71823.6197639393],[1710158400000,71879.4350659567],[1710162000000,71989.322576165],[1710165600000,72076.1832867116],[1710169200000,72380.7144313385],[1710172800000,72406.3048316385],[1710176400000,72486.7488551685],[1710180000000,72472.6563169571],[1710183600000,72421.582937338],[1710187200000,72148.610607249],[1710190800000,72293.6885360615],[1710194400000,72602.1145958952],[1710198000000,72270.8372805915],[1710201600000,72243.6425123162],[1710205200000,72295.753970148],[1710208800000,72267.0415535571],[1710212400000,71799.2409874039],[1710216000000,71807.5133778969],[1710219600000,71922.4876928696],[1710223200000,71823.8607063431],[1710226800000,72196.149507455],[1710230400000,71777.0029119539],[1710234000000,71942.6965307599],[1710237600000,71836.7117110554],[1710241200000,72129.2186949844],[1710244800000,71958.8280105228],[1710248400000,72103.2238484688],[1710252000000,72140.4368883156],[1710255600000,71680.1687418175],[1710259200000,70747.0218963375],[1710262800000,69831.5292617162],[1710266400000,71020.8227625354],[1710270000000,71510.0368509183],[1710273600000,71063.7243890294],[1710277200000,70913.5734506227],[1710280800000,71064.180027509],[1710284400000,71360.2118171413],[1710288000000,71491.3073949422],[1710291600000,71752.4674498549],[1710295200000,71982.5032592749],[1710298800000,72069.3411065831],[1710302400000,72134.9948118648],[1710306000000,72162.9927569381],[1710309600000,72494.363874665],[1710313200000,72953.1451456856],[1710316800000,73392.7700960008],[1710320400000,73430.4451953545],[1710324000000,73286.9601293485],[1710327600000,73304.5250382642],[1710331200000,72961.4163091399],[1710334800000,72543.1262683019],[1710338400000,72550.8794047277],[1710342000000,72770.2855180774],[1710345600000,72740.8119360035],[1710349200000,72986.6418843774],[1710352800000,73095.7647801359],[1710356400000,73134.5200663425],[1710360000000,73275.7428861803],[1710363600000,73214.3208155899],[1710367200000,73102.3823462233],[1710370800000,73069.2916192971],[1710374400000,73011.318660849],[1710378000000,72770.4073414757],[1710381600000,73040.8689109992],[1710385200000,73241.0739310015],[1710388800000,73253.0027495011],[1710392400000,73036.6778028695],[1710396000000,73457.9565808573],[1710399600000,73456.1906982901],[1710403200000,73453.4468810549],[1710406800000,73380.2477991086],[1710410400000,73144.2858733999],[1710414000000,72850.1741419157],[1710417600000,72776.0708790565],[1710421200000,72068.988906122],[1710424800000,71883.2157989861],[1710428400000,71177.4377271873],[1710432000000,70653.1071111006],[1710435600000,70974.5864070723],[1710439200000,70191.097809766],[1710442800000,69399.8909191733],[1710446400000,70395.5871379673],[1710450000000,70734.3573848902],[1710453600000,71410.2966559239],[1710457200000,71520.3538827916],[1710460800000,71827.4449974313],[1710464400000,71814.0100289761],[1710468000000,69467.4527672066],[1710471600000,68112.2789974225],[1710475200000,67742.8109533334],[1710478800000,67694.5728798296],[1710482400000,68093.6361699623],[1710486000000,68422.8101879976],[1710489600000,67741.0151388665],[1710493200000,66702.2557727286],[1710496800000,67611.2455601347],[1710500400000,67209.7504688287],[1710504000000,67682.4801633597],[1710507600000,68033.2811673063],[1710511200000,68168.6172453867],[1710514800000,68368.7297709666],[1710518400000,67958.0525079573],[1710522000000,68027.8200304039],[1710525600000,69120.2995542052],[1710529200000,69629.648031024],[1710532800000,68120.4746177027],[1710536400000,68125.3774427754],[1710540000000,68589.3493624246],[1710543600000,69296.4416776616],[1710547200000,69813.9583085164],[1710550800000,69453.7966481559],[1710554400000,69151.4217090963],[1710558000000,69212.3371480973],[1710561600000,69036.0448883277],[1710565200000,68805.393405542]],"mixed_lasts":null,"buys":[[1709964000000,68687.2],[1709967600000,68700.41],[1709971200000,68599.83],[1709974800000,68527.18],[1709978400000,68459.42],[1709982000000,68500.0],[1709985600000,68666.54],[1709989200000,68687.94],[1709992800000,68574.75],[1709996400000,68516.45],[1710000000000,68494.04],[1710003600000,68389.4],[1710007200000,68525.75],[1710010800000,68508.08],[1710014400000,68549.0],[1710018000000,68597.34],[1710021600000,68598.37],[1710025200000,68590.95],[1710028800000,68647.76],[1710032400000,69399.89],[1710036000000,69369.0],[1710039600000,69282.32],[1710043200000,69790.45],[1710046800000,69672.88],[1710050400000,69523.83],[1710054000000,69573.94],[1710057600000,69817.83],[1710061200000,69970.0],[1710064800000,70000.0],[1710068400000,69978.33],[1710072000000,69978.29],[1710075600000,69962.19],[1710079200000,69612.74],[1710082800000,69559.08],[1710086400000,69893.59],[1710090000000,69730.21],[1710093600000,69615.06],[1710097200000,69586.14],[1710100800000,69592.12],[1710104400000,69514.04],[1710108000000,69218.48],[1710111600000,69065.32],[1710115200000,69038.7],[1710118800000,68428.06],[1710122400000,68559.04],[1710126000000,68660.4],[1710129600000,68838.04],[1710133200000,68835.0],[1710136800000,69372.81],[1710140400000,71312.53],[1710144000000,71700.0],[1710147600000,71855.03],[1710151200000,71859.97],[1710154800000,72303.04],[1710158400000,72218.0],[1710162000000,72422.82],[1710165600000,72447.14],[1710169200000,72764.36],[1710172800000,72689.52],[1710176400000,72675.46],[1710180000000,72712.88],[1710183600000,72943.98],[1710187200000,72324.99],[1710190800000,72620.62],[1710194400000,72788.0],[1710198000000,72500.54],[1710201600000,72372.67],[1710205200000,72505.1],[1710208800000,72477.35],[1710212400000,72270.0],[1710216000000,72095.27],[1710219600000,72087.91],[1710223200000,72294.11],[1710226800000,72375.33],[1710230400000,72227.4],[1710234000000,72119.29],[1710237600000,72015.31],[1710241200000,72355.0],[1710244800000,72281.1],[1710248400000,72355.41],[1710252000000,73027.63],[1710255600000,72081.95],[1710259200000,71836.71],[1710262800000,71136.58],[1710266400000,71493.76],[1710270000000,71709.07],[1710273600000,71440.03],[1710277200000,71161.91],[1710280800000,71294.35],[1710284400000,71562.9],[1710288000000,71768.72],[1710291600000,72142.98],[1710295200000,72107.8],[1710298800000,72241.98],[1710302400000,72244.53],[1710306000000,72244.53],[1710309600000,72770.0],[1710313200000,73229.87],[1710316800000,73709.99],[1710320400000,73688.0],[1710324000000,73438.43],[1710327600000,73513.0],[1710331200000,73229.64],[1710334800000,73095.87],[1710338400000,72999.86],[1710342000000,73167.54],[1710345600000,73118.21],[1710349200000,73291.05],[1710352800000,73396.72],[1710356400000,73541.02],[1710360000000,73549.48],[1710363600000,73456.77],[1710367200000,73355.84],[1710370800000,73188.35],[1710374400000,73235.82],[1710378000000,72998.05],[1710381600000,73267.67],[1710385200000,73373.32],[1710388800000,73732.64],[1710392400000,73297.06],[1710396000000,73657.67],[1710399600000,73835.57],[1710403200000,73606.17],[1710406800000,73550.68],[1710410400000,73375.76],[1710414000000,73072.07],[1710417600000,73096.88],[1710421200000,72958.0],[1710424800000,72531.93],[1710428400000,72062.84],[1710432000000,71408.54],[1710435600000,71408.89],[1710439200000,71006.82],[1710442800000,70428.86],[1710446400000,71149.23],[1710450000000,71091.99],[1710453600000,71634.23],[1710457200000,71706.81],[1710460800000,72414.96],[1710464400000,72030.71],[1710468000000,71702.27],[1710471600000,69449.76],[1710475200000,68478.57],[1710478800000,68205.68],[1710482400000,68622.81],[1710486000000,68811.82],[1710489600000,68552.07],[1710493200000,68064.02],[1710496800000,68065.48],[1710500400000,67833.34],[1710504000000,67987.64],[1710507600000,68637.84],[1710511200000,68632.15],[1710514800000,68731.93],[1710518400000,68422.84],[1710522000000,68337.55],[1710525600000,70517.35],[1710529200000,70647.85],[1710532800000,69029.42],[1710536400000,68299.89],[1710540000000,69210.94],[1710543600000,69765.52],[1710547200000,70050.0],[1710550800000,69886.9],[1710554400000,69318.01],[1710558000000,69430.31],[1710561600000,69295.99],[1710565200000,69044.92]],"sells":[[1709964000000,68305.31],[1709967600000,68424.77],[1709971200000,68426.58],[1709974800000,68300.01],[1709978400000,68109.16],[1709982000000,68293.53],[1709985600000,68350.0],[1709989200000,68486.14],[1709992800000,68266.93],[1709996400000,68312.38],[1710000000000,68174.5],[1710003600000,68163.94],[1710007200000,68277.28],[1710010800000,68308.35],[1710014400000,68378.0],[1710018000000,68370.19],[1710021600000,68495.98],[1710025200000,68414.98],[1710028800000,68367.3],[1710032400000,68478.35],[1710036000000,68925.02],[1710039600000,68853.23],[1710043200000,69159.63],[1710046800000,69333.34],[1710050400000,69189.51],[1710054000000,69377.04],[1710057600000,69456.25],[1710061200000,69345.67],[1710064800000,69700.03],[1710068400000,69563.58],[1710072000000,69572.84],[1710075600000,69265.47],[1710079200000,68816.0],[1710082800000,69113.2],[1710086400000,69314.55],[1710090000000,69477.01],[1710093600000,69333.33],[1710097200000,69254.23],[1710100800000,69356.3],[1710104400000,69200.0],[1710108000000,68221.13],[1710111600000,68465.51],[1710115200000,67112.21],[1710118800000,67636.62],[1710122400000,68280.63],[1710126000000,68314.32],[1710129600000,68501.01],[1710133200000,68529.98],[1710136800000,68483.51],[1710140400000,69294.31],[1710144000000,71003.6],[1710147600000,71162.99],[1710151200000,71489.8],[1710154800000,71330.01],[1710158400000,71446.66],[1710162000000,71350.55],[1710165600000,71508.58],[1710169200000,72053.59],[1710172800000,71850.01],[1710176400000,72275.97],[1710180000000,72253.85],[1710183600000,71810.14],[1710187200000,71883.72],[1710190800000,72066.96],[1710194400000,72408.97],[1710198000000,72084.8],[1710201600000,72005.51],[1710205200000,71933.21],[1710208800000,72143.07],[1710212400000,71422.37],[1710216000000,71324.02],[1710219600000,71706.39],[1710223200000,71450.01],[1710226800000,72022.3],[1710230400000,71432.68],[1710234000000,71720.96],[1710237600000,71647.18],[1710241200000,71843.39],[1710244800000,71462.26],[1710248400000,71710.01],[1710252000000,71622.36],[1710255600000,70852.94],[1710259200000,70080.0],[1710262800000,68603.0],[1710266400000,70351.07],[1710270000000,71209.73],[1710273600000,70679.22],[1710277200000,70641.76],[1710280800000,70696.9],[1710284400000,71042.13],[1710288000000,71337.3],[1710291600000,71381.9],[1710295200000,71900.0],[1710298800000,71912.42],[1710302400000,71935.47],[1710306000000,72019.78],[1710309600000,72138.43],[1710313200000,72678.23],[1710316800000,72879.58],[1710320400000,73188.0],[1710324000000,73165.69],[1710327600000,73088.0],[1710331200000,72469.39],[1710334800000,71700.0],[1710338400000,72051.95],[1710342000000,72350.01],[1710345600000,72410.0],[1710349200000,72753.93],[1710352800000,72811.0],[1710356400000,72863.98],[1710360000000,73000.79],[1710363600000,73021.95],[1710367200000,72884.17],[1710370800000,72939.85],[1710374400000,72800.01],[1710378000000,72530.01],[1710381600000,72851.47],[1710385200000,73048.0],[1710388800000,72750.0],[1710392400000,72782.82],[1710396000000,73182.08],[1710399600000,73147.43],[1710403200000,73260.92],[1710406800000,73185.95],[1710410400000,72885.67],[1710414000000,72664.21],[1710417600000,72470.03],[1710421200000,71441.49],[1710424800000,71155.3],[1710428400000,70501.11],[1710432000000,69813.74],[1710435600000,70643.75],[1710439200000,69300.0],[1710442800000,68454.47],[1710446400000,69168.74],[1710450000000,70229.01],[1710453600000,71046.29],[1710457200000,71334.74],[1710460800000,71226.6],[1710464400000,71534.62],[1710468000000,67920.76],[1710471600000,66729.59],[1710475200000,66848.35],[1710478800000,67070.55],[1710482400000,67374.41],[1710486000000,68078.6],[1710489600000,66970.03],[1710493200000,65565.7],[1710496800000,67172.53],[1710500400000,66696.97],[1710504000000,67411.58],[1710507600000,67379.08],[1710511200000,67814.68],[1710514800000,68102.44],[1710518400000,67500.0],[1710522000000,67604.66],[1710525600000,68191.15],[1710529200000,68543.5],[1710532800000,67423.23],[1710536400000,67804.16],[1710540000000,68191.01],[1710543600000,68791.52],[1710547200000,69413.26],[1710550800000,69051.44],[1710554400000,68913.28],[1710558000000,68965.04],[1710561600000,68796.25],[1710565200000,68589.24]],"price_high":"73835.57","price_low":"65565.70","price_change":"0.48","volume":[[1709954400000,54140347.8057589],[1709971200000,128568714.5682704],[1709988000000,137917775.1257295],[1710004800000,87006824.6201314],[1710021600000,187487255.3040126],[1710038400000,169290240.3140007],[1710055200000,157582818.2607879],[1710072000000,167673416.09453028],[1710088800000,96365849.93703389],[1710105600000,281521608.01542497],[1710122400000,98723352.2583466],[1710139200000,604581446.7700331],[1710156000000,635688958.4949399],[1710172800000,660804644.6176729],[1710189600000,211919388.163924],[1710206400000,183866183.3168899],[1710223200000,154899518.7801852],[1710240000000,544534857.297393],[1710256800000,980404982.515352],[1710273600000,199575821.1975757],[1710290400000,158250334.28557873],[1710307200000,245624787.23434103],[1710324000000,379516869.0720066],[1710340800000,550914008.4902794],[1710357600000,193199059.4069826],[1710374400000,189148587.42480868],[1710391200000,203021814.1975727],[1710408000000,346281210.8443646],[1710424800000,831883113.0070027],[1710441600000,865377709.9212136],[1710458400000,635172581.4621086],[1710475200000,508256301.8164131],[1710492000000,565620807.0176792],[1710508800000,351742902.98374397],[1710525600000,636412289.7386522],[1710542400000,199245807.6176506],[1710559200000,43057372.559788495]]} diff --git a/bitcoinity/model.go b/bitcoinity/model.go new file mode 100644 index 0000000..d6c32f1 --- /dev/null +++ b/bitcoinity/model.go @@ -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) +} diff --git a/bitcoinity/model_test.go b/bitcoinity/model_test.go new file mode 100644 index 0000000..1b1b842 --- /dev/null +++ b/bitcoinity/model_test.go @@ -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 diff --git a/bitcoinity/websocket.go b/bitcoinity/websocket.go new file mode 100644 index 0000000..c1c4061 --- /dev/null +++ b/bitcoinity/websocket.go @@ -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 + } +} diff --git a/coindesk/lib_test.go b/coindesk/lib_test.go new file mode 100644 index 0000000..4a205d3 --- /dev/null +++ b/coindesk/lib_test.go @@ -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() +} diff --git a/coindesk/model.go b/coindesk/model.go new file mode 100644 index 0000000..72c0e40 --- /dev/null +++ b/coindesk/model.go @@ -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 +} diff --git a/coindesk/requests.go b/coindesk/requests.go new file mode 100644 index 0000000..367d83b --- /dev/null +++ b/coindesk/requests.go @@ -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") +} diff --git a/example.json b/example.json new file mode 100644 index 0000000..ded1e51 --- /dev/null +++ b/example.json @@ -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" + } +] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df8bcb7 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e21b16d --- /dev/null +++ b/go.sum @@ -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= diff --git a/moon/moon.go b/moon/moon.go new file mode 100644 index 0000000..a1f87b3 --- /dev/null +++ b/moon/moon.go @@ -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 +} diff --git a/moon/moon_test.go b/moon/moon_test.go new file mode 100644 index 0000000..19b6543 --- /dev/null +++ b/moon/moon_test.go @@ -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 +} diff --git a/moonmath.go b/moonmath.go new file mode 100644 index 0000000..54ef8ba --- /dev/null +++ b/moonmath.go @@ -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) + } +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..26f9b36 Binary files /dev/null and b/screenshot.png differ