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,303 @@
1
+ """Trader indicators on OHLCV frames (lower-case columns). Pure pandas, one
2
+ implementation shared by the chart, the alert engine, the scanner and tests.
3
+
4
+ Formulas for the oscillators, channels, Ichimoku, candlestick patterns and
5
+ divergences follow the OpenBB technical extension (Apache 2.0,
6
+ https://github.com/OpenBB-finance/OpenBB), reimplemented here without pandas-ta."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import pandas as pd
11
+
12
+
13
+ def ema(series: pd.Series, period: int) -> pd.Series:
14
+ return series.ewm(span=period, adjust=False).mean()
15
+
16
+
17
+ def rsi(close: pd.Series, period: int = 14) -> pd.Series:
18
+ """Wilder's RSI."""
19
+ delta = close.diff()
20
+ gain = delta.clip(lower=0).ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
21
+ loss = (-delta.clip(upper=0)).ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
22
+ rs = gain / loss
23
+ out = 100 - 100 / (1 + rs)
24
+ return out.where(loss != 0, 100.0).where(gain.notna())
25
+
26
+
27
+ def atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
28
+ prev_close = df["close"].shift(1)
29
+ tr = pd.concat(
30
+ [df["high"] - df["low"], (df["high"] - prev_close).abs(), (df["low"] - prev_close).abs()], axis=1
31
+ ).max(axis=1)
32
+ return tr.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
33
+
34
+
35
+ def session_key(index: pd.Index) -> pd.Index:
36
+ """Trading day of each bar, in the bars' own (exchange) timezone."""
37
+ return pd.Index(pd.DatetimeIndex(index).date)
38
+
39
+
40
+ def vwap(df: pd.DataFrame) -> pd.Series:
41
+ """Volume-weighted average price, restarting every session."""
42
+ typical = (df["high"] + df["low"] + df["close"]) / 3
43
+ volume = df["volume"].fillna(0)
44
+ day = session_key(df.index)
45
+ pv = (typical * volume).groupby(day).cumsum()
46
+ vol = volume.groupby(day).cumsum()
47
+ return (pv / vol.where(vol > 0)).set_axis(df.index)
48
+
49
+
50
+ def supertrend(df: pd.DataFrame, period: int = 10, multiplier: float = 3.0) -> tuple[pd.Series, pd.Series]:
51
+ """(line, direction): direction 1 = uptrend (line below price), -1 = down."""
52
+ mid = (df["high"] + df["low"]) / 2
53
+ band = multiplier * atr(df, period)
54
+ upper, lower = (mid + band).to_numpy(), (mid - band).to_numpy()
55
+ close = df["close"].to_numpy()
56
+ n = len(df)
57
+ line = [float("nan")] * n
58
+ direction = [0] * n
59
+ final_upper, final_lower = upper.copy(), lower.copy()
60
+ for i in range(n):
61
+ if i == 0 or pd.isna(upper[i]) or pd.isna(final_upper[i - 1]):
62
+ direction[i] = 1
63
+ continue
64
+ final_upper[i] = upper[i] if upper[i] < final_upper[i - 1] or close[i - 1] > final_upper[i - 1] else final_upper[i - 1]
65
+ final_lower[i] = lower[i] if lower[i] > final_lower[i - 1] or close[i - 1] < final_lower[i - 1] else final_lower[i - 1]
66
+ if direction[i - 1] == -1 and close[i] > final_upper[i - 1]:
67
+ direction[i] = 1
68
+ elif direction[i - 1] == 1 and close[i] < final_lower[i - 1]:
69
+ direction[i] = -1
70
+ else:
71
+ direction[i] = direction[i - 1] or 1
72
+ line[i] = final_lower[i] if direction[i] == 1 else final_upper[i]
73
+ return pd.Series(line, index=df.index), pd.Series(direction, index=df.index)
74
+
75
+
76
+ def pivots(high: float, low: float, close: float) -> dict[str, float]:
77
+ """Classic floor pivots from the previous session's high / low / close."""
78
+ p = (high + low + close) / 3
79
+ return {
80
+ "R3": high + 2 * (p - low),
81
+ "R2": p + (high - low),
82
+ "R1": 2 * p - low,
83
+ "P": p,
84
+ "S1": 2 * p - high,
85
+ "S2": p - (high - low),
86
+ "S3": low - 2 * (high - p),
87
+ }
88
+
89
+
90
+ def opening_range(df: pd.DataFrame, minutes: int = 15) -> dict[str, float] | None:
91
+ """High / low of the first `minutes` of the latest session (intraday bars)."""
92
+ if df.empty:
93
+ return None
94
+ index = pd.DatetimeIndex(df.index)
95
+ last_day = index[-1].date()
96
+ today = df[index.date == last_day]
97
+ start = pd.DatetimeIndex(today.index)[0]
98
+ first = today[pd.DatetimeIndex(today.index) < start + pd.Timedelta(minutes=minutes)]
99
+ if first.empty:
100
+ return None
101
+ return {"high": float(first["high"].max()), "low": float(first["low"].min())}
102
+
103
+
104
+ # ------------------------------------------------------------------ trend / channels
105
+
106
+
107
+ def sma(series: pd.Series, period: int) -> pd.Series:
108
+ return series.rolling(period, min_periods=period).mean()
109
+
110
+
111
+ def macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame:
112
+ """Columns: macd, signal, hist."""
113
+ line = ema(close, fast) - ema(close, slow)
114
+ sig = ema(line, signal)
115
+ return pd.DataFrame({"macd": line, "signal": sig, "hist": line - sig})
116
+
117
+
118
+ def bollinger(close: pd.Series, period: int = 20, std: float = 2.0) -> pd.DataFrame:
119
+ """Columns: mid, upper, lower, width (band width as % of mid), pct_b (0 = lower, 1 = upper)."""
120
+ mid = sma(close, period)
121
+ dev = close.rolling(period, min_periods=period).std(ddof=0)
122
+ upper, lower = mid + std * dev, mid - std * dev
123
+ span = (upper - lower).where(upper != lower)
124
+ return pd.DataFrame({"mid": mid, "upper": upper, "lower": lower,
125
+ "width": (upper - lower) / mid * 100, "pct_b": (close - lower) / span})
126
+
127
+
128
+ def keltner(df: pd.DataFrame, period: int = 20, atr_period: int = 10, multiplier: float = 2.0) -> pd.DataFrame:
129
+ """Columns: mid (EMA), upper, lower."""
130
+ mid = ema(df["close"], period)
131
+ band = multiplier * atr(df, atr_period)
132
+ return pd.DataFrame({"mid": mid, "upper": mid + band, "lower": mid - band})
133
+
134
+
135
+ def donchian(df: pd.DataFrame, period: int = 20) -> pd.DataFrame:
136
+ """Highest high / lowest low of the previous `period` bars (excludes the current bar)."""
137
+ upper = df["high"].shift(1).rolling(period, min_periods=period).max()
138
+ lower = df["low"].shift(1).rolling(period, min_periods=period).min()
139
+ return pd.DataFrame({"upper": upper, "lower": lower, "mid": (upper + lower) / 2})
140
+
141
+
142
+ def squeeze(df: pd.DataFrame, period: int = 20) -> pd.Series:
143
+ """True while the Bollinger band sits inside the Keltner channel (TTM squeeze)."""
144
+ bb, kc = bollinger(df["close"], period), keltner(df, period, period, 1.5)
145
+ return (bb["upper"] < kc["upper"]) & (bb["lower"] > kc["lower"])
146
+
147
+
148
+ def adx(df: pd.DataFrame, period: int = 14) -> pd.DataFrame:
149
+ """Wilder's ADX. Columns: adx, plus_di, minus_di."""
150
+ up = df["high"].diff()
151
+ down = -df["low"].diff()
152
+ plus_dm = up.where((up > down) & (up > 0), 0.0)
153
+ minus_dm = down.where((down > up) & (down > 0), 0.0)
154
+ rng = atr(df, period)
155
+ smooth = lambda s: s.ewm(alpha=1 / period, adjust=False, min_periods=period).mean() # noqa: E731
156
+ plus_di = 100 * smooth(plus_dm) / rng
157
+ minus_di = 100 * smooth(minus_dm) / rng
158
+ total = (plus_di + minus_di).where(lambda x: x != 0)
159
+ dx = 100 * (plus_di - minus_di).abs() / total
160
+ return pd.DataFrame({"adx": smooth(dx), "plus_di": plus_di, "minus_di": minus_di})
161
+
162
+
163
+ def aroon(df: pd.DataFrame, period: int = 25) -> pd.DataFrame:
164
+ """Columns: up, down, osc (up - down). 100 = extreme set this bar."""
165
+ window = period + 1
166
+ since_high = df["high"].rolling(window, min_periods=window).apply(lambda x: period - x.argmax(), raw=True)
167
+ since_low = df["low"].rolling(window, min_periods=window).apply(lambda x: period - x.argmin(), raw=True)
168
+ up = 100 * (period - since_high) / period
169
+ down = 100 * (period - since_low) / period
170
+ return pd.DataFrame({"up": up, "down": down, "osc": up - down})
171
+
172
+
173
+ def ichimoku(df: pd.DataFrame, tenkan: int = 9, kijun: int = 26, senkou: int = 52) -> pd.DataFrame:
174
+ """Columns: tenkan, kijun, span_a, span_b. Spans are already shifted
175
+ forward, so row i holds the cloud drawn under bar i."""
176
+ mid = lambda n: (df["high"].rolling(n, min_periods=n).max() + df["low"].rolling(n, min_periods=n).min()) / 2 # noqa: E731
177
+ t, k = mid(tenkan), mid(kijun)
178
+ return pd.DataFrame({"tenkan": t, "kijun": k, "span_a": ((t + k) / 2).shift(kijun),
179
+ "span_b": mid(senkou).shift(kijun)})
180
+
181
+
182
+ # ------------------------------------------------------------------ oscillators
183
+
184
+
185
+ def stochastic(df: pd.DataFrame, period: int = 14, smooth_d: int = 3) -> pd.DataFrame:
186
+ """Columns: k, d (0-100)."""
187
+ hi = df["high"].rolling(period, min_periods=period).max()
188
+ lo = df["low"].rolling(period, min_periods=period).min()
189
+ k = 100 * (df["close"] - lo) / (hi - lo).where(hi != lo)
190
+ return pd.DataFrame({"k": k, "d": k.rolling(smooth_d, min_periods=smooth_d).mean()})
191
+
192
+
193
+ def williams_r(df: pd.DataFrame, period: int = 14) -> pd.Series:
194
+ """-100 (at the low) .. 0 (at the high)."""
195
+ hi = df["high"].rolling(period, min_periods=period).max()
196
+ lo = df["low"].rolling(period, min_periods=period).min()
197
+ return -100 * (hi - df["close"]) / (hi - lo).where(hi != lo)
198
+
199
+
200
+ def cci(df: pd.DataFrame, period: int = 20) -> pd.Series:
201
+ typical = (df["high"] + df["low"] + df["close"]) / 3
202
+ mean = typical.rolling(period, min_periods=period).mean()
203
+ mad = typical.rolling(period, min_periods=period).apply(lambda x: abs(x - x.mean()).mean(), raw=True)
204
+ return (typical - mean) / (0.015 * mad.where(mad != 0))
205
+
206
+
207
+ def mfi(df: pd.DataFrame, period: int = 14) -> pd.Series:
208
+ """Money flow index (volume-weighted RSI), 0-100."""
209
+ typical = (df["high"] + df["low"] + df["close"]) / 3
210
+ flow = typical * df["volume"].fillna(0)
211
+ move = typical.diff()
212
+ pos = flow.where(move > 0, 0.0).rolling(period, min_periods=period).sum()
213
+ neg = flow.where(move < 0, 0.0).rolling(period, min_periods=period).sum()
214
+ out = 100 - 100 / (1 + pos / neg.where(neg != 0))
215
+ return out.where(neg != 0, 100.0).where(pos.notna())
216
+
217
+
218
+ # ------------------------------------------------------------------ patterns
219
+
220
+
221
+ CANDLE_PATTERNS: dict[str, tuple[str, str]] = {
222
+ "bullish_engulfing": ("Bullish engulfing", "bullish"),
223
+ "bearish_engulfing": ("Bearish engulfing", "bearish"),
224
+ "hammer": ("Hammer", "bullish"),
225
+ "shooting_star": ("Shooting star", "bearish"),
226
+ "morning_star": ("Morning star", "bullish"),
227
+ "evening_star": ("Evening star", "bearish"),
228
+ "doji": ("Doji", "neutral"),
229
+ }
230
+
231
+
232
+ def candle_patterns(df: pd.DataFrame, trend_bars: int = 5) -> list[str]:
233
+ """Pattern ids (keys of CANDLE_PATTERNS) completed on the last bar.
234
+ Reversal patterns need the preceding `trend_bars` move to point the other way."""
235
+ d = df.dropna(subset=["open", "high", "low", "close"])
236
+ if len(d) < trend_bars + 4:
237
+ return []
238
+ o, h, l, c = (d[k].astype(float).to_numpy() for k in ("open", "high", "low", "close"))
239
+ body = abs(c - o)
240
+ rng = h - l
241
+ avg_body = float(pd.Series(body[-11:-1]).mean()) or 1e-9
242
+ i = len(d) - 1
243
+ prior = c[i - 1] - c[i - 1 - trend_bars] # trend into the pattern
244
+ down_before, up_before = prior < 0, prior > 0
245
+ upper = h[i] - max(o[i], c[i])
246
+ lower = min(o[i], c[i]) - l[i]
247
+ found: list[str] = []
248
+
249
+ if rng[i] > 0 and body[i] <= 0.1 * rng[i]:
250
+ found.append("doji")
251
+ if body[i] > 0 and lower >= 2 * body[i] and upper <= body[i] and down_before:
252
+ found.append("hammer")
253
+ if body[i] > 0 and upper >= 2 * body[i] and lower <= body[i] and up_before:
254
+ found.append("shooting_star")
255
+ if c[i - 1] < o[i - 1] and c[i] > o[i] and o[i] <= c[i - 1] and c[i] >= o[i - 1] and body[i] > body[i - 1] and down_before:
256
+ found.append("bullish_engulfing")
257
+ if c[i - 1] > o[i - 1] and c[i] < o[i] and o[i] >= c[i - 1] and c[i] <= o[i - 1] and body[i] > body[i - 1] and up_before:
258
+ found.append("bearish_engulfing")
259
+ first, star = i - 2, i - 1
260
+ if body[first] >= avg_body and body[star] <= 0.5 * body[first]:
261
+ if c[first] < o[first] and c[i] > o[i] and c[i] > (o[first] + c[first]) / 2 and c[first - 1] - c[first - 1 - trend_bars] < 0:
262
+ found.append("morning_star")
263
+ if c[first] > o[first] and c[i] < o[i] and c[i] < (o[first] + c[first]) / 2 and c[first - 1] - c[first - 1 - trend_bars] > 0:
264
+ found.append("evening_star")
265
+ return found
266
+
267
+
268
+ def _swings(values, k: int, lows: bool) -> list[int]:
269
+ """Indexes of confirmed swing lows (or highs) with k bars each side. Strict
270
+ against the left side, so a flat bottom counts once (at its first bar)."""
271
+ out = []
272
+ for i in range(k, len(values) - k):
273
+ v, left, right = values[i], values[i - k:i], values[i + 1:i + k + 1]
274
+ if v != v: # NaN
275
+ continue
276
+ if lows and all(v < x for x in left) and all(v <= x for x in right):
277
+ out.append(i)
278
+ if not lows and all(v > x for x in left) and all(v >= x for x in right):
279
+ out.append(i)
280
+ return out
281
+
282
+
283
+ def divergence(df: pd.DataFrame, oscillator: pd.Series, lookback: int = 60, k: int = 3,
284
+ recent: int = 8) -> str | None:
285
+ """'bullish' when price makes a lower swing low while the oscillator makes a
286
+ higher low, 'bearish' for the mirror on swing highs. The latest swing must be
287
+ within `recent` bars of the end so the signal is actionable."""
288
+ d = df.tail(lookback)
289
+ osc = oscillator.reindex(d.index).to_numpy(dtype=float)
290
+ last = len(d) - 1
291
+ for lows, kind in ((True, "bullish"), (False, "bearish")):
292
+ price = d["low" if lows else "high"].to_numpy(dtype=float)
293
+ swings = [i for i in _swings(price, k, lows) if osc[i] == osc[i]]
294
+ if len(swings) < 2:
295
+ continue
296
+ a, b = swings[-2], swings[-1]
297
+ if last - b > recent or b - a < k:
298
+ continue
299
+ if lows and price[b] < price[a] and osc[b] > osc[a]:
300
+ return kind
301
+ if not lows and price[b] > price[a] and osc[b] < osc[a]:
302
+ return kind
303
+ return None
@@ -0,0 +1,74 @@
1
+ """Trade journal statistics (pure; computed from TradeRecord dicts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from typing import Optional
7
+
8
+
9
+ def trade_pnl(t: dict) -> Optional[float]:
10
+ if t.get("exit_price") is None:
11
+ return None
12
+ sign = -1 if t.get("side") == "short" else 1
13
+ return (t["exit_price"] - t["entry_price"]) * t["quantity"] * sign - (t.get("fees") or 0)
14
+
15
+
16
+ def r_multiple(t: dict) -> Optional[float]:
17
+ """Profit in units of the risk taken (entry-to-stop distance)."""
18
+ pnl = trade_pnl(t)
19
+ stop = t.get("stop")
20
+ if pnl is None or stop is None or stop == t["entry_price"]:
21
+ return None
22
+ risk = abs(t["entry_price"] - stop) * t["quantity"]
23
+ return pnl / risk if risk else None
24
+
25
+
26
+ def stats(trades: list[dict]) -> dict:
27
+ closed = sorted((t for t in trades if t.get("exit_price") is not None),
28
+ key=lambda t: t.get("exit_at") or t.get("entry_at"))
29
+ pnls = [trade_pnl(t) for t in closed]
30
+ wins = [p for p in pnls if p > 0]
31
+ losses = [p for p in pnls if p <= 0]
32
+ rs = [r for r in (r_multiple(t) for t in closed) if r is not None]
33
+
34
+ streak = worst_streak = 0
35
+ for p in pnls:
36
+ streak = streak + 1 if p <= 0 else 0
37
+ worst_streak = max(worst_streak, streak)
38
+
39
+ equity, running = [], 0.0
40
+ for t, p in zip(closed, pnls):
41
+ running += p
42
+ when = t.get("exit_at") or t.get("entry_at")
43
+ equity.append({"date": when.isoformat() if hasattr(when, "isoformat") else when, "pnl": round(running, 2)})
44
+
45
+ by_setup: dict[str, list[float]] = defaultdict(list)
46
+ for t, p in zip(closed, pnls):
47
+ by_setup[(t.get("setup") or "").strip() or "(none)"].append(p)
48
+
49
+ gross_loss = -sum(losses)
50
+ return {
51
+ "closed": len(closed),
52
+ "open": sum(1 for t in trades if t.get("exit_price") is None),
53
+ "wins": len(wins),
54
+ "losses": len(losses),
55
+ "win_rate": round(len(wins) / len(closed) * 100, 1) if closed else None,
56
+ "total_pnl": round(sum(pnls), 2),
57
+ "avg_win": round(sum(wins) / len(wins), 2) if wins else None,
58
+ "avg_loss": round(sum(losses) / len(losses), 2) if losses else None,
59
+ "profit_factor": round(sum(wins) / gross_loss, 2) if gross_loss > 0 else None,
60
+ "expectancy": round(sum(pnls) / len(pnls), 2) if pnls else None,
61
+ "avg_r": round(sum(rs) / len(rs), 2) if rs else None,
62
+ "best": round(max(pnls), 2) if pnls else None,
63
+ "worst": round(min(pnls), 2) if pnls else None,
64
+ "max_losing_streak": worst_streak,
65
+ "by_setup": sorted(
66
+ (
67
+ {"setup": s, "trades": len(v), "win_rate": round(sum(1 for p in v if p > 0) / len(v) * 100, 1),
68
+ "pnl": round(sum(v), 2)}
69
+ for s, v in by_setup.items()
70
+ ),
71
+ key=lambda r: -r["pnl"],
72
+ ),
73
+ "equity": equity,
74
+ }
@@ -0,0 +1,164 @@
1
+ """Nearest-neighbour (kNN) signal filters, written for MarketMyna from
2
+ general methods (k-nearest neighbours, Fisher's discriminant ratio, ATR
3
+ bands); not a port of any published script.
4
+
5
+ Everything is causal: at bar t the model only learns from rows whose
6
+ outcome was already known at t, so a backtest never sees the future.
7
+
8
+ analog_score for each bar, find the k most similar past market states
9
+ (trend, stretch, momentum, volatility, position in the bar's
10
+ range, short slope) and average what happened next
11
+ (+1 / -1, weighted by similarity). Features are weighted by
12
+ how well they separated up from down outcomes in the memory
13
+ (Fisher ratio), so useless features count less.
14
+ crossover_vote for moving-average crossovers: each past crossover is stored
15
+ with its ADX and MFI and whether the trade that followed
16
+ made money; a new crossover gets the sum of the votes of its
17
+ k nearest past crossovers.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from . import indicators as ind
26
+
27
+ FEATURES = ("trend", "stretch", "momentum", "volatility", "range_pos", "slope")
28
+
29
+
30
+ def features(df: pd.DataFrame) -> pd.DataFrame:
31
+ """Six bounded features per bar (each roughly in -1..1)."""
32
+ close, high, low = df["close"], df["high"], df["low"]
33
+ atr = ind.atr(df, 14).replace(0, np.nan)
34
+ trend = ((ind.ema(close, 10) - ind.ema(close, 34)) / atr).clip(-3, 3) / 3
35
+ basis, dev = close.rolling(30).mean(), close.rolling(30).std().replace(0, np.nan)
36
+ stretch = (-(close - basis) / dev).clip(-3, 3) / 3 # stretched up -> negative
37
+ momentum = ((close / close.shift(14) - 1) / 0.05).clip(-3, 3) / 3
38
+ vol = close.rolling(20).std()
39
+ lo, hi = vol.rolling(100).min(), vol.rolling(100).max()
40
+ volatility = (((vol - lo) / (hi - lo).replace(0, np.nan)).clip(0, 1) * 2 - 1)
41
+ rng = (high - low).replace(0, np.nan)
42
+ range_pos = (((close - low) / rng) * 2 - 1).clip(-1, 1)
43
+ slope = ((close - close.shift(3)) / atr).clip(-3, 3) / 3
44
+ return pd.DataFrame({"trend": trend, "stretch": stretch, "momentum": momentum, "volatility": volatility,
45
+ "range_pos": range_pos, "slope": slope}, index=df.index)
46
+
47
+
48
+ def outcomes(df: pd.DataFrame, horizon: int, atr_band: float) -> pd.Series:
49
+ """+1 / -1 when the close `horizon` bars later moved more than atr_band x ATR
50
+ up / down, 0 for a flat outcome (not learned from). Known only at t + horizon."""
51
+ move = df["close"].shift(-horizon) - df["close"]
52
+ band = atr_band * ind.atr(df, 14)
53
+ return pd.Series(np.where(move > band, 1.0, np.where(move < -band, -1.0, 0.0)), index=df.index).where(move.notna())
54
+
55
+
56
+ def fisher_weights(x: np.ndarray, y: np.ndarray, floor: float = 0.4) -> np.ndarray:
57
+ """Per-feature (mean_up - mean_down)^2 / (var_up + var_down), scaled so the
58
+ best feature gets 1; `floor` keeps every feature in play."""
59
+ up, dn = x[y > 0], x[y < 0]
60
+ if len(up) < 5 or len(dn) < 5:
61
+ return np.ones(x.shape[1])
62
+ f = (up.mean(0) - dn.mean(0)) ** 2 / (up.var(0) + dn.var(0) + 1e-9)
63
+ return np.maximum(floor, f / f.max()) if f.max() > 0 else np.ones(x.shape[1])
64
+
65
+
66
+ def analog_score(df: pd.DataFrame, horizon: int = 4, k: int = 9, memory: int = 400, atr_band: float = 0.45,
67
+ min_rows: int = 60, refit_every: int = 20) -> pd.DataFrame:
68
+ """Columns: score (-1..1, similarity-weighted average outcome of the k
69
+ nearest past states) and agree (share of that weight on the winning side)."""
70
+ feats = features(df)
71
+ out_lbl = outcomes(df, horizon, atr_band)
72
+ x_all = feats.to_numpy(dtype=float)
73
+ y_all = out_lbl.to_numpy(dtype=float)
74
+ n = len(df)
75
+ score = np.full(n, np.nan)
76
+ agree = np.full(n, np.nan)
77
+ weights = np.ones(len(FEATURES))
78
+ for t in range(n):
79
+ xt = x_all[t]
80
+ if np.isnan(xt).any():
81
+ continue
82
+ last_known = t - horizon # rows up to here have a known outcome at bar t
83
+ lo = max(0, last_known - memory + 1)
84
+ if last_known - lo + 1 < min_rows:
85
+ continue
86
+ xs, ys = x_all[lo:last_known + 1], y_all[lo:last_known + 1]
87
+ ok = ~np.isnan(xs).any(axis=1) & ~np.isnan(ys) & (ys != 0)
88
+ xs, ys = xs[ok], ys[ok]
89
+ if len(ys) < max(k, 10):
90
+ continue
91
+ if t % refit_every == 0:
92
+ weights = fisher_weights(xs, ys)
93
+ dist = (np.log1p(np.abs(xs - xt)) * weights).sum(axis=1)
94
+ idx = np.argpartition(dist, min(k, len(dist) - 1))[:k]
95
+ w = 1.0 / (1.0 + dist[idx])
96
+ s = float((ys[idx] * w).sum() / w.sum())
97
+ score[t] = s
98
+ side = ys[idx] > 0 if s >= 0 else ys[idx] < 0
99
+ agree[t] = float(w[side].sum() / w.sum())
100
+ return pd.DataFrame({"score": score, "agree": agree}, index=df.index)
101
+
102
+
103
+ def adaptive_supertrend(df: pd.DataFrame, confidence: pd.Series, period: int = 10, multiplier: float = 2.0,
104
+ adapt: float = 0.8) -> tuple[pd.Series, pd.Series]:
105
+ """Supertrend whose band widens when the model is unsure (confidence near 0)
106
+ and tightens toward `multiplier` when it is sure: mult x (1 + adapt x (1 - conf))."""
107
+ close = df["close"].to_numpy(dtype=float)
108
+ hl2 = ((df["high"] + df["low"]) / 2).to_numpy(dtype=float)
109
+ atr = ind.atr(df, period).to_numpy(dtype=float)
110
+ conf = confidence.fillna(0).clip(0, 1).to_numpy(dtype=float)
111
+ mult = multiplier * (1 + adapt * (1 - conf))
112
+ n = len(df)
113
+ line = np.full(n, np.nan)
114
+ direction = np.zeros(n)
115
+ up = dn = np.nan
116
+ d = 1
117
+ for i in range(n):
118
+ if np.isnan(atr[i]):
119
+ continue
120
+ lower, upper = hl2[i] - mult[i] * atr[i], hl2[i] + mult[i] * atr[i]
121
+ up = lower if np.isnan(up) or close[i - 1] <= up else max(lower, up)
122
+ dn = upper if np.isnan(dn) or close[i - 1] >= dn else min(upper, dn)
123
+ if d == -1 and close[i] > dn:
124
+ d = 1
125
+ elif d == 1 and close[i] < up:
126
+ d = -1
127
+ direction[i] = d
128
+ line[i] = up if d == 1 else dn
129
+ return pd.Series(line, index=df.index), pd.Series(direction, index=df.index)
130
+
131
+
132
+ def vwma(df: pd.DataFrame, period: int) -> pd.Series:
133
+ vol = df["volume"].astype(float)
134
+ return (df["close"] * vol).rolling(period).sum() / vol.rolling(period).sum().replace(0, np.nan)
135
+
136
+
137
+ def crossover_vote(df: pd.DataFrame, fast: int = 13, slow: int = 19, k: int = 23, filter_len: int = 13) -> pd.DataFrame:
138
+ """For every bar: cross (+1 up / -1 down / 0) and, on cross bars, the vote of
139
+ the k nearest past crossovers in (ADX, MFI) space. A past crossover's label
140
+ is +1 when price at the following crossover was higher (the move it started
141
+ went up), known only once that following crossover has happened."""
142
+ f, s = vwma(df, fast), vwma(df, slow)
143
+ above = (f > s).astype(int)
144
+ cross = above.diff().fillna(0)
145
+ adx = ind.adx(df, filter_len)["adx"]
146
+ mfi = ind.mfi(df, filter_len)
147
+ close = df["close"].to_numpy(dtype=float)
148
+ feats: list[tuple[float, float]] = []
149
+ labels: list[float] = []
150
+ prev = None # (adx, mfi, price) of the last crossover, label pending
151
+ vote = np.full(len(df), np.nan)
152
+ for i, c in enumerate(cross.to_numpy()):
153
+ if c == 0 or np.isnan(adx.iloc[i]) or np.isnan(mfi.iloc[i]):
154
+ continue
155
+ a, m = float(adx.iloc[i]) / 100, float(mfi.iloc[i]) / 100
156
+ if feats:
157
+ d = np.hypot(np.array([p[0] for p in feats]) - a, np.array([p[1] for p in feats]) - m)
158
+ idx = np.argsort(d)[:k]
159
+ vote[i] = float(np.array(labels)[idx].sum())
160
+ if prev is not None: # the previous crossover's outcome is known now
161
+ feats.append((prev[0], prev[1]))
162
+ labels.append(1.0 if close[i] >= prev[2] else -1.0)
163
+ prev = (a, m, close[i])
164
+ return pd.DataFrame({"cross": cross, "vote": vote}, index=df.index)
@@ -0,0 +1,109 @@
1
+ """NSE F&O lot sizes from brokers' public instrument files.
2
+
3
+ NSE only publishes lot sizes as a PDF and revises them every few months,
4
+ so they're read from Upstox's public instrument dump (Kite's as a
5
+ fallback), cached on disk for a day. Lot sizes can differ by expiry around
6
+ a revision, hence {symbol: {expiry: lot}}."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import gzip
12
+ import io
13
+ import json
14
+ import logging
15
+ import threading
16
+ import time
17
+ from datetime import date, datetime
18
+ from pathlib import Path
19
+ from typing import Optional
20
+ from zoneinfo import ZoneInfo
21
+
22
+ from marketmyna.core.home import app_home
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ UPSTOX_URL = "https://assets.upstox.com/market-quote/instruments/exchange/NSE.json.gz"
27
+ KITE_URL = "https://api.kite.trade/instruments/NFO"
28
+ MAX_AGE = 24 * 3600
29
+ IST = ZoneInfo("Asia/Kolkata")
30
+
31
+ _lock = threading.Lock()
32
+ _memory: Optional[tuple[float, dict]] = None
33
+
34
+
35
+ def _cache_path() -> Path:
36
+ return app_home() / "cache" / "fo_lots.json"
37
+
38
+
39
+ def _from_upstox() -> dict[str, dict[str, int]]:
40
+ import requests
41
+
42
+ r = requests.get(UPSTOX_URL, timeout=30)
43
+ r.raise_for_status()
44
+ out: dict[str, dict[str, int]] = {}
45
+ for d in json.loads(gzip.decompress(r.content)):
46
+ if d.get("segment") != "NSE_FO" or not d.get("lot_size"):
47
+ continue
48
+ sym = (d.get("underlying_symbol") or d.get("name") or "").upper()
49
+ exp = datetime.fromtimestamp(d["expiry"] / 1000, IST).date().isoformat() if d.get("expiry") else ""
50
+ out.setdefault(sym, {})[exp] = int(d["lot_size"])
51
+ return out
52
+
53
+
54
+ def _from_kite() -> dict[str, dict[str, int]]:
55
+ import requests
56
+
57
+ r = requests.get(KITE_URL, timeout=30)
58
+ r.raise_for_status()
59
+ out: dict[str, dict[str, int]] = {}
60
+ for row in csv.DictReader(io.StringIO(r.text)):
61
+ if row.get("lot_size") and row.get("name"):
62
+ out.setdefault(row["name"].upper(), {})[row.get("expiry", "")] = int(float(row["lot_size"]))
63
+ return out
64
+
65
+
66
+ def load(force: bool = False) -> dict[str, dict[str, int]]:
67
+ global _memory
68
+ with _lock:
69
+ if not force and _memory and time.time() - _memory[0] < MAX_AGE:
70
+ return _memory[1]
71
+ path = _cache_path()
72
+ if not force and path.exists() and time.time() - path.stat().st_mtime < MAX_AGE:
73
+ try:
74
+ data = json.loads(path.read_text(encoding="utf-8"))
75
+ _memory = (path.stat().st_mtime, data)
76
+ return data
77
+ except ValueError:
78
+ pass
79
+ data: dict = {}
80
+ for source in (_from_upstox, _from_kite):
81
+ try:
82
+ data = source()
83
+ if data:
84
+ break
85
+ except Exception as exc:
86
+ logger.warning("Lot sizes from %s failed: %s", source.__name__, exc)
87
+ if data:
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ path.write_text(json.dumps(data), encoding="utf-8")
90
+ _memory = (time.time(), data)
91
+ return data
92
+ if path.exists(): # stale beats nothing
93
+ return json.loads(path.read_text(encoding="utf-8"))
94
+ return {}
95
+
96
+
97
+ def lot_size(symbol: str, expiry: Optional[date] = None, data: Optional[dict] = None) -> Optional[int]:
98
+ """Lot for `symbol` at `expiry` (or its nearest listed expiry)."""
99
+ by_expiry = (data if data is not None else load()).get(symbol.upper())
100
+ if not by_expiry:
101
+ return None
102
+ if expiry is not None and expiry.isoformat() in by_expiry:
103
+ return by_expiry[expiry.isoformat()]
104
+ dated = sorted(k for k in by_expiry if k)
105
+ if not dated:
106
+ return next(iter(by_expiry.values()))
107
+ target = (expiry or date.today()).isoformat()
108
+ upcoming = [k for k in dated if k >= target]
109
+ return by_expiry[(upcoming or dated)[0] if upcoming else dated[-1]]