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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- 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))
|