1
0

finance.lisp 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  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) (member user-id *admins*)) 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 chat-id
  179. (if last
  180. (format nil "~{~A *~$*~^, ~} @ _~A_"
  181. (loop for (ts symbol price) in last
  182. append (list (get-symbol-label symbol) price))
  183. (format-ts (local-time:unix-to-timestamp (caar last))))
  184. "Пока нет данных. Если тикер реальный, задай через /setrates <tickers>")
  185. :parse-mode "markdown")))
  186. (defun get-order-info (order)
  187. (destructuring-bind (row-id user-id user-name symbol amount-text open-text open-at close-text close-at)
  188. order
  189. (let* ((amount (parse-float amount-text))
  190. (open (parse-float open-text))
  191. (open-amount (* amount open))
  192. (close-at (or close-at (local-time:timestamp-to-unix (local-time:now))))
  193. (close (if close-text
  194. (parse-float close-text)
  195. (db/get-finance-at symbol close-at)))
  196. (close-amount (when close (* amount close)))
  197. (duration (- close-at open-at))
  198. (income (when close-amount (- close-amount open-amount)))
  199. (roi (when income (/ income open-amount)))
  200. (annual (when roi (* (/ roi duration) (* +day+ 365.25)))))
  201. `((:row-id . ,row-id)
  202. (:user-id . ,user-id)
  203. (:user-name . ,user-name)
  204. (:symbol . ,symbol)
  205. (:amount . ,amount)
  206. (:open . ,open)
  207. (:open-at . ,open-at)
  208. (:open-amount . ,open-amount)
  209. (:open-p . ,(null close-text))
  210. (:close . ,close)
  211. (:close-at . ,close-at)
  212. (:close-amount . ,close-amount)
  213. (:duration . ,duration)
  214. (:income . ,income)
  215. (:roi . ,roi)
  216. (:annual . ,annual)))))
  217. (defun print-order-info (idx info)
  218. (format nil "~A) @~A: *~A* ~A _~A_ @ *~$* ~A _~A_ @ *~$*, заработал *~$*, ROI *~$*%"
  219. idx
  220. (agets info :user-name)
  221. (agets info :amount)
  222. (get-symbol-label (agets info :symbol))
  223. (format-ts (local-time:unix-to-timestamp (agets info :open-at)))
  224. (agets info :open)
  225. (if (agets info :open-p)
  226. (if (> (or (agets info :income) 0) 0) "📈" "📉")
  227. (if (> (or (agets info :income) 0) 0) "🏆" "🐟"))
  228. (if (agets info :open-p) "now"
  229. (format-ts (local-time:unix-to-timestamp (agets info :close-at))))
  230. (or (agets info :close) 0)
  231. (or (agets info :income) 0)
  232. (* 100 (or (agets info :roi) 0))))
  233. (defun handle-hodl-show (chat-id)
  234. (let ((orders (db/orders-get chat-id)))
  235. (bot-send-message chat-id
  236. (if orders (format nil "~{~A~%~} 😇"
  237. (loop for order in orders for idx from 1
  238. collect (print-order-info idx (get-order-info order))))
  239. "Нет ходлеров :(")
  240. :parse-mode "markdown")))
  241. (defun handle-hodl-buy (chat-id from args)
  242. (handler-case
  243. (let ((symbol (get-label-symbol (nth 1 args)))
  244. (amount (parse-float (nth 2 args)))
  245. (open (parse-float (nth 3 args)))
  246. (open-at (if (nth 4 args) (local-time:parse-timestring (nth 4 args)) (local-time:now))))
  247. (db/order-buy chat-id (agets from "id") (agets from "username")
  248. symbol (princ-to-string amount) (princ-to-string open)
  249. (local-time:timestamp-to-unix open-at))
  250. (handle-hodl-show chat-id))
  251. (error (e)
  252. (log:error "~A" e)
  253. (bot-send-message chat-id "/hodl buy <SYM> <AMT> <PRICE> [YYYY-MM-DD]"))))
  254. (defun handle-hodl-sell (chat-id from args)
  255. (handler-case
  256. (let* ((symbol (get-label-symbol (nth 1 args)))
  257. (amount (parse-float (nth 2 args)))
  258. (close (parse-float (nth 3 args)))
  259. (close-at (if (nth 4 args) (local-time:parse-timestring (nth 4 args)) (local-time:now)))
  260. (user-id (agets from "id"))
  261. (orders (db/orders-get chat-id user-id symbol t))
  262. (total-amount (apply #'+ (mapcar #'(lambda (o) (parse-float (nth 4 o))) orders))))
  263. (if (> amount total-amount)
  264. (bot-send-message chat-id (format nil "Нет у тебя столько ~A! (не хватает ~A)"
  265. (nth 1 args)
  266. (- amount total-amount)))
  267. (progn
  268. (loop for order in orders
  269. for order-amount = (parse-float (nth 4 order))
  270. while (> amount 0)
  271. when (> order-amount amount)
  272. do (db/order-buy chat-id (nth 1 order) (nth 2 order) (nth 3 order)
  273. (princ-to-string (- order-amount amount))
  274. (nth 5 order) (nth 6 order))
  275. do (progn
  276. (db/order-sell (nth 0 order)
  277. (princ-to-string (min amount order-amount))
  278. (princ-to-string close)
  279. (local-time:timestamp-to-unix close-at))
  280. (decf amount order-amount)))
  281. (handle-hodl-show chat-id))))
  282. (error (e)
  283. (log:error "~A" e)
  284. (bot-send-message chat-id "/hodl sell <SYM> <AMT> <PRICE> [YYYY-MM-DD]"))))
  285. (defun handle-hodl-del (chat-id from args)
  286. (handler-case
  287. (let* ((symbol (get-label-symbol (nth 1 args)))
  288. (amount (or (nth 2 args) (error "no amount")))
  289. (order (find amount (db/orders-get chat-id (agets from "id") symbol)
  290. :key #'(lambda (r) (nth 4 r)) :test #'equal)))
  291. (if order
  292. (progn
  293. (db/order-del (car order))
  294. (handle-hodl-show chat-id))
  295. (bot-send-message chat-id (format nil "Не нашёл ~A ~A :(" amount (nth 1 args)))))
  296. (error (e)
  297. (log:error "~A" e)
  298. (bot-send-message chat-id "/hodl del <SYM> <AMT>"))))
  299. (def-message-cmd-handler handle-hodl (:hodl :hodlers)
  300. (cond
  301. ((equal (car args) "buy") (handle-hodl-buy chat-id (agets message "from") args))
  302. ((equal (car args) "sell") (handle-hodl-sell chat-id (agets message "from") args))
  303. ((equal (car args) "del") (handle-hodl-del chat-id (agets message "from") args))
  304. (:otherwise (handle-hodl-show chat-id))))
  305. (def-message-cmd-handler handler-charts (:charts)
  306. (telegram-send-chat-action chat-id "upload_photo")
  307. (let* ((args (mapcar 'string-downcase args))
  308. (all-symbols (db/get-chat-symbols))
  309. (symbols (or (intersection (mapcar #'get-label-symbol args) all-symbols :test 'equal) all-symbols))
  310. (day-range (some #'(lambda (a) (aget a +chart-ranges+)) args))
  311. (number (some #'(lambda (a) (parse-integer a :junk-allowed t)) args))
  312. (avg (* 60 (cond
  313. (day-range (round day-range *chart-points*))
  314. (number)
  315. (:otherwise 1))))
  316. (after-ts (local-time:timestamp- (local-time:now)
  317. (* avg *chart-points*) :sec))
  318. (rates (db/get-last-finance symbols))
  319. (chart (make-chart (db/get-series after-ts symbols avg) symbols)))
  320. (telegram-send-photo chat-id chart
  321. :caption
  322. (format nil "~{~A ~$~^, ~} @ ~A"
  323. (loop for (ts symbol price) in rates
  324. append (list (get-symbol-label symbol) price))
  325. (format-ts (local-time:unix-to-timestamp (caar rates)))))))
  326. (def-message-cmd-handler handler-set-rates (:setrates)
  327. (when args
  328. (db/set-chat-symbols chat-id (mapcar #'get-label-symbol args)))
  329. (bot-send-message chat-id
  330. (format nil "Пишем ~{_~a_~#[~; и ~:;, ~]~}"
  331. (mapcar #'get-symbol-label (db/get-chat-symbols chat-id)))
  332. :parse-mode "markdown"))