finance.lisp 17 KB

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