marketmyna 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. marketmyna-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,226 @@
1
+ """Ready-made crypto screens. Coins have no earnings or book value, so these
2
+ work on price and volume: momentum, strength against Bitcoin, oversold RSI,
3
+ breakouts, drawdowns, and liquidity.
4
+
5
+ Universe: the 100 most-traded USD pairs in our crypto listing (stablecoins
6
+ dropped), six months of daily closes from Binance's public klines (Yahoo for the
7
+ few coins Binance lacks; Yahoo alone misses many, e.g. SUI, UNI, PEPE).
8
+ Cached CACHE_SECONDS. Same result shape as marketmyna/services/screens.py so the Screens
9
+ card renders both.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import math
16
+ import threading
17
+ import time
18
+ from typing import Callable, Optional
19
+
20
+ import pandas as pd
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ UNIVERSE = 100
25
+ CACHE_SECONDS = 3600
26
+ STABLES = {"USDT", "USDC", "DAI", "FDUSD", "TUSD", "USDE", "PYUSD", "BUSD", "USDD", "USDP", "GUSD", "EURC", "USD1", "RLUSD",
27
+ "PAXG", "XAUT", "EUR", "EURI", "AEUR"} # stablecoins and gold tokens: no crypto price action to screen
28
+
29
+ LABELS = {
30
+ "volume": "24h volume $", "ret_7d": "7d %", "ret_30d": "30d %", "ret_90d": "90d %", "vs_btc_30d": "vs BTC 30d pts",
31
+ "rsi14": "RSI 14", "from_high_90d": "From 90d high %", "volatility": "Volatility %/yr", "above_ma50": "Above 50d avg %",
32
+ }
33
+
34
+ # key: title, description, [(field, op, value)], (sort field, descending), columns
35
+ PRESETS = {
36
+ "volume_leaders": ("Most traded", "The most liquid coins by 24-hour dollar volume",
37
+ [], ("volume", True), ["volume", "ret_7d", "ret_30d", "volatility"]),
38
+ "momentum": ("Strong momentum", "Up over 10% in 30 days and trading above the 50-day average",
39
+ [("ret_30d", ">", 10), ("above_ma50", ">", 0)], ("ret_30d", True),
40
+ ["ret_30d", "ret_7d", "above_ma50", "rsi14", "volume"]),
41
+ "beating_btc": ("Beating Bitcoin", "30-day return ahead of Bitcoin's",
42
+ [("vs_btc_30d", ">", 0)], ("vs_btc_30d", True), ["vs_btc_30d", "ret_30d", "ret_90d", "volume"]),
43
+ "oversold": ("Oversold (RSI < 35)", "Sold hard recently: 14-day RSI under 35",
44
+ [("rsi14", "<", 35)], ("rsi14", False), ["rsi14", "ret_7d", "ret_30d", "from_high_90d", "volume"]),
45
+ "near_high": ("Near 90-day high", "Within 5% of the 90-day closing high",
46
+ [("from_high_90d", ">", -5)], ("from_high_90d", True), ["from_high_90d", "ret_30d", "rsi14", "volume"]),
47
+ "drawdown": ("Deep drawdown", "More than 40% below the 90-day high",
48
+ [("from_high_90d", "<", -40)], ("from_high_90d", False), ["from_high_90d", "ret_90d", "rsi14", "volume"]),
49
+ }
50
+
51
+ Fetch = Callable[[list[str]], dict[str, pd.Series]]
52
+
53
+ _cache: dict[str, tuple[float, tuple[list[dict], dict]]] = {}
54
+ _lock = threading.Lock()
55
+
56
+
57
+ def _binance_one(symbol: str, days: int = 180) -> Optional[pd.Series]:
58
+ import requests
59
+
60
+ pair = symbol.split("-")[0] + "USDT"
61
+ r = requests.get("https://api.binance.com/api/v3/klines",
62
+ params={"symbol": pair, "interval": "1d", "limit": days}, timeout=15)
63
+ if r.status_code != 200:
64
+ return None
65
+ rows = r.json()
66
+ if not rows:
67
+ return None
68
+ return pd.Series([float(k[4]) for k in rows], index=pd.to_datetime([k[0] for k in rows], unit="ms"))
69
+
70
+
71
+ def _fetch(tickers: list[str], days: int = 180) -> dict[str, pd.Series]:
72
+ """Binance first (parallel), Yahoo for the rest."""
73
+ from concurrent.futures import ThreadPoolExecutor
74
+
75
+ def one(t):
76
+ try:
77
+ return t, _binance_one(t, days)
78
+ except Exception:
79
+ return t, None
80
+
81
+ with ThreadPoolExecutor(max_workers=8) as pool:
82
+ got = {t: s for t, s in pool.map(one, tickers) if s is not None and len(s) > 20}
83
+ missing = [t for t in tickers if t not in got]
84
+ if missing:
85
+ try:
86
+ got.update(_yahoo(missing))
87
+ except Exception as exc:
88
+ logger.warning("Yahoo crypto history failed: %s", exc)
89
+ return got
90
+
91
+
92
+ def _yahoo(tickers: list[str]) -> dict[str, pd.Series]:
93
+ import yfinance as yf
94
+
95
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
96
+
97
+ with YF_DOWNLOAD_LOCK:
98
+ df = yf.download(tickers, period="6mo", interval="1d", progress=False, auto_adjust=True, threads=True)
99
+ closes = df["Close"]
100
+ return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
101
+
102
+
103
+ def universe(rows: list[dict]) -> list[dict]:
104
+ coins = [
105
+ r for r in rows
106
+ if r.get("symbol", "").endswith("-USD") and r["symbol"].split("-")[0] not in STABLES and (r.get("volume") or 0) > 0
107
+ ]
108
+ if not any(r["symbol"] == "BTC-USD" for r in coins):
109
+ coins.append({"symbol": "BTC-USD", "name": "BTC", "volume": 0})
110
+ return sorted(coins, key=lambda r: -(r.get("volume") or 0))[:UNIVERSE]
111
+
112
+
113
+ def _ret(s: pd.Series, days: int) -> Optional[float]:
114
+ if len(s) <= days:
115
+ return None
116
+ return float(s.iloc[-1] / s.iloc[-1 - days] - 1) * 100
117
+
118
+
119
+ def rsi(s: pd.Series, n: int = 14) -> Optional[float]:
120
+ if len(s) <= n:
121
+ return None
122
+ d = s.diff().dropna()
123
+ gain = d.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean().iloc[-1]
124
+ loss = (-d.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean().iloc[-1]
125
+ if loss == 0:
126
+ return 100.0
127
+ return float(100 - 100 / (1 + gain / loss))
128
+
129
+
130
+ def metrics(s: pd.Series, btc_30d: Optional[float]) -> dict:
131
+ s = s.dropna()
132
+ out = {"ret_7d": _ret(s, 7), "ret_30d": _ret(s, 30), "ret_90d": _ret(s, 90), "rsi14": rsi(s)}
133
+ last90 = s.tail(90)
134
+ out["from_high_90d"] = float(s.iloc[-1] / last90.max() - 1) * 100 if len(last90) else None
135
+ ma50 = s.tail(50).mean() if len(s) >= 50 else None
136
+ out["above_ma50"] = float(s.iloc[-1] / ma50 - 1) * 100 if ma50 else None
137
+ daily = s.pct_change().dropna().tail(30)
138
+ out["volatility"] = float(daily.std() * math.sqrt(365) * 100) if len(daily) > 5 else None
139
+ out["vs_btc_30d"] = out["ret_30d"] - btc_30d if out["ret_30d"] is not None and btc_30d is not None else None
140
+ return out
141
+
142
+
143
+ def _passes(m: dict, rules: list) -> bool:
144
+ for field, op, value in rules:
145
+ v = m.get(field)
146
+ if v is None or (op == ">" and not v > value) or (op == "<" and not v < value):
147
+ return False
148
+ return True
149
+
150
+
151
+ def _rule_text(rule) -> str:
152
+ field, op, value = rule
153
+ return f"{LABELS.get(field, field)} {op} {value:g}"
154
+
155
+
156
+ def list_presets() -> list[dict]:
157
+ return [{"key": k, "title": p[0], "description": p[1], "large_only": False, "rules": [_rule_text(r) for r in p[2]]}
158
+ for k, p in PRESETS.items()]
159
+
160
+
161
+ _history: dict[str, tuple[float, dict[str, pd.Series], set[str]]] = {}
162
+
163
+
164
+ def history(coins: list[dict], fetch: Fetch = _fetch, use_cache: bool = True) -> dict[str, pd.Series]:
165
+ """Six months of daily closes for `coins`, cached CACHE_SECONDS (shared by
166
+ the screens and the crypto market mood)."""
167
+ wanted = [c["symbol"] for c in coins]
168
+ if use_cache:
169
+ with _lock:
170
+ hit = _history.get("all")
171
+ if hit and time.time() - hit[0] < CACHE_SECONDS and set(wanted) <= hit[2]:
172
+ return hit[1]
173
+ closes = fetch(wanted)
174
+ if use_cache and closes:
175
+ with _lock:
176
+ _history["all"] = (time.time(), closes, set(wanted))
177
+ return closes
178
+
179
+
180
+ def _measured(rows: Optional[list[dict]], fetch: Fetch, use_cache: bool = False) -> tuple[list[dict], dict]:
181
+ if rows is None:
182
+ from ..data.listings import get_store
183
+
184
+ rows = get_store().rows("crypto")
185
+ coins = universe(rows)
186
+ closes = history(coins, fetch, use_cache)
187
+ btc_30d = _ret(closes["BTC-USD"], 30) if "BTC-USD" in closes else None
188
+ return coins, {sym: metrics(s, btc_30d) for sym, s in closes.items() if len(s) > 20}
189
+
190
+
191
+ def run(key: str, fetch: Fetch = _fetch, rows: Optional[list[dict]] = None, use_cache: bool = True) -> dict:
192
+ if key not in PRESETS:
193
+ raise ValueError(f"Unknown screen '{key}'")
194
+ title, desc, rules, (sort_field, desc_sort), columns = PRESETS[key]
195
+ hit = None
196
+ if use_cache:
197
+ with _lock:
198
+ hit = _cache.get("all")
199
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
200
+ coins, ms = hit[1]
201
+ else:
202
+ coins, ms = _measured(rows, fetch, use_cache)
203
+ if use_cache and ms:
204
+ with _lock:
205
+ _cache["all"] = (time.time(), (coins, ms))
206
+
207
+ hits = []
208
+ for c in coins:
209
+ m = ms.get(c["symbol"])
210
+ if m is None:
211
+ continue
212
+ m = {**m, "volume": c.get("volume")}
213
+ if _passes(m, rules):
214
+ hits.append({"symbol": c["symbol"], "name": c.get("name"), "market": "crypto", "market_cap": None,
215
+ "volume": c.get("volume"), "price": c.get("price"), "currency": "USD",
216
+ "change_percent": c.get("change_percent"), "sector": None,
217
+ **{col: m.get(col) for col in columns}})
218
+ hits.sort(key=lambda h: (h.get(sort_field) is None, -(h.get(sort_field) or 0) if desc_sort else (h.get(sort_field) or 0)))
219
+ return {
220
+ "key": key, "title": title, "description": desc, "market": "crypto",
221
+ "rules": [_rule_text(r) for r in rules] or ["Ranked by 24h volume"],
222
+ "columns": [{"key": c, "label": LABELS.get(c, c)} for c in columns],
223
+ "universe": f"{UNIVERSE} most-traded coins (stablecoins excluded)",
224
+ "scanned": len(ms),
225
+ "results": hits[:50],
226
+ }
@@ -0,0 +1,91 @@
1
+ """Personal data export (GDPR art. 15 / 20, India DPDP Act s. 11): everything
2
+ the app stores about one user, as a single JSON document.
3
+
4
+ Walks the same tables account deletion does (every table with a ``user_id``
5
+ pointing at users, plus their child tables such as holdings, transactions and
6
+ watchlist items), so a table added later is exported without changes here.
7
+ Credentials are never exported: password and 2FA secrets, session and
8
+ one-time token hashes, encrypted API keys and push-subscription keys are left
9
+ out (see REDACT).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ from datetime import date, datetime
16
+ from decimal import Decimal
17
+
18
+ from sqlalchemy.orm import Session
19
+
20
+ from ..core import tenant
21
+ from ..storage import Base
22
+
23
+ # table -> columns never exported (secrets or security tokens, not personal data
24
+ # a person can use). Tables in SKIP_TABLES are left out entirely.
25
+ REDACT = {
26
+ "user_api_keys": {"key_ciphertext"},
27
+ "report_runs": {"token"},
28
+ "web_push_subscriptions": {"p256dh", "auth"},
29
+ }
30
+ SKIP_TABLES: set[str] = set()
31
+ FORMAT_VERSION = 1
32
+
33
+
34
+ def _plain(value):
35
+ if isinstance(value, (datetime, date)):
36
+ return value.isoformat()
37
+ if isinstance(value, Decimal):
38
+ return float(value)
39
+ if isinstance(value, (bytes, bytearray, memoryview)):
40
+ return base64.b64encode(bytes(value)).decode()
41
+ return value
42
+
43
+
44
+ def _rows(session: Session, table, where) -> list[dict]:
45
+ hidden = REDACT.get(table.name, set())
46
+ cols = [c for c in table.columns if c.name not in hidden]
47
+ return [{c.name: _plain(row._mapping[c.name]) for c in cols}
48
+ for row in session.execute(table.select().where(where))]
49
+
50
+
51
+ def export_user(session: Session, user_id: int) -> dict:
52
+ """{"tables": {table: [rows]}} for one user, credentials removed."""
53
+ tables: dict[str, list[dict]] = {}
54
+ with tenant.system_scope():
55
+ users = Base.metadata.tables["users"]
56
+ tables["users"] = _rows(session, users, users.c.id == user_id)
57
+ owned: dict[str, list] = {}
58
+ for table in Base.metadata.sorted_tables:
59
+ col = table.columns.get("user_id")
60
+ if col is None or table.name in SKIP_TABLES or table.name == "users":
61
+ continue
62
+ if not any(f.column.table.name == "users" for f in col.foreign_keys):
63
+ continue
64
+ rows = _rows(session, table, col == user_id)
65
+ tables[table.name] = rows
66
+ if "id" in table.columns:
67
+ owned[table.name] = [r["id"] for r in rows if r.get("id") is not None]
68
+ # Child tables: rows that point at something the user owns (holdings,
69
+ # transactions, watchlist items) but carry no user_id of their own.
70
+ for table in Base.metadata.sorted_tables:
71
+ if table.name in tables or table.name in SKIP_TABLES or "user_id" in table.columns:
72
+ continue
73
+ for col in table.columns:
74
+ parent = next((f.column.table.name for f in col.foreign_keys if f.column.table.name in owned), None)
75
+ if parent and owned[parent]:
76
+ tables[table.name] = _rows(session, table, col.in_(owned[parent]))
77
+ break
78
+ return {
79
+ "format": "marketmyna-personal-data",
80
+ "version": FORMAT_VERSION,
81
+ "exported_at": datetime.now().isoformat(timespec="seconds"),
82
+ "user_id": user_id,
83
+ "notes": [
84
+ "Everything MarketMyna stores about your account, one list per table.",
85
+ "Left out on purpose: your password and 2-factor secrets, login and email-link tokens, "
86
+ "your saved AI keys and webhook links (stored encrypted) "
87
+ "and the keys of your browser push subscriptions.",
88
+ "Market data (prices, fund NAVs, listings) is public data, not personal data, and is not included.",
89
+ ],
90
+ "tables": {k: v for k, v in tables.items() if v},
91
+ }
@@ -0,0 +1,220 @@
1
+ """Discounted cash flow, reverse DCF and the Graham number for one stock.
2
+
3
+ DCF: free cash flow (operating cash flow minus capital expenditure and
4
+ stock-based compensation, latest annual filing) grows at the chosen rate for
5
+ 5 years, fades in a straight line to the terminal rate over the next 5, then
6
+ grows at the terminal rate for ever (Gordon growth). Each year is discounted
7
+ at the chosen rate; net cash (cash, short-term and long-term investments,
8
+ minus debt) is added and the total divided by the share count.
9
+
10
+ Stock-based compensation is a real cost paid in shares instead of cash, so
11
+ it is taken off; leaving it in flatters companies that pay staff in stock.
12
+
13
+ Reverse DCF: the growth rate in years 1-5 that makes the model value equal
14
+ today's price, with the same discount and terminal rates. It answers "what
15
+ does the price assume?" rather than "what is it worth?".
16
+
17
+ Graham number: sqrt(22.5 x EPS x book value per share), Benjamin Graham's
18
+ ceiling for a defensive investor (P/E 15 x P/B 1.5).
19
+
20
+ The page lets the viewer change growth, discount and terminal rates and
21
+ recomputes the same formula in the browser (frontend/src/lib/dcf.ts).
22
+ Defaults are plain starting points, not forecasts. Banks and insurers get
23
+ no DCF (their cash flow statement mixes in lending), only the Graham number.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import math
30
+ import threading
31
+ import time
32
+ from typing import Optional
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ HIGH_YEARS, TOTAL_YEARS = 5, 10
37
+ CACHE_SECONDS = 6 * 3600
38
+
39
+ # currency -> (discount rate, terminal growth): long-run government yield plus
40
+ # an equity risk premium, terminal growth near long-run nominal GDP growth
41
+ DEFAULTS = {"INR": (0.12, 0.05), "USD": (0.09, 0.025)}
42
+ OTHER = (0.10, 0.03)
43
+
44
+ _cache: dict[tuple[str, str], tuple[float, dict]] = {}
45
+ _lock = threading.Lock()
46
+
47
+
48
+ def model_value(fcf: float, growth: float, discount: float, terminal: float,
49
+ net_cash: float, shares: float) -> Optional[float]:
50
+ """Per-share value; None when discount <= terminal (no finite answer)."""
51
+ if discount <= terminal or shares <= 0:
52
+ return None
53
+ pv, cash = 0.0, fcf
54
+ for year in range(1, TOTAL_YEARS + 1):
55
+ if year <= HIGH_YEARS:
56
+ g = growth
57
+ else: # straight-line fade from the high-growth rate to the terminal rate
58
+ g = growth + (terminal - growth) * (year - HIGH_YEARS) / (TOTAL_YEARS - HIGH_YEARS)
59
+ cash *= 1 + g
60
+ pv += cash / (1 + discount) ** year
61
+ tv = cash * (1 + terminal) / (discount - terminal)
62
+ pv += tv / (1 + discount) ** TOTAL_YEARS
63
+ return (pv + net_cash) / shares
64
+
65
+
66
+ def implied_growth(price: float, fcf: float, discount: float, terminal: float,
67
+ net_cash: float, shares: float) -> Optional[float]:
68
+ """Years 1-5 growth that the price implies (bisection), None if outside -50%..+100%."""
69
+ lo, hi = -0.5, 1.0
70
+
71
+ def gap(g):
72
+ v = model_value(fcf, g, discount, terminal, net_cash, shares)
73
+ return None if v is None else v - price
74
+
75
+ a, b = gap(lo), gap(hi)
76
+ if a is None or b is None or a > 0 or b < 0:
77
+ return None
78
+ for _ in range(80):
79
+ mid = (lo + hi) / 2
80
+ if gap(mid) < 0:
81
+ lo = mid
82
+ else:
83
+ hi = mid
84
+ return (lo + hi) / 2
85
+
86
+
87
+ def graham_number(eps: Optional[float], bvps: Optional[float]) -> Optional[float]:
88
+ if not eps or not bvps or eps <= 0 or bvps <= 0:
89
+ return None
90
+ return math.sqrt(22.5 * eps * bvps)
91
+
92
+
93
+ def _cagr(newest: Optional[float], oldest: Optional[float], years: int) -> Optional[float]:
94
+ if not newest or not oldest or newest <= 0 or oldest <= 0 or years <= 0:
95
+ return None
96
+ return (newest / oldest) ** (1 / years) - 1
97
+
98
+
99
+ def _row(statement: dict, key: str) -> list[Optional[float]]:
100
+ return next((r["values"] for r in statement.get("rows", []) if r["key"] == key), [])
101
+
102
+
103
+ def _fx(from_ccy: str, to_ccy: str) -> Optional[float]:
104
+ """Units of to_ccy per from_ccy: Yahoo's pair, else the ECB reference
105
+ rate (the ECB has no TWD, SAR and a few others)."""
106
+ from ..data import ecb_fx
107
+ from ..data.yahoo_price import info_price
108
+ from .compare import _yahoo_info
109
+
110
+ pair = f"{from_ccy}{to_ccy}=X"
111
+ try:
112
+ rate = info_price(_yahoo_info(pair))
113
+ if rate and rate > 0:
114
+ return float(rate)
115
+ except Exception as exc:
116
+ logger.info("Yahoo FX %s for DCF failed: %s", pair, exc)
117
+ try:
118
+ s = ecb_fx.series([pair], days=14).get(pair)
119
+ return float(s.iloc[-1]) if s is not None and len(s) else None
120
+ except Exception as exc:
121
+ logger.info("FX %s for DCF failed: %s", pair, exc)
122
+ return None
123
+
124
+
125
+ def build(symbol: str, market: str, *, financials: Optional[dict] = None, info: Optional[dict] = None,
126
+ use_cache: bool = True) -> dict:
127
+ from ..data.yahoo_price import info_price
128
+ from .analysis_run import yahoo_symbol
129
+ from .compare import _yahoo_info
130
+
131
+ if market == "crypto":
132
+ raise ValueError("A DCF does not apply to crypto")
133
+ key = (symbol, market)
134
+ if use_cache and financials is None:
135
+ with _lock:
136
+ hit = _cache.get(key)
137
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
138
+ return hit[1]
139
+
140
+ if info is None:
141
+ info = _yahoo_info(yahoo_symbol(symbol, market))
142
+ if financials is None:
143
+ from .financials import cached_financials
144
+
145
+ financials = cached_financials(symbol, market, "annual")
146
+
147
+ price = info_price(info)
148
+ price_ccy = info.get("currency") or financials.get("currency")
149
+ fin_ccy = financials.get("currency") or info.get("financialCurrency") or price_ccy
150
+ rate = 1.0
151
+ if fin_ccy and price_ccy and fin_ccy != price_ccy:
152
+ rate = _fx(fin_ccy, price_ccy) or 0.0
153
+ discount, terminal = DEFAULTS.get(price_ccy or "", OTHER)
154
+
155
+ out: dict = {
156
+ "symbol": symbol, "market": market, "name": financials.get("name") or symbol,
157
+ "currency": price_ccy, "price": price, "periods": financials.get("periods", []),
158
+ "dcf": None, "graham": None, "note": None,
159
+ "source": f"Annual figures: {financials.get('source') or 'reported financial statements'}; price: market data feed",
160
+ }
161
+
162
+ # Graham number on Yahoo's trailing EPS and book value per share (same currency as the price
163
+ # only when the filings are in that currency)
164
+ if fin_ccy == price_ccy:
165
+ eps, bvps = info.get("trailingEps"), info.get("bookValue")
166
+ g = graham_number(eps, bvps)
167
+ if g:
168
+ out["graham"] = {"value": round(g, 2), "eps": eps, "book_value_per_share": bvps,
169
+ "vs_price_pct": round((price / g - 1) * 100, 1) if price else None}
170
+
171
+ if financials.get("financial_sector"):
172
+ out["note"] = "No DCF for banks and insurers: their cash flow statement mixes lending in with operations."
173
+ else:
174
+ ocf, capex = _row(financials["cashflow"], "operating_cf"), _row(financials["cashflow"], "capex")
175
+ sbc = _row(financials["cashflow"], "sbc")
176
+ sbc = sbc + [None] * (len(ocf) - len(sbc))
177
+ # Not reported (many Indian filers): counted as zero.
178
+ fcf_hist = [o + c - (s or 0.0) if o is not None and c is not None else None for o, c, s in zip(ocf, capex, sbc)]
179
+ cash = next((v for v in _row(financials["balance"], "cash") if v is not None), 0.0)
180
+ long_term = next((v for v in _row(financials["balance"], "long_term_investments") if v is not None), 0.0)
181
+ debt = next((v for v in _row(financials["balance"], "total_debt") if v is not None), 0.0)
182
+ shares = info.get("sharesOutstanding") or next((v for v in _row(financials["balance"], "shares") if v), None)
183
+ recent = [v for v in fcf_hist[:3] if v is not None]
184
+ base = fcf_hist[0] if fcf_hist and fcf_hist[0] and fcf_hist[0] > 0 else None
185
+ base_label = "latest year"
186
+ if base is None and recent and sum(recent) / len(recent) > 0:
187
+ base, base_label = sum(recent) / len(recent), f"{len(recent)}-year average (latest year negative)"
188
+ # Default growth: revenue growth over the filings shown (steadier than free
189
+ # cash flow, which swings with capex), else free cash flow growth
190
+ rev = [v for v in _row(financials["income"], "revenue") if v is not None]
191
+ growth = _cagr(rev[0], rev[-1], len(rev) - 1) if len(rev) >= 3 else None
192
+ known = [(i, v) for i, v in enumerate(fcf_hist) if v is not None]
193
+ if growth is None and len(known) >= 3:
194
+ growth = _cagr(known[0][1], known[-1][1], known[-1][0] - known[0][0])
195
+ growth = min(max(growth if growth is not None else 0.06, 0.0), 0.15)
196
+
197
+ if base is None or not shares or not price or not rate:
198
+ out["note"] = ("No DCF: free cash flow has been negative" if base is None
199
+ else "No DCF: share count, price or currency conversion unavailable")
200
+ else:
201
+ base, net_cash = base * rate, (cash + long_term - debt) * rate
202
+ value = model_value(base, growth, discount, terminal, net_cash, shares)
203
+ implied = implied_growth(price, base, discount, terminal, net_cash, shares)
204
+ out["dcf"] = {
205
+ "base_fcf": base, "base_label": base_label, "net_cash": net_cash, "shares": shares,
206
+ "sbc": sbc[0] * rate if sbc and sbc[0] is not None else None,
207
+ "long_term_investments": long_term * rate,
208
+ "fcf_history": [v * rate if v is not None else None for v in fcf_hist],
209
+ "converted_from": fin_ccy if rate != 1.0 else None,
210
+ "defaults": {"growth": round(growth, 4), "discount": discount, "terminal": terminal,
211
+ "high_years": HIGH_YEARS, "total_years": TOTAL_YEARS},
212
+ "value": round(value, 2) if value is not None else None,
213
+ "vs_price_pct": round((price / value - 1) * 100, 1) if value and value > 0 else None,
214
+ "implied_growth": round(implied, 4) if implied is not None else None,
215
+ }
216
+
217
+ if use_cache:
218
+ with _lock:
219
+ _cache[key] = (time.time(), out)
220
+ return out