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,202 @@
1
+ """Central-bank policy rates for about 40 economies (BIS, no key).
2
+
3
+ From the BIS daily policy-rate series: today's rate, when it last changed
4
+ and from what, the change over a year, and the real rate against the IMF's
5
+ inflation projection for this year (marketmyna/services/country_macro.py). Kept
6
+ on disk for 12 hours; a failed refresh serves the saved copy marked stale.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import io
13
+ import json
14
+ import logging
15
+ import threading
16
+ import time
17
+ from datetime import date, timedelta
18
+ from pathlib import Path
19
+ from typing import Callable, Optional
20
+
21
+ import requests
22
+
23
+ from marketmyna.core.home import app_home
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ URL = "https://stats.bis.org/api/v1/data/WS_CBPOL/D./all"
28
+ REFRESH_SECONDS = 12 * 3600
29
+
30
+ # BIS area -> (IMF code, central bank)
31
+ AREAS = {
32
+ "US": ("USA", "Federal Reserve"), "XM": ("", "European Central Bank"), "GB": ("GBR", "Bank of England"),
33
+ "JP": ("JPN", "Bank of Japan"), "CN": ("CHN", "People's Bank of China"), "IN": ("IND", "Reserve Bank of India"),
34
+ "CA": ("CAN", "Bank of Canada"), "AU": ("AUS", "Reserve Bank of Australia"), "CH": ("CHE", "Swiss National Bank"),
35
+ "KR": ("KOR", "Bank of Korea"), "BR": ("BRA", "Central Bank of Brazil"), "MX": ("MEX", "Bank of Mexico"),
36
+ "ID": ("IDN", "Bank Indonesia"), "ZA": ("ZAF", "South African Reserve Bank"), "TR": ("TUR", "Central Bank of Türkiye"),
37
+ "RU": ("RUS", "Bank of Russia"), "SA": ("SAU", "Saudi Central Bank"), "HK": ("HKG", "Hong Kong Monetary Authority"),
38
+ "SE": ("SWE", "Riksbank"), "NO": ("NOR", "Norges Bank"), "DK": ("DNK", "Danmarks Nationalbank"),
39
+ "NZ": ("NZL", "Reserve Bank of New Zealand"), "PL": ("POL", "National Bank of Poland"),
40
+ "CZ": ("CZE", "Czech National Bank"), "HU": ("HUN", "Magyar Nemzeti Bank"), "RO": ("ROU", "National Bank of Romania"),
41
+ "IL": ("ISR", "Bank of Israel"), "TH": ("THA", "Bank of Thailand"), "MY": ("MYS", "Bank Negara Malaysia"),
42
+ "PH": ("PHL", "Bangko Sentral ng Pilipinas"), "CL": ("CHL", "Central Bank of Chile"),
43
+ "CO": ("COL", "Central Bank of Colombia"), "PE": ("PER", "Central Reserve Bank of Peru"),
44
+ "AR": ("ARG", "Central Bank of Argentina"), "IS": ("ISL", "Central Bank of Iceland"),
45
+ "KW": ("KWT", "Central Bank of Kuwait"), "MA": ("MAR", "Bank Al-Maghrib"), "RS": ("SRB", "National Bank of Serbia"),
46
+ "MK": ("MKD", "National Bank of North Macedonia"),
47
+ }
48
+ FIRST = ["IN", "US", "XM", "GB", "JP", "CN"] # shown first; the rest by name
49
+
50
+ Fetch = Callable[[str], str]
51
+
52
+ _lock = threading.Lock()
53
+ _state: dict[str, object] = {}
54
+
55
+
56
+ def _dir() -> Path:
57
+ path = app_home() / "bis"
58
+ path.mkdir(parents=True, exist_ok=True)
59
+ return path
60
+
61
+
62
+ def _get(start: str) -> str:
63
+ r = requests.get(URL, params={"startPeriod": start, "format": "csv"}, timeout=60)
64
+ r.raise_for_status()
65
+ return r.text
66
+
67
+
68
+ def parse(text: str) -> dict[str, dict]:
69
+ """{area: {"name": ..., "points": [(date, rate), ...]}} oldest first, gaps dropped."""
70
+ out: dict[str, dict] = {}
71
+ for row in csv.DictReader(io.StringIO(text)):
72
+ try:
73
+ value = float(row["OBS_VALUE"])
74
+ except (TypeError, ValueError):
75
+ continue
76
+ if value != value: # NaN
77
+ continue
78
+ area = row["REF_AREA"]
79
+ title = (row.get("TITLE") or "").split(" - ")
80
+ item = out.setdefault(area, {"name": title[1].strip() if len(title) > 1 else area, "points": []})
81
+ item["points"].append((row["TIME_PERIOD"], value))
82
+ for item in out.values():
83
+ item["points"].sort()
84
+ return out
85
+
86
+
87
+ def _data(fetch: Fetch, today: date) -> tuple[dict, bool]:
88
+ with _lock:
89
+ data = _state.get("data")
90
+ if data is None:
91
+ try:
92
+ data = json.loads((_dir() / "policy-rates.json").read_text(encoding="utf-8"))
93
+ except (OSError, ValueError):
94
+ data = None
95
+ if data is not None and time.time() - data.get("fetched", 0) < REFRESH_SECONDS:
96
+ with _lock:
97
+ _state["data"] = data
98
+ return data, False
99
+ try:
100
+ areas = parse(fetch((today - timedelta(days=730)).isoformat()))
101
+ if not areas:
102
+ raise ValueError("BIS returned no rates")
103
+ fresh = {"areas": areas, "fetched": time.time()}
104
+ except Exception as exc: # noqa: BLE001 - serve the saved copy
105
+ logger.warning("BIS policy rates failed: %s", exc)
106
+ if data is None:
107
+ raise
108
+ return data, True
109
+ try:
110
+ (_dir() / "policy-rates.json").write_text(json.dumps(fresh), encoding="utf-8")
111
+ except OSError as exc:
112
+ logger.warning("Could not save policy rates: %s", exc)
113
+ with _lock:
114
+ _state["data"] = fresh
115
+ return fresh, False
116
+
117
+
118
+ def _inflation(year: int) -> dict[str, float]:
119
+ """IMF inflation projection for this year by IMF code, if already loaded (never blocks on the IMF)."""
120
+ try:
121
+ from . import country_macro
122
+
123
+ with country_macro._lock:
124
+ data = country_macro._state.get("data")
125
+ if data is None:
126
+ data = json.loads((country_macro._dir() / "weo.json").read_text(encoding="utf-8"))
127
+ series = (data.get("values") or {}).get("PCPIPCH") or {}
128
+ return {code: v[str(year)] for code, v in series.items() if v.get(str(year)) is not None}
129
+ except Exception: # noqa: BLE001 - the real rate is an extra
130
+ return {}
131
+
132
+
133
+ def _rbi_repo() -> Optional[tuple[str, float]]:
134
+ """(date, repo rate) from the RBI page snapshot marketmyna/services/india_rates.py
135
+ keeps; the BIS series for India can lag the RBI by months."""
136
+ try:
137
+ from . import india_rates
138
+
139
+ path = india_rates._dir() / "rbi.json"
140
+ if not path.exists() or time.time() - path.stat().st_mtime > 86400:
141
+ india_rates.build() # refreshes the snapshot (it has its own cache and fallback)
142
+ data = json.loads(path.read_text(encoding="utf-8"))
143
+ repo = next(p["value"] for p in data.get("policy") or [] if p.get("key") == "repo")
144
+ return str(data.get("fetched_at") or "")[:10], float(repo)
145
+ except Exception: # noqa: BLE001 - BIS alone is fine
146
+ return None
147
+
148
+
149
+ def with_rbi(points: list, rbi: Optional[tuple[str, float]]) -> list:
150
+ """Append the RBI's own reading when it is newer than the BIS series."""
151
+ if rbi and rbi[0] and (not points or rbi[0] > points[-1][0]):
152
+ return [*points, rbi]
153
+ return points
154
+
155
+
156
+ def summarise(area: str, name: str, points: list, today: date, inflation: dict[str, float]) -> Optional[dict]:
157
+ if not points:
158
+ return None
159
+ as_of, rate = points[-1]
160
+ changed, previous = None, None
161
+ for when, value in reversed(points):
162
+ if value != rate:
163
+ previous = value
164
+ break
165
+ changed = when
166
+ year_ago = [v for d, v in points if d <= (today - timedelta(days=365)).isoformat()]
167
+ imf, bank = AREAS.get(area, ("", ""))
168
+ infl = inflation.get(imf) if imf else None
169
+ monthly: dict[str, float] = {}
170
+ for when, value in points:
171
+ monthly[when[:7]] = value # month-end value
172
+ return {
173
+ "area": area, "name": name, "bank": bank, "rate": rate, "as_of": as_of,
174
+ "last_change": changed if previous is not None else None, "previous": previous,
175
+ "direction": None if previous is None else ("cut" if rate < previous else "hike"),
176
+ "change_1y": round(rate - year_ago[-1], 3) if year_ago else None,
177
+ "inflation": infl, "real_rate": round(rate - infl, 2) if infl is not None else None,
178
+ "spark": [{"month": m, "rate": v} for m, v in monthly.items()],
179
+ }
180
+
181
+
182
+ def build(fetch: Fetch = _get, today: Optional[date] = None) -> dict:
183
+ today = today or date.today()
184
+ data, stale = _data(fetch, today)
185
+ inflation = _inflation(today.year)
186
+ rbi = _rbi_repo()
187
+ rows = []
188
+ for area, item in (data.get("areas") or {}).items():
189
+ points = [tuple(p) for p in item["points"]]
190
+ bis_last = points[-1] if points else None
191
+ if area == "IN":
192
+ points = with_rbi(points, rbi)
193
+ if r := summarise(area, item["name"], points, today, inflation):
194
+ if bis_last and points[-1] != bis_last and points[-1][1] != bis_last[1]:
195
+ # moved between the last BIS day and the RBI reading: exact date unknown
196
+ r["last_change"], r["changed_between"] = None, [bis_last[0], points[-1][0]]
197
+ rows.append(r)
198
+ rows.sort(key=lambda r: (FIRST.index(r["area"]) if r["area"] in FIRST else len(FIRST), r["name"]))
199
+ return {"rows": rows, "stale": stale,
200
+ "as_of": time.strftime("%Y-%m-%d", time.localtime(data.get("fetched", time.time()))),
201
+ "source": "BIS central bank policy rates (India: RBI's own page when newer); real rate uses the IMF inflation projection for "
202
+ f"{today.year}"}
@@ -0,0 +1,134 @@
1
+ """Commodities snapshot: front-month futures from Yahoo (no key) with 1-day,
2
+ 1-month and 1-year change and a 3-month sparkline, plus gold and silver in
3
+ rupees for Indian users. Covers the useful core of OpenBB's commodity
4
+ endpoints. CFTC positioning (cot.py) and EIA oil stocks (eia_stocks.py) are
5
+ separate endpoints the card loads after the prices.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ import time
13
+ from typing import Callable, Optional
14
+
15
+ import pandas as pd
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ CACHE_SECONDS = 15 * 60
20
+ TROY_OZ_G = 31.1034768
21
+
22
+ # ticker, label, group, unit, note
23
+ ITEMS = [
24
+ ("GC=F", "Gold", "metals", "$/oz", "Safe-haven; tends to rise when real interest rates fall"),
25
+ ("SI=F", "Silver", "metals", "$/oz", "Part precious, part industrial metal"),
26
+ ("HG=F", "Copper", "metals", "$/lb", "Industrial demand gauge (\"Dr Copper\")"),
27
+ ("CL=F", "WTI crude", "energy", "$/bbl", "US oil benchmark"),
28
+ ("BZ=F", "Brent crude", "energy", "$/bbl", "Global oil benchmark; India's import bill moves with it"),
29
+ ("NG=F", "Natural gas", "energy", "$/MMBtu", "US Henry Hub gas"),
30
+ ("ZW=F", "Wheat", "agri", "¢/bu", "CBOT wheat"),
31
+ ("ZC=F", "Corn", "agri", "¢/bu", "CBOT corn"),
32
+ ]
33
+ FX = "USDINR=X"
34
+
35
+ Fetch = Callable[[list[str]], dict[str, pd.Series]]
36
+
37
+ _cache: dict[str, tuple[float, dict]] = {}
38
+ _raw_cache: dict[str, tuple[float, dict]] = {}
39
+ _lock = threading.Lock()
40
+
41
+
42
+ def _yahoo(tickers: list[str]) -> dict[str, pd.Series]:
43
+ import yfinance as yf
44
+
45
+ from ..data import ecb_fx
46
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
47
+
48
+ def download() -> dict[str, pd.Series]:
49
+ # 2y, not 1y: a 1-year window ending at a bar dated yesterday has no bar a
50
+ # full year back, so the 1-year change would come out empty.
51
+ with YF_DOWNLOAD_LOCK:
52
+ df = yf.download(tickers, period="2y", interval="1d", progress=False, auto_adjust=False, threads=True)
53
+ closes = df["Close"] if isinstance(df.columns, pd.MultiIndex) else df[["Close"]].rename(columns={"Close": tickers[0]})
54
+ return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
55
+
56
+ # Currency pairs Yahoo misses come from the ECB's daily reference rates
57
+ return ecb_fx.guarded(download, tickers)
58
+
59
+
60
+ def _change(s: pd.Series, days: int) -> Optional[float]:
61
+ when = s.index[-1] - pd.Timedelta(days=days)
62
+ past = s[s.index <= when]
63
+ return float(s.iloc[-1] / past.iloc[-1] - 1) * 100 if len(past) and past.iloc[-1] else None
64
+
65
+
66
+ def _row(key: str, label: str, group: str, unit: str, note: str, s: pd.Series) -> dict:
67
+ spark = s[s.index >= s.index[-1] - pd.Timedelta(days=92)]
68
+ return {
69
+ "key": key, "label": label, "group": group, "unit": unit, "note": note,
70
+ "price": float(s.iloc[-1]),
71
+ "as_of": str(pd.Timestamp(s.index[-1]).date()),
72
+ "change_1d": float(s.iloc[-1] / s.iloc[-2] - 1) * 100 if len(s) > 1 and s.iloc[-2] else None,
73
+ "change_1m": _change(s, 30),
74
+ "change_1y": _change(s, 365),
75
+ "spark": [round(float(v), 4) for v in spark.to_numpy()],
76
+ }
77
+
78
+
79
+ def tickers() -> list[str]:
80
+ return [t for t, *_ in ITEMS] + [FX]
81
+
82
+
83
+ def build(fetch: Fetch = _yahoo, use_cache: bool = True, live: Optional[dict] = None) -> dict:
84
+ """`live`: ticker -> {price, day} from live_market.latest, moving each
85
+ contract's latest price (and the rupee prices with it); the history
86
+ behind it is cached CACHE_SECONDS."""
87
+ from .live_market import extend_all
88
+
89
+ if use_cache and not live:
90
+ with _lock:
91
+ hit = _cache.get("all")
92
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
93
+ return hit[1]
94
+
95
+ data = None
96
+ if use_cache:
97
+ with _lock:
98
+ hit = _raw_cache.get("all")
99
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
100
+ data = hit[1]
101
+ if data is None:
102
+ try:
103
+ data = fetch(tickers())
104
+ except Exception as exc: # network / Yahoo hiccup
105
+ logger.warning("Commodity fetch failed: %s", exc)
106
+ data = {}
107
+ if use_cache and data:
108
+ with _lock:
109
+ _raw_cache["all"] = (time.time(), data)
110
+ if live:
111
+ data = extend_all(data, live)
112
+
113
+ rows = [_row(t, label, group, unit, note, data[t]) for t, label, group, unit, note in ITEMS
114
+ if t in data and len(data[t]) > 1]
115
+
116
+ # Gold / silver in rupees: international price x USDINR, before import duty and GST,
117
+ # so MCX / jeweller prices run a few percent higher.
118
+ fx = data.get(FX)
119
+ if fx is not None and len(fx) > 1:
120
+ for t, label, unit, grams in (("GC=F", "Gold (₹)", "₹/10g", 10), ("SI=F", "Silver (₹)", "₹/kg", 1000)):
121
+ if t in data:
122
+ usd = data[t]
123
+ joined = pd.concat([usd, fx], axis=1, keys=["p", "fx"]).ffill().dropna()
124
+ inr = joined["p"] * joined["fx"] / TROY_OZ_G * grams
125
+ if len(inr) > 1:
126
+ rows.append(_row(f"{t}:INR", label, "india", unit,
127
+ "International price in rupees, before import duty and GST", inr))
128
+
129
+ out = {"items": rows, "updated": time.strftime("%Y-%m-%dT%H:%M:%S"),
130
+ "usd_inr": float(fx.iloc[-1]) if fx is not None and len(fx) else None}
131
+ if use_cache and rows and not live:
132
+ with _lock:
133
+ _cache["all"] = (time.time(), out)
134
+ return out
@@ -0,0 +1,307 @@
1
+ """Side-by-side comparison of 2-5 instruments, and automatic peer discovery.
2
+
3
+ Mirrors OpenBB's equity "compare" / "peers" commands with free data: prices
4
+ from Yahoo (adjusted, so dividends are included), fundamentals from Yahoo's
5
+ quote summary, peers from our own listings (same market and sector, nearest in
6
+ size) confirmed against Yahoo's finer "industry" label.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import math
15
+ import os
16
+ import threading
17
+ import time
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from pathlib import Path
20
+ from typing import Callable, Optional
21
+
22
+ import pandas as pd
23
+
24
+ from marketmyna.core.home import app_home
25
+
26
+ from ..core.numbers import finite as _f
27
+ from ..data.yahoo_price import info_price
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ MAX_SYMBOLS = 5
32
+ PERIODS = {"6mo": 182, "1y": 365, "3y": 1095, "5y": 1826}
33
+ RETURN_WINDOWS = [("1M", 30), ("3M", 91), ("6M", 182), ("1Y", 365), ("3Y", 1095), ("5Y", 1826)]
34
+ INFO_TTL = 6 * 3600
35
+
36
+ # label, yahoo key, kind, higher is better (None = no ranking), group
37
+ METRICS = [
38
+ ("Market cap", "marketCap", "money", None, "Size & valuation"),
39
+ ("P/E (trailing)", "trailingPE", "ratio", False, "Size & valuation"),
40
+ ("P/E (forward)", "forwardPE", "ratio", False, "Size & valuation"),
41
+ ("PEG", "pegRatio", "ratio", False, "Size & valuation"),
42
+ ("Price / book", "priceToBook", "ratio", False, "Size & valuation"),
43
+ ("Price / sales", "priceToSalesTrailing12Months", "ratio", False, "Size & valuation"),
44
+ ("EV / EBITDA", "enterpriseToEbitda", "ratio", False, "Size & valuation"),
45
+ ("Revenue growth", "revenueGrowth", "pct", True, "Growth & profitability"),
46
+ ("Earnings growth", "earningsGrowth", "pct", True, "Growth & profitability"),
47
+ ("Gross margin", "grossMargins", "pct", True, "Growth & profitability"),
48
+ ("Operating margin", "operatingMargins", "pct", True, "Growth & profitability"),
49
+ ("Profit margin", "profitMargins", "pct", True, "Growth & profitability"),
50
+ ("Return on equity", "returnOnEquity", "pct", True, "Growth & profitability"),
51
+ ("Return on assets", "returnOnAssets", "pct", True, "Growth & profitability"),
52
+ ("Debt / equity", "debtToEquity", "ratio", False, "Balance sheet & income"),
53
+ ("Current ratio", "currentRatio", "ratio", True, "Balance sheet & income"),
54
+ ("Free cash flow", "freeCashflow", "money", True, "Balance sheet & income"),
55
+ ("Dividend yield", "dividendYield", "pct_raw", True, "Balance sheet & income"),
56
+ ("Payout ratio", "payoutRatio", "pct", None, "Balance sheet & income"),
57
+ ("Analyst target upside", None, "pct_raw", True, "Analysts"),
58
+ ("Analyst rating (1 buy - 5 sell)", "recommendationMean", "ratio", False, "Analysts"),
59
+ ("Analysts", "numberOfAnalystOpinions", "count", None, "Analysts"),
60
+ ]
61
+
62
+ InfoFetch = Callable[[str], dict]
63
+ PriceFetch = Callable[[list[str], str], dict[str, pd.Series]]
64
+
65
+ _info_cache: dict[str, tuple[float, dict]] = {}
66
+ _info_lock = threading.Lock()
67
+
68
+
69
+ def _info_file(yf_symbol: str) -> Path:
70
+ base = Path(os.environ.get("YAHOO_INFO_CACHE_DIR") or app_home() / "cache" / "yahoo_info")
71
+ return base / (hashlib.sha256(yf_symbol.encode()).hexdigest()[:24] + ".json")
72
+
73
+
74
+ def _disk_info(yf_symbol: str) -> Optional[tuple[float, dict]]:
75
+ """The saved copy, so an API restart or deploy doesn't make every screen
76
+ and Compare page fetch ~100 stocks from Yahoo again."""
77
+ try:
78
+ saved = json.loads(_info_file(yf_symbol).read_text(encoding="utf-8"))
79
+ return float(saved["at"]), saved["info"]
80
+ except (OSError, ValueError, KeyError, TypeError):
81
+ return None
82
+
83
+
84
+ def _save_info(yf_symbol: str, at: float, info: dict) -> None:
85
+ path = _info_file(yf_symbol)
86
+ try:
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ tmp = path.with_suffix(".tmp")
89
+ tmp.write_text(json.dumps({"at": at, "info": info}, default=str), encoding="utf-8")
90
+ tmp.replace(path)
91
+ except OSError as exc:
92
+ logger.debug("Could not save Yahoo info for %s: %s", yf_symbol, exc)
93
+
94
+
95
+ def _yahoo_info(yf_symbol: str) -> dict:
96
+ with _info_lock:
97
+ hit = _info_cache.get(yf_symbol)
98
+ if not hit:
99
+ hit = _disk_info(yf_symbol)
100
+ if hit:
101
+ with _info_lock:
102
+ _info_cache[yf_symbol] = hit
103
+ if hit and time.time() - hit[0] < INFO_TTL:
104
+ return hit[1]
105
+ import yfinance as yf
106
+
107
+ try:
108
+ info = yf.Ticker(yf_symbol).info or {}
109
+ except Exception as exc:
110
+ logger.info("Yahoo info %s failed: %s", yf_symbol, exc)
111
+ info = {}
112
+ if info: # never cache a failed lookup; Yahoo throttles in bursts and the next call usually works
113
+ now = time.time()
114
+ with _info_lock:
115
+ _info_cache[yf_symbol] = (now, info)
116
+ _save_info(yf_symbol, now, info)
117
+ return info
118
+
119
+
120
+ def _yahoo_prices(tickers: list[str], period: str) -> dict[str, pd.Series]:
121
+ import yfinance as yf
122
+
123
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
124
+
125
+ with YF_DOWNLOAD_LOCK:
126
+ df = yf.download(tickers, period=period, interval="1d", progress=False, auto_adjust=True, threads=True)
127
+ if df is None or df.empty:
128
+ return {}
129
+ closes = df["Close"] if isinstance(df.columns, pd.MultiIndex) else df[["Close"]].rename(columns={"Close": tickers[0]})
130
+ return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
131
+
132
+
133
+ def _naive(s: pd.Series) -> pd.Series:
134
+ idx = pd.DatetimeIndex(s.index)
135
+ if idx.tz is not None:
136
+ idx = idx.tz_localize(None)
137
+ return pd.Series(s.to_numpy(dtype=float), index=idx.normalize())
138
+
139
+
140
+ def _ret(s: pd.Series, days: int) -> Optional[float]:
141
+ if s.empty or (s.index[-1] - s.index[0]).days < days * 0.97:
142
+ return None
143
+ past = s[s.index <= s.index[-1] - pd.Timedelta(days=days)]
144
+ return float(s.iloc[-1] / past.iloc[-1] - 1) * 100 if len(past) else None
145
+
146
+
147
+ def _metric_value(info: dict, key: Optional[str], kind: str, label: str) -> Optional[float]:
148
+ if label == "Analyst target upside":
149
+ target, price = _f(info.get("targetMeanPrice")), info_price(info)
150
+ return (target / price - 1) * 100 if target and price else None
151
+ v = _f(info.get(key)) if key else None
152
+ if v is None:
153
+ return None
154
+ if key in ("grossMargins", "operatingMargins") and v == 0:
155
+ return None # Yahoo reports 0 for banks and insurers, where margins do not apply
156
+ if kind == "pct":
157
+ return v * 100
158
+ if key == "debtToEquity":
159
+ return v / 100 # Yahoo reports D/E in percent
160
+ return v
161
+
162
+
163
+ def compare(items: list[tuple[str, str]], period: str = "1y",
164
+ info_fetch: InfoFetch = _yahoo_info, price_fetch: PriceFetch = _yahoo_prices) -> dict:
165
+ """items: [(symbol, market)]. Raises ValueError on bad input."""
166
+ from .analysis_run import MARKET_INDICES, yahoo_symbol
167
+
168
+ items = list(dict.fromkeys((s.strip().upper(), m.strip().lower()) for s, m in items if s.strip()))
169
+ if not 2 <= len(items) <= MAX_SYMBOLS:
170
+ raise ValueError(f"Pick between 2 and {MAX_SYMBOLS} symbols")
171
+ if period not in PERIODS:
172
+ raise ValueError(f"period must be one of {', '.join(PERIODS)}")
173
+
174
+ ticker = {(s, m): yahoo_symbol(s, m) for s, m in items}
175
+ bench = {m: (MARKET_INDICES.get(m) or MARKET_INDICES["us"])[0] for _, m in items}
176
+ fetch_period = "5y" if period in ("3y", "5y") else "2y" # extra history so 1Y/3Y returns resolve
177
+ with ThreadPoolExecutor(max_workers=6) as pool:
178
+ infos_f = {k: pool.submit(info_fetch, t) for k, t in ticker.items() if k[1] != "crypto"}
179
+ prices = price_fetch(list(dict.fromkeys([*ticker.values(), *(b[0] for b in bench.values())])), fetch_period)
180
+ infos = {k: f.result() for k, f in infos_f.items()}
181
+
182
+ out_items, series, warnings = [], {}, []
183
+ for key in items:
184
+ s, m = key
185
+ info = infos.get(key, {})
186
+ raw = prices.get(ticker[key])
187
+ if raw is None or len(raw) < 20:
188
+ warnings.append(f"{s}: no price history, left out of the chart and risk numbers.")
189
+ else:
190
+ series[key] = _naive(raw)
191
+ out_items.append({
192
+ "symbol": s, "market": m, "yahoo": ticker[key],
193
+ "name": info.get("shortName") or info.get("longName") or s,
194
+ "currency": info.get("currency") or ("USD" if m == "crypto" else None),
195
+ "sector": info.get("sector"), "industry": info.get("industry"),
196
+ "price": info_price(info) or (float(series[key].iloc[-1]) if key in series else None),
197
+ })
198
+ currencies = {i["currency"] for i in out_items if i["currency"]}
199
+ if len(currencies) > 1:
200
+ warnings.append("Different currencies: returns are compared in each stock's own currency.")
201
+
202
+ start = pd.Timestamp.today().normalize() - pd.Timedelta(days=PERIODS[period])
203
+ chart: dict[str, dict[str, float]] = {}
204
+ risk, daily = {}, {}
205
+ for key, s in series.items():
206
+ label = key[0]
207
+ win = s[s.index >= start]
208
+ if len(win) < 2:
209
+ continue
210
+ for d, v in (win / win.iloc[0] * 100).items():
211
+ chart.setdefault(str(d.date()), {})[label] = round(float(v), 3)
212
+ r = win.pct_change().dropna()
213
+ daily[label] = r
214
+ curve = (1 + r).cumprod()
215
+ b = prices.get(bench[key[1]][0])
216
+ beta = None
217
+ if b is not None and len(r) > 30:
218
+ br = _naive(b).pct_change().dropna()
219
+ j = pd.concat([r, br], axis=1, join="inner").dropna()
220
+ if len(j) > 30 and j.iloc[:, 1].var() > 0:
221
+ beta = float(j.iloc[:, 0].cov(j.iloc[:, 1]) / j.iloc[:, 1].var())
222
+ days_per_year = 365 if key[1] == "crypto" else 252
223
+ vol = float(r.std() * math.sqrt(days_per_year))
224
+ years = max((win.index[-1] - win.index[0]).days / 365.25, 1e-9)
225
+ cagr = float((win.iloc[-1] / win.iloc[0]) ** (1 / years) - 1)
226
+ risk[label] = {
227
+ "volatility": vol * 100,
228
+ "max_drawdown": float((curve / curve.cummax() - 1).min()) * 100,
229
+ "beta": beta, "benchmark": bench[key[1]][1],
230
+ "return_period": float(win.iloc[-1] / win.iloc[0] - 1) * 100,
231
+ "sharpe_like": cagr / vol if vol else None,
232
+ }
233
+
234
+ returns = [{"window": w, "values": {k[0]: _ret(s, d) for k, s in series.items()}} for w, d in RETURN_WINDOWS]
235
+ returns = [r for r in returns if any(v is not None for v in r["values"].values())]
236
+
237
+ corr = None
238
+ if len(daily) >= 2:
239
+ c = pd.DataFrame(daily).dropna().corr()
240
+ corr = {"symbols": list(c.columns), "matrix": [[_f(v) for v in row] for row in c.to_numpy()]}
241
+
242
+ metrics = []
243
+ for label, key, kind, higher, group in METRICS:
244
+ vals = {i["symbol"]: _metric_value(infos.get((i["symbol"], i["market"]), {}), key, kind, label) for i in out_items}
245
+ if all(v is None for v in vals.values()):
246
+ continue
247
+ best = None
248
+ ranked = {k: v for k, v in vals.items() if v is not None and not (label.startswith("P/") and v <= 0)}
249
+ if kind == "money" and len(currencies) > 1:
250
+ ranked = {} # amounts in different currencies are not comparable
251
+ if higher is not None and len(ranked) >= 2:
252
+ best = (max if higher else min)(ranked, key=ranked.get)
253
+ metrics.append({"label": label, "kind": "pct" if kind == "pct_raw" else kind, "group": group,
254
+ "values": vals, "best": best, "higher_better": higher})
255
+
256
+ return {
257
+ "period": period,
258
+ "items": out_items,
259
+ "chart": [{"time": d, **v} for d, v in sorted(chart.items())],
260
+ "returns": returns,
261
+ "risk": risk,
262
+ "correlation": corr,
263
+ "metrics": metrics,
264
+ "warnings": warnings,
265
+ }
266
+
267
+
268
+ def peers(symbol: str, market: str, limit: int = 6, info_fetch: InfoFetch = _yahoo_info,
269
+ rows: Optional[list[dict]] = None) -> dict:
270
+ """Same-market stocks in the same sector, nearest in market cap, preferring Yahoo's same industry."""
271
+ from .analysis_run import yahoo_symbol
272
+
273
+ symbol, market = symbol.upper(), market.lower()
274
+ if rows is None:
275
+ from ..data.listings import get_store
276
+
277
+ rows = get_store().rows(market)
278
+ stocks = [r for r in rows if r.get("type") == "stock" and r.get("market_cap")]
279
+ me = next((r for r in stocks if r["symbol"].upper() == symbol), None)
280
+ my_info = info_fetch(yahoo_symbol(symbol, market))
281
+ industry, sector = my_info.get("industry"), my_info.get("sector")
282
+ cap = (me or {}).get("market_cap") or _f(my_info.get("marketCap"))
283
+ group_key = "category" if me and me.get("category") else "sector"
284
+ group_val = (me or {}).get(group_key)
285
+ if not cap or not group_val:
286
+ return {"symbol": symbol, "market": market, "peers": [], "basis": None,
287
+ "note": "Not enough sector or size data to find peers."}
288
+
289
+ pool_rows = [r for r in stocks if r.get(group_key) == group_val and r["symbol"].upper() != symbol]
290
+ pool_rows.sort(key=lambda r: abs(math.log(r["market_cap"] / cap)))
291
+ candidates = pool_rows[:24]
292
+ with ThreadPoolExecutor(max_workers=8) as pool:
293
+ infos = list(pool.map(lambda r: info_fetch(yahoo_symbol(r["symbol"], market)), candidates))
294
+ same_ind = [(r, i) for r, i in zip(candidates, infos) if industry and i.get("industry") == industry]
295
+ if len(same_ind) >= 3:
296
+ chosen, basis = same_ind[:limit], f"Same industry: {industry}"
297
+ else:
298
+ same_sec = [(r, i) for r, i in zip(candidates, infos) if sector and i.get("sector") == sector]
299
+ chosen = (same_ind + [x for x in same_sec if x not in same_ind])[:limit] or list(zip(candidates, infos))[:limit]
300
+ basis = f"Same sector: {sector or group_val}, closest in size"
301
+ return {
302
+ "symbol": symbol, "market": market, "industry": industry, "sector": sector, "basis": basis,
303
+ "peers": [{"symbol": r["symbol"], "name": r.get("name"), "market_cap": r["market_cap"],
304
+ "industry": i.get("industry"), "price": r.get("price"),
305
+ "change_percent": r.get("change_percent"),
306
+ "pe": _f(i.get("trailingPE")), "currency": r.get("currency")} for r, i in chosen],
307
+ }