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,328 @@
1
+ """Thesis tracker: why you own (or watch) a stock, kept honest against data.
2
+
3
+ Adapted from the thesis-tracker skill in anthropics/financial-services
4
+ (Apache-2.0). A thesis should be falsifiable, so it carries:
5
+
6
+ statement 1-2 sentences ("margins expand as the mix shifts to software")
7
+ pillars up to 5 supporting points, each on track / watch / broken
8
+ risks up to 5 things that would invalidate it
9
+ exit_below the price at which the thesis is broken; exit_note for the rest
10
+ target what it is worth if the thesis plays out
11
+ conviction high / medium / low
12
+ log dated updates: what happened and whether it strengthened or
13
+ weakened the thesis - disconfirming evidence included
14
+
15
+ The data check sets the thesis against live numbers: price vs the exit and
16
+ target levels, the next results date, estimate revisions, pillars marked
17
+ broken and whether the quarterly review is overdue. All of it is the user's
18
+ own view measured against arithmetic - nothing here is a recommendation.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import time
25
+ from datetime import date, datetime
26
+ from typing import Callable, Optional
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ STATUSES = ("on_track", "watch", "broken")
31
+ IMPACTS = ("strengthens", "weakens", "neutral")
32
+ CONVICTIONS = ("high", "medium", "low")
33
+ MAX_ITEMS = 5
34
+ MAX_LOG = 200
35
+ REVIEW_DAYS = 90 # review at least quarterly, even when nothing happens
36
+ REARM = 0.03 # an exit / target alert re-arms once the price is 3% back on the other side
37
+ ALERT_EVERY = 30 * 60 # price checks per user at most this often
38
+
39
+ _last_run: dict[Optional[int], float] = {}
40
+
41
+
42
+ class ThesisError(ValueError):
43
+ pass
44
+
45
+
46
+ def _db(config):
47
+ from ..storage import DatabaseManager, get_db_url
48
+
49
+ return DatabaseManager.instance(get_db_url(config=config))
50
+
51
+
52
+ def to_dict(r) -> dict:
53
+ return {"id": r.id, "symbol": r.symbol, "market": r.market, "statement": r.statement,
54
+ "pillars": r.pillars or [], "risks": r.risks or [], "exit_below": r.exit_below,
55
+ "exit_note": r.exit_note or "", "target": r.target, "conviction": r.conviction or "medium",
56
+ "log": r.log or [], "created_at": r.created_at.date().isoformat() if r.created_at else None,
57
+ "reviewed_at": r.reviewed_at, "alerted": r.alerted or {}}
58
+
59
+
60
+ def _clean(fields: dict) -> dict:
61
+ out = {}
62
+ if "statement" in fields:
63
+ text = (fields["statement"] or "").strip()
64
+ if not text:
65
+ raise ThesisError("Write the thesis in a sentence or two")
66
+ out["statement"] = text[:2000]
67
+ if "pillars" in fields:
68
+ pillars = []
69
+ for p in (fields["pillars"] or [])[:MAX_ITEMS]:
70
+ text = str((p or {}).get("text", "")).strip()[:300]
71
+ if text:
72
+ status = (p or {}).get("status") or "on_track"
73
+ if status not in STATUSES:
74
+ raise ThesisError(f"Unknown pillar status '{status}'")
75
+ pillars.append({"text": text, "status": status})
76
+ out["pillars"] = pillars
77
+ if "risks" in fields:
78
+ out["risks"] = [str(r).strip()[:300] for r in (fields["risks"] or [])[:MAX_ITEMS] if str(r).strip()]
79
+ for key in ("exit_below", "target"):
80
+ if key in fields:
81
+ v = fields[key]
82
+ if v is not None and (not isinstance(v, (int, float)) or v <= 0):
83
+ raise ThesisError(f"{key.replace('_', ' ').capitalize()} must be a price above 0")
84
+ out[key] = float(v) if v is not None else None
85
+ if out.get("exit_below") and out.get("target") and out["exit_below"] >= out["target"]:
86
+ raise ThesisError("The exit price should be below the target")
87
+ if "exit_note" in fields:
88
+ out["exit_note"] = (fields["exit_note"] or "").strip()[:500]
89
+ if "conviction" in fields:
90
+ if fields["conviction"] not in CONVICTIONS:
91
+ raise ThesisError("Conviction is high, medium or low")
92
+ out["conviction"] = fields["conviction"]
93
+ return out
94
+
95
+
96
+ def get(config, symbol: str, market: str) -> Optional[dict]:
97
+ from ..storage import ThesisRecord
98
+
99
+ with _db(config).get_session() as s:
100
+ r = s.query(ThesisRecord).filter(ThesisRecord.symbol == symbol, ThesisRecord.market == market).first()
101
+ return to_dict(r) if r else None
102
+
103
+
104
+ def list_all(config) -> list[dict]:
105
+ from ..storage import ThesisRecord
106
+
107
+ with _db(config).get_session() as s:
108
+ return [to_dict(r) for r in s.query(ThesisRecord).order_by(ThesisRecord.symbol)]
109
+
110
+
111
+ def save(config, symbol: str, market: str, **fields) -> dict:
112
+ """Create or update the thesis for one stock."""
113
+ from ..storage import ThesisRecord
114
+
115
+ clean = _clean(fields)
116
+ with _db(config).get_session() as s:
117
+ r = s.query(ThesisRecord).filter(ThesisRecord.symbol == symbol, ThesisRecord.market == market).first()
118
+ if r is None:
119
+ if "statement" not in clean:
120
+ raise ThesisError("Write the thesis in a sentence or two")
121
+ r = ThesisRecord(symbol=symbol, market=market, conviction="medium", created_at=datetime.now(),
122
+ reviewed_at=date.today().isoformat(), log=[])
123
+ s.add(r)
124
+ changed_levels = {"exit_below", "target"} & set(clean)
125
+ for k, v in clean.items():
126
+ setattr(r, k, v)
127
+ if changed_levels: # new levels: their alerts may fire again
128
+ r.alerted = {k: v for k, v in (r.alerted or {}).items() if k not in ("exit", "target")}
129
+ s.flush()
130
+ return to_dict(r)
131
+
132
+
133
+ def add_log(config, thesis_id: int, note: str, impact: str, conviction: Optional[str] = None,
134
+ day: Optional[str] = None) -> Optional[dict]:
135
+ """A dated update; adding one counts as a review."""
136
+ from ..storage import ThesisRecord
137
+
138
+ note = (note or "").strip()[:1000]
139
+ if not note:
140
+ raise ThesisError("Write what happened")
141
+ if impact not in IMPACTS:
142
+ raise ThesisError("Impact is strengthens, weakens or neutral")
143
+ if conviction is not None and conviction not in CONVICTIONS:
144
+ raise ThesisError("Conviction is high, medium or low")
145
+ with _db(config).get_session() as s:
146
+ r = s.query(ThesisRecord).filter(ThesisRecord.id == thesis_id).first()
147
+ if r is None:
148
+ return None
149
+ today = day or date.today().isoformat()
150
+ entry = {"date": today, "note": note, "impact": impact, "conviction": conviction or r.conviction}
151
+ r.log = ([entry] + list(r.log or []))[:MAX_LOG] # newest first
152
+ if conviction:
153
+ r.conviction = conviction
154
+ r.reviewed_at = today
155
+ r.alerted = {k: v for k, v in (r.alerted or {}).items() if k != "review"}
156
+ s.flush()
157
+ return to_dict(r)
158
+
159
+
160
+ def mark_reviewed(config, thesis_id: int, day: Optional[str] = None) -> Optional[dict]:
161
+ from ..storage import ThesisRecord
162
+
163
+ with _db(config).get_session() as s:
164
+ r = s.query(ThesisRecord).filter(ThesisRecord.id == thesis_id).first()
165
+ if r is None:
166
+ return None
167
+ r.reviewed_at = day or date.today().isoformat()
168
+ r.alerted = {k: v for k, v in (r.alerted or {}).items() if k != "review"}
169
+ s.flush()
170
+ return to_dict(r)
171
+
172
+
173
+ def delete(config, thesis_id: int) -> bool:
174
+ from ..storage import ThesisRecord
175
+
176
+ with _db(config).get_session() as s:
177
+ return bool(s.query(ThesisRecord).filter(ThesisRecord.id == thesis_id).delete())
178
+
179
+
180
+ # ------------------------------------------------------------------ data check
181
+
182
+
183
+ def review_due(t: dict, today: date) -> Optional[int]:
184
+ """Days since the last review when it is overdue, else None."""
185
+ last = t.get("reviewed_at") or t.get("created_at")
186
+ if not last:
187
+ return None
188
+ days = (today - date.fromisoformat(last)).days
189
+ return days if days >= REVIEW_DAYS else None
190
+
191
+
192
+ def check(t: dict, price: Optional[float], earnings: Optional[dict] = None, today: Optional[date] = None) -> list[dict]:
193
+ """The thesis against live numbers: [{tone: up | down | warn | info, text}]."""
194
+ today = today or date.today()
195
+ out = []
196
+ if price:
197
+ if t.get("exit_below"):
198
+ gap = (price / t["exit_below"] - 1) * 100
199
+ if price <= t["exit_below"]:
200
+ out.append({"tone": "down", "key": "exit",
201
+ "text": f"Price {price:,.2f} is at or below your exit level {t['exit_below']:,.2f}: "
202
+ "by your own rule the thesis is broken."})
203
+ else:
204
+ out.append({"tone": "info", "key": "exit",
205
+ "text": f"{gap:.1f}% above your exit level {t['exit_below']:,.2f}."})
206
+ if t.get("target"):
207
+ if price >= t["target"]:
208
+ out.append({"tone": "up", "key": "target",
209
+ "text": f"Price {price:,.2f} has reached your target {t['target']:,.2f}: time to "
210
+ "decide whether the thesis still has room to run."})
211
+ else:
212
+ out.append({"tone": "info", "key": "target",
213
+ "text": f"Your target {t['target']:,.2f} is {(t['target'] / price - 1) * 100:.1f}% above the price."})
214
+ pillars = t.get("pillars") or []
215
+ broken = [p for p in pillars if p["status"] == "broken"]
216
+ watch = [p for p in pillars if p["status"] == "watch"]
217
+ if broken:
218
+ out.append({"tone": "down", "key": "pillars",
219
+ "text": f"{len(broken)} of {len(pillars)} pillars marked broken: is the thesis still intact?"})
220
+ elif watch:
221
+ out.append({"tone": "warn", "key": "pillars", "text": f"{len(watch)} of {len(pillars)} pillars on watch."})
222
+ weak = [e for e in (t.get("log") or [])[:3] if e.get("impact") == "weakens"]
223
+ if len(weak) >= 2:
224
+ out.append({"tone": "warn", "key": "log",
225
+ "text": f"{len(weak)} of your last 3 updates weakened the thesis."})
226
+ due = review_due(t, today)
227
+ if due is not None:
228
+ out.append({"tone": "warn", "key": "review",
229
+ "text": f"Last reviewed {due} days ago: review at least quarterly, even when nothing happened."})
230
+ if earnings:
231
+ nxt, days = earnings.get("next"), earnings.get("days_to_next")
232
+ if nxt and days is not None and 0 <= days <= 21:
233
+ out.append({"tone": "info", "key": "earnings",
234
+ "text": f"Results on {nxt['date']} ({'today' if days == 0 else f'in {days} days'}): "
235
+ "a fresh data point for each pillar."})
236
+ latest = earnings.get("latest")
237
+ if latest and latest.get("actual") is not None and latest.get("surprise") is not None:
238
+ word = "beat" if latest["surprise"] > 0 else "missed"
239
+ out.append({"tone": "info", "key": "results",
240
+ "text": f"Just reported: EPS {word} estimates by {abs(latest['surprise']):.1f}%. "
241
+ "Log what it means for the thesis."})
242
+ momentum = ((earnings.get("consensus") or {}).get("momentum") or {}).get("label")
243
+ if momentum == "falling":
244
+ out.append({"tone": "warn", "key": "estimates", "text": "Analysts' estimates are falling (90 days)."})
245
+ elif momentum == "rising":
246
+ out.append({"tone": "up", "key": "estimates", "text": "Analysts' estimates are rising (90 days)."})
247
+ return out
248
+
249
+
250
+ def _price(symbol: str, market: str) -> Optional[float]:
251
+ from ..agent.agents import _resolve_fetcher
252
+
253
+ try:
254
+ return float((_resolve_fetcher(market).get_quote(symbol) or {}).get("price") or 0) or None
255
+ except Exception as exc:
256
+ logger.info("Thesis price for %s unavailable: %s", symbol, exc)
257
+ return None
258
+
259
+
260
+ def _earnings(symbol: str, market: str) -> Optional[dict]:
261
+ from .earnings import build
262
+
263
+ try:
264
+ return build(symbol, market, with_options=False)
265
+ except Exception as exc:
266
+ logger.info("Thesis earnings for %s unavailable: %s", symbol, exc)
267
+ return None
268
+
269
+
270
+ def with_check(t: dict, price: Optional[Callable] = None, earnings: Optional[Callable] = None) -> dict:
271
+ p = (price or _price)(t["symbol"], t["market"])
272
+ e = (earnings or _earnings)(t["symbol"], t["market"]) if t["market"] != "crypto" else None
273
+ return {**t, "price": p, "signals": check(t, p, e)}
274
+
275
+
276
+ # ------------------------------------------------------------------ alerts
277
+
278
+
279
+ def check_alerts(config, dispatcher=None, today: Optional[date] = None, price: Optional[Callable] = None,
280
+ force: bool = False) -> list[str]:
281
+ """Monitor hook: one message per thesis when the price crosses the exit or
282
+ target level, or the quarterly review falls due. Re-arms after the price
283
+ moves back 3%; a review reminder repeats monthly until reviewed."""
284
+ from ..core import tenant
285
+ from ..storage import ThesisRecord
286
+
287
+ uid = tenant.current_user_id()
288
+ clock = time.time()
289
+ if not force and clock - _last_run.get(uid, 0) < ALERT_EVERY:
290
+ return []
291
+ _last_run[uid] = clock
292
+ today = today or date.today()
293
+ price = price or _price
294
+ lines = []
295
+ with _db(config).get_session() as s:
296
+ for r in s.query(ThesisRecord).all():
297
+ t, alerted, changed = to_dict(r), dict(r.alerted or {}), False
298
+ p = price(r.symbol, r.market) if (r.exit_below or r.target) else None
299
+ if p and r.exit_below:
300
+ if p <= r.exit_below and "exit" not in alerted:
301
+ lines.append(f"• {r.symbol}: price {p:,.2f} is at or below your exit level {r.exit_below:,.2f}")
302
+ alerted["exit"], changed = today.isoformat(), True
303
+ elif p > r.exit_below * (1 + REARM) and "exit" in alerted:
304
+ alerted.pop("exit")
305
+ changed = True
306
+ if p and r.target:
307
+ if p >= r.target and "target" not in alerted:
308
+ lines.append(f"• {r.symbol}: price {p:,.2f} reached your target {r.target:,.2f}")
309
+ alerted["target"], changed = today.isoformat(), True
310
+ elif p < r.target * (1 - REARM) and "target" in alerted:
311
+ alerted.pop("target")
312
+ changed = True
313
+ due = review_due(t, today)
314
+ last = alerted.get("review")
315
+ if due is not None and (not last or (today - date.fromisoformat(last)).days >= 30):
316
+ lines.append(f"• {r.symbol}: thesis last reviewed {due} days ago - time for a quarterly review")
317
+ alerted["review"], changed = today.isoformat(), True
318
+ if changed:
319
+ r.alerted = alerted
320
+ if lines:
321
+ try:
322
+ from ..notifiers.dispatcher import NotificationDispatcher
323
+
324
+ dispatcher = dispatcher or NotificationDispatcher(config)
325
+ dispatcher.send_all("📝 Your theses\n" + "\n".join(lines), title="Thesis check", purpose="alert")
326
+ except Exception as exc:
327
+ logger.error("Thesis alert failed: %s", exc)
328
+ return lines
@@ -0,0 +1,146 @@
1
+ """English for the Chinese names China A data comes with (opt-in on the page).
2
+
3
+ No AI needed:
4
+ - Stock names: the company's own English name from Yahoo ("宁德时代" ->
5
+ "Contemporary Amperex Technology"), fetched only for the symbols on
6
+ screen and saved for good in ~/.marketmyna/cache/english_names.json.
7
+ Beijing-exchange stocks Yahoo lacks fall back to pinyin.
8
+ - Free text (block-trade broker branches, ...): a glossary of brokers and
9
+ standard terms, the rest romanised with pypinyin ("上海浦东新区" ->
10
+ "Shanghai Pudong Xinqu").
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import re
18
+ import threading
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from pathlib import Path
21
+ from typing import Callable, Optional
22
+
23
+ from marketmyna.core.home import app_home
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ CJK = re.compile(r"[㐀-鿿]")
28
+ MAX_LOOKUPS = 80 # per request; the rest come on the next scroll / page
29
+
30
+ # Longest first when applied. Brokers people will recognise, then the
31
+ # boilerplate every branch name carries.
32
+ GLOSSARY = {
33
+ "机构专用": "Institutional seat",
34
+ "沪股通专用": "Northbound (Shanghai Connect)",
35
+ "深股通专用": "Northbound (Shenzhen Connect)",
36
+ "中国国际金融": "CICC", "中信证券": "CITIC Securities", "中信建投": "CSC Financial", "华泰证券": "Huatai Securities",
37
+ "国泰君安": "Guotai Junan", "国泰海通": "Guotai Haitong", "海通证券": "Haitong Securities", "招商证券": "China Merchants Securities",
38
+ "广发证券": "GF Securities", "银河证券": "China Galaxy Securities", "中国银河": "China Galaxy", "申万宏源": "Shenwan Hongyuan",
39
+ "东方财富": "East Money", "国信证券": "Guosen Securities", "光大证券": "Everbright Securities", "兴业证券": "Industrial Securities",
40
+ "长江证券": "Changjiang Securities", "方正证券": "Founder Securities", "东方证券": "Orient Securities", "平安证券": "Ping An Securities",
41
+ "安信证券": "Essence Securities", "国金证券": "Sinolink Securities", "华西证券": "Huaxi Securities", "浙商证券": "Zheshang Securities",
42
+ "财通证券": "Caitong Securities", "长城证券": "Great Wall Securities", "中泰证券": "Zhongtai Securities", "东吴证券": "Soochow Securities",
43
+ "华安证券": "Huaan Securities", "国元证券": "Guoyuan Securities", "西部证券": "Western Securities", "天风证券": "Tianfeng Securities",
44
+ "开源证券": "Kaiyuan Securities", "华鑫证券": "Huaxin Securities", "中金财富": "CICC Wealth", "瑞银证券": "UBS Securities",
45
+ "摩根大通证券": "J.P. Morgan Securities", "高盛高华": "Goldman Sachs Gao Hua", "华宝证券": "Huabao Securities",
46
+ "股份有限公司": "", "有限责任公司": "", "有限公司": "", "分公司": " branch", "证券营业部": " branch", "营业部": " branch",
47
+ "证券": " Securities", "总部": " HQ", "路": " Rd", "大道": " Ave", "大街": " St", "街": " St",
48
+ "新区": " New Area", "开发区": " Dev Zone", "自贸区": " FTZ",
49
+ }
50
+ _GLOSS = sorted(GLOSSARY.items(), key=lambda kv: -len(kv[0]))
51
+ _SUFFIX = re.compile(r",?\s*(co\.?,?\s*ltd\.?|company limited|co\.?,?\s*limited|limited|corporation|corp\.?|inc\.?)\s*$", re.I)
52
+
53
+ _lock = threading.Lock()
54
+ _names: Optional[dict[str, str]] = None
55
+
56
+
57
+ def has_cjk(text: Optional[str]) -> bool:
58
+ return bool(text and CJK.search(text))
59
+
60
+
61
+ def pinyin(text: str) -> str:
62
+ """CJK runs -> capitalised pinyin words; everything else kept."""
63
+ try:
64
+ from pypinyin import lazy_pinyin
65
+ except ImportError:
66
+ return text
67
+
68
+ def run(m: re.Match) -> str:
69
+ # No word segmentation offline; most Chinese place and street names
70
+ # are two characters, so pair the syllables: "Ningbo Tiantong Bei".
71
+ syl = lazy_pinyin(m.group(0))
72
+ return " " + " ".join("".join(syl[i:i + 2]).capitalize() for i in range(0, len(syl), 2)) + " "
73
+
74
+ return re.sub(r"[㐀-鿿]+", run, text)
75
+
76
+
77
+ def english_text(text: str) -> str:
78
+ if not has_cjk(text):
79
+ return text
80
+ out = text
81
+ for zh, en in _GLOSS:
82
+ out = out.replace(zh, f" {en} " if en and not en.startswith(" ") else en)
83
+ out = pinyin(out)
84
+ out = re.sub(r"\s+", " ", out).strip()
85
+ return re.sub(r"\s+([,)])", r"\1", out)
86
+
87
+
88
+ def clean_company(name: str) -> str:
89
+ return _SUFFIX.sub("", (name or "").strip()).strip(" ,")
90
+
91
+
92
+ # ------------------------------------------------------------ stock names
93
+
94
+
95
+ def _path() -> Path:
96
+ path = app_home() / "cache"
97
+ path.mkdir(parents=True, exist_ok=True)
98
+ return path / "english_names.json"
99
+
100
+
101
+ def _load() -> dict[str, str]:
102
+ global _names
103
+ if _names is None:
104
+ try:
105
+ _names = json.loads(_path().read_text(encoding="utf-8"))
106
+ except (OSError, ValueError):
107
+ _names = {}
108
+ return _names
109
+
110
+
111
+ def _save() -> None:
112
+ try:
113
+ _path().write_text(json.dumps(_names, ensure_ascii=False, indent=0), encoding="utf-8")
114
+ except OSError as exc:
115
+ logger.warning("English names cache not saved: %s", exc)
116
+
117
+
118
+ def _yahoo_name(yahoo: str) -> Optional[str]:
119
+ import yfinance as yf
120
+
121
+ try:
122
+ info = yf.Ticker(yahoo).info or {}
123
+ except Exception:
124
+ return None
125
+ name = info.get("longName") or info.get("shortName")
126
+ return clean_company(name) if name and not has_cjk(name) else None
127
+
128
+
129
+ def english_names(items: list[tuple[str, str]], market: str,
130
+ lookup: Callable[[str], Optional[str]] = _yahoo_name) -> dict[str, str]:
131
+ """[(symbol, chinese name)] -> {symbol: English name}. Cached forever;
132
+ unknown to Yahoo -> pinyin of the Chinese name (also cached)."""
133
+ from .analysis_run import yahoo_symbol
134
+
135
+ with _lock:
136
+ names = _load()
137
+ missing = [(s, n) for s, n in items if f"{market}:{s}" not in names][:MAX_LOOKUPS]
138
+ if missing:
139
+ with ThreadPoolExecutor(max_workers=8) as pool:
140
+ found = list(pool.map(lambda sn: lookup(yahoo_symbol(sn[0], market)), missing))
141
+ with _lock:
142
+ for (sym, zh), en in zip(missing, found):
143
+ names[f"{market}:{sym}"] = en or english_text(zh)
144
+ _save()
145
+ with _lock:
146
+ return {s: names[f"{market}:{s}"] for s, _ in items if f"{market}:{s}" in names}
@@ -0,0 +1,173 @@
1
+ """Valuation meter for one stock: how its price compares with its sector.
2
+
3
+ At today's price, the stock's P/E, price/book and price/sales are each divided
4
+ by the median of the same sector among the market's largest companies (the
5
+ universe the ready-made screens use). The combined position is the geometric
6
+ mean of those multiples:
7
+
8
+ below 0.8x -> priced below its sector
9
+ 0.8 - 1.2x -> in line with its sector
10
+ above 1.2x -> priced above its sector
11
+
12
+ A separate growth check reports PEG (P/E / earnings growth %) and the rules of
13
+ the "Growth at a reasonable price" screen, each pass/fail, so a dear stock
14
+ that is growing fast is visible as such. Everything is a comparison with
15
+ numbers shown; the label wording is chosen by the frontend (see
16
+ sebi-compliance-mode.md: no bare "overvalued" on public deploys).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import threading
23
+ import time
24
+ from concurrent.futures import ThreadPoolExecutor
25
+ from typing import Callable, Optional
26
+
27
+ from ..core.numbers import finite as _num
28
+ from ..core.numbers import median as _median
29
+ from ..data.yahoo_price import info_price
30
+
31
+ UNIVERSE = 200
32
+ MIN_PEERS = 5 # profitable companies a sector needs before its median means anything
33
+ BELOW, ABOVE = 0.8, 1.2
34
+ CACHE_SECONDS = 6 * 3600
35
+
36
+ # key -> (Yahoo field, label)
37
+ MULTIPLES = {
38
+ "pe": ("trailingPE", "P/E"),
39
+ "pb": ("priceToBook", "Price / book"),
40
+ "ps": ("priceToSalesTrailing12Months", "Price / sales"),
41
+ }
42
+
43
+ InfoFetch = Callable[[str], dict]
44
+
45
+ _cache: dict[str, tuple[float, dict]] = {}
46
+ _lock = threading.Lock()
47
+
48
+
49
+ def sector_medians(market: str, info_fetch: Optional[InfoFetch] = None, rows: Optional[list[dict]] = None) -> dict:
50
+ """sector -> {pe, pb, ps: median or None, n: profitable companies}, from
51
+ the market's largest stocks. Cached for a few hours per market."""
52
+ if info_fetch is None and rows is None:
53
+ with _lock:
54
+ hit = _cache.get(market)
55
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
56
+ return hit[1]
57
+ from .analysis_run import yahoo_symbol
58
+ from .compare import _yahoo_info
59
+
60
+ fetch = info_fetch or _yahoo_info
61
+ if rows is None:
62
+ from ..data.listings import get_store
63
+
64
+ rows = get_store().rows(market)
65
+ stocks = sorted((r for r in rows if r.get("type") == "stock" and r.get("market_cap")),
66
+ key=lambda r: -r["market_cap"])[:UNIVERSE]
67
+ with ThreadPoolExecutor(max_workers=8) as pool:
68
+ infos = list(pool.map(lambda r: fetch(yahoo_symbol(r["symbol"], market)), stocks))
69
+
70
+ groups: dict[str, dict[str, list[float]]] = {}
71
+ for info in infos:
72
+ sector = (info or {}).get("sector")
73
+ pe = _num((info or {}).get("trailingPE"))
74
+ if not sector or not pe or pe <= 0:
75
+ continue # loss-makers would drag a P/E median around
76
+ g = groups.setdefault(sector, {k: [] for k in MULTIPLES})
77
+ for key, (field, _) in MULTIPLES.items():
78
+ v = _num(info.get(field))
79
+ if v and v > 0:
80
+ g[key].append(v)
81
+ out = {s: {"n": len(g["pe"]), **{k: _median(g[k]) for k in MULTIPLES}} for s, g in groups.items()}
82
+ if info_fetch is None and rows is None:
83
+ with _lock:
84
+ _cache[market] = (time.time(), out)
85
+ return out
86
+
87
+
88
+ def zone(position: Optional[float]) -> Optional[str]:
89
+ if position is None:
90
+ return None
91
+ if position < BELOW:
92
+ return "below"
93
+ if position > ABOVE:
94
+ return "above"
95
+ return "in_line"
96
+
97
+
98
+ def _garp(info: dict, pe: Optional[float]) -> dict:
99
+ """PEG and the "Growth at a reasonable price" screen rules, each checked."""
100
+ from .screens import PRESETS, _rule_text, metrics
101
+
102
+ m = metrics(info)
103
+ growth = m.get("earnings_growth")
104
+ peg = pe / growth if pe and pe > 0 and growth and growth > 0 else None
105
+ rules = []
106
+ for rule in PRESETS["garp"][3]:
107
+ field, op, value = rule
108
+ v = m.get(field)
109
+ ok = v is not None and ((op == ">" and v > value) or (op == "<" and v < value)
110
+ or (op == "between" and value[0] < v < value[1]))
111
+ # pass: True / False, or None when Yahoo has no figure (not counted).
112
+ rules.append({"rule": _rule_text(rule), "field": field, "value": v, "pass": None if v is None else bool(ok)})
113
+ known = [r for r in rules if r["pass"] is not None]
114
+ return {
115
+ "peg": peg,
116
+ "earnings_growth": growth,
117
+ "revenue_growth": m.get("revenue_growth"),
118
+ "rules": rules,
119
+ "passed": sum(1 for r in known if r["pass"]),
120
+ "total": len(known),
121
+ "missing": len(rules) - len(known),
122
+ }
123
+
124
+
125
+ def meter(symbol: str, market: str, info_fetch: Optional[InfoFetch] = None,
126
+ medians: Optional[dict] = None) -> dict:
127
+ from .analysis_run import yahoo_symbol
128
+ from .compare import _yahoo_info
129
+
130
+ if market == "crypto":
131
+ return {"status": "unavailable", "reason": "Crypto has no earnings or book value to compare."}
132
+ info = (info_fetch or _yahoo_info)(yahoo_symbol(symbol, market)) or {}
133
+ if not info:
134
+ return {"status": "unavailable", "reason": "No fundamentals available for this symbol right now."}
135
+ sector = info.get("sector")
136
+ if medians is None:
137
+ medians = sector_medians(market)
138
+ peers = medians.get(sector or "") or {}
139
+
140
+ pe = _num(info.get("trailingPE"))
141
+ multiples, ratios = [], []
142
+ for key, (field, label) in MULTIPLES.items():
143
+ v = _num(info.get(field))
144
+ med = peers.get(key) if peers.get("n", 0) >= MIN_PEERS else None
145
+ ratio = v / med if v and v > 0 and med else None
146
+ if ratio:
147
+ ratios.append(ratio)
148
+ multiples.append({"key": key, "label": label, "value": v, "sector_median": med, "ratio": ratio})
149
+
150
+ position = math.exp(sum(math.log(r) for r in ratios) / len(ratios)) if ratios else None
151
+ reason = None
152
+ if position is None:
153
+ if not sector:
154
+ reason = "Our market data has no sector for this company, so there is no peer group to compare with."
155
+ elif peers.get("n", 0) < MIN_PEERS:
156
+ reason = f"Too few profitable {sector} companies among the market's largest to form a sector median."
157
+ else:
158
+ reason = "The company has no positive P/E, book value or sales figure to compare (for example a loss-maker)."
159
+ return {
160
+ "status": "ok" if position is not None else "unavailable",
161
+ "reason": reason,
162
+ "symbol": symbol,
163
+ "sector": sector,
164
+ "peers": peers.get("n", 0),
165
+ "universe": f"the {UNIVERSE} largest companies in this market",
166
+ "price": info_price(info),
167
+ "currency": info.get("currency"),
168
+ "multiples": multiples,
169
+ "position": position,
170
+ "zone": zone(position),
171
+ "bands": {"below": BELOW, "above": ABOVE},
172
+ "garp": _garp(info, pe),
173
+ }