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