Compare commits

...
29 Commits
Author SHA1 Message Date
magnusandClaude Opus 4.6 94c56b7cc3 Fall back to DB for price when Binance websocket not connected
The /api/price/latest endpoint now reads the latest row from
binance_price table when the in-memory atom is empty on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:11:33 +01:00
magnusandClaude Opus 4.6 0caa8db0b3 Add buys query table from exchange_to_wallet events
Denormalized read table with id, event_id, occurred_at, wallet, sats,
fee_sats, and amount_eur. Projected on event creation and rebuildable
via POST /api/buys/rebuild. Fiat amounts converted to EUR using
Frankfurter rates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:44:44 +01:00
magnusandClaude Opus 4.6 93073820a2 Change Kraken hourly poller to 60s interval, fetch last 5 candles
Keeps the in-progress hour candle fresh instead of only updating at
the top of each hour.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:06:27 +01:00
magnusandClaude Opus 4.6 bba9652ced Add datetime field to kraken-hour and kraken-minute API responses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:41:52 +01:00
magnusandClaude Opus 4.6 b6c8745a86 Add /api/kraken-minute endpoint and limit kraken-hour to 24 rows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:37:00 +01:00
magnusandClaude Opus 4.6 42454646bb Add /api/kraken-hour endpoint returning last 60 hourly candles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:33:08 +01:00
magnusandClaude Opus 4.6 44bb9a68d4 Add OHLC values to Kraken minute poller log output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:18:05 +01:00
magnusandClaude Opus 4.6 ae65ea3722 Show human-readable timestamps in Kraken minute poller logs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:14:35 +01:00
magnusandClaude Opus 4.6 f27c198634 Limit minute poller to last 5 candles after initial fetch
Reduces unnecessary upserts on each 15-second poll cycle by only
processing the 5 most recent candles from Kraken's response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:09:24 +01:00
magnusandClaude Opus 4.6 b49e8afd59 Add kraken_minute table with 15-second polling
1-minute OHLC candles from Kraken, polled every 15 seconds to keep the
in-progress candle fresh. Unlike hourly/daily pollers, this one keeps
the last candle and upserts it as it updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:06:36 +01:00
magnusandClaude Opus 4.6 747c834e6a Add KRAKEN_ENABLED flag to disable Kraken pollers in local dev
Same pattern as BINANCE_ENABLED and STRIKE_ENABLED. Prevents local
dev from fetching external pricing data when pointed at btcprod.
Docker explicitly sets KRAKEN_ENABLED=true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:45:10 +01:00
magnusandClaude Opus 4.6 8b5c1faeaa Add deposits rebuild endpoint and fix date serialization
Add POST /api/deposits/rebuild to re-project all bank_to_exchange events.
Fix date-off-by-one by converting java.sql.Date to LocalDate via
ReadableColumn and serializing with Jackson JavaTimeModule.
Sort all event/deposit queries by occurred_at, id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:30:43 +01:00
magnusandClaude Opus 4.6 96d12f170b Change events.occurred_at from TIMESTAMPTZ to DATE
Make date required, remove default. Simplifies date handling
throughout the controller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:04:24 +01:00
magnusandClaude Opus 4.6 130815e32f Add BINANCE_ENABLED and STRIKE_ENABLED env flags
Skip live WebSocket/API connections when set to false.
Defaults to true so Docker keeps fetching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:54:50 +01:00
magnusandClaude Opus 4.6 793c63927c Add wallet_type column to distinguish exchanges from wallets
Exchanges (can receive bank deposits) vs wallets (cold storage only).
Supports ?type= filter on GET /api/wallets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:48:41 +01:00
magnusandClaude Opus 4.6 174ea24786 Add deposits read table (CQRS projection from bank_to_exchange events)
Project each bank_to_exchange event into a denormalized deposits table
with pre-computed EUR, DKK, and USD amounts using Frankfurter currency
rates. Replaces the aggregate get-deposit-totals query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:52:32 +01:00
magnusandClaude Opus 4.6 0446d928e6 Add deposit totals API endpoint
GET /api/deposits/totals returns fiat deposited per exchange per currency
from bank_to_exchange events, aggregated via SQL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:32:05 +01:00
magnusandClaude Opus 4.6 5874952115 Add event-sourced wallet and transaction tracking system
Wallets table with 7 seeded wallets, events table for three transaction
types (bank_to_exchange, exchange_to_wallet, wallet_to_wallet). Balances
computed from event credits/debits in sats. REST endpoints for CRUD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:30:48 +01:00
magnusandClaude Opus 4.6 e61f38d4f3 Add GET /api/currencies/:date endpoint for historical EUR/DKK rates
Returns rates for a given date: today uses latest DB row, past dates
check DB first then fetch from Frankfurter and persist for caching.
Future dates and invalid formats return 400.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:36:22 +01:00
magnusandClaude Opus 4.6 11a1fc6b8f Persist Frankfurter EUR/DKK rates in currencies table
Store daily exchange rates in PostgreSQL with EUR/DKK cross rate.
Seed rates atom from DB on startup so WebSocket works immediately
even if Frankfurter is unreachable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:16:53 +01:00
magnusandClaude Opus 4.6 146c3a4788 Add Strike quote endpoint for real sats price with fee-adjusted amount
Use POST /v1/currency-exchange-quotes to get actual buy price including
spread, matching the Strike app. Fee is cached daily to calculate the
pre-fee amount from the configured total (default 55 EUR). Store
quote_sats in the strike_price DB table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:42:35 +01:00
magnusandClaude Opus 4.6 3e5fd490b4 Move HTML rendering to btcprice, send raw JSON over both WebSockets
Price and Strike WebSockets now emit JSON instead of HTML fragments.
Added Strike WebSocket endpoint and wired strike/ticker into API routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:48 +01:00
magnus c786b01679 Ignore affald.txt 2026-03-06 09:17:29 +01:00
magnusandClaude Opus 4.6 8125b4a743 Rename shared network to btc-network, remove default network
- All services use btc-network exclusively
- Increase Frankfurter retry to 12x10s for cold start resilience

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:10:56 +01:00
magnusandClaude Opus 4.6 dbf19566e8 Add Strike ticker poller, Docker .env config, and Frankfurter retry logic
- Poll Strike API every 10s, store all rates as JSONB in strike_price table
- Move Docker env vars to .env file, use shared network for cross-project communication
- Add startup retry (5x 5s) for Frankfurter poller to handle container race condition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:50:21 +01:00
magnusandClaude Opus 4.6 26420fd6ec Use idiomatic undertow/websocket maps instead of raw WebSocket API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:50:55 +01:00
magnusandClaude Opus 4.6 faad73c4ef Use Danish number formatting for all prices and rates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:03:17 +01:00
magnusandClaude Opus 4.6 1a22cc3b02 Enlarge EUR/DKK converted prices and show exchange rates below
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:51:38 +01:00
magnusandClaude Opus 4.6 46666aa994 Fix Frankfurter integration: require namespace, use v1 API, show converted BTC prices
- Require frankfurter.rates namespace in core.clj so Integrant can find it
- Use /v1/ prefix for self-hosted Frankfurter API
- Display BTC price converted to EUR and DKK instead of raw exchange rates
- Remove unused host port mapping for Frankfurter container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:43:19 +01:00
38 changed files with 1108 additions and 163 deletions
+1
View File
@@ -15,3 +15,4 @@ pom.xml.asc
*.log *.log
/.env /.env
/token /token
affald.txt
+2
View File
@@ -26,6 +26,8 @@
;; Serialization ;; Serialization
metosin/muuntaja {:mvn/version "0.6.11"} metosin/muuntaja {:mvn/version "0.6.11"}
metosin/jsonista {:mvn/version "0.3.12"}
com.fasterxml.jackson.datatype/jackson-datatype-jsr310 {:mvn/version "2.18.3"}
luminus-transit/luminus-transit {:mvn/version "0.1.6"} luminus-transit/luminus-transit {:mvn/version "0.1.6"}
;; Database ;; Database
+16 -7
View File
@@ -1,21 +1,30 @@
networks:
btc-network:
name: btc-network
external: true
services: services:
btcdata: btcdata:
build: . build: .
restart: unless-stopped restart: unless-stopped
ports: ports:
- "4101:4101" - "${DOCKER_BTCDATA_PORT}:${DOCKER_BTCDATA_PORT}"
depends_on: depends_on:
- frankfurter - frankfurter
networks:
- btc-network
environment: environment:
BTCDATA_PORT: "4101" BTCDATA_PORT: "${DOCKER_BTCDATA_PORT}"
JDBC_URL: "jdbc:postgresql://postgres:5432/btcprod?user=postgres&password=ratata,123" JDBC_URL: "${DOCKER_JDBC_URL}"
CORS_ORIGIN: "http://localhost:4041" CORS_ORIGIN: "${DOCKER_CORS_ORIGIN}"
FRANKFURTER_URL: "http://frankfurter:8080" FRANKFURTER_URL: "${DOCKER_FRANKFURTER_URL}"
STRIKE_API_KEY: "${STRIKE_API_KEY}"
KRAKEN_ENABLED: "true"
extra_hosts: extra_hosts:
- "postgres:host-gateway" - "postgres:host-gateway"
frankfurter: frankfurter:
image: lineofflight/frankfurter image: lineofflight/frankfurter
restart: unless-stopped restart: unless-stopped
ports: networks:
- "8080:8080" - btc-network
@@ -0,0 +1 @@
DROP TABLE strike_price;
@@ -0,0 +1,5 @@
CREATE TABLE strike_price (
id BIGSERIAL PRIMARY KEY,
rates JSONB NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1 @@
ALTER TABLE strike_price DROP COLUMN quote_sats;
@@ -0,0 +1 @@
ALTER TABLE strike_price ADD COLUMN quote_sats BIGINT;
@@ -0,0 +1 @@
DROP TABLE currencies;
@@ -0,0 +1,7 @@
CREATE TABLE currencies (
rate_date DATE PRIMARY KEY,
eur NUMERIC(12,6) NOT NULL,
dkk NUMERIC(12,6) NOT NULL,
eur_dkk NUMERIC(12,6) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1 @@
DROP TABLE wallets;
@@ -0,0 +1,9 @@
CREATE TABLE wallets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
--;;
INSERT INTO wallets (name) VALUES
('Crypto.Com'), ('Nexo'), ('CoinCorner'), ('Strike'),
('Cold'), ('Coldcard'), ('Nunchuk Multi Sig');
@@ -0,0 +1 @@
DROP TABLE events;
@@ -0,0 +1,17 @@
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sats BIGINT,
fee_sats BIGINT,
fiat_amount NUMERIC(18,2),
fiat_currency TEXT,
from_wallet_id UUID REFERENCES wallets(id),
to_wallet_id UUID REFERENCES wallets(id),
note TEXT
);
--;;
CREATE INDEX idx_events_type ON events (event_type);
--;;
CREATE INDEX idx_events_occurred ON events (occurred_at);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS deposits;
@@ -0,0 +1,12 @@
CREATE TABLE deposits (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT NOT NULL REFERENCES events(id) UNIQUE,
occurred_at TIMESTAMPTZ NOT NULL,
exchange TEXT NOT NULL,
fiat_amount NUMERIC(18,2) NOT NULL,
fiat_currency TEXT NOT NULL,
amount_eur NUMERIC(18,2),
amount_dkk NUMERIC(18,2),
amount_usd NUMERIC(18,2),
note TEXT
);
@@ -0,0 +1 @@
ALTER TABLE wallets DROP COLUMN wallet_type;
@@ -0,0 +1,4 @@
ALTER TABLE wallets ADD COLUMN wallet_type TEXT NOT NULL DEFAULT 'exchange';
--;;
UPDATE wallets SET wallet_type = 'wallet'
WHERE name IN ('Cold', 'Coldcard', 'Nunchuk Multi Sig');
@@ -0,0 +1,5 @@
ALTER TABLE deposits ALTER COLUMN occurred_at TYPE TIMESTAMPTZ USING occurred_at::timestamptz;
--;;
ALTER TABLE events ALTER COLUMN occurred_at SET DEFAULT NOW();
--;;
ALTER TABLE events ALTER COLUMN occurred_at TYPE TIMESTAMPTZ USING occurred_at::timestamptz;
@@ -0,0 +1,5 @@
ALTER TABLE events ALTER COLUMN occurred_at TYPE DATE USING occurred_at::date;
--;;
ALTER TABLE events ALTER COLUMN occurred_at DROP DEFAULT;
--;;
ALTER TABLE deposits ALTER COLUMN occurred_at TYPE DATE USING occurred_at::date;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS kraken_minute;
@@ -0,0 +1,11 @@
CREATE TABLE kraken_minute (
ts BIGINT NOT NULL PRIMARY KEY,
open NUMERIC(18,8) NOT NULL,
high NUMERIC(18,8) NOT NULL,
low NUMERIC(18,8) NOT NULL,
close NUMERIC(18,8) NOT NULL,
vwap NUMERIC(18,8) NOT NULL,
volume NUMERIC(24,8) NOT NULL,
trade_count INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -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;
+175
View File
@@ -42,3 +42,178 @@ SET open = EXCLUDED.open,
-- :name get-latest-kraken-day :? :1 -- :name get-latest-kraken-day :? :1
-- :doc Get the most recent Kraken daily candle by timestamp -- :doc Get the most recent Kraken daily candle by timestamp
SELECT ts FROM kraken_day ORDER BY ts DESC LIMIT 1 SELECT ts FROM kraken_day ORDER BY ts DESC LIMIT 1
-- :name insert-strike-price! :! :n
-- :doc Insert a Strike ticker snapshot
INSERT INTO strike_price (rates, quote_sats) VALUES (:rates, :quote-sats)
-- :name get-latest-strike-price :? :1
-- :doc Get the most recent Strike ticker snapshot
SELECT rates, recorded_at FROM strike_price ORDER BY id DESC LIMIT 1
-- :name upsert-currency-rates! :! :n
-- :doc Upsert daily currency rates from Frankfurter
INSERT INTO currencies (rate_date, eur, dkk, eur_dkk, updated_at)
VALUES (:rate-date, :eur, :dkk, :eur-dkk, NOW())
ON CONFLICT (rate_date) DO UPDATE
SET eur = EXCLUDED.eur, dkk = EXCLUDED.dkk, eur_dkk = EXCLUDED.eur_dkk, updated_at = NOW()
-- :name get-latest-currency-rates :? :1
-- :doc Get the most recent currency rates
SELECT rate_date, eur, dkk, eur_dkk, updated_at FROM currencies ORDER BY rate_date DESC LIMIT 1
-- :name get-currency-rates-by-date :? :1
-- :doc Get currency rates for a specific date
SELECT rate_date, eur, dkk, eur_dkk, updated_at FROM currencies WHERE rate_date = :rate-date
-- Wallets -------------------------------------------------------------------
-- :name insert-wallet! :! :n
-- :doc Insert a new wallet
INSERT INTO wallets (name, wallet_type) VALUES (:name, :wallet-type)
-- :name get-all-wallets :? :*
-- :doc Get all wallets
SELECT id, name, wallet_type, created_at FROM wallets ORDER BY name
-- :name get-wallets-by-type :? :*
-- :doc Get wallets filtered by type
SELECT id, name, wallet_type, created_at FROM wallets WHERE wallet_type = :wallet-type ORDER BY name
-- :name get-wallet-by-id :? :1
-- :doc Get a wallet by ID
SELECT id, name, wallet_type, created_at FROM wallets WHERE id = :id
-- Events --------------------------------------------------------------------
-- :name insert-event! :? :1
-- :doc Insert a new event, returning its id
INSERT INTO events (event_type, occurred_at, sats, fee_sats, fiat_amount, fiat_currency, from_wallet_id, to_wallet_id, note)
VALUES (:event-type, :occurred-at, :sats, :fee-sats, :fiat-amount, :fiat-currency, :from-wallet-id, :to-wallet-id, :note)
RETURNING id
-- :name get-all-events :? :*
-- :doc Get all events with wallet names
SELECT e.id, e.event_type, e.occurred_at, e.recorded_at,
e.sats, e.fee_sats, e.fiat_amount, e.fiat_currency,
e.from_wallet_id, fw.name AS from_wallet_name,
e.to_wallet_id, tw.name AS to_wallet_name,
e.note
FROM events e
LEFT JOIN wallets fw ON fw.id = e.from_wallet_id
LEFT JOIN wallets tw ON tw.id = e.to_wallet_id
ORDER BY e.occurred_at DESC, e.id DESC
-- :name get-events-by-type :? :*
-- :doc Get events filtered by type
SELECT e.id, e.event_type, e.occurred_at, e.recorded_at,
e.sats, e.fee_sats, e.fiat_amount, e.fiat_currency,
e.from_wallet_id, fw.name AS from_wallet_name,
e.to_wallet_id, tw.name AS to_wallet_name,
e.note
FROM events e
LEFT JOIN wallets fw ON fw.id = e.from_wallet_id
LEFT JOIN wallets tw ON tw.id = e.to_wallet_id
WHERE e.event_type = :event-type
ORDER BY e.occurred_at DESC, e.id DESC
-- :name get-events-by-wallet :? :*
-- :doc Get events involving a specific wallet
SELECT e.id, e.event_type, e.occurred_at, e.recorded_at,
e.sats, e.fee_sats, e.fiat_amount, e.fiat_currency,
e.from_wallet_id, fw.name AS from_wallet_name,
e.to_wallet_id, tw.name AS to_wallet_name,
e.note
FROM events e
LEFT JOIN wallets fw ON fw.id = e.from_wallet_id
LEFT JOIN wallets tw ON tw.id = e.to_wallet_id
WHERE e.from_wallet_id = :wallet-id OR e.to_wallet_id = :wallet-id
ORDER BY e.occurred_at DESC, e.id DESC
-- :name truncate-deposits! :! :n
-- :doc Delete all rows from the deposits read table
TRUNCATE deposits
-- :name get-deposit-events :? :*
-- :doc Get bank_to_exchange events with exchange name, oldest first
SELECT e.id, e.occurred_at, e.fiat_amount, e.fiat_currency,
e.to_wallet_id, tw.name AS exchange, e.note
FROM events e
LEFT JOIN wallets tw ON tw.id = e.to_wallet_id
WHERE e.event_type = 'bank_to_exchange'
ORDER BY e.occurred_at ASC, e.id ASC
-- :name insert-deposit! :! :n
-- :doc Insert a projected deposit row
INSERT INTO deposits (event_id, occurred_at, exchange, fiat_amount, fiat_currency, amount_eur, amount_dkk, amount_usd, note)
VALUES (:event-id, :occurred-at, :exchange, :fiat-amount, :fiat-currency, :amount-eur, :amount-dkk, :amount-usd, :note)
-- :name get-all-deposits :? :*
-- :doc Get all projected deposits ordered by date descending
SELECT id, event_id, occurred_at, exchange, fiat_amount, fiat_currency,
amount_eur, amount_dkk, amount_usd, note
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)
VALUES (:ts, :open, :high, :low, :close, :vwap, :volume, :trade-count)
ON CONFLICT (ts) DO UPDATE
SET open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close,
vwap = EXCLUDED.vwap,
volume = EXCLUDED.volume,
trade_count = EXCLUDED.trade_count
-- :name get-latest-kraken-minute :? :1
-- :doc Get the most recent Kraken minute candle timestamp
SELECT ts FROM kraken_minute ORDER BY ts DESC LIMIT 1
-- :name get-wallet-balances :? :*
-- :doc Compute sats balance per wallet from events
SELECT w.id, w.name,
COALESCE(SUM(CASE WHEN e.to_wallet_id = w.id THEN e.sats ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN e.from_wallet_id = w.id THEN e.sats ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN e.from_wallet_id = w.id THEN e.fee_sats ELSE 0 END), 0)
AS balance_sats
FROM wallets w
LEFT JOIN events e ON e.from_wallet_id = w.id OR e.to_wallet_id = w.id
GROUP BY w.id, w.name
ORDER BY w.name
+20 -5
View File
@@ -47,17 +47,32 @@
{:base-path "/api" {:base-path "/api"
:query-fn #ig/ref :db.sql/query-fn :query-fn #ig/ref :db.sql/query-fn
:binance #ig/ref :ws/binance :binance #ig/ref :ws/binance
:frankfurter #ig/ref :frankfurter/rates} :frankfurter #ig/ref :frankfurter/rates
:strike #ig/ref :strike/ticker}
:ws/binance :ws/binance
{:query-fn #ig/ref :db.sql/query-fn {:query-fn #ig/ref :db.sql/query-fn
:uri "wss://stream.binance.com:9443/ws/btcusdt@trade"} :uri "wss://stream.binance.com:9443/ws/btcusdt@trade"
:enabled? #or [#env BINANCE_ENABLED "true"]}
:kraken/ohlc :kraken/ohlc
{:query-fn #ig/ref :db.sql/query-fn} {:query-fn #ig/ref :db.sql/query-fn
:enabled? #or [#env KRAKEN_ENABLED "true"]}
:kraken/ohlc-day :kraken/ohlc-day
{:query-fn #ig/ref :db.sql/query-fn} {:query-fn #ig/ref :db.sql/query-fn
:enabled? #or [#env KRAKEN_ENABLED "true"]}
:kraken/ohlc-minute
{:query-fn #ig/ref :db.sql/query-fn
:enabled? #or [#env KRAKEN_ENABLED "true"]}
:frankfurter/rates :frankfurter/rates
{:url #or [#env FRANKFURTER_URL "http://localhost:8080"]}} {:url #or [#env FRANKFURTER_URL "http://localhost:8080"]
:query-fn #ig/ref :db.sql/query-fn}
:strike/ticker
{:query-fn #ig/ref :db.sql/query-fn
:api-key #env STRIKE_API_KEY
:sats-eur-amount #or [#env SATS_EUR_AMOUNT "55"]
:enabled? #or [#env STRIKE_ENABLED "true"]}}
+21 -2
View File
@@ -2,6 +2,7 @@
(:require (:require
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[integrant.core :as ig] [integrant.core :as ig]
[next.jdbc.result-set]
[pmagnus.btcdata.config :as config] [pmagnus.btcdata.config :as config]
[pmagnus.btcdata.env :refer [defaults]] [pmagnus.btcdata.env :refer [defaults]]
@@ -17,8 +18,26 @@
[pmagnus.btcdata.ws.binance] [pmagnus.btcdata.ws.binance]
;; Pollers ;; Pollers
[pmagnus.btcdata.kraken.ohlc] [pmagnus.btcdata.kraken.ohlc]
[pmagnus.btcdata.kraken.ohlc-daily]) [pmagnus.btcdata.kraken.ohlc-daily]
(:gen-class)) [pmagnus.btcdata.kraken.ohlc-minute]
[pmagnus.btcdata.frankfurter.rates]
[pmagnus.btcdata.strike.ticker])
(:gen-class)
(:import
[java.sql Date Timestamp]))
;; Read SQL DATE as LocalDate and TIMESTAMP as Instant (timezone-safe)
(extend-protocol next.jdbc.result-set/ReadableColumn
Date
(read-column-by-label [v _]
(.toLocalDate v))
(read-column-by-index [v _ _]
(.toLocalDate v))
Timestamp
(read-column-by-label [v _]
(.toInstant v))
(read-column-by-index [v _ _]
(.toInstant v)))
(defonce system (atom nil)) (defonce system (atom nil))
+71 -23
View File
@@ -6,58 +6,106 @@
(:import (:import
[java.net URI] [java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers] [java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
[java.time Instant])) [java.time Instant LocalDate]))
(defn- fetch-rates (defn- fetch-rates*
"GET /latest?from=USD&to=EUR,DKK from Frankfurter. Returns {:EUR x :DKK y}." "GET a Frankfurter endpoint. Returns parsed body."
[^HttpClient client base-url] [^HttpClient client url]
(let [url (str base-url "/latest?from=USD&to=EUR,DKK") (let [request (-> (HttpRequest/newBuilder)
request (-> (HttpRequest/newBuilder)
(.uri (URI. url)) (.uri (URI. url))
(.header "Accept" "application/json") (.header "Accept" "application/json")
(.GET) (.GET)
(.build)) (.build))
resp (.send client request (HttpResponse$BodyHandlers/ofString)) resp (.send client request (HttpResponse$BodyHandlers/ofString))]
body (json/read-str (.body resp) :key-fn keyword)] (json/read-str (.body resp) :key-fn keyword)))
(:rates body)))
(defn- fetch-rates
"GET /latest?from=USD&to=EUR,DKK from Frankfurter. Returns {:EUR x :DKK y}."
[^HttpClient client base-url]
(:rates (fetch-rates* client (str base-url "/v1/latest?from=USD&to=EUR,DKK"))))
(defn fetch-and-persist-for-date!
"Fetch rates for a specific date from Frankfurter, persist, and return the rate map."
[^HttpClient client base-url query-fn ^LocalDate date]
(let [url (str base-url "/v1/" date "?from=USD&to=EUR,DKK")
rates (:rates (fetch-rates* client url))]
(when (and (:EUR rates) (:DKK rates))
(let [eur (bigdec (str (:EUR rates)))
dkk (bigdec (str (:DKK rates)))
eur-dkk (.divide dkk eur 6 java.math.RoundingMode/HALF_UP)]
(query-fn :upsert-currency-rates!
{:rate-date date :eur eur :dkk dkk :eur-dkk eur-dkk})
(log/info "Frankfurter historical rates for" (str date) "— EUR:" eur "DKK:" dkk)
{:rate_date date :eur eur :dkk dkk :eur_dkk eur-dkk}))))
(defn- poll! (defn- poll!
"Fetch rates and update the atom." "Fetch rates, update the atom, and persist to DB."
[client base-url rates-atom] [client base-url rates-atom query-fn]
(let [rates (fetch-rates client base-url)] (let [rates (fetch-rates client base-url)]
(when (and (:EUR rates) (:DKK rates)) (when (and (:EUR rates) (:DKK rates))
(let [eur (bigdec (str (:EUR rates)))
dkk (bigdec (str (:DKK rates)))
eur-dkk (.divide dkk eur 6 java.math.RoundingMode/HALF_UP)]
(reset! rates-atom (reset! rates-atom
{:eur (bigdec (str (:EUR rates))) {:eur eur
:dkk (bigdec (str (:DKK rates))) :dkk dkk
:updated-at (Instant/now)}) :updated-at (Instant/now)})
(log/info "Frankfurter rates — EUR:" (:EUR rates) "DKK:" (:DKK rates))))) (query-fn :upsert-currency-rates!
{:rate-date (LocalDate/now)
:eur eur
:dkk dkk
:eur-dkk eur-dkk})
(log/info "Frankfurter rates — EUR:" eur "DKK:" dkk "EUR/DKK:" eur-dkk)))))
(defn- start-poll-loop! (defn- start-poll-loop!
"Fetch immediately, then poll every 60 minutes." "Fetch immediately, then poll every 60 minutes."
[client base-url rates-atom running?] [client base-url rates-atom running? query-fn]
(future (future
(try (loop [retries 12]
(poll! client base-url rates-atom) (let [ok? (try
(poll! client base-url rates-atom query-fn)
true
(catch Exception e (catch Exception e
(log/error e "Frankfurter initial fetch failed"))) (log/error e (str "Frankfurter fetch failed, retrying in 10s (" retries " left)"))
false))]
(when (and (not ok?) @running? (pos? retries))
(Thread/sleep 10000)
(recur (dec retries)))))
(while @running? (while @running?
(Thread/sleep 3600000) (Thread/sleep 3600000)
(when @running? (when @running?
(try (try
(poll! client base-url rates-atom) (poll! client base-url rates-atom query-fn)
(catch Exception e (catch Exception e
(log/error e "Frankfurter poll failed"))))))) (log/error e "Frankfurter poll failed")))))))
(defn- seed-from-db!
"Load latest rates from DB into the atom so we have data immediately."
[rates-atom query-fn]
(try
(when-let [row (query-fn :get-latest-currency-rates {})]
(reset! rates-atom
{:eur (:eur row)
:dkk (:dkk row)
:updated-at (:updated_at row)})
(log/info "Seeded Frankfurter rates from DB — EUR:" (:eur row) "DKK:" (:dkk row)))
(catch Exception e
(log/warn e "Could not seed rates from DB"))))
(defmethod ig/init-key :frankfurter/rates (defmethod ig/init-key :frankfurter/rates
[_ {:keys [url]}] [_ {:keys [url query-fn]}]
(log/info "Starting Frankfurter rates poller:" url) (log/info "Starting Frankfurter rates poller:" url)
(let [client (HttpClient/newHttpClient) (let [client (HttpClient/newHttpClient)
rates (atom nil) rates (atom nil)
running? (atom true) running? (atom true)]
fut (start-poll-loop! client url rates running?)] (seed-from-db! rates query-fn)
(let [fut (start-poll-loop! client url rates running? query-fn)]
{:rates rates {:rates rates
:running? running? :running? running?
:future fut})) :future fut
:client client
:url url
:query-fn query-fn})))
(defmethod ig/halt-key! :frankfurter/rates (defmethod ig/halt-key! :frankfurter/rates
[_ {:keys [running? future]}] [_ {:keys [running? future]}]
+19 -34
View File
@@ -54,68 +54,53 @@
:ts)) :ts))
(defn- poll! (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." Returns the count of saved candles."
[client query-fn since-atom] [client query-fn since-atom initial?]
(let [result (fetch-ohlc client @since-atom) (let [result (fetch-ohlc client @since-atom)
;; Kraken returns a map with the pair key and a "last" key ;; Kraken returns a map with the pair key and a "last" key
last-ts (:last result) last-ts (:last result)
pair-key (first (remove #{:last} (keys result))) pair-key (first (remove #{:last} (keys result)))
raw (get result pair-key) 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) (when (seq candles)
(save-candles! query-fn candles) (save-candles! query-fn candles)
(when last-ts (when last-ts
(reset! since-atom 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))) (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! (defn- start-poll-loop!
"Start a background future that polls Kraken aligned to HH:01:00 UTC. "Start a background future that polls Kraken every 60 seconds."
When `has-data?` is false, fetches immediately to backfill." [client query-fn since-atom running?]
[client query-fn since-atom running? has-data?]
(future (future
(when-not has-data?
(try (try
(poll! client query-fn since-atom) (poll! client query-fn since-atom true)
(catch Exception e (catch Exception e
(log/error e "Kraken OHLC initial poll failed")))) (log/error e "Kraken OHLC initial poll failed")))
(while @running? (while @running?
(let [wait (ms-until-next-poll)] (Thread/sleep 60000)
(log/info "Next Kraken OHLC poll in" (quot wait 60000) "minutes")
(Thread/sleep wait)
(when @running? (when @running?
(try (try
(poll! client query-fn since-atom) (poll! client query-fn since-atom false)
(catch Exception e (catch Exception e
(log/error e "Kraken OHLC poll failed")))))))) (log/error e "Kraken OHLC poll failed")))))))
(defmethod ig/init-key :kraken/ohlc (defmethod ig/init-key :kraken/ohlc
[_ {:keys [query-fn]}] [_ {:keys [query-fn enabled?]}]
(if (= enabled? "false")
(do (log/info "Kraken OHLC poller disabled")
{:running? (atom false)})
(let [client (HttpClient/newHttpClient) (let [client (HttpClient/newHttpClient)
db-since (seed-since-from-db query-fn) db-since (seed-since-from-db query-fn)
since (atom db-since) since (atom db-since)
running? (atom true) running? (atom true)
has-data? (some? db-since) fut (start-poll-loop! client query-fn since running?)]
fut (start-poll-loop! client query-fn since running? has-data?)] (log/info "Starting Kraken OHLC poller (60s interval)")
(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)"))
{:running? running? {:running? running?
:future fut :future fut
:since since})) :since since})))
(defmethod ig/halt-key! :kraken/ohlc (defmethod ig/halt-key! :kraken/ohlc
[_ {:keys [running? future]}] [_ {:keys [running? future]}]
@@ -102,7 +102,10 @@
(log/error e "Kraken daily OHLC poll failed")))))))) (log/error e "Kraken daily OHLC poll failed"))))))))
(defmethod ig/init-key :kraken/ohlc-day (defmethod ig/init-key :kraken/ohlc-day
[_ {:keys [query-fn]}] [_ {:keys [query-fn enabled?]}]
(if (= enabled? "false")
(do (log/info "Kraken daily OHLC poller disabled")
{:running? (atom false)})
(let [client (HttpClient/newHttpClient) (let [client (HttpClient/newHttpClient)
db-since (seed-since-from-db query-fn) db-since (seed-since-from-db query-fn)
since (atom db-since) since (atom db-since)
@@ -114,7 +117,7 @@
(log/info "Starting Kraken daily OHLC poller, fetching immediately (no data)")) (log/info "Starting Kraken daily OHLC poller, fetching immediately (no data)"))
{:running? running? {:running? running?
:future fut :future fut
:since since})) :since since})))
(defmethod ig/halt-key! :kraken/ohlc-day (defmethod ig/halt-key! :kraken/ohlc-day
[_ {:keys [running? future]}] [_ {:keys [running? future]}]
@@ -0,0 +1,114 @@
(ns pmagnus.btcdata.kraken.ohlc-minute
(:require
[clojure.data.json :as json]
[clojure.tools.logging :as log]
[integrant.core :as ig])
(:import
[java.net URI]
[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")
(defn- fetch-ohlc
"HTTP GET to Kraken OHLC endpoint for 1-minute candles.
When `since` is provided, appends &since= to fetch only newer candles."
[^HttpClient client since]
(let [url (if since
(str kraken-ohlc-url "&since=" since)
kraken-ohlc-url)
request (-> (HttpRequest/newBuilder)
(.uri (URI. url))
(.header "Accept" "application/json")
(.GET)
(.build))
resp (.send client request (HttpResponse$BodyHandlers/ofString))
body (json/read-str (.body resp) :key-fn keyword)]
(when-let [errors (seq (:error body))]
(throw (ex-info "Kraken API error" {:errors errors})))
(:result body)))
(defn- parse-candle
"Convert a Kraken OHLC array [ts, open, high, low, close, vwap, volume, count]
to a map with bigdec values."
[[ts open high low close vwap volume count]]
{:ts (long ts)
:open (bigdec open)
:high (bigdec high)
:low (bigdec low)
:close (bigdec close)
:vwap (bigdec vwap)
:volume (bigdec volume)
:trade-count (int count)})
(defn- save-candles!
"Upsert each candle into the kraken_minute table."
[query-fn candles]
(doseq [candle candles]
(query-fn :upsert-kraken-minute! candle)))
(defn- seed-since-from-db
"Query DB for the latest candle timestamp. Returns it or nil."
[query-fn]
(some-> (query-fn :get-latest-kraken-minute {})
:ts))
(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 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 (if initial? raw (take-last 5 raw)))]
(when (seq candles)
(save-candles! query-fn candles)
(when last-ts
(reset! since-atom last-ts))
(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!
"Start a background future that polls Kraken every 15 seconds."
[client query-fn since-atom running?]
(future
(try
(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 false)
(catch Exception e
(log/error e "Kraken minute poll failed")))))))
(defmethod ig/init-key :kraken/ohlc-minute
[_ {:keys [query-fn enabled?]}]
(if (= enabled? "false")
(do (log/info "Kraken minute poller disabled")
{:running? (atom false)})
(let [client (HttpClient/newHttpClient)
db-since (seed-since-from-db query-fn)
since (atom db-since)
running? (atom true)
fut (start-poll-loop! client query-fn since running?)]
(log/info "Starting Kraken minute poller (15s interval)")
{:running? running?
:future fut
:since since})))
(defmethod ig/halt-key! :kraken/ohlc-minute
[_ {:keys [running? future]}]
(log/info "Stopping Kraken minute poller")
(reset! running? false)
(future-cancel future))
+159
View File
@@ -0,0 +1,159 @@
(ns pmagnus.btcdata.strike.ticker
(:require
[clojure.data.json :as json]
[clojure.string :as str]
[clojure.tools.logging :as log]
[integrant.core :as ig])
(:import
[java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
[java.time Duration Instant]
[org.postgresql.util PGobject]))
(defn- ->jsonb
"Wrap a JSON string as a PGobject with type jsonb."
[^String s]
(doto (PGobject.)
(.setType "jsonb")
(.setValue s)))
(defn- fetch-rates
"GET /v1/rates/ticker from Strike. Returns the raw JSON response body string."
[^HttpClient client ^String api-key]
(let [request (-> (HttpRequest/newBuilder)
(.uri (URI. "https://api.strike.me/v1/rates/ticker"))
(.header "Accept" "application/json")
(.header "Authorization" (str "Bearer " api-key))
(.GET)
(.build))
resp (.send client request (HttpResponse$BodyHandlers/ofString))
body (.body resp)]
body))
(defn- post-quote
"POST /v1/currency-exchange-quotes. Returns parsed response map or nil."
[^HttpClient client ^String api-key ^String eur-amount]
(let [body-str (json/write-str {:sell "EUR" :buy "BTC"
:amount {:amount eur-amount :currency "EUR"}})
request (-> (HttpRequest/newBuilder)
(.uri (URI. "https://api.strike.me/v1/currency-exchange-quotes"))
(.header "Accept" "application/json")
(.header "Content-Type" "application/json")
(.header "Authorization" (str "Bearer " api-key))
(.POST (java.net.http.HttpRequest$BodyPublishers/ofString body-str))
(.build))
resp (.send client request (HttpResponse$BodyHandlers/ofString))
status (.statusCode resp)
body (.body resp)]
(if (= 200 status)
(json/read-str body :key-fn keyword)
(do (log/warn "Strike quote HTTP" status body)
nil))))
(def ^:private fee-max-age (Duration/ofHours 24))
(defn- refresh-fee!
"Fetch fee for sats-eur-amount and cache it. Returns the fee BigDecimal or nil."
[client api-key sats-eur-amount fee-cache]
(when-let [parsed (post-quote client api-key sats-eur-amount)]
(let [fee (bigdec (get-in parsed [:fee :amount]))]
(log/info "Strike fee for" sats-eur-amount "EUR:" fee "EUR")
(reset! fee-cache {:fee fee :fetched-at (Instant/now)})
fee)))
(defn- cached-fee
"Return cached fee if fresh, otherwise refresh. Returns BigDecimal or nil."
[client api-key sats-eur-amount fee-cache]
(let [{:keys [fee fetched-at]} @fee-cache]
(if (and fee fetched-at
(.isBefore (Instant/now) (.plus ^Instant fetched-at fee-max-age)))
fee
(refresh-fee! client api-key sats-eur-amount fee-cache))))
(defn- fetch-quote-sats
"Get sats for a total EUR amount (including fee). Uses cached fee to calculate
pre-fee amount, then quotes that. Returns sats as long or nil."
[client api-key sats-eur-amount fee-cache]
(let [total (bigdec sats-eur-amount)
fee (cached-fee client api-key sats-eur-amount fee-cache)
pre-fee (if fee (.toPlainString (.subtract total fee)) sats-eur-amount)]
(when-let [parsed (post-quote client api-key pre-fee)]
(let [btc-amt (get-in parsed [:target :amount])]
(when btc-amt
(long (* (bigdec btc-amt) 100000000)))))))
(defn- format-rates
"Build a compact log string from the parsed rates array."
[rates]
(->> rates
(map (fn [{:keys [sourceCurrency targetCurrency amount]}]
(str sourceCurrency "/" targetCurrency " " amount)))
(str/join " ")))
(defn- rates->map
"Index the rates array by sourceCurrency/targetCurrency pair."
[rates]
(into {}
(map (fn [{:keys [sourceCurrency targetCurrency amount]}]
[(str sourceCurrency "/" targetCurrency) amount])
rates)))
(defn- poll!
"Fetch rates and insert into the database. Updates latest-atom."
[client api-key query-fn latest-atom sats-eur-amount fee-cache]
(let [body (fetch-rates client api-key)
rates (json/read-str body :key-fn keyword)
rmap (rates->map rates)
quote-sats (when sats-eur-amount
(try
(let [sats (fetch-quote-sats client api-key sats-eur-amount fee-cache)]
(when sats
(log/info "Strike quote:" sats-eur-amount "EUR ->" sats "sats"))
sats)
(catch Exception e
(log/error e "Strike quote fetch failed")
nil)))
rmap (if quote-sats (assoc rmap "quote-sats" quote-sats) rmap)]
(query-fn :insert-strike-price! {:rates (->jsonb body)
:quote-sats quote-sats})
(reset! latest-atom rmap)
(log/info "Strike:" (format-rates rates))))
(defn- start-poll-loop!
"Fetch immediately, then poll every 10 seconds."
[client api-key query-fn running? latest-atom sats-eur-amount fee-cache]
(future
(try
(poll! client api-key query-fn latest-atom sats-eur-amount fee-cache)
(catch Exception e
(log/error e "Strike initial fetch failed")))
(while @running?
(Thread/sleep 10000)
(when @running?
(try
(poll! client api-key query-fn latest-atom sats-eur-amount fee-cache)
(catch Exception e
(log/error e "Strike poll failed")))))))
(defmethod ig/init-key :strike/ticker
[_ {:keys [query-fn api-key sats-eur-amount enabled?] :or {enabled? "true"}}]
(if-not (= enabled? "true")
(do (log/info "Strike ticker disabled")
{:running? (atom false)
:future nil
:latest-rates (atom nil)})
(do (log/info "Starting Strike ticker poller")
(let [client (HttpClient/newHttpClient)
running? (atom true)
latest-rates (atom nil)
fee-cache (atom nil)
fut (start-poll-loop! client api-key query-fn running? latest-rates sats-eur-amount fee-cache)]
{:running? running?
:future fut
:latest-rates latest-rates}))))
(defmethod ig/halt-key! :strike/ticker
[_ {:keys [running? future]}]
(log/info "Stopping Strike ticker poller")
(reset! running? false)
(when future (future-cancel future)))
@@ -0,0 +1,203 @@
(ns pmagnus.btcdata.web.controllers.transactions
(:require
[clojure.string :as str]
[clojure.tools.logging :as log]
[pmagnus.btcdata.frankfurter.rates :as rates]
[ring.util.http-response :as response])
(:import
[java.time LocalDate]
[java.util UUID]))
(defn list-wallets [{:keys [query-fn]} req]
(let [wallet-type (get-in req [:query-params "type"])]
(if wallet-type
(response/ok (query-fn :get-wallets-by-type {:wallet-type wallet-type}))
(response/ok (query-fn :get-all-wallets {})))))
(defn create-wallet! [{:keys [query-fn]} req]
(let [{:keys [name wallet_type]} (:body-params req)]
(if (str/blank? name)
(response/bad-request {:error "name is required"})
(do (query-fn :insert-wallet! {:name name :wallet-type (or wallet_type "exchange")})
(response/created "/api/wallets" {:name name})))))
(defn get-wallet-balances [{:keys [query-fn]} _req]
(response/ok (query-fn :get-wallet-balances {})))
(defn- convert-amount
"Convert fiat-amount in fiat-currency to EUR, DKK, USD using rates map.
rates has keys :eur (EUR/USD), :dkk (DKK/USD), :eur_dkk (DKK/EUR)."
[fiat-amount fiat-currency rates]
(let [amt (bigdec fiat-amount)
eur (:eur rates)
dkk (:dkk rates)
eur-dkk (:eur_dkk rates)]
(case fiat-currency
"EUR" {:amount-eur amt
:amount-dkk (.setScale (* amt eur-dkk) 2 java.math.RoundingMode/HALF_UP)
:amount-usd (.setScale (.divide amt eur 6 java.math.RoundingMode/HALF_UP) 2 java.math.RoundingMode/HALF_UP)}
"USD" {:amount-eur (.setScale (* amt eur) 2 java.math.RoundingMode/HALF_UP)
:amount-dkk (.setScale (* amt dkk) 2 java.math.RoundingMode/HALF_UP)
:amount-usd amt}
"DKK" {:amount-eur (.setScale (.divide amt eur-dkk 6 java.math.RoundingMode/HALF_UP) 2 java.math.RoundingMode/HALF_UP)
:amount-dkk amt
:amount-usd (.setScale (.divide amt dkk 6 java.math.RoundingMode/HALF_UP) 2 java.math.RoundingMode/HALF_UP)}
;; Unknown currency — store nil conversions
{:amount-eur nil :amount-dkk nil :amount-usd nil})))
(defn- project-deposit!
"Project a bank_to_exchange event into the deposits read table."
[{:keys [query-fn frankfurter]} event-id occurred-at to-wallet-id fiat-amount fiat-currency note]
(try
(let [wallet (query-fn :get-wallet-by-id {:id to-wallet-id})
exchange (or (:name wallet) "Unknown")
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))
amounts (if rates
(convert-amount fiat-amount fiat-currency rates)
{:amount-eur nil :amount-dkk nil :amount-usd nil})]
(query-fn :insert-deposit!
(merge {:event-id event-id
:occurred-at occurred-at
:exchange exchange
:fiat-amount fiat-amount
:fiat-currency fiat-currency
:note note}
amounts)))
(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)]
(if-not (#{"bank_to_exchange" "exchange_to_wallet" "wallet_to_wallet"} event-type)
(response/bad-request {:error "Invalid event_type"})
(let [occurred-at (if-let [d (:occurred_at params)]
(LocalDate/parse d)
(LocalDate/now))
row {:event-type event-type
:occurred-at occurred-at
:sats (:sats params)
:fee-sats (:fee_sats params)
:fiat-amount (:fiat_amount params)
:fiat-currency (:fiat_currency params)
:from-wallet-id (some-> (:from_wallet_id params) UUID/fromString)
:to-wallet-id (some-> (:to_wallet_id params) UUID/fromString)
:note (:note params)}
result (query-fn :insert-event! row)]
(when (= event-type "bank_to_exchange")
(project-deposit! opts (:id result) occurred-at
(some-> (:to_wallet_id params) UUID/fromString)
(: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]
(response/ok (query-fn :get-all-deposits {})))
(defn rebuild-deposits! [{:keys [query-fn frankfurter]} _req]
(query-fn :truncate-deposits! {})
(let [events (query-fn :get-deposit-events {})
n (reduce
(fn [cnt {:keys [id occurred_at fiat_amount fiat_currency exchange note]}]
(if-not fiat_amount
cnt
(try
(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))
amounts (if rates
(convert-amount fiat_amount fiat_currency rates)
{:amount-eur nil :amount-dkk nil :amount-usd nil})]
(query-fn :insert-deposit!
(merge {:event-id id
:occurred-at occurred_at
:exchange (or exchange "Unknown")
:fiat-amount fiat_amount
:fiat-currency fiat_currency
:note note}
amounts))
(inc cnt))
(catch Exception e
(log/error e "Failed to rebuild deposit for event" id)
cnt))))
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
(response/ok (query-fn :get-events-by-type {:event-type event-type}))
(response/ok (query-fn :get-all-events {})))))
(defn get-wallet-events [{:keys [query-fn]} req]
(let [id (UUID/fromString (get-in req [:path-params :id]))]
(response/ok (query-fn :get-events-by-wallet {:wallet-id id}))))
@@ -9,7 +9,7 @@
(if (= :options (:request-method request)) (if (= :options (:request-method request))
{:status 204 {:status 204
:headers {"Access-Control-Allow-Origin" origin :headers {"Access-Control-Allow-Origin" origin
"Access-Control-Allow-Methods" "GET, OPTIONS" "Access-Control-Allow-Methods" "GET, POST, DELETE, OPTIONS"
"Access-Control-Allow-Headers" "Content-Type" "Access-Control-Allow-Headers" "Content-Type"
"Access-Control-Max-Age" "86400"}} "Access-Control-Max-Age" "86400"}}
(when-let [resp (handler request)] (when-let [resp (handler request)]
@@ -22,7 +22,7 @@
[handler] [handler]
(fn [request] (fn [request]
(let [resp (handler request)] (let [resp (handler request)]
(when (:status resp) resp)))) (when (or (:status resp) (:undertow/websocket resp)) resp))))
(defn wrap-base [{:keys [site-defaults-config cors-origin]}] (defn wrap-base [{:keys [site-defaults-config cors-origin]}]
(let [origin (or cors-origin (let [origin (or cors-origin
@@ -1,11 +1,23 @@
(ns pmagnus.btcdata.web.middleware.formats (ns pmagnus.btcdata.web.middleware.formats
(:require (:require
[jsonista.core :as j]
[luminus-transit.time :as time] [luminus-transit.time :as time]
[muuntaja.core :as m])) [muuntaja.core :as m])
(:import
[com.fasterxml.jackson.databind SerializationFeature]
[com.fasterxml.jackson.datatype.jsr310 JavaTimeModule]))
(def ^:private mapper
(j/object-mapper
{:modules [(JavaTimeModule.)]
:decode-key-fn true
:configure {SerializationFeature/WRITE_DATES_AS_TIMESTAMPS false}}))
(def instance (def instance
(m/create (m/create
(-> m/default-options (-> m/default-options
(assoc-in [:formats "application/json" :decoder-opts] {:mapper mapper})
(assoc-in [:formats "application/json" :encoder-opts] {:mapper mapper})
(update-in [:formats "application/transit+json" :decoder-opts] (update-in [:formats "application/transit+json" :decoder-opts]
(partial merge time/time-deserialization-handlers)) (partial merge time/time-deserialization-handlers))
(update-in [:formats "application/transit+json" :encoder-opts] (update-in [:formats "application/transit+json" :encoder-opts]
+147 -49
View File
@@ -2,7 +2,9 @@
(:require (:require
[clojure.data.json :as json] [clojure.data.json :as json]
[integrant.core :as ig] [integrant.core :as ig]
[pmagnus.btcdata.frankfurter.rates :as rates]
[pmagnus.btcdata.web.controllers.health :as health] [pmagnus.btcdata.web.controllers.health :as health]
[pmagnus.btcdata.web.controllers.transactions :as tx]
[pmagnus.btcdata.web.middleware.exception :as exception] [pmagnus.btcdata.web.middleware.exception :as exception]
[pmagnus.btcdata.web.middleware.formats :as formats] [pmagnus.btcdata.web.middleware.formats :as formats]
[reitit.coercion.malli :as malli] [reitit.coercion.malli :as malli]
@@ -11,11 +13,9 @@
[reitit.ring.middleware.parameters :as parameters] [reitit.ring.middleware.parameters :as parameters]
[reitit.swagger :as swagger]) [reitit.swagger :as swagger])
(:import (:import
[io.undertow.server HttpServerExchange] [io.undertow.websockets.core WebSockets WebSocketChannel]
[io.undertow.websockets WebSocketConnectionCallback WebSocketProtocolHandshakeHandler] [java.time LocalDate]
[io.undertow.websockets.core AbstractReceiveListener WebSockets WebSocketChannel] [java.time.format DateTimeParseException]
[java.time Instant ZoneId]
[java.time.format DateTimeFormatter]
[java.util.concurrent LinkedBlockingQueue TimeUnit])) [java.util.concurrent LinkedBlockingQueue TimeUnit]))
(defn- price->json [{:keys [price prev-price recorded-at]} rates] (defn- price->json [{:keys [price prev-price recorded-at]} rates]
@@ -23,41 +23,13 @@
(cond-> {:price (str price) (cond-> {:price (str price)
:prev_price (when prev-price (str prev-price)) :prev_price (when prev-price (str prev-price))
:recorded_at (str recorded-at)} :recorded_at (str recorded-at)}
rates (assoc :eur (str (:eur rates)) rates (assoc :price_eur (str (.multiply (bigdec price) (:eur rates)))
:dkk (str (:dkk rates)))))) :price_dkk (str (.multiply (bigdec price) (:dkk rates)))))))
(def ^:private ts-fmt (DateTimeFormatter/ofPattern "dd-MM HH:mm:ss"))
(defn- price->html [{:keys [price prev-price recorded-at]} rates]
(let [p (double (bigdec price))
pp (when prev-price (double (bigdec prev-price)))
color (cond
(nil? pp) "text-gray-900"
(> p pp) "text-green-600"
(< p pp) "text-red-600"
:else "text-gray-900")
ts (.format (.atZone (Instant/from recorded-at) (ZoneId/systemDefault)) ts-fmt)]
(str "<div id=\"price-display\">"
"<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">"
"<div class=\"text-center\">"
"<p class=\"text-4xl font-bold " color "\">$" (format "%,.2f" p) "</p>"
(when rates
(let [eur (double (:eur rates))
dkk (double (:dkk rates))]
(str "<div class=\"flex justify-center gap-4 mt-3\">"
"<span class=\"text-sm text-gray-600\">EUR " (format "%.4f" eur) "</span>"
"<span class=\"text-sm text-gray-600\">DKK " (format "%.4f" dkk) "</span>"
"</div>")))
"<p class=\"text-xs text-gray-400 mt-2\">Updated " ts "</p>"
"</div></div></div>")))
(defn- start-ws-send-loop! [^WebSocketChannel channel price-atom rates-atom] (defn- start-ws-send-loop! [^WebSocketChannel channel price-atom rates-atom]
(let [queue (LinkedBlockingQueue.) (let [queue (LinkedBlockingQueue.)
wkey (keyword (gensym "ws-")) wkey (keyword (gensym "ws-"))
rkey (keyword (gensym "ws-r-"))] rkey (keyword (gensym "ws-r-"))]
(.set (.getReceiveSetter channel)
(proxy [AbstractReceiveListener] []))
(.resumeReceives channel)
(add-watch price-atom wkey (add-watch price-atom wkey
(fn [_ _ _ _] (.offer queue :update))) (fn [_ _ _ _] (.offer queue :update)))
(add-watch rates-atom rkey (add-watch rates-atom rkey
@@ -70,7 +42,7 @@
(when (.isOpen channel) (when (.isOpen channel)
(if (.poll queue 15 TimeUnit/SECONDS) (if (.poll queue 15 TimeUnit/SECONDS)
(when-let [v @price-atom] (when-let [v @price-atom]
(WebSockets/sendTextBlocking (price->html v @rates-atom) channel)) (WebSockets/sendTextBlocking (price->json v @rates-atom) channel))
(WebSockets/sendTextBlocking "" channel)) (WebSockets/sendTextBlocking "" channel))
(recur))) (recur)))
(catch Exception _) (catch Exception _)
@@ -80,21 +52,49 @@
(when (.isOpen channel) (when (.isOpen channel)
(try (.close channel) (catch Exception _)))))))) (try (.close channel) (catch Exception _))))))))
(defn- ws-price-handler [{:keys [binance frankfurter]} req] (defn- ws-price-handler [{:keys [binance frankfurter]} _req]
(let [^HttpServerExchange exchange (:server-exchange req) (let [price-atom (:latest-price binance)
price-atom (:latest-price binance) rates-atom (:rates frankfurter)]
rates-atom (:rates frankfurter) {:undertow/websocket
callback (proxy [WebSocketConnectionCallback] [] {:on-open (fn [{:keys [^WebSocketChannel channel]}]
(onConnect [_ws-exchange channel] (start-ws-send-loop! channel price-atom rates-atom))}}))
(start-ws-send-loop! channel price-atom rates-atom)))
handler (WebSocketProtocolHandshakeHandler. callback)]
(.handleRequest handler exchange)
nil))
(defn- latest-price-handler [{:keys [binance frankfurter]} _req] (defn- start-strike-ws-loop! [^WebSocketChannel channel strike-atom]
(let [queue (LinkedBlockingQueue.)
wkey (keyword (gensym "ws-s-"))]
(add-watch strike-atom wkey
(fn [_ _ _ _] (.offer queue :update)))
(when @strike-atom
(.offer queue :update))
(future
(try
(loop []
(when (.isOpen channel)
(if (.poll queue 15 TimeUnit/SECONDS)
(when-let [rates @strike-atom]
(WebSockets/sendTextBlocking (json/write-str rates) channel))
(WebSockets/sendTextBlocking "" channel))
(recur)))
(catch Exception _)
(finally
(remove-watch strike-atom wkey)
(when (.isOpen channel)
(try (.close channel) (catch Exception _))))))))
(defn- ws-strike-handler [{:keys [strike]} _req]
(let [strike-atom (:latest-rates strike)]
{:undertow/websocket
{:on-open (fn [{:keys [^WebSocketChannel channel]}]
(start-strike-ws-loop! channel strike-atom))}}))
(defn- latest-price-handler [{:keys [binance frankfurter query-fn]} _req]
(let [price-atom (:latest-price binance) (let [price-atom (:latest-price binance)
rates-atom (:rates frankfurter) 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 (if v
{:status 200 {:status 200
:headers {"Content-Type" "application/json"} :headers {"Content-Type" "application/json"}
@@ -103,6 +103,76 @@
:headers {"Content-Type" "application/json"} :headers {"Content-Type" "application/json"}
:body (json/write-str {:error "No price data yet"})}))) :body (json/write-str {:error "No price data yet"})})))
(defn- rate-row->json [row]
(json/write-str {:rate_date (str (:rate_date row))
:eur (str (:eur row))
:dkk (str (:dkk row))
:eur_dkk (str (:eur_dkk row))}))
(defn- currencies-handler [{:keys [query-fn frankfurter]} req]
(let [date-str (get-in req [:path-params :date])]
(try
(let [date (LocalDate/parse date-str)
today (LocalDate/now)]
(cond
(.isAfter date today)
{:status 400
:headers {"Content-Type" "application/json"}
:body (json/write-str {:error "Future date not allowed"})}
(.isEqual date today)
(if-let [row (query-fn :get-latest-currency-rates {})]
{:status 200
:headers {"Content-Type" "application/json"}
:body (rate-row->json row)}
{:status 404
:headers {"Content-Type" "application/json"}
:body (json/write-str {:error "No rates available yet"})})
:else
(if-let [row (query-fn :get-currency-rates-by-date {:rate-date date})]
{:status 200
:headers {"Content-Type" "application/json"}
:body (rate-row->json row)}
(if-let [result (rates/fetch-and-persist-for-date!
(:client frankfurter) (:url frankfurter)
query-fn date)]
{:status 200
:headers {"Content-Type" "application/json"}
:body (rate-row->json result)}
{:status 502
:headers {"Content-Type" "application/json"}
:body (json/write-str {:error "Failed to fetch rates from Frankfurter"})}))))
(catch DateTimeParseException _
{:status 400
: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] (defn- api-routes [opts]
[["/swagger.json" [["/swagger.json"
{:get {:no-doc true {:get {:no-doc true
@@ -115,7 +185,35 @@
:no-doc true :no-doc true
:middleware []}] :middleware []}]
["/price/latest" ["/price/latest"
{:get (fn [req] (latest-price-handler opts req))}]]) {:get (fn [req] (latest-price-handler opts req))}]
["/currencies/:date"
{:get (fn [req] (currencies-handler opts req))}]
["/strike/ws"
{:get (fn [req] (ws-strike-handler opts req))
:no-doc true
:middleware []}]
["/wallets"
{:get (fn [req] (tx/list-wallets opts req))
:post (fn [req] (tx/create-wallet! opts req))}]
["/wallets/balances"
{:get (fn [req] (tx/get-wallet-balances opts req))}]
["/wallets/:id/events"
{:get (fn [req] (tx/get-wallet-events opts req))}]
["/events"
{:get (fn [req] (tx/list-events opts req))
:post (fn [req] (tx/create-event! opts req))}]
["/deposits"
{:get (fn [req] (tx/list-deposits opts req))}]
["/deposits/rebuild"
{: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] (defn route-data [opts]
(merge (merge
+7 -3
View File
@@ -71,8 +71,12 @@
(.join)))) (.join))))
(defmethod ig/init-key :ws/binance (defmethod ig/init-key :ws/binance
[_ {:keys [query-fn uri]}] [_ {:keys [query-fn uri enabled?] :or {enabled? "true"}}]
(log/info "Starting Binance WebSocket listener:" uri) (if-not (= enabled? "true")
(do (log/info "Binance WebSocket disabled")
{:state (atom {:running? false})
:latest-price (atom nil)})
(do (log/info "Starting Binance WebSocket listener:" uri)
(let [latest-price (atom nil) (let [latest-price (atom nil)
state (atom {:running? true state (atom {:running? true
:last-write 0 :last-write 0
@@ -81,7 +85,7 @@
ws (connect! uri query-fn state latest-price)] ws (connect! uri query-fn state latest-price)]
(swap! state assoc :ws ws) (swap! state assoc :ws ws)
{:state state {:state state
:latest-price latest-price})) :latest-price latest-price}))))
(defmethod ig/halt-key! :ws/binance (defmethod ig/halt-key! :ws/binance
[_ {:keys [state]}] [_ {:keys [state]}]