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,173 @@
1
+ """Circuit-breaker-protected fallback chains for data fetchers.
2
+
3
+ Mechanics borrowed from daily_stock_analysis's data_provider circuit
4
+ breaker (closed -> open -> half_open state machine, per-source failure
5
+ count + cooldown), reimplemented here against MarketMyna's own,
6
+ differently-shaped DataFetcher interface rather than copied verbatim.
7
+
8
+ Key design choice: state is keyed by an arbitrary string, not just the
9
+ source name, so callers can compose "{source}:{method}" keys. A source
10
+ failing on get_quote should not necessarily open the circuit for that
11
+ same source's get_history - the two calls can have very different
12
+ failure modes (e.g. a quote endpoint rate-limited while history is
13
+ fine), so isolating per-method avoids over-tripping the breaker.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import time
20
+ from threading import RLock
21
+ from typing import Any, Callable, Optional
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class DataSourceUnavailable(Exception):
27
+ """Every source in a FallbackChain failed or is circuit-open."""
28
+
29
+
30
+ class CircuitBreaker:
31
+ """Per-key closed/open/half_open state machine.
32
+
33
+ - CLOSED: normal, calls go through.
34
+ - OPEN: skip calls for `cooldown_seconds` after `failure_threshold`
35
+ consecutive failures.
36
+ - HALF_OPEN: after cooldown, allow `half_open_max_calls` probe(s);
37
+ success fully recovers, failure re-opens for another cooldown.
38
+ """
39
+
40
+ CLOSED = "closed"
41
+ OPEN = "open"
42
+ HALF_OPEN = "half_open"
43
+
44
+ def __init__(
45
+ self,
46
+ failure_threshold: int = 3,
47
+ cooldown_seconds: float = 300.0,
48
+ half_open_max_calls: int = 1,
49
+ ):
50
+ self.failure_threshold = failure_threshold
51
+ self.cooldown_seconds = cooldown_seconds
52
+ self.half_open_max_calls = half_open_max_calls
53
+ self._states: dict[str, dict[str, Any]] = {}
54
+ self._lock = RLock()
55
+
56
+ def _state(self, key: str) -> dict[str, Any]:
57
+ if key not in self._states:
58
+ self._states[key] = {
59
+ "state": self.CLOSED,
60
+ "failures": 0,
61
+ "last_failure_time": 0.0,
62
+ "half_open_calls": 0,
63
+ }
64
+ return self._states[key]
65
+
66
+ def is_available(self, key: str) -> bool:
67
+ """True if a call should be attempted for this key."""
68
+ with self._lock:
69
+ state = self._state(key)
70
+ now = time.time()
71
+
72
+ if state["state"] == self.CLOSED:
73
+ return True
74
+
75
+ if state["state"] == self.OPEN:
76
+ elapsed = now - state["last_failure_time"]
77
+ if elapsed < self.cooldown_seconds:
78
+ return False
79
+ state["state"] = self.HALF_OPEN
80
+ state["half_open_calls"] = 0
81
+ logger.info("Circuit breaker %s: cooldown elapsed, half-opening", key)
82
+
83
+ # HALF_OPEN
84
+ if state["half_open_calls"] < self.half_open_max_calls:
85
+ state["half_open_calls"] += 1
86
+ return True
87
+ return False
88
+
89
+ def record_success(self, key: str) -> None:
90
+ with self._lock:
91
+ state = self._state(key)
92
+ if state["state"] == self.HALF_OPEN:
93
+ logger.info("Circuit breaker %s: probe succeeded, closing", key)
94
+ state["state"] = self.CLOSED
95
+ state["failures"] = 0
96
+ state["half_open_calls"] = 0
97
+
98
+ def record_failure(self, key: str, error: Optional[str] = None) -> None:
99
+ with self._lock:
100
+ state = self._state(key)
101
+ state["failures"] += 1
102
+ state["last_failure_time"] = time.time()
103
+
104
+ if state["state"] == self.HALF_OPEN:
105
+ state["state"] = self.OPEN
106
+ state["half_open_calls"] = 0
107
+ logger.warning("Circuit breaker %s: probe failed, re-opening", key)
108
+ elif state["failures"] >= self.failure_threshold:
109
+ state["state"] = self.OPEN
110
+ logger.warning(
111
+ "Circuit breaker %s: %d consecutive failures, opening for %.0fs (%s)",
112
+ key, state["failures"], self.cooldown_seconds, error or "",
113
+ )
114
+
115
+ def record_inconclusive(self, key: str) -> None:
116
+ """An empty/unusable (but non-exception) result - only matters in
117
+ HALF_OPEN, where it re-opens the breaker rather than fully closing."""
118
+ with self._lock:
119
+ state = self._state(key)
120
+ if state["state"] == self.HALF_OPEN:
121
+ state["state"] = self.OPEN
122
+ state["half_open_calls"] = 0
123
+ state["last_failure_time"] = time.time()
124
+
125
+ def status(self) -> dict[str, str]:
126
+ with self._lock:
127
+ return {key: info["state"] for key, info in self._states.items()}
128
+
129
+
130
+ # Shared default breaker: fetcher instances come and go (a new
131
+ # PortfolioManager/StockDataFetcher per API request, per Phase 0's
132
+ # get_portfolio_manager pattern) but failure history should persist
133
+ # across those instances for the breaker to mean anything.
134
+ default_breaker = CircuitBreaker()
135
+
136
+
137
+ class FallbackChain:
138
+ """An ordered list of named callables for one logical operation
139
+ (e.g. "get_quote"), tried in order with circuit-breaker protection."""
140
+
141
+ def __init__(self, method: str, breaker: Optional[CircuitBreaker] = None):
142
+ self.method = method
143
+ self.breaker = breaker or default_breaker
144
+ self._sources: list[tuple[str, Callable[..., Any]]] = []
145
+
146
+ def add(self, name: str, func: Callable[..., Any]) -> "FallbackChain":
147
+ self._sources.append((name, func))
148
+ return self
149
+
150
+ def call(self, *args: Any, is_empty: Optional[Callable[[Any], bool]] = None, **kwargs: Any) -> Any:
151
+ last_exc: Optional[Exception] = None
152
+ for name, func in self._sources:
153
+ key = f"{name}:{self.method}"
154
+ if not self.breaker.is_available(key):
155
+ continue
156
+ try:
157
+ result = func(*args, **kwargs)
158
+ except Exception as exc: # noqa: BLE001 - deliberately broad, this is the fallback boundary
159
+ self.breaker.record_failure(key, str(exc))
160
+ last_exc = exc
161
+ continue
162
+
163
+ if is_empty is not None and is_empty(result):
164
+ self.breaker.record_inconclusive(key)
165
+ last_exc = last_exc or RuntimeError(f"{name} returned an empty result")
166
+ continue
167
+
168
+ self.breaker.record_success(key)
169
+ return result
170
+
171
+ raise DataSourceUnavailable(
172
+ f"All sources exhausted for {self.method}"
173
+ ) from last_exc
@@ -0,0 +1,252 @@
1
+ """US financial statements from SEC XBRL company facts (data.sec.gov, no key).
2
+
3
+ companyfacts holds every number a company has tagged in its 10-K / 10-Q
4
+ filings since about 2009. This turns the common US-GAAP tags into the same
5
+ three DataFrames yfinance returns (rows named like Yahoo's, columns = period
6
+ end dates, newest first), so marketmyna/services/financials.py and the quality
7
+ scores can use either source. Used when Yahoo has no statements and for
8
+ longer histories (Yahoo keeps 4-5 years).
9
+
10
+ Total debt excludes lease liabilities (Yahoo includes them).
11
+ Annual = 10-K full-year figures. Quarterly = three-month figures; the fourth
12
+ quarter, which companies do not file separately, is the year minus the first
13
+ three quarters. Flows that are outflows (capex, dividends, buybacks, debt
14
+ repaid) are made negative, as Yahoo shows them. SEC needs SEC_USER_AGENT
15
+ (a name and contact email), see marketmyna/services/sec_mdna.py.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import threading
21
+ import time
22
+ from datetime import date
23
+ from typing import Optional
24
+
25
+ import pandas as pd
26
+
27
+ FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik:010d}.json"
28
+ CACHE_SECONDS = 12 * 3600
29
+
30
+ # Yahoo row name -> US-GAAP tags, first one with data wins
31
+ INCOME = {
32
+ "Total Revenue": ["Revenues", "RevenueFromContractWithCustomerExcludingAssessedTax", "SalesRevenueNet",
33
+ "RevenueFromContractWithCustomerIncludingAssessedTax", "SalesRevenueGoodsNet",
34
+ "RevenuesNetOfInterestExpense"],
35
+ "Cost Of Revenue": ["CostOfRevenue", "CostOfGoodsAndServicesSold", "CostOfGoodsSold"],
36
+ "Gross Profit": ["GrossProfit"],
37
+ "Operating Expense": ["OperatingExpenses"],
38
+ "Selling General And Administration": ["SellingGeneralAndAdministrativeExpense"],
39
+ "Operating Income": ["OperatingIncomeLoss"],
40
+ "Interest Expense": ["InterestExpense", "InterestExpenseNonoperating", "InterestExpenseDebt"],
41
+ "Pretax Income": ["IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest",
42
+ "IncomeLossFromContinuingOperationsBeforeIncomeTaxesMinorityInterestAndIncomeLossFromEquityMethodInvestments"],
43
+ "Tax Provision": ["IncomeTaxExpenseBenefit"],
44
+ "Net Income": ["NetIncomeLoss", "ProfitLoss"],
45
+ "Diluted EPS": ["EarningsPerShareDiluted", "EarningsPerShareBasicAndDiluted"],
46
+ "Basic EPS": ["EarningsPerShareBasic"],
47
+ "Diluted Average Shares": ["WeightedAverageNumberOfDilutedSharesOutstanding"],
48
+ }
49
+ BALANCE = {
50
+ "Total Assets": ["Assets"],
51
+ "Current Assets": ["AssetsCurrent"],
52
+ "Cash And Cash Equivalents": ["CashAndCashEquivalentsAtCarryingValue", "Cash"],
53
+ "Short Term Investments": ["ShortTermInvestments", "MarketableSecuritiesCurrent", "AvailableForSaleSecuritiesDebtSecuritiesCurrent"],
54
+ "Accounts Receivable": ["AccountsReceivableNetCurrent", "ReceivablesNetCurrent"],
55
+ "Inventory": ["InventoryNet"],
56
+ "Net PPE": ["PropertyPlantAndEquipmentNet"],
57
+ "Total Liabilities Net Minority Interest": ["Liabilities"],
58
+ "Current Liabilities": ["LiabilitiesCurrent"],
59
+ "Long Term Debt": ["LongTermDebtNoncurrent", "LongTermDebt"],
60
+ "Current Debt": ["LongTermDebtCurrent", "DebtCurrent"],
61
+ "Commercial Paper": ["CommercialPaper", "ShortTermBorrowings"],
62
+ "Stockholders Equity": ["StockholdersEquity", "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"],
63
+ "Retained Earnings": ["RetainedEarningsAccumulatedDeficit"],
64
+ "Goodwill": ["Goodwill"],
65
+ "Other Intangible Assets": ["IntangibleAssetsNetExcludingGoodwill", "FiniteLivedIntangibleAssetsNet"],
66
+ "Ordinary Shares Number": ["CommonStockSharesOutstanding"],
67
+ }
68
+ CASHFLOW = {
69
+ "Operating Cash Flow": ["NetCashProvidedByUsedInOperatingActivities",
70
+ "NetCashProvidedByUsedInOperatingActivitiesContinuingOperations"],
71
+ "Capital Expenditure": ["PaymentsToAcquirePropertyPlantAndEquipment", "PaymentsToAcquireProductiveAssets"],
72
+ "Investing Cash Flow": ["NetCashProvidedByUsedInInvestingActivities",
73
+ "NetCashProvidedByUsedInInvestingActivitiesContinuingOperations"],
74
+ "Financing Cash Flow": ["NetCashProvidedByUsedInFinancingActivities",
75
+ "NetCashProvidedByUsedInFinancingActivitiesContinuingOperations"],
76
+ "Cash Dividends Paid": ["PaymentsOfDividends", "PaymentsOfDividendsCommonStock"],
77
+ "Repurchase Of Capital Stock": ["PaymentsForRepurchaseOfCommonStock"],
78
+ "Issuance Of Debt": ["ProceedsFromIssuanceOfLongTermDebt", "ProceedsFromIssuanceOfDebt"],
79
+ "Repayment Of Debt": ["RepaymentsOfLongTermDebt", "RepaymentsOfDebt"],
80
+ "Stock Based Compensation": ["ShareBasedCompensation", "AllocatedShareBasedCompensationExpense"],
81
+ "Depreciation And Amortization": ["DepreciationDepletionAndAmortization", "DepreciationAndAmortization",
82
+ "DepreciationAmortizationAndAccretionNet"],
83
+ "Changes In Cash": ["CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalentsPeriodIncreaseDecreaseIncludingExchangeRateEffect",
84
+ "CashAndCashEquivalentsPeriodIncreaseDecrease"],
85
+ }
86
+ OUTFLOWS = {"Capital Expenditure", "Cash Dividends Paid", "Repurchase Of Capital Stock", "Repayment Of Debt"}
87
+ PER_SHARE = {"Diluted EPS", "Basic EPS"}
88
+ # Not sums over time: no quarter is derived from year-to-date or full-year figures
89
+ NON_ADDITIVE = PER_SHARE | {"Diluted Average Shares"}
90
+
91
+ _cache: dict[int, tuple[float, dict]] = {}
92
+ _lock = threading.Lock()
93
+
94
+
95
+ def company_facts(symbol: str) -> dict:
96
+ from ..services.sec_mdna import _get, cik_for
97
+
98
+ cik = cik_for(symbol)
99
+ with _lock:
100
+ hit = _cache.get(cik)
101
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
102
+ return hit[1]
103
+ facts = _get(FACTS_URL.format(cik=cik)).json()
104
+ with _lock:
105
+ _cache[cik] = (time.time(), facts)
106
+ return facts
107
+
108
+
109
+ def _days(start: str, end: str) -> int:
110
+ return (date.fromisoformat(end) - date.fromisoformat(start)).days
111
+
112
+
113
+ def _entries(facts: dict, tag: str) -> list[dict]:
114
+ node = ((facts.get("facts") or {}).get("us-gaap") or {}).get(tag) or {}
115
+ units = node.get("units") or {}
116
+ for unit in ("USD", "USD/shares", "shares"):
117
+ if unit in units:
118
+ return units[unit]
119
+ return next(iter(units.values()), [])
120
+
121
+
122
+ def _latest_by_end(rows: list[dict]) -> dict[str, float]:
123
+ """end date -> value, keeping the most recently filed figure (restatements win)."""
124
+ best: dict[str, tuple[str, float]] = {}
125
+ for r in rows:
126
+ end, filed = r.get("end"), r.get("filed") or ""
127
+ if end and (end not in best or filed >= best[end][0]):
128
+ best[end] = (filed, float(r["val"]))
129
+ return {end: v for end, (_, v) in best.items()}
130
+
131
+
132
+ def _flow_one(facts: dict, tag: str, quarterly: bool, additive: bool = True) -> dict[str, float]:
133
+ entries = [e for e in _entries(facts, tag) if e.get("start") and e.get("end")]
134
+ if not entries:
135
+ return {}
136
+ yearly = [e for e in entries if 350 <= _days(e["start"], e["end"]) <= 380]
137
+ annual = _latest_by_end([e for e in yearly if (e.get("form") or "").startswith("10-K")])
138
+ if not quarterly:
139
+ return annual
140
+ quarters = _latest_by_end([e for e in entries if 80 <= _days(e["start"], e["end"]) <= 100])
141
+ if not additive:
142
+ return quarters
143
+ # Cash-flow statements in a 10-Q are year to date (6 or 9 months): a quarter
144
+ # is the year-to-date figure minus the one before it from the same start.
145
+ by_start: dict[str, dict[str, float]] = {}
146
+ for e in entries:
147
+ by_start.setdefault(e["start"], {})
148
+ for start in by_start:
149
+ by_start[start] = _latest_by_end([e for e in entries if e["start"] == start])
150
+ for start, ytd in by_start.items():
151
+ ends = sorted(ytd)
152
+ for prev, end in zip(ends, ends[1:]):
153
+ if end not in quarters and 80 <= _days(prev, end) <= 100:
154
+ quarters[end] = ytd[end] - ytd[prev]
155
+ # Q4 = year minus the three quarters inside it
156
+ for end, total in annual.items():
157
+ if end in quarters:
158
+ continue
159
+ start = next(e["start"] for e in yearly if e["end"] == end)
160
+ inside = [v for q_end, v in quarters.items() if start < q_end < end]
161
+ if len(inside) == 3:
162
+ quarters[end] = round(total - sum(inside), 4)
163
+ return quarters
164
+
165
+
166
+ def _instant_one(facts: dict, tag: str, quarterly: bool) -> dict[str, float]:
167
+ entries = [e for e in _entries(facts, tag) if e.get("end") and not e.get("start")]
168
+ if not quarterly:
169
+ entries = [e for e in entries if (e.get("form") or "").startswith("10-K") and e.get("fp") == "FY"]
170
+ return _latest_by_end(entries)
171
+
172
+
173
+ def _merge(series: list[dict[str, float]]) -> dict[str, float]:
174
+ """Companies switch tags over the years (Apple's revenue moved from Revenues to
175
+ RevenueFromContractWithCustomer... in 2018): each period takes the first tag,
176
+ in priority order, that has it."""
177
+ out: dict[str, float] = {}
178
+ for values in series:
179
+ for end, v in values.items():
180
+ out.setdefault(end, v)
181
+ return out
182
+
183
+
184
+ def flow_series(facts: dict, tags: list[str], quarterly: bool, additive: bool = True) -> dict[str, float]:
185
+ """Figures over a period (income, cash flow) by period end."""
186
+ return _merge([_flow_one(facts, t, quarterly, additive) for t in tags])
187
+
188
+
189
+ def instant_series(facts: dict, tags: list[str], quarterly: bool) -> dict[str, float]:
190
+ """Balances at a date (balance sheet), from 10-K (annual) or any filing (quarterly)."""
191
+ return _merge([_instant_one(facts, t, quarterly) for t in tags])
192
+
193
+
194
+ def _frame(series: dict[str, dict[str, float]], periods: list[str]) -> pd.DataFrame:
195
+ cols = [pd.Timestamp(p) for p in periods]
196
+ data = {name: [values.get(p) for p in periods] for name, values in series.items() if values}
197
+ return pd.DataFrame.from_dict(data, orient="index", columns=cols) if data else pd.DataFrame(columns=cols)
198
+
199
+
200
+ def statements(symbol: str, quarterly: bool = False, max_periods: Optional[int] = None,
201
+ facts: Optional[dict] = None) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
202
+ """(income, balance, cashflow) like yfinance's, newest period first."""
203
+ facts = facts if facts is not None else company_facts(symbol)
204
+ limit = max_periods or (5 if quarterly else 10)
205
+
206
+ income = {name: flow_series(facts, tags, quarterly, name not in NON_ADDITIVE) for name, tags in INCOME.items()}
207
+ cash = {name: flow_series(facts, tags, quarterly) for name, tags in CASHFLOW.items()}
208
+ balance = {name: instant_series(facts, tags, quarterly) for name, tags in BALANCE.items()}
209
+ for name in OUTFLOWS:
210
+ cash[name] = {k: -abs(v) for k, v in cash[name].items()}
211
+
212
+ # Revenue or net income: banks stop tagging quarterly revenue under one tag for years
213
+ periods = sorted(set(income["Total Revenue"]) | set(income["Net Income"]), reverse=True)[:limit]
214
+ if not periods:
215
+ raise ValueError(f"No SEC financial statements tagged for {symbol}")
216
+
217
+ # Balance sheets dated a few days off the income period end are matched to it.
218
+ def align(values: dict[str, float]) -> dict[str, float]:
219
+ out = {}
220
+ for p in periods:
221
+ if p in values:
222
+ out[p] = values[p]
223
+ continue
224
+ near = [d for d in values if abs(_days(d, p)) <= 7]
225
+ if near:
226
+ out[p] = values[min(near, key=lambda d: abs(_days(d, p)))]
227
+ return out
228
+
229
+ balance = {k: align(v) for k, v in balance.items()}
230
+ cash = {k: align(v) for k, v in cash.items()}
231
+
232
+ # Derived rows Yahoo also shows
233
+ def combine(*rows: dict[str, float]) -> dict[str, float]:
234
+ return {p: sum(r[p] for r in rows if p in r) for p in periods if any(p in r for r in rows)}
235
+
236
+ if not income["Gross Profit"] and income["Total Revenue"] and income["Cost Of Revenue"]:
237
+ income["Gross Profit"] = {p: income["Total Revenue"][p] - income["Cost Of Revenue"][p]
238
+ for p in periods if p in income["Total Revenue"] and p in income["Cost Of Revenue"]}
239
+ if income["Operating Income"] and cash["Depreciation And Amortization"]:
240
+ income["EBITDA"] = combine(income["Operating Income"], cash["Depreciation And Amortization"])
241
+ balance["Total Debt"] = combine(balance.pop("Long Term Debt"), balance.pop("Current Debt"),
242
+ balance.pop("Commercial Paper"))
243
+ balance["Cash Cash Equivalents And Short Term Investments"] = combine(
244
+ balance["Cash And Cash Equivalents"], balance.pop("Short Term Investments"))
245
+ if balance["Total Debt"]:
246
+ balance["Net Debt"] = {p: balance["Total Debt"][p] - balance["Cash And Cash Equivalents"].get(p, 0)
247
+ for p in balance["Total Debt"]}
248
+ balance["Goodwill And Other Intangible Assets"] = combine(balance["Goodwill"], balance.pop("Other Intangible Assets"))
249
+ if cash["Operating Cash Flow"] and cash["Capital Expenditure"]:
250
+ cash["Free Cash Flow"] = combine(cash["Operating Cash Flow"], cash["Capital Expenditure"])
251
+
252
+ return _frame(income, periods), _frame(balance, periods), _frame(cash, periods)