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
marketmyna/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MarketMyna - Investment Analysis Platform
|
|
3
|
+
A comprehensive tool for stock market analysis, portfolio management,
|
|
4
|
+
and investment insights across US, Global, Indian markets, and Crypto.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
__author__ = "MarketMyna Team"
|
|
9
|
+
|
|
10
|
+
# Every process that imports the app (API, scheduled run, report workers) masks
|
|
11
|
+
# secrets such as the Telegram bot token in its logs.
|
|
12
|
+
from .utils import log_redact as _log_redact # noqa: E402
|
|
13
|
+
|
|
14
|
+
_log_redact.install()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""LLM-synthesis agent pipeline: technical/risk agents call MarketMyna's
|
|
2
|
+
own analysis modules as deterministic tools, an LLM backend narrates
|
|
3
|
+
each result, and a decision agent synthesizes everything into a report.
|
|
4
|
+
|
|
5
|
+
See orchestrator.py for the pipeline itself, context.py for the shared
|
|
6
|
+
state agents pass between each other, and dashboard.py for the final
|
|
7
|
+
report shape.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""The three built agents: technical, risk, decision.
|
|
2
|
+
|
|
3
|
+
Each agent's `run(ctx, backend)` follows the same shape: pull/compute
|
|
4
|
+
deterministic data via MarketMyna's own analysis modules (never
|
|
5
|
+
reimplemented here), hand a compact structured summary of that to the
|
|
6
|
+
LLM backend for narration, and return an AgentOpinion. The *signal*
|
|
7
|
+
each agent reports is derived from the deterministic computation, not
|
|
8
|
+
parsed out of the LLM's free text - the LLM's job is narration, not
|
|
9
|
+
being the source of truth for a value code can already compute exactly.
|
|
10
|
+
|
|
11
|
+
DecisionAgent is the one exception: it has no tools of its own (matches
|
|
12
|
+
the reference's decision agent having tool_names=[]) and its whole job
|
|
13
|
+
IS to produce a decision, so its output does require parsing the LLM's
|
|
14
|
+
response - done defensively, with a safe "hold" fallback on anything
|
|
15
|
+
that doesn't parse.
|
|
16
|
+
|
|
17
|
+
Scope cut: no IntelAgent (news search + risk-event detection) - that
|
|
18
|
+
needs a news-search API/tool that isn't configured. RiskAgent covers a
|
|
19
|
+
narrower, purely data-derived notion of risk (valuation extremes,
|
|
20
|
+
volatility, drawdown) instead of news-driven risk events.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
from typing import Callable, Optional
|
|
29
|
+
|
|
30
|
+
import pandas as pd
|
|
31
|
+
|
|
32
|
+
from ..analysis.fundamental import FundamentalAnalyzer
|
|
33
|
+
from ..analysis.technical import Signal, TechnicalAnalyzer
|
|
34
|
+
from ..core.compliance import guard_prompt, outlook
|
|
35
|
+
from ..core.constants import Markets
|
|
36
|
+
from ..data.china import ChinaAFetcher, HongKongFetcher
|
|
37
|
+
from ..data.crypto import CryptoDataFetcher
|
|
38
|
+
from ..data.india import IndiaDataFetcher
|
|
39
|
+
from ..data.stocks import StockDataFetcher
|
|
40
|
+
from ..llm.protocol import GenerationBackend, GenerationConfig
|
|
41
|
+
from .context import AgentContext, AgentOpinion
|
|
42
|
+
from .skills import Skill
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Same fetcher-per-market pattern as marketmyna/api/v1/endpoints/stocks.py.
|
|
47
|
+
_FETCHERS = {
|
|
48
|
+
Markets.US: StockDataFetcher(),
|
|
49
|
+
Markets.GLOBAL: StockDataFetcher(),
|
|
50
|
+
Markets.INDIA: IndiaDataFetcher(),
|
|
51
|
+
Markets.CRYPTO: CryptoDataFetcher(),
|
|
52
|
+
Markets.CHINA_A: ChinaAFetcher(),
|
|
53
|
+
Markets.HONG_KONG: HongKongFetcher(),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_TREND_SIGNAL_MAP = {
|
|
57
|
+
Signal.STRONG_BUY: "bullish",
|
|
58
|
+
Signal.BUY: "bullish",
|
|
59
|
+
Signal.NEUTRAL: "neutral",
|
|
60
|
+
Signal.SELL: "bearish",
|
|
61
|
+
Signal.STRONG_SELL: "bearish",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_fetcher(market: str):
|
|
66
|
+
try:
|
|
67
|
+
return _FETCHERS[Markets(market.lower())]
|
|
68
|
+
except (KeyError, ValueError) as exc:
|
|
69
|
+
raise ValueError(f"Unknown market '{market}'") from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Appended to every agent's instructions: the app computes the numbers,
|
|
73
|
+
# the model only explains them - and must keep facts and opinion apart.
|
|
74
|
+
GUARDRAIL = (
|
|
75
|
+
" Use only the figures given in the input; never invent or recompute prices, ratios or "
|
|
76
|
+
"dates. Describe outlooks as possibilities with their reasons, not as certainties, and "
|
|
77
|
+
"do not present the output as personalised investment advice."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _scope(ctx: AgentContext, stage: str) -> str:
|
|
82
|
+
"""Cache scope for one stage of one stock's analysis: the AI's wording is
|
|
83
|
+
reused for the cache's lifetime (marketmyna/llm/store.py) even as the live
|
|
84
|
+
numbers in the prompt move, so a stock opened again, or booked by many
|
|
85
|
+
users, costs no new AI call."""
|
|
86
|
+
parts = [stage, ctx.symbol.upper(), ctx.market, getattr(ctx, "mode", ""), getattr(ctx, "area", None) or ""]
|
|
87
|
+
return "|".join(["agent", *parts])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _call_llm(backend: GenerationBackend, system_prompt: str, prompt: str, scope: Optional[str] = None) -> str:
|
|
91
|
+
"""Every agent's one LLM call, unified so error handling/config is in
|
|
92
|
+
one place. Raises GenerationError on failure - callers don't catch it
|
|
93
|
+
themselves, the orchestrator decides critical-vs-not per stage."""
|
|
94
|
+
result = backend.generate(prompt, GenerationConfig(cache_scope=scope), system_prompt=system_prompt + GUARDRAIL)
|
|
95
|
+
return result.text.strip()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _narrate(backend: GenerationBackend, system_prompt: str, prompt: str, fallback: str,
|
|
99
|
+
scope: Optional[str] = None) -> str:
|
|
100
|
+
"""LLM commentary for a stage whose numbers are computed without AI
|
|
101
|
+
(technical, risk). If the LLM is missing or failing, the stage still
|
|
102
|
+
succeeds with a rule-based sentence instead of taking the whole
|
|
103
|
+
pipeline down - only the decision stage truly needs the model."""
|
|
104
|
+
try:
|
|
105
|
+
# Commentary is a "level 1" job: use the fast/cheap model when one
|
|
106
|
+
# is configured (the backend falls back to the main chain itself).
|
|
107
|
+
return _call_llm(getattr(backend, "fast", backend), system_prompt, prompt, scope)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.info("LLM commentary unavailable (%s); using rule-based summary", exc)
|
|
110
|
+
return fallback
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TechnicalAgent:
|
|
114
|
+
agent_name = "technical"
|
|
115
|
+
|
|
116
|
+
def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
|
|
117
|
+
fetcher = _resolve_fetcher(ctx.market)
|
|
118
|
+
|
|
119
|
+
ctx.quote = fetcher.get_quote(ctx.symbol)
|
|
120
|
+
ctx.history = fetcher.get_history(ctx.symbol, period="1y")
|
|
121
|
+
|
|
122
|
+
analyzer = TechnicalAnalyzer(ctx.history)
|
|
123
|
+
raw_summary = analyzer.get_summary()
|
|
124
|
+
# get_summary()'s overall_signal is a Signal enum instance, not
|
|
125
|
+
# JSON-safe - normalize before it goes anywhere near a prompt or
|
|
126
|
+
# a dashboard payload.
|
|
127
|
+
overall_signal: Signal = raw_summary["overall_signal"]
|
|
128
|
+
ctx.technical_summary = {
|
|
129
|
+
"overall_signal": overall_signal.value,
|
|
130
|
+
"buy_signals": raw_summary["buy_signals"],
|
|
131
|
+
"sell_signals": raw_summary["sell_signals"],
|
|
132
|
+
"neutral_signals": raw_summary["neutral_signals"],
|
|
133
|
+
"indicators": raw_summary["indicators"],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
total = max(
|
|
137
|
+
ctx.technical_summary["buy_signals"]
|
|
138
|
+
+ ctx.technical_summary["sell_signals"]
|
|
139
|
+
+ ctx.technical_summary["neutral_signals"],
|
|
140
|
+
1,
|
|
141
|
+
)
|
|
142
|
+
dominant = max(
|
|
143
|
+
ctx.technical_summary["buy_signals"],
|
|
144
|
+
ctx.technical_summary["sell_signals"],
|
|
145
|
+
ctx.technical_summary["neutral_signals"],
|
|
146
|
+
)
|
|
147
|
+
confidence = round(dominant / total, 2)
|
|
148
|
+
|
|
149
|
+
system_prompt = (
|
|
150
|
+
"You are a technical analysis assistant for a stock research tool. "
|
|
151
|
+
"Given a structured summary of technical indicators, write a concise "
|
|
152
|
+
"2-4 sentence opinion on the current trend and momentum. Do not "
|
|
153
|
+
"restate raw numbers verbatim, interpret them."
|
|
154
|
+
)
|
|
155
|
+
system_prompt = guard_prompt(system_prompt)
|
|
156
|
+
prompt = (
|
|
157
|
+
f"Symbol: {ctx.symbol} ({ctx.market})\n"
|
|
158
|
+
f"Current price: {ctx.quote.get('price')}\n"
|
|
159
|
+
f"Technical summary: {json.dumps(ctx.technical_summary)}"
|
|
160
|
+
)
|
|
161
|
+
# Without a model: the indicator values only, no verdict words.
|
|
162
|
+
fallback = "Indicator readings: " + ", ".join(
|
|
163
|
+
f"{name} {info['value']:.2f}" if isinstance(info.get("value"), (int, float)) else name
|
|
164
|
+
for name, info in list(ctx.technical_summary["indicators"].items())[:6]) + "."
|
|
165
|
+
narrative = _narrate(backend, system_prompt, prompt, fallback, scope=_scope(ctx, self.agent_name))
|
|
166
|
+
|
|
167
|
+
return AgentOpinion(
|
|
168
|
+
agent_name=self.agent_name,
|
|
169
|
+
summary=narrative,
|
|
170
|
+
signal=_TREND_SIGNAL_MAP.get(overall_signal, "neutral"),
|
|
171
|
+
confidence=confidence,
|
|
172
|
+
details=ctx.technical_summary,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _max_drawdown(close: pd.Series) -> float:
|
|
177
|
+
running_max = close.cummax()
|
|
178
|
+
drawdown = (close - running_max) / running_max
|
|
179
|
+
return float(drawdown.min())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _annualized_volatility(close: pd.Series) -> float:
|
|
183
|
+
returns = close.pct_change().dropna()
|
|
184
|
+
if returns.empty:
|
|
185
|
+
return 0.0
|
|
186
|
+
return float(returns.std() * (252 ** 0.5))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class RiskAgent:
|
|
190
|
+
agent_name = "risk"
|
|
191
|
+
|
|
192
|
+
def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
|
|
193
|
+
fetcher = _resolve_fetcher(ctx.market)
|
|
194
|
+
|
|
195
|
+
# Reuse history/quote TechnicalAgent already fetched when this
|
|
196
|
+
# runs after it in the chain; only fetch if run standalone.
|
|
197
|
+
if ctx.history is None:
|
|
198
|
+
ctx.history = fetcher.get_history(ctx.symbol, period="1y")
|
|
199
|
+
if ctx.quote is None:
|
|
200
|
+
ctx.quote = fetcher.get_quote(ctx.symbol)
|
|
201
|
+
|
|
202
|
+
volatility = _annualized_volatility(ctx.history["close"])
|
|
203
|
+
max_drawdown = _max_drawdown(ctx.history["close"])
|
|
204
|
+
|
|
205
|
+
fundamental_summary = None
|
|
206
|
+
try:
|
|
207
|
+
info = fetcher.get_info(ctx.symbol)
|
|
208
|
+
ctx.info = info
|
|
209
|
+
fundamental_summary = FundamentalAnalyzer(info).get_summary()
|
|
210
|
+
ctx.fundamental_summary = fundamental_summary
|
|
211
|
+
except Exception:
|
|
212
|
+
# Fundamentals aren't available/meaningful for every market
|
|
213
|
+
# (e.g. crypto) - risk assessment still works from price
|
|
214
|
+
# action alone.
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
risk_level = "low"
|
|
218
|
+
if volatility > 0.5 or max_drawdown < -0.35:
|
|
219
|
+
risk_level = "high"
|
|
220
|
+
elif volatility > 0.25 or max_drawdown < -0.2:
|
|
221
|
+
risk_level = "medium"
|
|
222
|
+
|
|
223
|
+
ctx.risk_metrics = {
|
|
224
|
+
"volatility": round(volatility, 4),
|
|
225
|
+
"max_drawdown": round(max_drawdown, 4),
|
|
226
|
+
"risk_level": risk_level,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
system_prompt = (
|
|
230
|
+
"You are a risk analyst for a stock research tool. Given price-risk "
|
|
231
|
+
"statistics and (if available) fundamental valuation data, write a "
|
|
232
|
+
"concise 2-3 sentence risk commentary. Be specific about what is "
|
|
233
|
+
"driving the risk level, not generic."
|
|
234
|
+
)
|
|
235
|
+
system_prompt = guard_prompt(system_prompt)
|
|
236
|
+
prompt_parts = [
|
|
237
|
+
f"Symbol: {ctx.symbol} ({ctx.market})",
|
|
238
|
+
f"Annualized volatility: {volatility:.2%}",
|
|
239
|
+
f"Max drawdown (lookback): {max_drawdown:.2%}",
|
|
240
|
+
f"Computed risk level: {risk_level}",
|
|
241
|
+
]
|
|
242
|
+
if fundamental_summary:
|
|
243
|
+
prompt_parts.append(
|
|
244
|
+
f"Fundamental valuation: {fundamental_summary['valuation']['status']}, "
|
|
245
|
+
f"overall score {fundamental_summary['overall_score']}/100"
|
|
246
|
+
)
|
|
247
|
+
fallback = (
|
|
248
|
+
f"Annualized volatility {volatility:.0%} and max drawdown {max_drawdown:.0%} "
|
|
249
|
+
f"over the past year put this at {risk_level} risk."
|
|
250
|
+
)
|
|
251
|
+
narrative = _narrate(backend, system_prompt, "\n".join(prompt_parts), fallback,
|
|
252
|
+
scope=_scope(ctx, self.agent_name))
|
|
253
|
+
|
|
254
|
+
return AgentOpinion(
|
|
255
|
+
agent_name=self.agent_name,
|
|
256
|
+
summary=narrative,
|
|
257
|
+
signal=None,
|
|
258
|
+
confidence=None,
|
|
259
|
+
details=ctx.risk_metrics,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
_JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class NewsAgent:
|
|
267
|
+
"""Collects recent headlines and reads their tone, so the decision
|
|
268
|
+
reflects events (results, deals, downgrades) and not just charts."""
|
|
269
|
+
|
|
270
|
+
agent_name = "news"
|
|
271
|
+
|
|
272
|
+
def __init__(self, search: Optional[Callable[..., list]] = None):
|
|
273
|
+
self._search = search
|
|
274
|
+
|
|
275
|
+
def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
|
|
276
|
+
search = self._search
|
|
277
|
+
if search is None:
|
|
278
|
+
from ..data.news_search import stock_news as search
|
|
279
|
+
|
|
280
|
+
name = (ctx.info or {}).get("name") or None
|
|
281
|
+
ctx.news = search(ctx.symbol, ctx.market, limit=6, name=name)
|
|
282
|
+
if not ctx.news:
|
|
283
|
+
return AgentOpinion(
|
|
284
|
+
agent_name=self.agent_name,
|
|
285
|
+
summary="No recent news found.",
|
|
286
|
+
signal="neutral",
|
|
287
|
+
confidence=None,
|
|
288
|
+
details={"headlines": []},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
headlines = "\n".join(
|
|
292
|
+
f"- {(n.get('published') or '')[:10]} {n['title']}" + (f" ({n['source']})" if n.get("source") else "")
|
|
293
|
+
for n in ctx.news
|
|
294
|
+
)
|
|
295
|
+
# The tone of the news, not a view on the stock.
|
|
296
|
+
system_prompt = guard_prompt(
|
|
297
|
+
"You summarise recent headlines about one company. Reply ONLY with JSON: "
|
|
298
|
+
"{\"sentiment\": \"positive\"|\"negative\"|\"neutral\" (the tone of the news), "
|
|
299
|
+
"\"summary\": \"2-3 sentences on what the headlines report\"}. "
|
|
300
|
+
"Ignore headlines that are about a different company.")
|
|
301
|
+
prompt = f"Stock: {ctx.symbol} ({ctx.market})\nRecent headlines:\n{headlines}"
|
|
302
|
+
fallback = json.dumps({
|
|
303
|
+
"sentiment": None, # without a model nobody judged the tone - don't guess
|
|
304
|
+
"summary": f"{len(ctx.news)} recent headlines; latest: {ctx.news[0]['title']}",
|
|
305
|
+
})
|
|
306
|
+
raw = _narrate(backend, system_prompt, prompt, fallback, scope=_scope(ctx, self.agent_name))
|
|
307
|
+
match = _JSON_RE.search(raw)
|
|
308
|
+
try:
|
|
309
|
+
parsed = json.loads(match.group(0)) if match else {}
|
|
310
|
+
except json.JSONDecodeError:
|
|
311
|
+
parsed = {}
|
|
312
|
+
sentiment = str(parsed.get("sentiment") or "").lower() or None
|
|
313
|
+
sentiment = {"positive": "bullish", "negative": "bearish"}.get(sentiment, sentiment)
|
|
314
|
+
if sentiment is not None and sentiment not in ("bullish", "bearish", "neutral"):
|
|
315
|
+
sentiment = "neutral"
|
|
316
|
+
return AgentOpinion(
|
|
317
|
+
agent_name=self.agent_name,
|
|
318
|
+
summary=str(parsed.get("summary") or raw).strip(),
|
|
319
|
+
signal=sentiment,
|
|
320
|
+
confidence=None,
|
|
321
|
+
details={"headlines": [n["title"] for n in ctx.news]},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class FundamentalsAgent:
|
|
326
|
+
"""Full / Specialist depth: reads the company's filings, its valuation
|
|
327
|
+
against the sector and the analyst consensus (the same services as the
|
|
328
|
+
Analyze page's cards) so the decision stage sees the business, not only
|
|
329
|
+
the chart. Non-critical: any source that fails or times out is skipped."""
|
|
330
|
+
|
|
331
|
+
agent_name = "fundamentals"
|
|
332
|
+
|
|
333
|
+
def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
|
|
334
|
+
from ..services.analysis_run import yahoo_symbol
|
|
335
|
+
from ..services.estimates import build as build_estimates
|
|
336
|
+
from ..services.financials import build_financials
|
|
337
|
+
from ..services.valuation import meter
|
|
338
|
+
from ..utils.deadline import call_with_deadline
|
|
339
|
+
|
|
340
|
+
if ctx.market == "crypto":
|
|
341
|
+
return AgentOpinion(agent_name=self.agent_name, summary="Company fundamentals don't apply to crypto.",
|
|
342
|
+
signal=None, confidence=None, details={})
|
|
343
|
+
|
|
344
|
+
def safe(fn, seconds, *args):
|
|
345
|
+
try:
|
|
346
|
+
return call_with_deadline(fn, seconds, *args)
|
|
347
|
+
except Exception as exc: # noqa: BLE001 - one missing source is fine
|
|
348
|
+
logger.info("Fundamentals source %s for %s skipped: %s", getattr(fn, "__name__", fn), ctx.symbol, exc)
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
352
|
+
|
|
353
|
+
with ThreadPoolExecutor(max_workers=3) as pool: # three independent sources, fetched together
|
|
354
|
+
fin_f = pool.submit(safe, build_financials, 60, ctx.symbol, ctx.market, "annual")
|
|
355
|
+
val_f = pool.submit(safe, meter, 60, ctx.symbol, ctx.market)
|
|
356
|
+
est_f = pool.submit(safe, build_estimates, 45, yahoo_symbol(ctx.symbol, ctx.market))
|
|
357
|
+
fin, val, est = fin_f.result() or {}, val_f.result() or {}, est_f.result() or {}
|
|
358
|
+
|
|
359
|
+
facts: dict = {}
|
|
360
|
+
if fin.get("overall"):
|
|
361
|
+
facts["financial_statements"] = {
|
|
362
|
+
"overall": fin["overall"],
|
|
363
|
+
**{k: {"verdict": (fin.get(k) or {}).get("verdict"),
|
|
364
|
+
"findings": [f["text"] for f in ((fin.get(k) or {}).get("findings") or [])[:4]]}
|
|
365
|
+
for k in ("income", "balance", "cashflow") if fin.get(k)},
|
|
366
|
+
}
|
|
367
|
+
if val.get("status") == "ok":
|
|
368
|
+
facts["valuation_vs_sector"] = {
|
|
369
|
+
"sector": val.get("sector"), "peers": val.get("peers"), "zone": val.get("zone"),
|
|
370
|
+
"multiples": [{"label": m["label"], "value": m.get("value"), "sector_median": m.get("sector_median")}
|
|
371
|
+
for m in val.get("multiples") or []],
|
|
372
|
+
"growth_checks_passed": f"{(val.get('garp') or {}).get('passed')}/{(val.get('garp') or {}).get('total')}",
|
|
373
|
+
}
|
|
374
|
+
if est.get("available"):
|
|
375
|
+
facts["analyst_estimates"] = {
|
|
376
|
+
"eps_trend_90d": (est.get("momentum") or {}).get("label"),
|
|
377
|
+
"beats": est.get("beats"),
|
|
378
|
+
"periods": [{"label": p.get("label"), "eps": (p.get("eps") or {}).get("avg"),
|
|
379
|
+
"eps_growth": (p.get("eps") or {}).get("growth")} for p in (est.get("periods") or [])[:4]],
|
|
380
|
+
}
|
|
381
|
+
if not facts:
|
|
382
|
+
raise RuntimeError("no fundamental data available")
|
|
383
|
+
|
|
384
|
+
system_prompt = guard_prompt(
|
|
385
|
+
"You are a fundamental analyst for a stock research tool. Given the company's financial-statement "
|
|
386
|
+
"checks, its valuation multiples against its sector median and the analyst estimate trend, write "
|
|
387
|
+
"3-4 sentences on the quality of the business, how it is priced relative to its sector, and how "
|
|
388
|
+
"expectations are moving. Use the numbers given; don't invent any."
|
|
389
|
+
)
|
|
390
|
+
prompt = f"Symbol: {ctx.symbol} ({ctx.market})\nFundamental data: {json.dumps(facts, default=str)}"
|
|
391
|
+
bits = []
|
|
392
|
+
if "financial_statements" in facts:
|
|
393
|
+
o = facts["financial_statements"]["overall"]
|
|
394
|
+
bits.append(f"filings read {o.get('label')} ({o.get('score')}/100)")
|
|
395
|
+
if "valuation_vs_sector" in facts:
|
|
396
|
+
v = facts["valuation_vs_sector"]
|
|
397
|
+
where = {"below": "below", "above": "above", "in_line": "in line with"}.get(v["zone"], "against")
|
|
398
|
+
bits.append(f"priced {where} its {v['sector']} sector on P/E, P/B and P/S")
|
|
399
|
+
if "analyst_estimates" in facts:
|
|
400
|
+
e = facts["analyst_estimates"]
|
|
401
|
+
bits.append(f"analyst EPS estimates {e['eps_trend_90d'] or 'unchanged'} over 90 days")
|
|
402
|
+
fallback = "Rule-based read: " + "; ".join(bits) + "."
|
|
403
|
+
narrative = _narrate(backend, system_prompt, prompt, fallback, scope=_scope(ctx, self.agent_name))
|
|
404
|
+
return AgentOpinion(agent_name=self.agent_name, summary=narrative, signal=None, confidence=None, details=facts)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
_EXPLAIN_FOCUS = {
|
|
408
|
+
"": (
|
|
409
|
+
"what changed recently, the main strengths and weaknesses in the data, the risks to watch "
|
|
410
|
+
"and the questions an investor would want to research next."
|
|
411
|
+
),
|
|
412
|
+
"trader": (
|
|
413
|
+
"the current trend and momentum, where price sits against its recent support, resistance "
|
|
414
|
+
"and normal range, how volatile it is, upcoming events in the next few weeks and the "
|
|
415
|
+
"questions a short-term trader would check next."
|
|
416
|
+
),
|
|
417
|
+
"investor": (
|
|
418
|
+
"how the business is doing (growth, margins, debt), how its valuation compares with its "
|
|
419
|
+
"sector and history, the long-term risks and the questions a long-term investor would "
|
|
420
|
+
"research next."
|
|
421
|
+
),
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _trade_levels(ctx: AgentContext) -> dict:
|
|
426
|
+
"""Support / resistance / ATR from the history TechnicalAgent already
|
|
427
|
+
fetched (same maths as the smart report), for the trader prompt."""
|
|
428
|
+
if ctx.history is None:
|
|
429
|
+
return {}
|
|
430
|
+
try:
|
|
431
|
+
from ..services.smart_report import compute_snapshot
|
|
432
|
+
|
|
433
|
+
snap = compute_snapshot(ctx.history)
|
|
434
|
+
except Exception:
|
|
435
|
+
return {}
|
|
436
|
+
names = {
|
|
437
|
+
"trend": "Trend", "buy_point": "Support", "sell_point": "Resistance", "atr": "ATR (14 days)",
|
|
438
|
+
"normal_low": "Normal range low (20-day band)", "normal_high": "Normal range high",
|
|
439
|
+
"week_low": "1-week expected low", "week_high": "1-week expected high",
|
|
440
|
+
"rsi": "RSI (14)", "volume_ratio": "Volume vs 20-day average",
|
|
441
|
+
}
|
|
442
|
+
return {label: (round(snap[k], 2) if isinstance(snap.get(k), float) else snap.get(k))
|
|
443
|
+
for k, label in names.items() if snap.get(k) is not None}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class DecisionAgent:
|
|
447
|
+
"""The last stage: a plain explanation of what the earlier stages found
|
|
448
|
+
and the outlook they point to (bullish / neutral / bearish), never a buy
|
|
449
|
+
/ hold / sell call, target or stop loss (marketmyna/core/compliance.py)."""
|
|
450
|
+
|
|
451
|
+
agent_name = "decision"
|
|
452
|
+
|
|
453
|
+
def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
|
|
454
|
+
opinions_text = "\n".join(
|
|
455
|
+
f"- {op.agent_name} (signal={op.signal}): {op.summary}" for op in ctx.opinions
|
|
456
|
+
)
|
|
457
|
+
focus = _EXPLAIN_FOCUS.get(getattr(ctx, "area", None) or "", _EXPLAIN_FOCUS[""])
|
|
458
|
+
system_prompt = guard_prompt(
|
|
459
|
+
"You summarise research for a stock analytics tool that is not a registered adviser. "
|
|
460
|
+
f"Write one paragraph that explains what the figures show: {focus} Never recommend buying, holding, selling, "
|
|
461
|
+
"accumulating or exiting, never give a price target or stop loss, and do not use words "
|
|
462
|
+
"like attractive, cheap or expensive as a verdict. Also give the outlook the findings point to: "
|
|
463
|
+
"bullish, neutral or bearish, as a direction, never as an instruction. Respond ONLY with a JSON "
|
|
464
|
+
'object: {"outlook": "bullish"|"neutral"|"bearish", "rationale": "one paragraph"}.'
|
|
465
|
+
)
|
|
466
|
+
prompt = (
|
|
467
|
+
f"Symbol: {ctx.symbol} ({ctx.market})\n"
|
|
468
|
+
f"Current price: {(ctx.quote or {}).get('price')}\n\n"
|
|
469
|
+
f"Findings from the analysis steps:\n{opinions_text}"
|
|
470
|
+
)
|
|
471
|
+
if getattr(ctx, "area", None) == "trader":
|
|
472
|
+
levels = _trade_levels(ctx)
|
|
473
|
+
if levels:
|
|
474
|
+
prompt += "\n\nPrice levels (daily bars):\n" + "\n".join(f"- {k}: {v}" for k, v in levels.items())
|
|
475
|
+
if ctx.news:
|
|
476
|
+
prompt += "\n\nRecent headlines:\n" + "\n".join(
|
|
477
|
+
f"- {(n.get('published') or '')[:10]} {n['title']}" for n in ctx.news[:6]
|
|
478
|
+
)
|
|
479
|
+
try:
|
|
480
|
+
from ..services.macro import cached_context
|
|
481
|
+
|
|
482
|
+
macro = cached_context()
|
|
483
|
+
except Exception:
|
|
484
|
+
macro = []
|
|
485
|
+
if macro:
|
|
486
|
+
prompt += "\n\nMacro backdrop (weigh lightly, it moves the whole market):\n" + "\n".join(f"- {m}" for m in macro)
|
|
487
|
+
from .sentiment import social_block
|
|
488
|
+
|
|
489
|
+
social = social_block(ctx)
|
|
490
|
+
if social:
|
|
491
|
+
prompt += "\n\n" + social
|
|
492
|
+
narrative = _call_llm(backend, system_prompt, prompt, scope=_scope(ctx, self.agent_name))
|
|
493
|
+
match = _JSON_RE.search(narrative)
|
|
494
|
+
rationale, direction = narrative, None
|
|
495
|
+
if match:
|
|
496
|
+
try:
|
|
497
|
+
parsed = json.loads(match.group(0))
|
|
498
|
+
rationale = parsed.get("rationale") or narrative
|
|
499
|
+
direction = outlook(parsed.get("outlook"))
|
|
500
|
+
except (json.JSONDecodeError, AttributeError, TypeError):
|
|
501
|
+
pass
|
|
502
|
+
return AgentOpinion(agent_name=self.agent_name, summary=rationale, signal=direction, confidence=None, details={})
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class SkillAgent:
|
|
506
|
+
"""Applies one Skill's strategy instructions to whatever real context
|
|
507
|
+
(technical summary, risk metrics, quote) is already on ctx. Multiple
|
|
508
|
+
SkillAgents can run in one pipeline, each under its own agent_name
|
|
509
|
+
(f"skill:{skill.name}") so their opinions don't collide in
|
|
510
|
+
ctx.opinions - unlike the fixed-name agents above, this one is
|
|
511
|
+
constructed per-skill rather than being a singleton in the chain."""
|
|
512
|
+
|
|
513
|
+
def __init__(self, skill: Skill):
|
|
514
|
+
self.skill = skill
|
|
515
|
+
self.agent_name = f"skill:{skill.name}"
|
|
516
|
+
|
|
517
|
+
def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
|
|
518
|
+
system_prompt = (
|
|
519
|
+
f"You are applying a specific trading strategy, '{self.skill.display_name}', "
|
|
520
|
+
"to a stock analysis. Follow the strategy instructions below to form an "
|
|
521
|
+
"opinion. If the instructions call for data that isn't provided here "
|
|
522
|
+
"(e.g. news or sector data), say so explicitly rather than inventing it. "
|
|
523
|
+
"Respond with a concise 2-4 sentence opinion.\n\n"
|
|
524
|
+
f"--- Strategy instructions ---\n{self.skill.instructions}"
|
|
525
|
+
)
|
|
526
|
+
system_prompt = guard_prompt(system_prompt)
|
|
527
|
+
prompt_parts = [f"Symbol: {ctx.symbol} ({ctx.market})"]
|
|
528
|
+
if ctx.quote:
|
|
529
|
+
prompt_parts.append(f"Current price: {ctx.quote.get('price')}")
|
|
530
|
+
if ctx.technical_summary:
|
|
531
|
+
prompt_parts.append(f"Technical summary: {json.dumps(ctx.technical_summary)}")
|
|
532
|
+
if ctx.risk_metrics:
|
|
533
|
+
prompt_parts.append(f"Risk metrics: {json.dumps(ctx.risk_metrics)}")
|
|
534
|
+
narrative = _call_llm(backend, system_prompt, "\n".join(prompt_parts),
|
|
535
|
+
scope=_scope(ctx, self.agent_name))
|
|
536
|
+
|
|
537
|
+
return AgentOpinion(
|
|
538
|
+
agent_name=self.agent_name,
|
|
539
|
+
summary=narrative,
|
|
540
|
+
signal=None,
|
|
541
|
+
confidence=None,
|
|
542
|
+
details={"skill": self.skill.name, "category": self.skill.category},
|
|
543
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared mutable state passed through the agent pipeline.
|
|
2
|
+
|
|
3
|
+
One AgentContext is created per orchestrator run and threaded through
|
|
4
|
+
every agent in the chain: each agent reads what earlier agents put on
|
|
5
|
+
it (e.g. RiskAgent reads the history TechnicalAgent already fetched
|
|
6
|
+
rather than fetching it again) and appends its own AgentOpinion.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AgentOpinion:
|
|
20
|
+
"""One agent's contribution to the pipeline: a narrative summary
|
|
21
|
+
(usually LLM-generated) plus whatever structured signal it computed."""
|
|
22
|
+
|
|
23
|
+
agent_name: str
|
|
24
|
+
summary: str
|
|
25
|
+
signal: Optional[str] = None
|
|
26
|
+
confidence: Optional[float] = None
|
|
27
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"agent_name": self.agent_name,
|
|
32
|
+
"summary": self.summary,
|
|
33
|
+
"signal": self.signal,
|
|
34
|
+
"confidence": self.confidence,
|
|
35
|
+
"details": self.details,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AgentContext:
|
|
41
|
+
symbol: str
|
|
42
|
+
market: str
|
|
43
|
+
mode: str = "standard"
|
|
44
|
+
# "trader" / "investor" (the login area) shapes the final decision;
|
|
45
|
+
# None = the original, area-neutral prompt (bot, scheduler, reports).
|
|
46
|
+
area: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
quote: Optional[dict] = None
|
|
49
|
+
history: Optional[pd.DataFrame] = None
|
|
50
|
+
info: Optional[dict] = None
|
|
51
|
+
technical_summary: Optional[dict] = None
|
|
52
|
+
fundamental_summary: Optional[dict] = None
|
|
53
|
+
risk_metrics: Optional[dict] = None
|
|
54
|
+
# Recent headlines (marketmyna/data/news_search.py dicts), set by NewsAgent.
|
|
55
|
+
news: Optional[list] = None
|
|
56
|
+
# What people say online (marketmyna/agent/sentiment.py): {"summary", "excerpts"}; None when not run or no posts.
|
|
57
|
+
social: Optional[dict] = None
|
|
58
|
+
|
|
59
|
+
opinions: list[AgentOpinion] = field(default_factory=list)
|
|
60
|
+
generated_at: datetime = field(default_factory=datetime.now)
|
|
61
|
+
|
|
62
|
+
def add_opinion(self, opinion: AgentOpinion) -> None:
|
|
63
|
+
self.opinions.append(opinion)
|
|
64
|
+
|
|
65
|
+
def get_opinion(self, agent_name: str) -> Optional[AgentOpinion]:
|
|
66
|
+
for opinion in self.opinions:
|
|
67
|
+
if opinion.agent_name == agent_name:
|
|
68
|
+
return opinion
|
|
69
|
+
return None
|