Initial Checkin
This commit is contained in:
commit
22a430239f
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
*.log
|
||||
tmp/
|
||||
|
||||
.cpcache/
|
||||
.nrepl-port
|
||||
target/
|
||||
result
|
1073
deps-lock.json
Normal file
1073
deps-lock.json
Normal file
File diff suppressed because it is too large
Load Diff
33
deps.edn
Normal file
33
deps.edn
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
:paths ["src"]
|
||||
:deps {
|
||||
org.clojure/clojure { :mvn/version "1.11.1" }
|
||||
|
||||
org.fudo/bebot {
|
||||
:git/url "https://git.fudo.org/fudo-public/bebot.git"
|
||||
:sha "6cda6ea9d4f2e0b751e88072134264fb888c8114"
|
||||
}
|
||||
org.fudo/fudo-clojure {
|
||||
:git/url "https://git.fudo.org/fudo-public/fudo-clojure.git"
|
||||
:sha "c6a1ebef2e5b64d432a46ac48639c674e62b7cee"
|
||||
}
|
||||
|
||||
org.fudo/coinbase-pro-client {
|
||||
:git/url "https://git.fudo.org/fudo-public/coinbase-pro-client.git"
|
||||
:sha "d6f2884c23508d5f9d13ef13004cfb32b94f8ece"
|
||||
}
|
||||
|
||||
org.clojure/math.numeric-tower { :mvn/version "0.0.5" }
|
||||
|
||||
camel-snake-kebab/camel-snake-kebab { :mvn/version "0.4.2" }
|
||||
}
|
||||
:aliases {
|
||||
:run {
|
||||
:main-opts ["-m" "pricebot.core"]
|
||||
}
|
||||
:lint {
|
||||
:replace-deps { clj-kondo/clj-kondo {:mvn/version "2022.04.25"} }
|
||||
:main-opts ["-m" "clj-kondo.main" "--lint" "src"]
|
||||
}
|
||||
}
|
||||
}
|
117
flake.lock
generated
Normal file
117
flake.lock
generated
Normal file
@ -0,0 +1,117 @@
|
||||
{
|
||||
"nodes": {
|
||||
"clj-nix": {
|
||||
"inputs": {
|
||||
"devshell": "devshell",
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1655125606,
|
||||
"narHash": "sha256-mnqND6/PZMug5Jr92adJcNmwS+XYtVvtKjuowK9A9ec=",
|
||||
"owner": "jlesquembre",
|
||||
"repo": "clj-nix",
|
||||
"rev": "77302aa77afa25e24292aa54eec31e70caa4faf0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "jlesquembre",
|
||||
"repo": "clj-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"clj-nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1644227066,
|
||||
"narHash": "sha256-FHcFZtpZEWnUh62xlyY3jfXAXHzJNEDLDzLsJxn+ve0=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "7033f64dd9ef8d9d8644c5030c73913351d2b660",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1642700792,
|
||||
"narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "846b2ae0fc4cc943637d3d1def4454213e203cba",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"locked": {
|
||||
"lastModified": 1644229661,
|
||||
"narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1655278232,
|
||||
"narHash": "sha256-H6s7tnHYiDKFCcLADS4sl1sUq0dDJuRQXCieguk/6SA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8b538fcb329a7bc3d153962f17c509ee49166973",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-22.05",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"clj-nix": "clj-nix",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"locked": {
|
||||
"lastModified": 1653893745,
|
||||
"narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
40
flake.nix
Normal file
40
flake.nix
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
description = "Fudo Coinbase Price Bot.";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "nixpkgs/nixos-22.05";
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
clj-nix = {
|
||||
url = "github:jlesquembre/clj-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, utils, clj-nix, ... }:
|
||||
utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages."${system}";
|
||||
cljpkgs = clj-nix.packages."${system}";
|
||||
update-deps = pkgs.writeShellScriptBin "update-deps.sh" ''
|
||||
${clj-nix.packages."${system}".deps-lock}/bin/deps-lock
|
||||
'';
|
||||
in {
|
||||
packages = {
|
||||
fudo-pricebot = cljpkgs.mkCljBin {
|
||||
projectSrc = ./.;
|
||||
name = "org.fudo/pricebot";
|
||||
main-ns = "pricebot.core";
|
||||
jdkRunner = pkgs.jdk17_headless;
|
||||
version = "0.1";
|
||||
};
|
||||
};
|
||||
|
||||
defaultPackage = self.packages."${system}".fudo-pricebot;
|
||||
|
||||
nixosModule =
|
||||
import ./module.nix self.packages."${system}".fudo-pricebot;
|
||||
|
||||
devShell =
|
||||
pkgs.mkShell { buildInputs = with pkgs; [ clojure update-deps ]; };
|
||||
});
|
||||
}
|
89
module.nix
Normal file
89
module.nix
Normal file
@ -0,0 +1,89 @@
|
||||
pricebot-jar:
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.fudo.pricebot;
|
||||
|
||||
botJobOpts = { name, ... }: {
|
||||
options = with types; {
|
||||
mattermost-channel-id = mkOption {
|
||||
type = str;
|
||||
description = "Channel ID in which to post updates.";
|
||||
};
|
||||
|
||||
currency = mkOption {
|
||||
type = str;
|
||||
description = "Cryptocurrency to watch for price changes.";
|
||||
default = name;
|
||||
};
|
||||
|
||||
notify-user = mkOption {
|
||||
type = str;
|
||||
description = "Mattermost username to notify of important events.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
in {
|
||||
options.fudo.pricebot = with types; {
|
||||
enable = mkEnableOption "Enable PriceBot.";
|
||||
|
||||
exchange-host = mkOption {
|
||||
type = str;
|
||||
description = "Coinbase Pro host to contact for prices.";
|
||||
default = "api.exchange.coinbase.com";
|
||||
};
|
||||
|
||||
mattermost-url = mkOption {
|
||||
type = str;
|
||||
description = "Mattermost host on which to emit price notifications.";
|
||||
};
|
||||
|
||||
mattermost-auth-token-file = mkOption {
|
||||
type = str;
|
||||
description =
|
||||
"File (on the local system) in which to find the auth token to pass to Mattermost.";
|
||||
};
|
||||
|
||||
monitors = mkOption {
|
||||
type = attrsOf (submodule botJobOpts);
|
||||
description = "Map of currency to notify options.";
|
||||
default = { };
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
systemd.services = mapAttrs' (currency: opts:
|
||||
nameValuePair "pricebot-${currency}" {
|
||||
description =
|
||||
"PriceBot for watching ${currency} prices and reporting them.";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
environment = {
|
||||
PRICEBOT_EXCHANGE_HOST = cfg.exchange-host;
|
||||
PRICEBOT_BEBOT_URL = cfg.mattermost-url;
|
||||
PRICEBOT_BEBOT_AUTH_TOKEN_FILE = cfg.mattermost-auth-token-file;
|
||||
PRICEBOT_BEBOT_CHANNEL_ID = opts.mattermost-channel-id;
|
||||
PRICEBOT_TARGET_CURRENCY = opts.currency;
|
||||
PRICEBOT_NOTIFY_USER = opts.notify-user;
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.jre}/bin/java -jar ${pricebot-jar}";
|
||||
DynamicUser = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectControlGroups = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectHostname = true;
|
||||
ProtectHome = true;
|
||||
ProtectClock = true;
|
||||
ProtectKernelLogs = true;
|
||||
Restart = "always";
|
||||
StandardOutput = "journal";
|
||||
};
|
||||
}) cfg.monitors;
|
||||
};
|
||||
}
|
187
src/pricebot/core.clj
Normal file
187
src/pricebot/core.clj
Normal file
@ -0,0 +1,187 @@
|
||||
(ns pricebot.core
|
||||
(:require [clojure.core.async :as async :refer [go go-loop >! >!! <! <!! chan timeout alt!]]
|
||||
[bebot.client :as bebot-client]
|
||||
[bebot.api.channel :as bebot]
|
||||
[bebot.logger :as bebot-log]
|
||||
[coinbase-pro.client :as coinbase-pro]
|
||||
[exchange.client :as client]
|
||||
[fudo-clojure.common :refer [*-> date<]]
|
||||
[fudo-clojure.result :as result :refer [map-success send-success dispatch-result success? unwrap]]
|
||||
[fudo-clojure.logging :as log]
|
||||
[clojure.string :as str]
|
||||
[clojure.math.numeric-tower :refer [floor]])
|
||||
(:import java.math.RoundingMode
|
||||
java.time.format.DateTimeFormatter
|
||||
java.time.Duration
|
||||
java.time.Instant
|
||||
java.time.ZoneId)
|
||||
(:gen-class))
|
||||
|
||||
(defprotocol IPriceBotProcess
|
||||
(stop! [self])
|
||||
(start! [self]))
|
||||
|
||||
(defprotocol IPriceChannel
|
||||
(get-price-channel [self]))
|
||||
|
||||
(defrecord PriceMeasurement [instant price])
|
||||
|
||||
(defn- make-price-channel [hostname currency &
|
||||
{:keys [delay buffer-size]
|
||||
:or {delay 60
|
||||
buffer-size 10}}]
|
||||
(let [stop-chan (chan)
|
||||
price-chan (chan 10)
|
||||
client (coinbase-pro/connect :hostname hostname)
|
||||
get-price #(client/get-market-price! client currency)]
|
||||
(reify
|
||||
IPriceBotProcess
|
||||
(stop! [self]
|
||||
(go (>! stop-chan true)
|
||||
(>! price-chan :stopped))
|
||||
self)
|
||||
(start! [self]
|
||||
(go-loop [result (get-price)]
|
||||
(>! price-chan (map-success result (partial ->PriceMeasurement (Instant/now))))
|
||||
(alt! stop-chan nil
|
||||
(timeout (* delay 1000)) (recur (get-price))))
|
||||
self)
|
||||
|
||||
IPriceChannel
|
||||
(get-price-channel [_] price-chan))))
|
||||
|
||||
;; Bots can have a list of price checkers that take current price & price
|
||||
;; history, and potentially return a message to send to the channel
|
||||
|
||||
(defn- lpthru [label o]
|
||||
(println (str "****** " label))
|
||||
(clojure.pprint/pprint o)
|
||||
(println "******")
|
||||
o)
|
||||
|
||||
(let [formatter (.withZone DateTimeFormatter/ISO_LOCAL_TIME (ZoneId/systemDefault))]
|
||||
(defn- format-instant [inst]
|
||||
(.format formatter inst)))
|
||||
|
||||
(defn- check-threshold [threshold report! & {:keys [mute-duration]
|
||||
:or {mute-duration (Duration/ofHours 1)}}]
|
||||
(let [prev (atom nil)
|
||||
threshold-dec (bigdec threshold)
|
||||
chop-threshold (fn [n] (int (floor (.divide n threshold-dec RoundingMode/DOWN))))
|
||||
mute-until (atom (Instant/now))
|
||||
unmuted? (fn [time] (.after @mute-until time))]
|
||||
(fn [curr]
|
||||
(when @prev
|
||||
(when (unmuted? (:instant curr))
|
||||
(when (not (= (chop-threshold (:price @prev))
|
||||
(chop-threshold (:price curr))))
|
||||
(report! (str "price crossed a " threshold " threshold "
|
||||
"between " (format-instant (:instant @prev)) " and " (format-instant (:instant curr)) ", "
|
||||
"from " (:price @prev) " to " (:price curr)))
|
||||
(report! (str "muting until " (.plus (:instant curr) mute-duration)))
|
||||
(swap! mute-until (fn [_] (.plus (:instant curr) mute-duration))))))
|
||||
(swap! prev (fn [_] curr)))))
|
||||
|
||||
(defn- minimize-time-gap [prices threshold target]
|
||||
(let [smaller-duration? (fn [a b] (> 0 (.compareTo a b)))
|
||||
get-gap (fn [m] (Duration/between target (:instant m)))]
|
||||
(when-let [initial-measure (first prices)]
|
||||
(loop [curr initial-measure
|
||||
curr-gap (get-gap initial-measure)
|
||||
remaining (rest prices)]
|
||||
(if (empty? remaining)
|
||||
curr
|
||||
(if (smaller-duration? curr-gap (get-gap (first remaining)))
|
||||
(recur (first remaining) (get-gap (first remaining)) (rest remaining))
|
||||
curr))))))
|
||||
|
||||
(defn- check-movement [percentage duration threshold report! debug!]
|
||||
(let [prices (atom (list))
|
||||
within-duration-of (fn [curr m]
|
||||
(date< (-> (:instant curr) (.minus duration) (.minus threshold))
|
||||
(:instant m)))
|
||||
append-to-prices (fn [prices curr] (cons curr (filter (partial within-duration-of curr) prices)))
|
||||
close-enough? (fn [target t] (date< (.minus target threshold) t (.plus target threshold)))]
|
||||
(fn [curr]
|
||||
(let [target-time (.minus (:instant curr) duration)
|
||||
prev (minimize-time-gap @prices threshold target-time)]
|
||||
(if prev
|
||||
(let [diff-pct (.divide (- (:price curr) (:price prev)) (:price curr) RoundingMode/HALF_EVEN)]
|
||||
(if (close-enough? target-time (:instant prev))
|
||||
(when (> (Math/abs diff-pct) percentage)
|
||||
(report! (str "price has changed by " (* 100 diff-pct) "% "
|
||||
"within " duration ": "
|
||||
(:price prev) " to " (:price curr))))
|
||||
(debug! (str "unable to find a measure within " threshold " of " target-time)))))
|
||||
(swap! prices append-to-prices curr)))))
|
||||
|
||||
(defn- create-checks [logger]
|
||||
(let [debug! (partial log/debug! logger)]
|
||||
[(check-threshold 100 (partial log/info! logger))
|
||||
(check-threshold 1000 (partial log/alert! logger))
|
||||
(check-movement 0.01 (Duration/ofHours 3) (Duration/ofMinutes 15) (partial log/info! logger) debug!)
|
||||
(check-movement 0.03 (Duration/ofHours 6) (Duration/ofMinutes 30) (partial log/notify! logger) debug!)
|
||||
(check-movement 0.05 (Duration/ofHours 6) (Duration/ofMinutes 30) (partial log/alert! logger) debug!)]))
|
||||
|
||||
(defprotocol IPriceBot
|
||||
(add-check! [self check])
|
||||
(remove-check! [self id]))
|
||||
|
||||
(defn- reify-bot
|
||||
([hostname currency] (reify-bot hostname currency []))
|
||||
([hostname currency initial-checks]
|
||||
(let [price-chan (atom nil)
|
||||
checks (atom (zipmap (repeatedly #(java.util.UUID/randomUUID)) initial-checks))]
|
||||
(reify
|
||||
IPriceBotProcess
|
||||
(start! [_]
|
||||
(swap! price-chan (fn [_] (make-price-channel hostname currency)))
|
||||
(let [chan (get-price-channel @price-chan)]
|
||||
(go-loop [measure (<! chan)]
|
||||
(when (not (= measure :stopped))
|
||||
(doseq [[id check] @checks]
|
||||
(send-success measure check))
|
||||
(recur (<! chan)))))
|
||||
(start! @price-chan)
|
||||
:started)
|
||||
(stop! [_]
|
||||
(swap! price-chan (fn [chan] (when chan (stop! chan))))
|
||||
:stopped)
|
||||
|
||||
IPriceBot
|
||||
(add-check! [_ f]
|
||||
(swap! checks assoc (java.util.UUID/randomUUID) f))
|
||||
(remove-check! [_ id]
|
||||
(swap! checks dissoc id))))))
|
||||
|
||||
(defn- make-bot [& {:keys [hostname currency logger]}]
|
||||
(let [checks (create-checks logger)]
|
||||
(reify-bot hostname hostname currency checks)))
|
||||
|
||||
(defn- getenv-or-fail [var]
|
||||
(if-let [val (System/getenv var)]
|
||||
val
|
||||
(throw (ex-info (str "Failed to read environment variable: " var) {}))))
|
||||
|
||||
(defn -main [& args]
|
||||
(let [exchange-hostname (getenv-or-fail "PRICEBOT_EXCHANGE_HOST")
|
||||
bebot-host (getenv-or-fail "PRICEBOT_BEBOT_URL")
|
||||
bebot-auth-token (-> (getenv-or-fail "PRICEBOT_BEBOT_AUTH_TOKEN_FILE")
|
||||
(slurp))
|
||||
bebot-channel-id (getenv-or-fail "PRICEBOT_BEBOT_CHANNEL_ID")
|
||||
target-currency (-> (getenv-or-fail "PRICEBOT_TARGET_CURRENCY")
|
||||
(str/lower-case)
|
||||
(keyword))
|
||||
notify-user (getenv-or-fail "PRICEBOT_NOTIFY_USER")]
|
||||
(let [logger (bebot-log/make-logger bebot-host bebot-auth-token bebot-channel-id notify-user)
|
||||
checks (create-checks logger)
|
||||
bot (reify-bot exchange-hostname target-currency)
|
||||
shutdown (chan)]
|
||||
(.addShutdownHook (Runtime/getRuntime)
|
||||
(Thread. (fn []
|
||||
(println "Stopping pricebot...")
|
||||
(stop! bot)
|
||||
(>!! shutdown true))))
|
||||
(start! bot)
|
||||
(<!! shutdown)
|
||||
(System/exit 0))))
|
Loading…
Reference in New Issue
Block a user