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,317 @@
1
+ """Strategy backtester.
2
+
3
+ Rules that keep results honest:
4
+ - signals are computed on a bar's close and filled at the NEXT bar's open
5
+ (no look-ahead);
6
+ - stops / targets are checked inside each bar with its high/low; if both
7
+ could have been hit in the same bar the stop is assumed first;
8
+ - a gap through the stop fills at the open, not at the stop;
9
+ - every fill pays slippage and costs (commission + taxes) per side.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import itertools
15
+ import math
16
+ from dataclasses import dataclass, field
17
+ from typing import Optional
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from . import indicators as ind
23
+
24
+ # id -> label, description, default params, optimisation grid
25
+ STRATEGIES: dict[str, dict] = {
26
+ "ema_cross": {
27
+ "label": "EMA crossover",
28
+ "description": "Buy when the fast EMA crosses above the slow EMA; exit on the cross back.",
29
+ "params": {"fast": 9, "slow": 21},
30
+ "grid": {"fast": [5, 9, 12, 20], "slow": [21, 30, 50, 100]},
31
+ },
32
+ "breakout": {
33
+ "label": "Donchian breakout",
34
+ "description": "Buy a close above the N-day high; exit on a close below the M-day low.",
35
+ "params": {"entry_days": 20, "exit_days": 10},
36
+ "grid": {"entry_days": [10, 20, 55], "exit_days": [5, 10, 20]},
37
+ },
38
+ "rsi_reversion": {
39
+ "label": "RSI mean reversion",
40
+ "description": "Buy when RSI(14) closes below the lower level; exit above the upper level.",
41
+ "params": {"buy_below": 30, "sell_above": 55},
42
+ "grid": {"buy_below": [20, 25, 30, 35], "sell_above": [50, 55, 60, 70]},
43
+ },
44
+ "supertrend": {
45
+ "label": "Supertrend",
46
+ "description": "Long while Supertrend is up; exit when it turns down.",
47
+ "params": {"period": 10, "multiplier": 3.0},
48
+ "grid": {"period": [7, 10, 14], "multiplier": [2.0, 2.5, 3.0, 4.0]},
49
+ },
50
+ "sma_trend": {
51
+ "label": "Moving-average trend",
52
+ "description": "Long while the close is above its N-bar simple moving average.",
53
+ "params": {"period": 200},
54
+ "grid": {"period": [20, 50, 100, 150, 200]},
55
+ },
56
+ "vwma_cross": {
57
+ "label": "VWMA crossover",
58
+ "description": "Buy when the fast volume-weighted moving average crosses above the slow one; exit on the "
59
+ "cross back. Volume-weighting gives heavy-volume days more say.",
60
+ "params": {"fast": 13, "slow": 19},
61
+ "grid": {"fast": [9, 13, 20], "slow": [19, 30, 50]},
62
+ },
63
+ "vwma_knn": {
64
+ "label": "VWMA crossover + kNN filter (experimental)",
65
+ "description": "The VWMA crossover, taken only when its nearest past crossovers (by ADX trend strength and "
66
+ "MFI money flow) mostly went the right way. Experimental: on 19 large Indian and US stocks "
67
+ "(10 years, judged on the unseen last 3) the filter cut returns from 13.9% to 1.6% on average "
68
+ "and beat the plain crossover on only 4. Test it on your stock before trusting it.",
69
+ "params": {"fast": 13, "slow": 19, "k": 23, "min_vote": 2, "use_filter": 1},
70
+ "grid": {"fast": [9, 13, 20], "slow": [19, 30, 50], "min_vote": [0, 2, 5]},
71
+ },
72
+ "knn_supertrend": {
73
+ "label": "kNN-filtered Supertrend (experimental)",
74
+ "description": "An adaptive Supertrend (band tightens when a nearest-neighbour model of similar past market "
75
+ "states is confident) taken only when that model leans up. Experimental: on 19 large stocks "
76
+ "(unseen last 3 of 10 years) it averaged -2.3% against +9.4% for a plain Supertrend and beat "
77
+ "it on 6. Test it on your stock before trusting it.",
78
+ "params": {"period": 10, "multiplier": 2.0, "min_score": 0.15, "k": 9, "horizon": 4},
79
+ "grid": {"multiplier": [1.5, 2.0, 3.0], "min_score": [0.0, 0.15, 0.3], "horizon": [4, 8]},
80
+ },
81
+ }
82
+
83
+ BARS_PER_YEAR = {"1d": 252, "1wk": 52, "1h": 252 * 7}
84
+
85
+
86
+ @dataclass
87
+ class Settings:
88
+ strategy: str = "ema_cross"
89
+ params: dict = field(default_factory=dict)
90
+ capital: float = 100000.0
91
+ sizing: str = "percent" # percent (of equity) | risk (% of equity lost at the stop)
92
+ position_pct: float = 100.0
93
+ risk_pct: float = 1.0
94
+ stop_type: str = "none" # none | percent | atr
95
+ stop_value: float = 2.0 # % or ATR multiple
96
+ target_r: Optional[float] = None # take profit at N x the stop distance
97
+ cost_pct: float = 0.1 # per side: commission + taxes
98
+ slippage_pct: float = 0.05 # per side
99
+ allow_short: bool = False
100
+
101
+
102
+ def signals(df: pd.DataFrame, strategy: str, p: dict) -> tuple[pd.Series, pd.Series]:
103
+ """(long_entry, long_exit) booleans evaluated on each bar's close.
104
+ Short trades use the mirror image: entry = long_exit, exit = long_entry."""
105
+ close = df["close"]
106
+ if strategy == "ema_cross":
107
+ fast, slow = ind.ema(close, int(p["fast"])), ind.ema(close, int(p["slow"]))
108
+ above = fast > slow
109
+ warm = pd.Series(np.arange(len(df)) >= int(p["slow"]), index=df.index)
110
+ return above & ~above.shift(1, fill_value=False) & warm, ~above & above.shift(1, fill_value=False) & warm
111
+ if strategy == "breakout":
112
+ hi = ind.donchian(df, int(p["entry_days"]))["upper"]
113
+ lo = ind.donchian(df, int(p["exit_days"]))["lower"]
114
+ return close > hi, close < lo
115
+ if strategy == "rsi_reversion":
116
+ r = ind.rsi(close)
117
+ return r < float(p["buy_below"]), r > float(p["sell_above"])
118
+ if strategy == "supertrend":
119
+ _, d = ind.supertrend(df, int(p["period"]), float(p["multiplier"]))
120
+ warm = pd.Series(np.arange(len(df)) > int(p["period"]), index=df.index)
121
+ return (d == 1) & (d.shift(1) == -1) & warm, (d == -1) & (d.shift(1) == 1) & warm
122
+ if strategy == "vwma_cross":
123
+ if "volume" not in df or df["volume"].fillna(0).sum() == 0:
124
+ raise ValueError("This strategy needs volume data")
125
+ from . import knn
126
+
127
+ f, sl = knn.vwma(df, int(p["fast"])), knn.vwma(df, int(p["slow"]))
128
+ above = f > sl
129
+ warm = sl.notna()
130
+ return above & ~above.shift(1, fill_value=False) & warm, ~above & above.shift(1, fill_value=False) & warm
131
+ if strategy == "vwma_knn":
132
+ if "volume" not in df or df["volume"].fillna(0).sum() == 0:
133
+ raise ValueError("This strategy needs volume data")
134
+ from . import knn
135
+
136
+ v = knn.crossover_vote(df, int(p["fast"]), int(p["slow"]), int(p["k"]))
137
+ ok = v["vote"] >= float(p["min_vote"]) if int(p.get("use_filter", 1)) else True
138
+ return (v["cross"] == 1) & ok, v["cross"] == -1
139
+ if strategy == "knn_supertrend":
140
+ from . import knn
141
+
142
+ a = knn.analog_score(df, horizon=int(p["horizon"]), k=int(p["k"]))
143
+ _, d = knn.adaptive_supertrend(df, a["agree"], int(p["period"]), float(p["multiplier"]))
144
+ state = (d == 1) & (a["score"] >= float(p["min_score"]))
145
+ return state & ~state.shift(1, fill_value=False), (d == -1) & (d.shift(1) == 1)
146
+ if strategy == "sma_trend":
147
+ sma = close.rolling(int(p["period"])).mean()
148
+ above = close > sma
149
+ return above & ~above.shift(1, fill_value=False) & sma.notna(), ~above & sma.notna()
150
+ raise ValueError(f"Unknown strategy '{strategy}'")
151
+
152
+
153
+ def run(df: pd.DataFrame, s: Settings, interval: str = "1d") -> dict:
154
+ cols = ["open", "high", "low", "close"] + (["volume"] if "volume" in df else [])
155
+ df = df[cols].astype(float).dropna(subset=["open", "high", "low", "close"])
156
+ n = len(df)
157
+ params = {**STRATEGIES[s.strategy]["params"], **(s.params or {})}
158
+ long_in, long_out = (x.to_numpy() for x in signals(df, s.strategy, params))
159
+ atr = ind.atr(df, 14).to_numpy()
160
+ o, h, l, c = (df[k].to_numpy() for k in ("open", "high", "low", "close"))
161
+ times = df.index
162
+ slip, cost = s.slippage_pct / 100, s.cost_pct / 100
163
+
164
+ # Cash holds realised equity; open trades are marked to market, so long
165
+ # and short are symmetric: equity = cash + (price - entry) * qty * side.
166
+ cash, qty, side = s.capital, 0.0, 0 # side 1 long, -1 short
167
+ entry_px = stop = target = risk_per_unit = None
168
+ entry_i = 0
169
+ entry_fee = 0.0
170
+ pending: Optional[int] = None # +1 open long, -1 open short, 0 close
171
+ trades: list[dict] = []
172
+ equity = np.empty(n)
173
+ bars_in = 0
174
+
175
+ def close_position(i: int, px: float, reason: str) -> None:
176
+ nonlocal cash, qty, side, entry_px, stop, target, risk_per_unit
177
+ fill = px * (1 - slip * side)
178
+ exit_fee = qty * fill * cost
179
+ gross = (fill - entry_px) * qty * side
180
+ cash += gross - exit_fee
181
+ pnl = gross - exit_fee - entry_fee
182
+ trades.append({
183
+ "side": "long" if side == 1 else "short",
184
+ "entry_time": times[entry_i].isoformat(), "exit_time": times[i].isoformat(),
185
+ "entry": round(entry_px, 4), "exit": round(fill, 4), "quantity": round(qty, 6),
186
+ "pnl": round(pnl, 2), "return_pct": round((fill / entry_px - 1) * 100 * side, 2),
187
+ "r": round(pnl / (risk_per_unit * qty), 2) if risk_per_unit and qty else None,
188
+ "bars": i - entry_i, "reason": reason,
189
+ })
190
+ qty, side, entry_px, stop, target, risk_per_unit = 0.0, 0, None, None, None, None
191
+
192
+ for i in range(n):
193
+ # 1) fill the previous bar's signal at this bar's open
194
+ if pending is not None:
195
+ if side != 0 and pending != side:
196
+ close_position(i, o[i], "signal")
197
+ if pending in (1, -1) and side == 0:
198
+ price_in = o[i] * (1 + slip * pending)
199
+ dist = None
200
+ if s.stop_type == "percent":
201
+ dist = price_in * s.stop_value / 100
202
+ elif s.stop_type == "atr" and i > 0 and not math.isnan(atr[i - 1]):
203
+ dist = atr[i - 1] * s.stop_value
204
+ if s.sizing == "risk" and dist:
205
+ units = cash * s.risk_pct / 100 / dist
206
+ else:
207
+ units = cash * s.position_pct / 100 / price_in
208
+ units = min(units, cash / (price_in * (1 + cost))) # no leverage
209
+ if units > 0:
210
+ side, qty, entry_px, entry_i, risk_per_unit = pending, units, price_in, i, dist
211
+ stop = entry_px - dist * side if dist else None
212
+ target = entry_px + dist * s.target_r * side if dist and s.target_r else None
213
+ entry_fee = qty * entry_px * cost
214
+ cash -= entry_fee
215
+ pending = None
216
+
217
+ # 2) stop / target inside the bar (stop first when both are possible)
218
+ if side != 0:
219
+ if stop is not None and ((side == 1 and l[i] <= stop) or (side == -1 and h[i] >= stop)):
220
+ close_position(i, min(o[i], stop) if side == 1 else max(o[i], stop), "stop")
221
+ elif target is not None and ((side == 1 and h[i] >= target) or (side == -1 and l[i] <= target)):
222
+ close_position(i, max(o[i], target) if side == 1 else min(o[i], target), "target")
223
+
224
+ # 3) signals on this close, filled at the next open
225
+ if i < n - 1:
226
+ if side == 1 and long_out[i]:
227
+ pending = -1 if s.allow_short else 0
228
+ elif side == -1 and long_in[i]:
229
+ pending = 1
230
+ elif side == 0:
231
+ if long_in[i]:
232
+ pending = 1
233
+ elif s.allow_short and long_out[i]:
234
+ pending = -1
235
+
236
+ if side != 0:
237
+ bars_in += 1
238
+ equity[i] = cash + ((c[i] - entry_px) * qty * side if side != 0 else 0.0)
239
+
240
+ if side != 0:
241
+ close_position(n - 1, c[-1], "end of test")
242
+ equity[-1] = cash
243
+
244
+ return _report(df, equity, trades, s, interval, bars_in)
245
+
246
+
247
+ def _report(df: pd.DataFrame, equity: np.ndarray, trades: list[dict], s: Settings, interval: str, bars_in: int) -> dict:
248
+ n = len(df)
249
+ eq = pd.Series(equity, index=df.index)
250
+ peak = eq.cummax()
251
+ dd = (eq / peak - 1) * 100
252
+ rets = eq.pct_change().dropna()
253
+ per_year = BARS_PER_YEAR.get(interval, 252)
254
+ years = n / per_year if per_year else 0
255
+ total = (eq.iloc[-1] / s.capital - 1) * 100
256
+ pnls = [t["pnl"] for t in trades]
257
+ wins = [p for p in pnls if p > 0]
258
+ losses = [p for p in pnls if p <= 0]
259
+ rs = [t["r"] for t in trades if t["r"] is not None]
260
+ step = max(1, n // 500)
261
+ close = df["close"]
262
+ return {
263
+ "metrics": {
264
+ "total_return": round(total, 2),
265
+ "cagr": round(((eq.iloc[-1] / s.capital) ** (1 / years) - 1) * 100, 2) if years > 0 and eq.iloc[-1] > 0 else None,
266
+ "max_drawdown": round(float(dd.min()), 2),
267
+ "sharpe": round(float(rets.mean() / rets.std() * math.sqrt(per_year)), 2) if len(rets) > 2 and rets.std() > 0 else None,
268
+ "trades": len(trades),
269
+ "win_rate": round(len(wins) / len(trades) * 100, 1) if trades else None,
270
+ "profit_factor": round(sum(wins) / -sum(losses), 2) if losses and sum(losses) < 0 else None,
271
+ "avg_trade_pct": round(float(np.mean([t["return_pct"] for t in trades])), 2) if trades else None,
272
+ "avg_r": round(float(np.mean(rs)), 2) if rs else None,
273
+ "best_trade": max(pnls) if pnls else None,
274
+ "worst_trade": min(pnls) if pnls else None,
275
+ "avg_bars": round(float(np.mean([t["bars"] for t in trades])), 1) if trades else None,
276
+ "exposure": round(bars_in / n * 100, 1) if n else None,
277
+ "buy_hold_return": round((close.iloc[-1] / close.iloc[0] - 1) * 100, 2),
278
+ "final_equity": round(float(eq.iloc[-1]), 2),
279
+ "start": df.index[0].isoformat(), "end": df.index[-1].isoformat(), "bars": n,
280
+ },
281
+ "equity": [
282
+ {"time": t.isoformat(), "equity": round(float(v), 2), "buy_hold": round(float(s.capital * close.loc[t] / close.iloc[0]), 2),
283
+ "drawdown": round(float(d), 2)}
284
+ for t, v, d in list(zip(eq.index, eq.to_numpy(), dd.to_numpy()))[::step]
285
+ ],
286
+ "trades": trades,
287
+ }
288
+
289
+
290
+ def split_test(df: pd.DataFrame, s: Settings, interval: str, train_frac: float = 0.7) -> dict:
291
+ """Same settings on the first 70% (in-sample) and the last 30% (out-of-sample)."""
292
+ cut = int(len(df) * train_frac)
293
+ return {"train": run(df.iloc[:cut], s, interval)["metrics"], "test": run(df.iloc[cut:], s, interval)["metrics"]}
294
+
295
+
296
+ def optimize(df: pd.DataFrame, s: Settings, interval: str, objective: str = "sharpe", train_frac: float = 0.7,
297
+ min_trades: int = 3) -> dict:
298
+ """Grid-search the strategy's parameters on the training part only,
299
+ then report the winner on the unseen test part (walk-forward check)."""
300
+ grid = STRATEGIES[s.strategy]["grid"]
301
+ keys = list(grid)
302
+ cut = int(len(df) * train_frac)
303
+ train, test = df.iloc[:cut], df.iloc[cut:]
304
+ results = []
305
+ for combo in itertools.product(*(grid[k] for k in keys)):
306
+ p = dict(zip(keys, combo))
307
+ if s.strategy == "ema_cross" and p["fast"] >= p["slow"]:
308
+ continue
309
+ m = run(train, Settings(**{**s.__dict__, "params": p}), interval)["metrics"]
310
+ score = m.get(objective)
311
+ results.append({"params": p, "score": score if m["trades"] >= min_trades else None, **{k: m[k] for k in
312
+ ("total_return", "sharpe", "max_drawdown", "trades", "win_rate", "profit_factor")}})
313
+ ranked = sorted((r for r in results if r["score"] is not None), key=lambda r: -r["score"])
314
+ best = ranked[0]["params"] if ranked else STRATEGIES[s.strategy]["params"]
315
+ test_metrics = run(test, Settings(**{**s.__dict__, "params": best}), interval)["metrics"]
316
+ return {"objective": objective, "best": best, "train": ranked[0] if ranked else None, "test": test_metrics,
317
+ "grid": results, "keys": keys}
@@ -0,0 +1,82 @@
1
+ """Trading charges per market and product (approximate public schedules;
2
+ the broker's contract note is final). One implementation for the position
3
+ sizer, the journal's automatic fees and the backtester's presets.
4
+
5
+ India: discount-broker schedule (brokerage ₹20 or 0.03%, whichever is lower;
6
+ 0 for delivery) plus statutory STT, exchange transaction charge, SEBI fee,
7
+ stamp duty and 18% GST. Rates as of 2025-26.
8
+ US: commission (default 0) plus SEC fee and FINRA TAF on sells."""
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+ RATES_AS_OF = "2025-26"
15
+
16
+ INDIA = {
17
+ # product: (brokerage % per order, brokerage cap ₹, STT buy %, STT sell %, exchange %, stamp buy %)
18
+ "intraday": (0.03, 20.0, 0.0, 0.025, 0.00297, 0.003),
19
+ "delivery": (0.0, 0.0, 0.1, 0.1, 0.00297, 0.015),
20
+ "futures": (0.03, 20.0, 0.0, 0.02, 0.00173, 0.002),
21
+ # options: premium turnover; brokerage is a flat ₹20 per order
22
+ "options": (None, 20.0, 0.0, 0.1, 0.03503, 0.003),
23
+ }
24
+ SEBI_PCT = 0.0001
25
+ GST_PCT = 18.0
26
+
27
+ US_SEC_PER_MILLION = 27.80
28
+ US_TAF_PER_SHARE = 0.000166
29
+ US_TAF_CAP = 8.30
30
+
31
+
32
+ def india(buy_value: float, sell_value: float, product: str) -> dict:
33
+ pct_brk, cap, stt_buy, stt_sell, exch, stamp = INDIA[product]
34
+ if pct_brk is None:
35
+ brokerage = (cap if buy_value else 0) + (cap if sell_value else 0)
36
+ else:
37
+ brokerage = sum(min(cap, v * pct_brk / 100) for v in (buy_value, sell_value) if v)
38
+ turnover = buy_value + sell_value
39
+ stt = buy_value * stt_buy / 100 + sell_value * stt_sell / 100
40
+ exchange = turnover * exch / 100
41
+ sebi = turnover * SEBI_PCT / 100
42
+ stamp_duty = buy_value * stamp / 100
43
+ gst = (brokerage + exchange + sebi) * GST_PCT / 100
44
+ return {"Brokerage": brokerage, "STT": stt, "Exchange txn": exchange, "SEBI": sebi,
45
+ "Stamp duty": stamp_duty, "GST": gst}
46
+
47
+
48
+ def us(buy_value: float, sell_value: float, quantity: float, commission_pct: float, flat: float) -> dict:
49
+ orders = (1 if buy_value else 0) + (1 if sell_value else 0)
50
+ return {
51
+ "Commission": (buy_value + sell_value) * commission_pct / 100 + flat * orders,
52
+ "SEC fee": sell_value * US_SEC_PER_MILLION / 1_000_000,
53
+ "FINRA TAF": min(US_TAF_CAP, quantity * US_TAF_PER_SHARE) if sell_value else 0.0,
54
+ }
55
+
56
+
57
+ def custom(buy_value: float, sell_value: float, commission_pct: float, flat: float) -> dict:
58
+ orders = (1 if buy_value else 0) + (1 if sell_value else 0)
59
+ return {"Commission %": (buy_value + sell_value) * commission_pct / 100, "Flat fees": flat * orders}
60
+
61
+
62
+ def estimate(market: str, buy_value: float, sell_value: float, quantity: float = 0, product: Optional[str] = None,
63
+ commission_pct: float = 0.0, flat: float = 0.0) -> dict:
64
+ """Charges for one buy and/or one sell. Pass 0 for a side not traded
65
+ yet (an open position pays only the entry side)."""
66
+ if market == "india":
67
+ parts = india(buy_value, sell_value, product if product in INDIA else "delivery")
68
+ elif market in ("us", "global"):
69
+ parts = us(buy_value, sell_value, quantity, commission_pct, flat)
70
+ else:
71
+ parts = custom(buy_value, sell_value, commission_pct, flat)
72
+ parts = {k: round(v, 2) for k, v in parts.items()}
73
+ return {"total": round(sum(parts.values()), 2), "parts": parts, "rates_as_of": RATES_AS_OF}
74
+
75
+
76
+ def for_trade(t: dict, exit_price: Optional[float] = None) -> float:
77
+ """Charges for a journal trade: entry side, plus exit side once closed."""
78
+ qty = t["quantity"]
79
+ entry_value = t["entry_price"] * qty
80
+ exit_value = (exit_price if exit_price is not None else t.get("exit_price") or 0) * qty
81
+ buy, sell = (entry_value, exit_value) if t.get("side", "long") == "long" else (exit_value, entry_value)
82
+ return estimate(t["market"], buy, sell, qty, t.get("product"))["total"]
@@ -0,0 +1,197 @@
1
+ """Chart payload for the Trade page: bars plus indicator series, pivots and
2
+ opening range, all computed here (marketmyna/trading/indicators.py)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import math
7
+
8
+ import pandas as pd
9
+
10
+ from ..core.constants import TimeFrame
11
+ from . import indicators as ind
12
+
13
+ INTERVALS = ("1m", "5m", "15m", "30m", "1h", "1d", "1wk")
14
+ INTRADAY = {"1m", "5m", "15m", "30m", "1h"}
15
+ # Longest history Yahoo serves per bar size (and a sensible default).
16
+ MAX_PERIOD = {"1m": "5d", "5m": "1mo", "15m": "1mo", "30m": "1mo", "1h": "6mo"}
17
+ DEFAULT_PERIOD = {"1m": "1d", "5m": "5d", "15m": "5d", "30m": "1mo", "1h": "3mo", "1d": "1y", "1wk": "5y"}
18
+ PERIOD_ORDER = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y"]
19
+ INDICATORS = ("vwap", "ema9", "ema21", "ema50", "ema200", "supertrend", "vwma", "knn_st")
20
+
21
+
22
+ def clamp_period(interval: str, period: str | None) -> str:
23
+ period = period if period in PERIOD_ORDER else DEFAULT_PERIOD[interval]
24
+ limit = MAX_PERIOD.get(interval)
25
+ if limit and PERIOD_ORDER.index(period) > PERIOD_ORDER.index(limit):
26
+ return limit
27
+ return period
28
+
29
+
30
+ def _times(index: pd.Index) -> list[int]:
31
+ """Bar times as exchange wall-clock seconds, so the chart's axis shows
32
+ 9:15 for an NSE open whatever the viewer's timezone."""
33
+ idx = pd.DatetimeIndex(index)
34
+ if idx.tz is not None:
35
+ idx = idx.tz_localize(None)
36
+ return [int(t.value // 1_000_000_000) for t in idx]
37
+
38
+
39
+ def _series(times: list[int], values: pd.Series) -> list[dict]:
40
+ return [
41
+ {"time": t, "value": round(float(v), 6)}
42
+ for t, v in zip(times, values.to_numpy())
43
+ if v is not None and not (isinstance(v, float) and math.isnan(v))
44
+ ]
45
+
46
+
47
+ def normalize(df: pd.DataFrame) -> pd.DataFrame:
48
+ df = df.rename(columns=str.lower)
49
+ for col in ("open", "high", "low"):
50
+ if col not in df:
51
+ df[col] = df["close"]
52
+ if "volume" not in df:
53
+ df["volume"] = 0.0
54
+ df = df[["open", "high", "low", "close", "volume"]].astype(float)
55
+ df = df[~df.index.duplicated(keep="last")].sort_index()
56
+ return df.dropna(subset=["close"])
57
+
58
+
59
+ def _vwma_knn(df: pd.DataFrame, times: list[int], series: dict) -> list[dict]:
60
+ """VWMA 13 / 19 lines; each crossover marked with its kNN vote (experimental:
61
+ tested on 19 stocks it had no out-of-sample edge over the plain crossover):
62
+ solid when the filter would take it."""
63
+ from . import knn
64
+
65
+ fast, slow = knn.vwma(df, 13), knn.vwma(df, 19)
66
+ series["vwma13"] = _series(times, fast)
67
+ series["vwma19"] = _series(times, slow)
68
+ v = knn.crossover_vote(df, 13, 19, 23)
69
+ out = []
70
+ for t, c, vote in zip(times, v["cross"].to_numpy(), v["vote"].to_numpy()):
71
+ if c == 0 or (isinstance(vote, float) and math.isnan(vote)):
72
+ continue
73
+ up = c > 0
74
+ take = vote >= 2 if up else vote <= -2
75
+ out.append({"time": t, "kind": ("buy" if up else "sell") + ("" if take else "_skip"),
76
+ "text": f"kNN {vote:+.0f}"})
77
+ return out
78
+
79
+
80
+ def _knn_supertrend(df: pd.DataFrame, times: list[int], series: dict) -> tuple[list[dict], dict]:
81
+ """Adaptive Supertrend legs and the filtered strategy's entries / exits
82
+ (experimental, same rules as the backtest strategy knn_supertrend)."""
83
+ from . import knn
84
+
85
+ a = knn.analog_score(df)
86
+ line, d = knn.adaptive_supertrend(df, a["agree"], 10, 2.0)
87
+ series["knn_st_up"] = _series(times, line.where(d == 1))
88
+ series["knn_st_down"] = _series(times, line.where(d == -1))
89
+ state = (d == 1) & (a["score"] >= 0.15)
90
+ entry = (state & ~state.shift(1, fill_value=False)).to_numpy()
91
+ exit_ = ((d == -1) & (d.shift(1) == 1)).to_numpy()
92
+ out = [{"time": t, "kind": "buy", "text": "kNN buy"} for t, e in zip(times, entry) if e]
93
+ out += [{"time": t, "kind": "sell", "text": "exit"} for t, x in zip(times, exit_) if x]
94
+ last = a.dropna().iloc[-1] if a["score"].notna().any() else None
95
+ now = None if last is None else {"score": round(float(last["score"]), 3), "agree": round(float(last["agree"]), 3),
96
+ "trend": "up" if d.iloc[-1] == 1 else "down"}
97
+ return out, now
98
+
99
+
100
+ def build_chart(df: pd.DataFrame, interval: str, wanted: list[str]) -> dict:
101
+ df = normalize(df)
102
+ if df.empty:
103
+ return {"bars": [], "series": {}, "markers": [], "knn": None, "pivots": None, "opening_range": None, "last": None}
104
+ times = _times(df.index)
105
+ bars = [
106
+ {"time": t, "open": o, "high": h, "low": low, "close": c, "volume": v}
107
+ for t, o, h, low, c, v in zip(times, *(df[k].round(6).tolist() for k in ("open", "high", "low", "close", "volume")))
108
+ ]
109
+ series: dict[str, list[dict]] = {}
110
+ markers: list[dict] = []
111
+ knn_now = None
112
+ intraday = interval in INTRADAY
113
+ for name in wanted:
114
+ if name == "vwap" and intraday and df["volume"].sum() > 0:
115
+ series["vwap"] = _series(times, ind.vwap(df))
116
+ elif name.startswith("ema") and name[3:].isdigit():
117
+ series[name] = _series(times, ind.ema(df["close"], int(name[3:])).where(
118
+ pd.Series(range(len(df)), index=df.index) >= int(name[3:]) - 1))
119
+ elif name == "supertrend" and len(df) > 12:
120
+ line, direction = ind.supertrend(df)
121
+ series["supertrend_up"] = _series(times, line.where(direction == 1))
122
+ series["supertrend_down"] = _series(times, line.where(direction == -1))
123
+ elif name == "vwma" and df["volume"].sum() > 0 and len(df) > 40:
124
+ markers += _vwma_knn(df, times, series)
125
+ elif name == "knn_st" and len(df) > 120:
126
+ extra, knn_now = _knn_supertrend(df, times, series)
127
+ markers += extra
128
+
129
+ # Pivots from the previous complete session.
130
+ pivots = None
131
+ if intraday:
132
+ days = df.groupby(ind.session_key(df.index))
133
+ sessions = list(days)
134
+ if len(sessions) >= 2:
135
+ prev = sessions[-2][1]
136
+ pivots = ind.pivots(float(prev["high"].max()), float(prev["low"].min()), float(prev["close"].iloc[-1]))
137
+ elif len(df) >= 2:
138
+ prev = df.iloc[-2]
139
+ pivots = ind.pivots(float(prev["high"]), float(prev["low"]), float(prev["close"]))
140
+
141
+ last = df.iloc[-1]
142
+ rsi = ind.rsi(df["close"]).iloc[-1] if len(df) > 15 else float("nan")
143
+ return {
144
+ "bars": bars,
145
+ "series": series,
146
+ "pivots": {k: round(v, 4) for k, v in pivots.items()} if pivots else None,
147
+ "opening_range": ind.opening_range(df) if intraday else None,
148
+ "markers": sorted(markers, key=lambda m: m["time"]),
149
+ "knn": knn_now,
150
+ "last": {
151
+ "time": times[-1],
152
+ "close": float(last["close"]),
153
+ "rsi": None if math.isnan(rsi) else round(float(rsi), 1),
154
+ },
155
+ }
156
+
157
+
158
+ BINANCE_INTERVALS = {"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m", "1h": "1h", "1d": "1d", "1wk": "1w"}
159
+ INTERVAL_SECONDS = {"1m": 60, "5m": 300, "15m": 900, "30m": 1800, "1h": 3600, "1d": 86400, "1wk": 604800}
160
+ PERIOD_DAYS = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 91, "6mo": 182, "1y": 365, "2y": 730, "5y": 1826}
161
+
162
+
163
+ def binance_pair(symbol: str) -> str:
164
+ """BTC-USD -> BTCUSDT (the listing's crypto symbols are Binance USDT pairs)."""
165
+ base = symbol.upper().split("-")[0]
166
+ return base if base.endswith("USDT") else f"{base}USDT"
167
+
168
+
169
+ def binance_klines(symbol: str, interval: str, period: str) -> pd.DataFrame:
170
+ """Crypto candles straight from Binance: real volume and no delay."""
171
+ import requests
172
+
173
+ limit = min(1000, max(2, PERIOD_DAYS.get(period, 30) * 86400 // INTERVAL_SECONDS[interval]))
174
+ r = requests.get("https://api.binance.com/api/v3/klines", timeout=10,
175
+ params={"symbol": binance_pair(symbol), "interval": BINANCE_INTERVALS[interval], "limit": limit})
176
+ r.raise_for_status()
177
+ rows = r.json()
178
+ if not rows:
179
+ return pd.DataFrame()
180
+ df = pd.DataFrame(rows, columns=["t", "open", "high", "low", "close", "volume", *range(6)])
181
+ df.index = pd.to_datetime(df["t"], unit="ms", utc=True)
182
+ return df[["open", "high", "low", "close", "volume"]].astype(float)
183
+
184
+
185
+ def fetch_bars(symbol: str, market: str, interval: str, period: str) -> pd.DataFrame:
186
+ from ..agent.agents import _resolve_fetcher
187
+
188
+ if market == "crypto":
189
+ try:
190
+ df = binance_klines(symbol, interval, period)
191
+ if not df.empty:
192
+ return df
193
+ except Exception as exc: # fall back to the fetcher chain (Yahoo)
194
+ import logging
195
+
196
+ logging.getLogger(__name__).warning("Binance klines for %s failed: %s", symbol, exc)
197
+ return _resolve_fetcher(market).get_history(symbol, period=period, interval=TimeFrame(interval))