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,709 @@
1
+ """Trade section: intraday chart data, price alerts, trade journal.
2
+ Read-only market data; nothing here places orders."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from datetime import datetime
7
+ from typing import Literal, Optional
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
10
+ from pydantic import BaseModel, Field
11
+
12
+ from marketmyna.api.deps import get_config_dep
13
+ from marketmyna.storage import (
14
+ DatabaseManager,
15
+ PriceAlertEventRecord,
16
+ PriceAlertRecord,
17
+ TradeRecord,
18
+ get_db_url,
19
+ )
20
+ from marketmyna.trading import chart as chart_mod
21
+ from marketmyna.trading.alerts import KINDS, AlertMonitor, describe
22
+ from marketmyna.trading.journal import r_multiple, stats, trade_pnl
23
+ from marketmyna.utils.deadline import call_with_deadline
24
+
25
+ router = APIRouter()
26
+
27
+ MARKETS = {"us", "india", "china_a", "hong_kong", "crypto", "global"}
28
+
29
+
30
+ def _db(config):
31
+ return DatabaseManager.instance(get_db_url(config=config))
32
+
33
+
34
+ def _row(rec) -> dict:
35
+ out = {}
36
+ for c in rec.__table__.columns:
37
+ v = getattr(rec, c.name)
38
+ out[c.name] = v.isoformat() if isinstance(v, datetime) else v
39
+ return out
40
+
41
+
42
+ def _check_market(market: str) -> str:
43
+ market = market.lower()
44
+ if market not in MARKETS:
45
+ raise HTTPException(status_code=400, detail=f"Unknown market '{market}'")
46
+ return market
47
+
48
+
49
+ # ------------------------------------------------------------------ pairs
50
+
51
+
52
+ @router.get("/trade/pairs")
53
+ def trade_pairs(
54
+ a: str,
55
+ b: str,
56
+ market: str = "us",
57
+ market_b: Optional[str] = None,
58
+ period: Literal["1y", "2y", "5y"] = "2y",
59
+ ) -> dict:
60
+ """Engle-Granger cointegration test, hedge ratio, half-life and spread z-score."""
61
+ from concurrent.futures import ThreadPoolExecutor
62
+
63
+ from marketmyna.trading.pairs import analyze
64
+
65
+ market = _check_market(market)
66
+ market_b = _check_market(market_b or market)
67
+ a, b = a.strip().upper(), b.strip().upper()
68
+ if a == b and market == market_b:
69
+ raise HTTPException(status_code=400, detail="Pick two different symbols")
70
+
71
+ def bars(sym, mkt):
72
+ return call_with_deadline(chart_mod.fetch_bars, 30, sym, mkt, "1d", period)
73
+
74
+ try:
75
+ with ThreadPoolExecutor(max_workers=2) as pool:
76
+ fa, fb = pool.submit(bars, a, market), pool.submit(bars, b, market_b)
77
+ da, db = fa.result(), fb.result()
78
+ except Exception as exc:
79
+ raise HTTPException(status_code=502, detail=f"Could not load prices: {exc}")
80
+
81
+ def closes(df):
82
+ if df is None or df.empty:
83
+ return None
84
+ col = "close" if "close" in df.columns else "Close"
85
+ s = df[col].astype(float)
86
+ s.index = s.index.tz_localize(None).normalize() if getattr(s.index, "tz", None) else s.index.normalize()
87
+ return s[~s.index.duplicated(keep="last")]
88
+
89
+ sa, sb = closes(da), closes(db)
90
+ if sa is None or sb is None:
91
+ raise HTTPException(status_code=404, detail=f"No price history for {a if sa is None else b}")
92
+ try:
93
+ out = analyze(sa, sb)
94
+ except ValueError as exc:
95
+ raise HTTPException(status_code=422, detail=str(exc))
96
+ return {"a": a, "b": b, "market": market, "market_b": market_b, "period": period, **out}
97
+
98
+
99
+ # ------------------------------------------------------------------ chart
100
+
101
+
102
+ @router.get("/trade/chart")
103
+ def trade_chart(
104
+ symbol: str,
105
+ market: str = "us",
106
+ interval: str = "5m",
107
+ period: Optional[str] = None,
108
+ indicators: str = Query(default="vwap,ema9,ema21,supertrend"),
109
+ ) -> dict:
110
+ market = _check_market(market)
111
+ if interval not in chart_mod.INTERVALS:
112
+ raise HTTPException(status_code=400, detail=f"Interval must be one of {', '.join(chart_mod.INTERVALS)}")
113
+ period = chart_mod.clamp_period(interval, period)
114
+ wanted = [i for i in indicators.split(",") if i in chart_mod.INDICATORS]
115
+ try:
116
+ df = call_with_deadline(chart_mod.fetch_bars, 30, symbol.strip().upper(), market, interval, period)
117
+ except Exception as exc:
118
+ raise HTTPException(status_code=502, detail=f"Could not load {interval} bars: {exc}")
119
+ return {"symbol": symbol.upper(), "market": market, "interval": interval, "period": period,
120
+ **chart_mod.build_chart(df, interval, wanted)}
121
+
122
+
123
+ @router.get("/trade/levels")
124
+ def trade_levels(symbol: str, market: str = "us") -> dict:
125
+ """Buy / sell / stop levels (same maths as the smart report) for
126
+ one-click alerts."""
127
+ from marketmyna.services.smart_report import snapshot
128
+
129
+ try:
130
+ row = call_with_deadline(snapshot, 25, symbol.strip().upper(), _check_market(market))
131
+ except TimeoutError as exc:
132
+ raise HTTPException(status_code=504, detail=str(exc))
133
+ if "error" in row:
134
+ raise HTTPException(status_code=502, detail=row["error"])
135
+ from marketmyna.core.compliance import CALL_FIELDS
136
+
137
+ # The volatility band and ranges only, no buy / sell / stop.
138
+ row = {k: v for k, v in row.items() if k not in CALL_FIELDS}
139
+ # "at its buy / sell point" is the same call in words.
140
+ row["reasons"] = [r for r in row.get("reasons") or [] if "buy point" not in r and "sell point" not in r]
141
+ return row
142
+
143
+
144
+ # ------------------------------------------------------------------ alerts
145
+
146
+
147
+ class AlertIn(BaseModel):
148
+ symbol: str
149
+ market: str = "us"
150
+ kind: str
151
+ value: float
152
+ note: str = ""
153
+ repeat: bool = False
154
+ cooldown_minutes: int = Field(default=30, ge=0, le=1440)
155
+ expires_at: Optional[datetime] = None
156
+
157
+
158
+ class AlertPatch(BaseModel):
159
+ value: Optional[float] = None
160
+ note: Optional[str] = None
161
+ repeat: Optional[bool] = None
162
+ cooldown_minutes: Optional[int] = Field(default=None, ge=0, le=1440)
163
+ status: Optional[Literal["active", "paused"]] = None
164
+ expires_at: Optional[datetime] = None
165
+
166
+
167
+ def _alert_dict(rec: PriceAlertRecord) -> dict:
168
+ return {**_row(rec), "description": describe(rec.kind, rec.value)}
169
+
170
+
171
+ @router.get("/trade/alert-kinds")
172
+ def alert_kinds() -> list[dict]:
173
+ return [{"kind": k, "label": label, "unit": unit} for k, (label, unit, _) in KINDS.items()]
174
+
175
+
176
+ @router.get("/trade/alerts")
177
+ def list_alerts(config=Depends(get_config_dep)) -> list[dict]:
178
+ with _db(config).get_session() as s:
179
+ recs = s.query(PriceAlertRecord).order_by(PriceAlertRecord.status, PriceAlertRecord.id.desc()).all()
180
+ return [_alert_dict(r) for r in recs]
181
+
182
+
183
+ @router.post("/trade/alerts", status_code=201)
184
+ def create_alert(body: AlertIn, config=Depends(get_config_dep)) -> dict:
185
+ if body.kind not in KINDS:
186
+ raise HTTPException(status_code=400, detail=f"Unknown alert kind '{body.kind}'")
187
+ symbol = body.symbol.strip().upper()
188
+ if not symbol:
189
+ raise HTTPException(status_code=400, detail="Symbol is required")
190
+ if KINDS[body.kind][1] in ("price", "x avg", "days") and body.value <= 0:
191
+ raise HTTPException(status_code=400, detail="Value must be above zero")
192
+ with _db(config).get_session() as s:
193
+ rec = PriceAlertRecord(
194
+ symbol=symbol, market=_check_market(body.market), kind=body.kind, value=body.value,
195
+ note=body.note.strip(), repeat=body.repeat, cooldown_minutes=body.cooldown_minutes,
196
+ expires_at=body.expires_at, status="active", armed=True,
197
+ )
198
+ s.add(rec)
199
+ s.flush()
200
+ return _alert_dict(rec)
201
+
202
+
203
+ @router.patch("/trade/alerts/{alert_id}")
204
+ def update_alert(alert_id: int, body: AlertPatch, config=Depends(get_config_dep)) -> dict:
205
+ with _db(config).get_session() as s:
206
+ rec = s.get(PriceAlertRecord, alert_id)
207
+ if rec is None:
208
+ raise HTTPException(status_code=404, detail="Alert not found")
209
+ for k, v in body.model_dump(exclude_unset=True).items():
210
+ setattr(rec, k, v)
211
+ if body.status == "active":
212
+ rec.armed = True # re-activating a fired alert arms it again
213
+ return _alert_dict(rec)
214
+
215
+
216
+ @router.delete("/trade/alerts/{alert_id}", status_code=204)
217
+ def delete_alert(alert_id: int, config=Depends(get_config_dep)) -> None:
218
+ with _db(config).get_session() as s:
219
+ rec = s.get(PriceAlertRecord, alert_id)
220
+ if rec is not None:
221
+ s.delete(rec)
222
+
223
+
224
+ @router.get("/trade/alerts/events")
225
+ def alert_events(limit: int = 50, config=Depends(get_config_dep)) -> list[dict]:
226
+ with _db(config).get_session() as s:
227
+ recs = (
228
+ s.query(PriceAlertEventRecord)
229
+ .order_by(PriceAlertEventRecord.created_at.desc(), PriceAlertEventRecord.id.desc())
230
+ .limit(max(1, min(500, limit)))
231
+ .all()
232
+ )
233
+ return [_row(r) for r in recs]
234
+
235
+
236
+ @router.post("/trade/alerts/check")
237
+ def check_alerts_now(request: Request, config=Depends(get_config_dep)) -> dict:
238
+ """Run one alert check immediately, ignoring market hours."""
239
+ monitor = getattr(request.app.state, "alert_monitor", None) or AlertMonitor(config)
240
+ return monitor.tick(force=True)
241
+
242
+
243
+ @router.get("/trade/alerts/status")
244
+ def alert_monitor_status(request: Request) -> dict:
245
+ monitor = getattr(request.app.state, "alert_monitor", None)
246
+ return {"running": bool(monitor and monitor.is_running()),
247
+ "poll_seconds": getattr(monitor, "_poll", None)}
248
+
249
+
250
+ # ------------------------------------------------------------------ journal
251
+
252
+
253
+ class TradeIn(BaseModel):
254
+ symbol: str
255
+ market: str = "us"
256
+ side: Literal["long", "short"] = "long"
257
+ quantity: float = Field(gt=0)
258
+ entry_price: float = Field(gt=0)
259
+ entry_at: Optional[datetime] = None
260
+ exit_price: Optional[float] = Field(default=None, gt=0)
261
+ exit_at: Optional[datetime] = None
262
+ stop: Optional[float] = Field(default=None, gt=0)
263
+ target: Optional[float] = Field(default=None, gt=0)
264
+ fees: Optional[float] = Field(default=None, ge=0) # empty = estimate from the charges schedule
265
+ product: Optional[Literal["intraday", "delivery", "futures", "options"]] = None
266
+ setup: str = ""
267
+ notes: str = ""
268
+
269
+
270
+ class TradePatch(BaseModel):
271
+ side: Optional[Literal["long", "short"]] = None
272
+ quantity: Optional[float] = Field(default=None, gt=0)
273
+ entry_price: Optional[float] = Field(default=None, gt=0)
274
+ entry_at: Optional[datetime] = None
275
+ exit_price: Optional[float] = Field(default=None, gt=0)
276
+ exit_at: Optional[datetime] = None
277
+ stop: Optional[float] = Field(default=None, gt=0)
278
+ target: Optional[float] = Field(default=None, gt=0)
279
+ fees: Optional[float] = Field(default=None, ge=0)
280
+ product: Optional[Literal["intraday", "delivery", "futures", "options"]] = None
281
+ setup: Optional[str] = None
282
+ notes: Optional[str] = None
283
+
284
+
285
+ def _auto_fees(rec: TradeRecord) -> None:
286
+ """Keep estimated fees current: entry side while open, round trip once closed."""
287
+ from marketmyna.trading.charges import for_trade
288
+
289
+ if rec.fees_auto:
290
+ rec.fees = for_trade(_plain(rec))
291
+
292
+
293
+ def _plain(rec: TradeRecord) -> dict:
294
+ return {c.name: getattr(rec, c.name) for c in rec.__table__.columns}
295
+
296
+
297
+ def _trade_dict(rec: TradeRecord) -> dict:
298
+ plain = _plain(rec)
299
+ pnl, r = trade_pnl(plain), r_multiple(plain)
300
+ return {**_row(rec), "pnl": round(pnl, 2) if pnl is not None else None,
301
+ "r_multiple": round(r, 2) if r is not None else None}
302
+
303
+
304
+ @router.get("/trade/journal")
305
+ def journal(market: Optional[str] = None, config=Depends(get_config_dep)) -> dict:
306
+ with _db(config).get_session() as s:
307
+ q = s.query(TradeRecord)
308
+ if market:
309
+ q = q.filter(TradeRecord.market == _check_market(market))
310
+ recs = q.order_by(TradeRecord.entry_at.desc(), TradeRecord.id.desc()).all()
311
+ markets = sorted({m for (m,) in s.query(TradeRecord.market).distinct()})
312
+ return {"trades": [_trade_dict(r) for r in recs], "stats": stats([_plain(r) for r in recs]),
313
+ "markets": markets}
314
+
315
+
316
+ @router.post("/trade/journal", status_code=201)
317
+ def add_trade(body: TradeIn, config=Depends(get_config_dep)) -> dict:
318
+ data = body.model_dump()
319
+ data["symbol"] = data["symbol"].strip().upper()
320
+ data["market"] = _check_market(data["market"])
321
+ data["entry_at"] = data["entry_at"] or datetime.now()
322
+ if data["exit_price"] is not None and data["exit_at"] is None:
323
+ data["exit_at"] = datetime.now()
324
+ data["fees_auto"] = data["fees"] is None
325
+ data["fees"] = data["fees"] or 0.0
326
+ with _db(config).get_session() as s:
327
+ rec = TradeRecord(**data)
328
+ _auto_fees(rec)
329
+ s.add(rec)
330
+ s.flush()
331
+ return _trade_dict(rec)
332
+
333
+
334
+ @router.patch("/trade/journal/{trade_id}")
335
+ def update_trade(trade_id: int, body: TradePatch, config=Depends(get_config_dep)) -> dict:
336
+ with _db(config).get_session() as s:
337
+ rec = s.get(TradeRecord, trade_id)
338
+ if rec is None:
339
+ raise HTTPException(status_code=404, detail="Trade not found")
340
+ changes = body.model_dump(exclude_unset=True)
341
+ if "fees" in changes:
342
+ rec.fees_auto = changes["fees"] is None
343
+ changes["fees"] = changes["fees"] or 0.0
344
+ for k, v in changes.items():
345
+ setattr(rec, k, v)
346
+ if changes.get("exit_price") is not None and rec.exit_at is None:
347
+ rec.exit_at = datetime.now()
348
+ _auto_fees(rec)
349
+ return _trade_dict(rec)
350
+
351
+
352
+ @router.delete("/trade/journal/{trade_id}", status_code=204)
353
+ def delete_trade(trade_id: int, config=Depends(get_config_dep)) -> None:
354
+ with _db(config).get_session() as s:
355
+ rec = s.get(TradeRecord, trade_id)
356
+ if rec is not None:
357
+ s.delete(rec)
358
+
359
+
360
+ # ------------------------------------------------------------------ scanner
361
+
362
+
363
+ class Universe(BaseModel):
364
+ sources: list[Literal["top", "watchlist", "portfolio", "custom"]] = ["top"]
365
+ market: str = "india"
366
+ top_n: int = Field(default=100, ge=1, le=500)
367
+ watchlist_ids: list[int] = []
368
+ portfolio_names: list[str] = []
369
+ symbols: list[str] = []
370
+
371
+
372
+ class ScanIn(BaseModel):
373
+ universe: Universe = Universe()
374
+ scans: list[str]
375
+ params: dict[str, float] = {}
376
+ match: Literal["any", "all"] = "any"
377
+
378
+
379
+ class SavedScanIn(ScanIn):
380
+ name: str
381
+ notify: bool = False
382
+ interval_minutes: int = Field(default=15, ge=5, le=1440)
383
+
384
+
385
+ class SavedScanPatch(BaseModel):
386
+ name: Optional[str] = None
387
+ universe: Optional[Universe] = None
388
+ scans: Optional[list[str]] = None
389
+ params: Optional[dict[str, float]] = None
390
+ match: Optional[Literal["any", "all"]] = None
391
+ notify: Optional[bool] = None
392
+ interval_minutes: Optional[int] = Field(default=None, ge=5, le=1440)
393
+
394
+
395
+ def _clean_scans(scans: list[str]) -> list[str]:
396
+ from marketmyna.trading.scanner import SCANS
397
+
398
+ clean = [s for s in dict.fromkeys(scans) if s in SCANS]
399
+ if not clean:
400
+ raise HTTPException(status_code=400, detail="Pick at least one scan")
401
+ return clean
402
+
403
+
404
+ @router.get("/trade/scans/catalog")
405
+ def scan_catalog() -> list[dict]:
406
+ from marketmyna.trading.scanner import SCANS
407
+
408
+ return [
409
+ {"id": k, "label": label, "group": group, "intraday": intraday, "default": default, "param": param}
410
+ for k, (label, group, intraday, default, param) in SCANS.items()
411
+ ]
412
+
413
+
414
+ @router.post("/trade/scan")
415
+ def run_scan_now(body: ScanIn, config=Depends(get_config_dep)) -> dict:
416
+ from marketmyna.trading.scanner import resolve_universe, run_scan
417
+
418
+ universe = body.universe.model_dump()
419
+ universe["market"] = _check_market(universe["market"])
420
+ targets, names = resolve_universe(universe, config)
421
+ if not targets:
422
+ raise HTTPException(status_code=400, detail="The chosen universe has no stocks")
423
+ try:
424
+ return call_with_deadline(run_scan, 170, targets, _clean_scans(body.scans), body.params, body.match, names=names)
425
+ except TimeoutError as exc:
426
+ raise HTTPException(status_code=504, detail=f"Scan timed out: {exc}")
427
+
428
+
429
+ @router.get("/trade/scans/saved")
430
+ def list_saved_scans(config=Depends(get_config_dep)) -> list[dict]:
431
+ from marketmyna.storage import SavedScanRecord
432
+
433
+ with _db(config).get_session() as s:
434
+ return [_row(r) for r in s.query(SavedScanRecord).order_by(SavedScanRecord.id)]
435
+
436
+
437
+ @router.post("/trade/scans/saved", status_code=201)
438
+ def save_scan(body: SavedScanIn, config=Depends(get_config_dep)) -> dict:
439
+ from marketmyna.storage import SavedScanRecord
440
+
441
+ if not body.name.strip():
442
+ raise HTTPException(status_code=400, detail="Name is required")
443
+ with _db(config).get_session() as s:
444
+ rec = SavedScanRecord(
445
+ name=body.name.strip(), universe=body.universe.model_dump(), scans=_clean_scans(body.scans),
446
+ params=body.params, match=body.match, notify=body.notify, interval_minutes=body.interval_minutes,
447
+ )
448
+ s.add(rec)
449
+ s.flush()
450
+ return _row(rec)
451
+
452
+
453
+ @router.patch("/trade/scans/saved/{scan_id}")
454
+ def update_saved_scan(scan_id: int, body: SavedScanPatch, config=Depends(get_config_dep)) -> dict:
455
+ from marketmyna.storage import SavedScanRecord
456
+
457
+ with _db(config).get_session() as s:
458
+ rec = s.get(SavedScanRecord, scan_id)
459
+ if rec is None:
460
+ raise HTTPException(status_code=404, detail="Saved scan not found")
461
+ changes = body.model_dump(exclude_unset=True)
462
+ if "scans" in changes:
463
+ changes["scans"] = _clean_scans(changes["scans"])
464
+ for k, v in changes.items():
465
+ setattr(rec, k, v)
466
+ return _row(rec)
467
+
468
+
469
+ @router.delete("/trade/scans/saved/{scan_id}", status_code=204)
470
+ def delete_saved_scan(scan_id: int, config=Depends(get_config_dep)) -> None:
471
+ from marketmyna.storage import SavedScanRecord
472
+
473
+ with _db(config).get_session() as s:
474
+ rec = s.get(SavedScanRecord, scan_id)
475
+ if rec is not None:
476
+ s.delete(rec)
477
+
478
+
479
+ # ------------------------------------------------------------------ daily risk
480
+
481
+
482
+ class RiskSettingsIn(BaseModel):
483
+ market: Optional[str] = None
484
+ capital: Optional[float] = Field(default=None, gt=0)
485
+ daily_loss_limit_pct: Optional[float] = Field(default=None, ge=0, le=100)
486
+ max_open_risk_pct: Optional[float] = Field(default=None, ge=0, le=100)
487
+ max_trades_per_day: Optional[int] = Field(default=None, ge=0, le=1000)
488
+ notify: Optional[bool] = None
489
+
490
+
491
+ @router.get("/trade/risk")
492
+ def risk_status(config=Depends(get_config_dep)) -> dict:
493
+ from marketmyna.trading.risk import status
494
+
495
+ return status(config)
496
+
497
+
498
+ @router.put("/trade/risk/settings")
499
+ def update_risk_settings(body: RiskSettingsIn, config=Depends(get_config_dep)) -> dict:
500
+ from marketmyna.trading.risk import save_settings
501
+
502
+ changes = body.model_dump(exclude_unset=True)
503
+ if "market" in changes:
504
+ changes["market"] = _check_market(changes["market"])
505
+ return save_settings(config, **changes)
506
+
507
+
508
+ # ------------------------------------------------------------------ backtest
509
+
510
+
511
+ class BacktestIn(BaseModel):
512
+ symbol: str
513
+ market: str = "india"
514
+ interval: Literal["1d", "1wk", "1h"] = "1d"
515
+ period: Literal["1y", "2y", "5y", "10y", "max"] = "5y"
516
+ strategy: str = "ema_cross"
517
+ params: dict[str, float] = {}
518
+ capital: float = Field(default=100000, gt=0)
519
+ sizing: Literal["percent", "risk"] = "percent"
520
+ position_pct: float = Field(default=100, gt=0, le=100)
521
+ risk_pct: float = Field(default=1, gt=0, le=100)
522
+ stop_type: Literal["none", "percent", "atr"] = "none"
523
+ stop_value: float = Field(default=2, gt=0)
524
+ target_r: Optional[float] = Field(default=None, gt=0)
525
+ cost_pct: float = Field(default=0.1, ge=0, le=5)
526
+ slippage_pct: float = Field(default=0.05, ge=0, le=5)
527
+ allow_short: bool = False
528
+ optimize: bool = False
529
+ objective: Literal["sharpe", "total_return", "profit_factor", "cagr"] = "sharpe"
530
+
531
+
532
+ @router.get("/trade/backtest/strategies")
533
+ def backtest_strategies() -> list[dict]:
534
+ from marketmyna.trading.backtest import STRATEGIES
535
+
536
+ return [{"id": k, **v} for k, v in STRATEGIES.items()]
537
+
538
+
539
+ @router.post("/trade/backtest")
540
+ def backtest(body: BacktestIn) -> dict:
541
+ from marketmyna.trading import backtest as bt
542
+
543
+ if body.strategy not in bt.STRATEGIES:
544
+ raise HTTPException(status_code=400, detail=f"Unknown strategy '{body.strategy}'")
545
+ market = _check_market(body.market)
546
+ period = "2y" if body.interval == "1h" and body.period not in ("1y", "2y") else body.period
547
+ try:
548
+ df = chart_mod.normalize(call_with_deadline(chart_mod.fetch_bars, 45, body.symbol.strip().upper(), market, body.interval, period))
549
+ except Exception as exc:
550
+ raise HTTPException(status_code=502, detail=f"Could not load history: {exc}")
551
+ if len(df) < 60:
552
+ raise HTTPException(status_code=400, detail=f"Only {len(df)} bars of history; need at least 60")
553
+ settings = bt.Settings(
554
+ strategy=body.strategy, params=body.params, capital=body.capital, sizing=body.sizing,
555
+ position_pct=body.position_pct, risk_pct=body.risk_pct, stop_type=body.stop_type,
556
+ stop_value=body.stop_value, target_r=body.target_r, cost_pct=body.cost_pct,
557
+ slippage_pct=body.slippage_pct, allow_short=body.allow_short,
558
+ )
559
+ out: dict = {"symbol": body.symbol.upper(), "market": market, "interval": body.interval, "period": period}
560
+ if body.optimize:
561
+ opt = bt.optimize(df, settings, body.interval, body.objective)
562
+ out["optimization"] = opt
563
+ settings.params = opt["best"]
564
+ out["params"] = {**bt.STRATEGIES[body.strategy]["params"], **settings.params}
565
+ out.update(bt.run(df, settings, body.interval))
566
+ out["split"] = bt.split_test(df, settings, body.interval)
567
+ return out
568
+
569
+
570
+ # ------------------------------------------------------------------ options
571
+
572
+
573
+ class Leg(BaseModel):
574
+ type: Literal["call", "put", "stock"]
575
+ side: Literal["buy", "sell"]
576
+ strike: float = 0
577
+ premium: float = Field(default=0, ge=0)
578
+ quantity: float = Field(gt=0)
579
+ iv: Optional[float] = Field(default=None, gt=0, le=500)
580
+
581
+
582
+ class PayoffIn(BaseModel):
583
+ legs: list[Leg] = Field(min_length=1, max_length=8)
584
+ spot: float = Field(gt=0)
585
+ days: float = Field(default=7, ge=0, le=1000)
586
+ market: str = "india"
587
+ width_pct: float = Field(default=15, gt=1, le=80)
588
+
589
+
590
+ @router.get("/trade/options/chain")
591
+ def option_chain(symbol: str, market: str = "india", expiry: Optional[str] = None) -> dict:
592
+ from marketmyna.trading import options
593
+
594
+ market = _check_market(market)
595
+ if market in ("china_a", "crypto"):
596
+ raise HTTPException(status_code=400, detail="Option chains are available for India (NSE) and US/global (Yahoo)")
597
+ try:
598
+ return call_with_deadline(options.chain, 40, symbol.strip().upper(), market, expiry)
599
+ except ValueError as exc:
600
+ raise HTTPException(status_code=404, detail=str(exc))
601
+ except Exception as exc:
602
+ raise HTTPException(status_code=502, detail=f"Option chain unavailable: {exc}")
603
+
604
+
605
+ @router.post("/trade/options/payoff")
606
+ def option_payoff(body: PayoffIn) -> dict:
607
+ from marketmyna.trading import options
608
+
609
+ legs = [leg.model_dump() for leg in body.legs]
610
+ for leg in legs:
611
+ if leg["type"] != "stock" and leg["strike"] <= 0:
612
+ raise HTTPException(status_code=400, detail="Every option leg needs a strike")
613
+ rate = options.RATES.get(_check_market(body.market), 0.05)
614
+ return options.payoff(legs, body.spot, body.days, rate, body.width_pct)
615
+
616
+
617
+ # ------------------------------------------------------------------ live quotes (SSE)
618
+
619
+
620
+ STREAM_SECONDS = 15
621
+
622
+
623
+ @router.get("/trade/stream")
624
+ async def quote_stream(request: Request, symbols: str, every: int = 10):
625
+ """Server-sent events: `{"symbol", "market", "price", "change_percent",
626
+ "volume", "time"}` for each `market:SYMBOL` in `symbols`, every `every`
627
+ seconds (min 5). Stock prices carry the source's delay (~15 min on free
628
+ data); crypto is live."""
629
+ import asyncio
630
+ import json
631
+
632
+ from fastapi.responses import StreamingResponse
633
+
634
+ from marketmyna.trading.alerts import _default_quote
635
+ from marketmyna.trading.market_hours import is_open
636
+
637
+ pairs = []
638
+ for item in symbols.split(",")[:20]:
639
+ if ":" in item:
640
+ mkt, sym = item.split(":", 1)
641
+ pairs.append((sym.strip().upper(), _check_market(mkt)))
642
+ if not pairs:
643
+ raise HTTPException(status_code=400, detail="symbols must look like india:RELIANCE,crypto:BTC-USD")
644
+ every = max(5, min(300, every))
645
+
646
+ async def events():
647
+ # Each connection lives ~15s, then the browser's EventSource reconnects
648
+ # (after `retry` ms). A never-ending response would otherwise block the
649
+ # server's graceful shutdown - and with it every --reload - for as long
650
+ # as a Trade page is open.
651
+ import time
652
+
653
+ started = time.monotonic()
654
+ yield "retry: 1000\n\n"
655
+ while not await request.is_disconnected() and time.monotonic() - started < STREAM_SECONDS:
656
+ for sym, mkt in pairs:
657
+ try:
658
+ q = await asyncio.to_thread(call_with_deadline, _default_quote, 15, sym, mkt)
659
+ except Exception as exc:
660
+ yield f"event: error\ndata: {json.dumps({'symbol': sym, 'error': str(exc)})}\n\n"
661
+ continue
662
+ payload = {"symbol": sym, "market": mkt, "open": is_open(mkt),
663
+ "price": q.get("price"), "change_percent": q.get("change_percent"),
664
+ "day_high": q.get("day_high"), "day_low": q.get("day_low"), "volume": q.get("volume"),
665
+ "time": datetime.now().isoformat(timespec="seconds")}
666
+ yield f"data: {json.dumps(payload)}\n\n"
667
+ await asyncio.sleep(every)
668
+
669
+ return StreamingResponse(events(), media_type="text/event-stream",
670
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
671
+
672
+
673
+ # ------------------------------------------------------------------ charges
674
+
675
+
676
+ class ChargesIn(BaseModel):
677
+ market: str = "india"
678
+ product: Optional[Literal["intraday", "delivery", "futures", "options"]] = None
679
+ buy_value: float = Field(default=0, ge=0)
680
+ sell_value: float = Field(default=0, ge=0)
681
+ quantity: float = Field(default=0, ge=0)
682
+ commission_pct: float = Field(default=0, ge=0, le=10)
683
+ flat: float = Field(default=0, ge=0)
684
+
685
+
686
+ @router.post("/trade/charges")
687
+ def trade_charges(body: ChargesIn) -> dict:
688
+ from marketmyna.trading.charges import estimate
689
+
690
+ return estimate(_check_market(body.market), body.buy_value, body.sell_value, body.quantity, body.product,
691
+ body.commission_pct, body.flat)
692
+
693
+
694
+ @router.get("/trade/lot-size")
695
+ def trade_lot_size(symbol: str) -> dict:
696
+ """NSE F&O lot size (nearest expiry) from the brokers' instrument files."""
697
+ from marketmyna.trading.lots import lot_size
698
+
699
+ try:
700
+ return {"symbol": symbol.upper(), "lot_size": lot_size(symbol.strip())}
701
+ except Exception as exc:
702
+ raise HTTPException(status_code=502, detail=f"Lot sizes unavailable: {exc}")
703
+
704
+
705
+ @router.get("/trade/market-status")
706
+ def market_status(market: str = "india") -> dict:
707
+ from marketmyna.trading.market_hours import status
708
+
709
+ return status(_check_market(market))