finance.lisp 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.finance
  3. (:use :cl :chatikbot.common))
  4. (in-package :chatikbot.plugins.finance)
  5. (defparameter +yahoo-url+ "https://query1.finance.yahoo.com/v7/finance/quote?lang=en-US&region=US&corsDomain=finance.yahoo.com&fields=regularMarketPrice" "Yahoo Finance API endpoint")
  6. (defparameter +brent-url+ "http://www.cmegroup.com/CmeWS/mvc/Quotes/Future/424/G")
  7. (defvar *default-symbols* '("USDRUB=X" "EURRUB=X" "GBPRUB=X"))
  8. (defparameter +chart-ranges+ (list (cons "day" (* 24 60))
  9. (cons "week" (* 7 24 60))
  10. (cons "month" (* 30 24 60))
  11. (cons "quarter" (* 91 24 60))
  12. (cons "year" (* 365 24 60))))
  13. (defun get-rates (&optional (symbols *default-symbols*))
  14. (let ((response (agets (json-request
  15. +yahoo-url+
  16. :parameters `(("symbols" . ,(format nil "~{~A~^,~}" symbols))))
  17. "quoteResponse")))
  18. (when (aget "error" response)
  19. (error "Error in rates request"))
  20. (loop for rate in (agets response "result")
  21. collect (cons (aget "symbol" rate)
  22. (aget "regularMarketPrice" rate)))))
  23. (defun get-brent ()
  24. (handler-case
  25. (let ((last (read-from-string
  26. (aget "last" (first (aget "quotes" (json-request +brent-url+)))))))
  27. (when (numberp last)
  28. last))
  29. (error (e) (log:error e))))
  30. (defparameter +btc-e-usd-url+ "https://btc-e.com/api/2/btc_usd/ticker" "BTC-e BTC/USD ticker url")
  31. (defun get-btc-e ()
  32. (handler-case
  33. (aget "last" (aget "ticker" (json-request +btc-e-usd-url+)))
  34. (error (e) (log:error e))))
  35. (defparameter +coindesk-btc-usd-url+ "https://api.coindesk.com/site/headerdata.json?currency=BTC" "coindesk.com api endpoint")
  36. (defun get-coindesk-btc-usd ()
  37. (handler-case
  38. (agets (json-request +coindesk-btc-usd-url+)
  39. "bpi" "USD" "rate_float")
  40. (error (e) (log:error "~A" e))))
  41. (defparameter +cex-currency-limits-url+ "https://cex.io/api/currency_limits" "cex.io currency limits endpoint")
  42. (defparameter +cex-last-prices-url+ "https://cex.io/api/last_prices/" "cex.io last prices api endpoint")
  43. (defun cex-pair-symbol (pair)
  44. (concatenate 'string (agets pair "symbol1") "/" (agets pair "symbol2")))
  45. (defun get-cex-io-symbols ()
  46. (handler-case
  47. (mapcar 'cex-pair-symbol (agets (json-request +cex-currency-limits-url+) "data" "pairs"))
  48. (error (e) (log:error "~A" e))))
  49. (defvar *cex-io-symbols* (get-cex-io-symbols) "List of all cex.io supported symbols. Will be loaded on init")
  50. (defun get-cex-io-last-prices (&rest symbols)
  51. (handler-case
  52. (let* ((ends (remove-duplicates (mapcar (lambda (s) (subseq s (1+ (position #\/ s)))) symbols)
  53. :test 'equal))
  54. (data (agets (json-request (format nil "~a~{~a~^/~}" +cex-last-prices-url+ ends)) "data")))
  55. (loop for pair in data
  56. for symbol = (cex-pair-symbol pair)
  57. when (member symbol symbols :test 'equal)
  58. collect (cons symbol (parse-float (agets pair "lprice")))))
  59. (error (e) (log:error "~A" e))))
  60. ;; Formatting
  61. (defvar *symbol-aliases*
  62. (alexandria:plist-hash-table
  63. '("BTCUSD" ("btc" "bitcoin" "биток")
  64. "BRENT" ("чёрная" "нефть" "brent" "oil")
  65. "USDRUB=X" ("зелёный" "бакс" "доллар" "usd")
  66. "EURRUB=X" ("гейро" "eur" "евро")
  67. "GBPRUB=X" ("британец" "gbp" "фунт" "стерлинг")
  68. "CZKRUB=X" ("чешка" "czk")
  69. "DKKRUB=X" ("дашка" "dkk")
  70. "ETHUSD=X" ("eth" "etherium" "эфир" "эфирум" "виталик"))
  71. :test #'equal))
  72. (defun get-symbol-label (symbol)
  73. (let ((aliases (gethash symbol *symbol-aliases*)))
  74. (if aliases
  75. (car aliases)
  76. (let ((lower (string-downcase symbol)))
  77. (when (equal "=x" (subseq lower (- (length lower) 2)))
  78. (setf lower (subseq lower 0 (- (length lower) 2))))
  79. (if (= 6 (length lower))
  80. (format nil "~A/~A" (subseq lower 0 3) (subseq lower 3))
  81. lower)))))
  82. ;; Charts
  83. (defun get-serie (series symbol)
  84. (mapcar #'(lambda (r) (list (car r) (caddr r)))
  85. (remove symbol series :key #'cadr :test-not #'equal)))
  86. (defun range (seq &key (key 'identity))
  87. (loop
  88. for item in seq
  89. for value = (funcall key item)
  90. minimizing value into min
  91. maximizing value into max
  92. finally (return (values min max))))
  93. (defvar *chart-points* 800 "Data points in chart")
  94. (defun make-chart (series symbols)
  95. (let* ((r (multiple-value-list (range series :key #'car)))
  96. (fmt (if (<= (- (second r) (first r))
  97. (* 60 60 36))
  98. '((:hour 2) ":" (:min 2))
  99. '((:day 2) "." (:month 2) " " (:hour 2) ":" (:min 2)))))
  100. (adw-charting:with-line-chart ((+ 90 *chart-points*)
  101. (floor (* 3 *chart-points*) 4))
  102. (loop for symbol in symbols
  103. do (adw-charting:add-series (get-symbol-label symbol)
  104. (get-serie series symbol)))
  105. (adw-charting:set-axis
  106. :x "Time" :draw-gridlines-p t
  107. :label-formatter #'(lambda (v)
  108. (local-time:format-timestring
  109. nil
  110. (local-time:unix-to-timestamp v)
  111. :format fmt)))
  112. (adw-charting:set-axis :y "RUB" :draw-gridlines-p t :label-formatter "~,2F")
  113. (adw-charting:save-file "chart.png"))))
  114. ;; Database
  115. (def-db-init
  116. (db-execute "create table if not exists finance_ticker (ts, symbol, price, primary key (ts, symbol)) without rowid")
  117. (db-execute "create table if not exists finance_chat_symbols (chat_id, symbol, primary key (chat_id, symbol)) without rowid")
  118. (db-execute "create table if not exists finance_orders (chat_id, user_id, user_name, symbol, amount, open, open_at, close, close_at)")
  119. (db-execute "create index if not exists idx_finance_ticker_ts on finance_ticker(ts desc)"))
  120. (defun migrate ()
  121. (db-execute "delete from finance_ticker")
  122. (db-execute "insert or ignore into finance_ticker (ts, symbol, price) select ts, ?, usd from finance where usd is not null" "USDRUB=X")
  123. (db-execute "insert or ignore into finance_ticker (ts, symbol, price) select ts, ?, eur from finance where eur is not null" "EURRUB=X")
  124. (db-execute "insert or ignore into finance_ticker (ts, symbol, price) select ts, ?, gbp from finance where gbp is not null" "GBPRUB=X")
  125. (db-execute "insert or ignore into finance_ticker (ts, symbol, price) select ts, ?, brent from finance where brent is not null" "BRENT")
  126. (db-execute "insert or ignore into finance_ticker (ts, symbol, price) select ts, ?, btc from finance where btc is not null" "BTCUSD"))
  127. (defun db/add-finance (ts symbol price)
  128. (db-execute "insert into finance_ticker (ts, symbol, price) values (?, ?, ?)" ts symbol price))
  129. (defun db/get-last-finance (&optional (symbols *default-symbols*))
  130. (loop for symbol in symbols
  131. for last = (db-select "select ts, symbol, price from finance_ticker where symbol = ? order by ts desc limit 1"
  132. symbol)
  133. when last append last))
  134. (defun db/get-finance-at (symbol ts &optional (window +DAY+))
  135. (db-single "select price from finance_ticker where symbol = ? and ts between ? and ? order by ts desc limit 1"
  136. symbol (- ts window) ts))
  137. (defun db/get-chat-symbols (&optional chat-id)
  138. (mapcar #'car
  139. (if chat-id
  140. (db-select "select symbol from finance_chat_symbols where chat_id = ?" chat-id)
  141. (db-select "select distinct symbol from finance_chat_symbols union select symbol from finance_orders"))))
  142. (defun db/set-chat-symbols (chat-id symbols)
  143. (db-transaction
  144. (db-execute "delete from finance_chat_symbols where chat_id = ?" chat-id)
  145. (dolist (symbol symbols)
  146. (db-execute "insert into finance_chat_symbols (chat_id, symbol) values (?, ?)"
  147. chat-id symbol))))
  148. (defun db/get-series (after-ts &optional (symbols *default-symbols*) (avg 60))
  149. (when symbols
  150. (let ((sql (format nil
  151. "select ts/~a*~:*~a, symbol, avg(price) from finance_ticker where ts >= ? and symbol in (~{?~*~^, ~}) group by ts/~a, symbol order by ts"
  152. avg symbols avg)))
  153. (apply #'db-select sql (local-time:timestamp-to-unix after-ts) symbols))))
  154. (defun db/order-buy (chat-id user-id user-name symbol amount open open-at)
  155. (db-execute "insert into finance_orders (chat_id, user_id, user_name, symbol, amount, open, open_at) values (?, ?, ?, ?, ?, ?, ?)"
  156. chat-id user-id user-name symbol amount open open-at))
  157. (defun db/order-sell (row-id amount close close-at)
  158. (db-execute "update finance_orders set amount = ?, close = ?, close_at = ? WHERE rowid = ?"
  159. amount close close-at row-id))
  160. (defun db/order-del (row-id)
  161. (db-execute "delete from finance_orders WHERE rowid = ?" row-id))
  162. (defun db/orders-get (chat-id &optional user-id symbol opened)
  163. (db-select "select rowid, user_id, user_name, symbol, amount, open, open_at, close, close_at from finance_orders where chat_id = ? and (user_id = ? or ?) and (symbol = ? or ?) and (close is null or ?) order by open_at, close_at"
  164. chat-id
  165. user-id (if (or (null user-id) (is-admin)) 1 0)
  166. symbol (if (null symbol) 1 0)
  167. (if opened 0 1)))
  168. ;; Cron
  169. (defcron process-rates ()
  170. (let* ((ts (local-time:timestamp-to-unix (local-time:now)))
  171. (symbols (db/get-chat-symbols))
  172. (cex-symbols (intersection symbols *cex-io-symbols* :test 'equal))
  173. (rest-symbols (set-difference symbols cex-symbols))
  174. (rates (when rest-symbols (get-rates rest-symbols)))
  175. (cex-prices (when cex-symbols (apply 'get-cex-io-last-prices cex-symbols))))
  176. (loop for (symbol . price) in (append rates cex-prices)
  177. do (db/add-finance ts symbol price))))
  178. ;;; Hooks
  179. (defun get-label-symbol (label)
  180. (or
  181. (loop for symbol being the hash-keys in *symbol-aliases* using (hash-value aliases)
  182. when (member label aliases :test #'string-equal)
  183. do (return symbol))
  184. (let ((upper (string-upcase label)))
  185. (if (member upper *cex-io-symbols* :test 'equal) upper
  186. (if (and (= 7 (length label))
  187. (equal (elt label 3) #\/))
  188. (concatenate 'string (remove #\/ upper) "=X")
  189. upper)))))
  190. (def-message-cmd-handler handler-rates (:rates)
  191. (let* ((symbols (or (mapcar #'get-label-symbol *args*)
  192. (db/get-chat-symbols *chat-id*)
  193. *default-symbols*))
  194. (last (db/get-last-finance symbols)))
  195. (bot-send-message (if last
  196. (format nil "~{~A *~$*~^, ~} @ _~A_"
  197. (loop for (ts symbol price) in last
  198. append (list (get-symbol-label symbol) price))
  199. (format-ts (local-time:unix-to-timestamp (caar last))))
  200. "Пока нет данных. Если тикер реальный, задай через /setrates <tickers>")
  201. :parse-mode "markdown")))
  202. (defun get-order-info (order)
  203. (destructuring-bind (row-id user-id user-name symbol amount-text open-text open-at close-text close-at)
  204. order
  205. (let* ((amount (parse-float amount-text))
  206. (open (parse-float open-text))
  207. (open-amount (* amount open))
  208. (close-at (or close-at (local-time:timestamp-to-unix (local-time:now))))
  209. (close (if close-text
  210. (parse-float close-text)
  211. (db/get-finance-at symbol close-at)))
  212. (close-amount (when close (* amount close)))
  213. (duration (- close-at open-at))
  214. (income (when close-amount (- close-amount open-amount)))
  215. (roi (when income (/ income open-amount)))
  216. (annual (when (and roi (not (zerop duration))) (* (/ roi duration) (* +day+ 365.25)))))
  217. `((:row-id . ,row-id)
  218. (:user-id . ,user-id)
  219. (:user-name . ,user-name)
  220. (:symbol . ,symbol)
  221. (:amount . ,amount)
  222. (:open . ,open)
  223. (:open-at . ,open-at)
  224. (:open-amount . ,open-amount)
  225. (:open-p . ,(null close-text))
  226. (:close . ,close)
  227. (:close-at . ,close-at)
  228. (:close-amount . ,close-amount)
  229. (:duration . ,duration)
  230. (:income . ,income)
  231. (:roi . ,roi)
  232. (:annual . ,annual)))))
  233. (defun print-order-info (idx info)
  234. (format nil "~A) ~A: *~A* ~A _~A_ @ *~$* ~A _~A_ @ *~$*, заработал *~$*, ROI *~$*%"
  235. idx
  236. (agets info :user-name)
  237. (agets info :amount)
  238. (get-symbol-label (agets info :symbol))
  239. (format-ts (local-time:unix-to-timestamp (agets info :open-at)))
  240. (agets info :open)
  241. (if (agets info :open-p)
  242. (if (> (or (agets info :income) 0) 0) "📈" "📉")
  243. (if (> (or (agets info :income) 0) 0) "🏆" "🐟"))
  244. (if (agets info :open-p) "now"
  245. (format-ts (local-time:unix-to-timestamp (agets info :close-at))))
  246. (or (agets info :close) 0)
  247. (or (agets info :income) 0)
  248. (* 100 (or (agets info :roi) 0))))
  249. (defun handle-hodl-show ()
  250. (let ((orders (db/orders-get *chat-id*)))
  251. (bot-send-message (if orders (format nil "~{~A~%~} 😇"
  252. (loop for order in orders for idx from 1
  253. collect (print-order-info idx (get-order-info order))))
  254. "Нет ходлеров :(")
  255. :parse-mode "markdown"
  256. :disable-notification 1)))
  257. (defun handle-hodl-buy ()
  258. (handler-case
  259. (let ((symbol (get-label-symbol (nth 1 *args*)))
  260. (amount (* (parse-float (nth 2 *args*)) 1))
  261. (open (* (parse-float (nth 3 *args*)) 1))
  262. (open-at (if (nth 4 *args*) (local-time:parse-timestring (nth 4 *args*)) (local-time:now))))
  263. (db/order-buy *chat-id* *from-id* (agets *from* "username")
  264. symbol (princ-to-string amount) (princ-to-string open)
  265. (local-time:timestamp-to-unix open-at))
  266. (handle-hodl-show))
  267. (error (e)
  268. (log:error "~A" e)
  269. (bot-send-message "/hodl buy <SYM> <AMT> <PRICE> [YYYY-MM-DD]"))))
  270. (defun handle-hodl-sell ()
  271. (handler-case
  272. (let* ((symbol (get-label-symbol (nth 1 *args*)))
  273. (amount (* (parse-float (nth 2 *args*)) 1))
  274. (close (* (parse-float (nth 3 *args*)) 1))
  275. (close-at (if (nth 4 *args*) (local-time:parse-timestring (nth 4 *args*)) (local-time:now)))
  276. (user-id (agets *from* "id"))
  277. (orders (db/orders-get *chat-id* user-id symbol t))
  278. (total-amount (apply #'+ (mapcar #'(lambda (o) (parse-float (nth 4 o))) orders))))
  279. (if (> amount total-amount)
  280. (bot-send-message (format nil "Нет у тебя столько ~A! (не хватает ~A)"
  281. (nth 1 *args*)
  282. (- amount total-amount)))
  283. (progn
  284. (loop for order in orders
  285. for order-amount = (parse-float (nth 4 order))
  286. while (> amount 0)
  287. when (> order-amount amount)
  288. do (db/order-buy *chat-id* (nth 1 order) (nth 2 order) (nth 3 order)
  289. (princ-to-string (- order-amount amount))
  290. (nth 5 order) (nth 6 order))
  291. do (progn
  292. (db/order-sell (nth 0 order)
  293. (princ-to-string (min amount order-amount))
  294. (princ-to-string close)
  295. (local-time:timestamp-to-unix close-at))
  296. (decf amount order-amount)))
  297. (handle-hodl-show))))
  298. (error (e)
  299. (log:error "~A" e)
  300. (bot-send-message "/hodl sell <SYM> <AMT> <PRICE> [YYYY-MM-DD]"))))
  301. (defun handle-hodl-del ()
  302. (handler-case
  303. (let* ((symbol (get-label-symbol (nth 1 *args*)))
  304. (amount (or (nth 2 *args*) (error "no amount")))
  305. (order (find amount (db/orders-get *chat-id* (agets *from* "id") symbol)
  306. :key #'(lambda (r) (nth 4 r)) :test #'equal)))
  307. (if order
  308. (progn
  309. (db/order-del (car order))
  310. (handle-hodl-show))
  311. (bot-send-message (format nil "Не нашёл ~A ~A :(" amount (nth 1 *args*)))))
  312. (error (e)
  313. (log:error "~A" e)
  314. (bot-send-message "/hodl del <SYM> <AMT>"))))
  315. (def-message-cmd-handler handle-hodl (:hodl :hodlers)
  316. (cond
  317. ((equal (car *args*) "buy") (handle-hodl-buy))
  318. ((equal (car *args*) "sell") (handle-hodl-sell))
  319. ((equal (car *args*) "del") (handle-hodl-del))
  320. (:otherwise (handle-hodl-show))))
  321. (def-message-cmd-handler handler-charts (:charts)
  322. (telegram-send-chat-action *chat-id* "upload_photo")
  323. (let* ((args (mapcar 'string-downcase *args*))
  324. (all-symbols (db/get-chat-symbols *chat-id*))
  325. (symbols (or (intersection (mapcar #'get-label-symbol args) all-symbols :test 'equal) all-symbols))
  326. (day-range (some #'(lambda (a) (aget a +chart-ranges+)) args))
  327. (number (some #'(lambda (a) (parse-integer a :junk-allowed t)) args))
  328. (avg (* 60 (cond
  329. (day-range (round day-range *chart-points*))
  330. (number)
  331. (:otherwise 1))))
  332. (after-ts (local-time:timestamp- (local-time:now)
  333. (* avg *chart-points*) :sec))
  334. (rates (db/get-last-finance symbols))
  335. (chart (make-chart (db/get-series after-ts symbols avg) symbols)))
  336. (telegram-send-photo *chat-id* chart
  337. :caption
  338. (format nil "~{~A ~$~^, ~} @ ~A"
  339. (loop for (ts symbol price) in rates
  340. append (list (get-symbol-label symbol) price))
  341. (format-ts (local-time:unix-to-timestamp (caar rates)))))))
  342. (def-message-cmd-handler handler-set-rates (:setrates)
  343. (when *args*
  344. (db/set-chat-symbols *chat-id* (mapcar #'get-label-symbol *args*)))
  345. (bot-send-message (format nil "Пишем ~{_~a_~#[~; и ~:;, ~]~}"
  346. (mapcar #'get-symbol-label (db/get-chat-symbols *chat-id*)))
  347. :parse-mode "markdown"))