Compare commits
9
Commits
b49e8afd59
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c56b7cc3 | ||
|
|
0caa8db0b3 | ||
|
|
93073820a2 | ||
|
|
bba9652ced | ||
|
|
b6c8745a86 | ||
|
|
42454646bb | ||
|
|
44bb9a68d4 | ||
|
|
ae65ea3722 | ||
|
|
f27c198634 |
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS buys;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE buys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id BIGINT NOT NULL REFERENCES events(id) UNIQUE,
|
||||
occurred_at DATE NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
amount_eur NUMERIC(18,2) NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE buys DROP COLUMN fee_sats;
|
||||
--;;
|
||||
ALTER TABLE buys DROP COLUMN sats;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE buys ADD COLUMN sats BIGINT NOT NULL DEFAULT 0;
|
||||
--;;
|
||||
ALTER TABLE buys ADD COLUMN fee_sats BIGINT;
|
||||
@@ -155,6 +155,40 @@ SELECT id, event_id, occurred_at, exchange, fiat_amount, fiat_currency,
|
||||
FROM deposits
|
||||
ORDER BY occurred_at DESC, id DESC
|
||||
|
||||
-- :name truncate-buys! :! :n
|
||||
-- :doc Delete all rows from the buys read table
|
||||
TRUNCATE buys
|
||||
|
||||
-- :name get-buy-events :? :*
|
||||
-- :doc Get exchange_to_wallet events with wallet name, oldest first
|
||||
SELECT e.id, e.occurred_at, e.sats, e.fee_sats, e.fiat_amount, e.fiat_currency,
|
||||
e.to_wallet_id, tw.name AS wallet
|
||||
FROM events e
|
||||
LEFT JOIN wallets tw ON tw.id = e.to_wallet_id
|
||||
WHERE e.event_type = 'exchange_to_wallet'
|
||||
ORDER BY e.occurred_at ASC, e.id ASC
|
||||
|
||||
-- :name insert-buy! :! :n
|
||||
-- :doc Insert a projected buy row
|
||||
INSERT INTO buys (event_id, occurred_at, wallet, sats, fee_sats, amount_eur)
|
||||
VALUES (:event-id, :occurred-at, :wallet, :sats, :fee-sats, :amount-eur)
|
||||
|
||||
-- :name get-all-buys :? :*
|
||||
-- :doc Get all projected buys ordered by date descending
|
||||
SELECT id, event_id, occurred_at, wallet, sats, fee_sats, amount_eur
|
||||
FROM buys
|
||||
ORDER BY occurred_at DESC, id DESC
|
||||
|
||||
-- :name get-kraken-hour-latest-24 :? :*
|
||||
-- :doc Get the 24 most recent Kraken hourly candles
|
||||
SELECT ts, open, high, low, close, vwap, volume, trade_count, created_at
|
||||
FROM kraken_hour ORDER BY ts DESC LIMIT 24
|
||||
|
||||
-- :name get-kraken-minute-latest-60 :? :*
|
||||
-- :doc Get the 60 most recent Kraken minute candles
|
||||
SELECT ts, open, high, low, close, vwap, volume, trade_count, created_at
|
||||
FROM kraken_minute ORDER BY ts DESC LIMIT 60
|
||||
|
||||
-- :name upsert-kraken-minute! :! :n
|
||||
-- :doc Upsert a Kraken minute OHLC candle
|
||||
INSERT INTO kraken_minute (ts, open, high, low, close, vwap, volume, trade_count)
|
||||
|
||||
@@ -54,53 +54,38 @@
|
||||
:ts))
|
||||
|
||||
(defn- poll!
|
||||
"Fetch OHLC data, drop the last (in-progress) candle, save completed ones.
|
||||
"Fetch OHLC data, upsert candles. On initial fetch saves all completed candles
|
||||
(drops last in-progress). On subsequent fetches saves last 5 including in-progress.
|
||||
Returns the count of saved candles."
|
||||
[client query-fn since-atom]
|
||||
[client query-fn since-atom initial?]
|
||||
(let [result (fetch-ohlc client @since-atom)
|
||||
;; Kraken returns a map with the pair key and a "last" key
|
||||
last-ts (:last result)
|
||||
pair-key (first (remove #{:last} (keys result)))
|
||||
raw (get result pair-key)
|
||||
candles (map parse-candle (butlast raw))]
|
||||
candles (map parse-candle (if initial? (butlast raw) (take-last 5 raw)))]
|
||||
(when (seq candles)
|
||||
(save-candles! query-fn candles)
|
||||
(when last-ts
|
||||
(reset! since-atom last-ts))
|
||||
(log/info "Fetched" (count candles) "completed Kraken hourly candles"))
|
||||
(log/info "Fetched" (count candles) "Kraken hourly candles"))
|
||||
(count candles)))
|
||||
|
||||
(defn- ms-until-next-poll
|
||||
"Milliseconds from now until next HH:01:00 UTC."
|
||||
[]
|
||||
(let [now (java.time.ZonedDateTime/now java.time.ZoneOffset/UTC)
|
||||
next (-> now
|
||||
(.truncatedTo java.time.temporal.ChronoUnit/HOURS)
|
||||
(.plusMinutes 1))
|
||||
target (if (.isAfter now next)
|
||||
(.plusHours next 1)
|
||||
next)]
|
||||
(.toMillis (java.time.Duration/between now target))))
|
||||
|
||||
(defn- start-poll-loop!
|
||||
"Start a background future that polls Kraken aligned to HH:01:00 UTC.
|
||||
When `has-data?` is false, fetches immediately to backfill."
|
||||
[client query-fn since-atom running? has-data?]
|
||||
"Start a background future that polls Kraken every 60 seconds."
|
||||
[client query-fn since-atom running?]
|
||||
(future
|
||||
(when-not has-data?
|
||||
(try
|
||||
(poll! client query-fn since-atom)
|
||||
(catch Exception e
|
||||
(log/error e "Kraken OHLC initial poll failed"))))
|
||||
(try
|
||||
(poll! client query-fn since-atom true)
|
||||
(catch Exception e
|
||||
(log/error e "Kraken OHLC initial poll failed")))
|
||||
(while @running?
|
||||
(let [wait (ms-until-next-poll)]
|
||||
(log/info "Next Kraken OHLC poll in" (quot wait 60000) "minutes")
|
||||
(Thread/sleep wait)
|
||||
(when @running?
|
||||
(try
|
||||
(poll! client query-fn since-atom)
|
||||
(catch Exception e
|
||||
(log/error e "Kraken OHLC poll failed"))))))))
|
||||
(Thread/sleep 60000)
|
||||
(when @running?
|
||||
(try
|
||||
(poll! client query-fn since-atom false)
|
||||
(catch Exception e
|
||||
(log/error e "Kraken OHLC poll failed")))))))
|
||||
|
||||
(defmethod ig/init-key :kraken/ohlc
|
||||
[_ {:keys [query-fn enabled?]}]
|
||||
@@ -111,11 +96,8 @@
|
||||
db-since (seed-since-from-db query-fn)
|
||||
since (atom db-since)
|
||||
running? (atom true)
|
||||
has-data? (some? db-since)
|
||||
fut (start-poll-loop! client query-fn since running? has-data?)]
|
||||
(if has-data?
|
||||
(log/info "Starting Kraken OHLC poller, next fetch in" (quot (ms-until-next-poll) 60000) "minutes")
|
||||
(log/info "Starting Kraken OHLC poller, fetching immediately (no data)"))
|
||||
fut (start-poll-loop! client query-fn since running?)]
|
||||
(log/info "Starting Kraken OHLC poller (60s interval)")
|
||||
{:running? running?
|
||||
:future fut
|
||||
:since since})))
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
[java.net URI]
|
||||
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]))
|
||||
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
|
||||
[java.time Instant ZoneOffset]
|
||||
[java.time.format DateTimeFormatter]))
|
||||
|
||||
(def ^:private kraken-ohlc-url
|
||||
"https://api.kraken.com/0/public/OHLC?pair=XBTUSD&interval=1")
|
||||
@@ -55,18 +57,23 @@
|
||||
|
||||
(defn- poll!
|
||||
"Fetch OHLC data including the last in-progress candle, upsert all.
|
||||
On initial fetch saves everything; subsequent fetches only take last 5.
|
||||
Returns the count of saved candles."
|
||||
[client query-fn since-atom]
|
||||
[client query-fn since-atom initial?]
|
||||
(let [result (fetch-ohlc client @since-atom)
|
||||
last-ts (:last result)
|
||||
pair-key (first (remove #{:last} (keys result)))
|
||||
raw (get result pair-key)
|
||||
candles (map parse-candle raw)]
|
||||
candles (map parse-candle (if initial? raw (take-last 5 raw)))]
|
||||
(when (seq candles)
|
||||
(save-candles! query-fn candles)
|
||||
(when last-ts
|
||||
(reset! since-atom last-ts))
|
||||
(log/info "Fetched" (count candles) "Kraken minute candles"))
|
||||
(let [fmt (DateTimeFormatter/ofPattern "HH:mm")
|
||||
c (last candles)
|
||||
ts-str (.format (.atOffset (Instant/ofEpochSecond (:ts c)) ZoneOffset/UTC) fmt)]
|
||||
(log/info "Kraken minute:" (count candles) "candles, latest" ts-str "UTC"
|
||||
"O" (str (:open c)) "H" (str (:high c)) "L" (str (:low c)) "C" (str (:close c)))))
|
||||
(count candles)))
|
||||
|
||||
(defn- start-poll-loop!
|
||||
@@ -74,14 +81,14 @@
|
||||
[client query-fn since-atom running?]
|
||||
(future
|
||||
(try
|
||||
(poll! client query-fn since-atom)
|
||||
(poll! client query-fn since-atom true)
|
||||
(catch Exception e
|
||||
(log/error e "Kraken minute initial poll failed")))
|
||||
(while @running?
|
||||
(Thread/sleep 15000)
|
||||
(when @running?
|
||||
(try
|
||||
(poll! client query-fn since-atom)
|
||||
(poll! client query-fn since-atom false)
|
||||
(catch Exception e
|
||||
(log/error e "Kraken minute poll failed")))))))
|
||||
|
||||
|
||||
@@ -69,6 +69,29 @@
|
||||
(catch Exception e
|
||||
(log/error e "Failed to project deposit for event" event-id))))
|
||||
|
||||
(defn- project-buy!
|
||||
"Project an exchange_to_wallet event into the buys read table."
|
||||
[{:keys [query-fn frankfurter]} event-id occurred-at to-wallet-id sats fee-sats fiat-amount fiat-currency]
|
||||
(try
|
||||
(let [wallet-name (or (:name (query-fn :get-wallet-by-id {:id to-wallet-id})) "Unknown")
|
||||
amount-eur (if (= fiat-currency "EUR")
|
||||
(bigdec fiat-amount)
|
||||
(let [rates (or (query-fn :get-currency-rates-by-date {:rate-date occurred-at})
|
||||
(rates/fetch-and-persist-for-date!
|
||||
(:client frankfurter) (:url frankfurter)
|
||||
query-fn occurred-at))]
|
||||
(:amount-eur (convert-amount fiat-amount fiat-currency rates))))]
|
||||
(when amount-eur
|
||||
(query-fn :insert-buy!
|
||||
{:event-id event-id
|
||||
:occurred-at occurred-at
|
||||
:wallet wallet-name
|
||||
:sats (or sats 0)
|
||||
:fee-sats fee-sats
|
||||
:amount-eur amount-eur})))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to project buy for event" event-id))))
|
||||
|
||||
(defn create-event! [{:keys [query-fn] :as opts} req]
|
||||
(let [params (:body-params req)
|
||||
event-type (:event_type params)]
|
||||
@@ -93,6 +116,13 @@
|
||||
(:fiat_amount params)
|
||||
(:fiat_currency params)
|
||||
(:note params)))
|
||||
(when (and (= event-type "exchange_to_wallet") (:fiat_amount params))
|
||||
(project-buy! opts (:id result) occurred-at
|
||||
(some-> (:to_wallet_id params) UUID/fromString)
|
||||
(:sats params)
|
||||
(:fee_sats params)
|
||||
(:fiat_amount params)
|
||||
(:fiat_currency params)))
|
||||
(response/created "/api/events" {:status "ok"})))))
|
||||
|
||||
(defn list-deposits [{:keys [query-fn]} _req]
|
||||
@@ -128,6 +158,40 @@
|
||||
0 events)]
|
||||
(response/ok {:rebuilt n})))
|
||||
|
||||
(defn list-buys [{:keys [query-fn]} _req]
|
||||
(response/ok (query-fn :get-all-buys {})))
|
||||
|
||||
(defn rebuild-buys! [{:keys [query-fn frankfurter]} _req]
|
||||
(query-fn :truncate-buys! {})
|
||||
(let [events (query-fn :get-buy-events {})
|
||||
n (reduce
|
||||
(fn [cnt {:keys [id occurred_at sats fee_sats fiat_amount fiat_currency wallet]}]
|
||||
(if-not fiat_amount
|
||||
cnt
|
||||
(try
|
||||
(let [amount-eur
|
||||
(if (= fiat_currency "EUR")
|
||||
(bigdec fiat_amount)
|
||||
(let [rates (or (query-fn :get-currency-rates-by-date {:rate-date occurred_at})
|
||||
(rates/fetch-and-persist-for-date!
|
||||
(:client frankfurter) (:url frankfurter)
|
||||
query-fn occurred_at))]
|
||||
(:amount-eur (convert-amount fiat_amount fiat_currency rates))))]
|
||||
(when amount-eur
|
||||
(query-fn :insert-buy!
|
||||
{:event-id id
|
||||
:occurred-at occurred_at
|
||||
:wallet (or wallet "Unknown")
|
||||
:sats (or sats 0)
|
||||
:fee-sats fee_sats
|
||||
:amount-eur amount-eur}))
|
||||
(inc cnt))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to rebuild buy for event" id)
|
||||
cnt))))
|
||||
0 events)]
|
||||
(response/ok {:rebuilt n})))
|
||||
|
||||
(defn list-events [{:keys [query-fn]} req]
|
||||
(let [event-type (get-in req [:query-params "type"])]
|
||||
(if event-type
|
||||
|
||||
@@ -87,10 +87,14 @@
|
||||
{:on-open (fn [{:keys [^WebSocketChannel channel]}]
|
||||
(start-strike-ws-loop! channel strike-atom))}}))
|
||||
|
||||
(defn- latest-price-handler [{:keys [binance frankfurter]} _req]
|
||||
(defn- latest-price-handler [{:keys [binance frankfurter query-fn]} _req]
|
||||
(let [price-atom (:latest-price binance)
|
||||
rates-atom (:rates frankfurter)
|
||||
v @price-atom]
|
||||
v (or @price-atom
|
||||
(when-let [row (query-fn :get-latest-binance-price {})]
|
||||
{:price (:price row)
|
||||
:prev-price (:price row)
|
||||
:recorded-at (:recorded_at row)}))]
|
||||
(if v
|
||||
{:status 200
|
||||
:headers {"Content-Type" "application/json"}
|
||||
@@ -144,6 +148,31 @@
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (json/write-str {:error "Invalid date format, use YYYY-MM-DD"})}))))
|
||||
|
||||
(def ^:private ohlc-fmt
|
||||
(java.time.format.DateTimeFormatter/ofPattern "yyyy-MM-dd HH:mm:ss"))
|
||||
|
||||
(defn- ohlc-row->map [r]
|
||||
{:ts (:ts r)
|
||||
:datetime (.format (.atOffset (java.time.Instant/ofEpochSecond (:ts r))
|
||||
java.time.ZoneOffset/UTC) ohlc-fmt)
|
||||
:open (str (:open r))
|
||||
:high (str (:high r))
|
||||
:low (str (:low r))
|
||||
:close (str (:close r))
|
||||
:vwap (str (:vwap r))
|
||||
:volume (str (:volume r))
|
||||
:trade_count (:trade_count r)})
|
||||
|
||||
(defn- kraken-hour-handler [{:keys [query-fn]} _req]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (json/write-str (mapv ohlc-row->map (query-fn :get-kraken-hour-latest-24 {})))})
|
||||
|
||||
(defn- kraken-minute-handler [{:keys [query-fn]} _req]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (json/write-str (mapv ohlc-row->map (query-fn :get-kraken-minute-latest-60 {})))})
|
||||
|
||||
(defn- api-routes [opts]
|
||||
[["/swagger.json"
|
||||
{:get {:no-doc true
|
||||
@@ -176,7 +205,15 @@
|
||||
["/deposits"
|
||||
{:get (fn [req] (tx/list-deposits opts req))}]
|
||||
["/deposits/rebuild"
|
||||
{:post (fn [req] (tx/rebuild-deposits! opts req))}]])
|
||||
{:post (fn [req] (tx/rebuild-deposits! opts req))}]
|
||||
["/buys"
|
||||
{:get (fn [req] (tx/list-buys opts req))}]
|
||||
["/buys/rebuild"
|
||||
{:post (fn [req] (tx/rebuild-buys! opts req))}]
|
||||
["/kraken-hour"
|
||||
{:get (fn [req] (kraken-hour-handler opts req))}]
|
||||
["/kraken-minute"
|
||||
{:get (fn [req] (kraken-minute-handler opts req))}]])
|
||||
|
||||
(defn route-data [opts]
|
||||
(merge
|
||||
|
||||
Reference in New Issue
Block a user