Commit 63580797 authored by xeron56's avatar xeron56

feat: Introduce AI-driven analysis and reporting for stock research

- Added AITraderReport and AIResearch models to encapsulate AI-generated trading reports and research details.
- Enhanced StockAnalysis model to include optional AI research data.
- Implemented AIStockAnalysisGenerator for generating AI-enhanced stock analysis.
- Created grounded prompts for AI stock research in a new stock_research.py file.
- Developed a lightweight dashboard CLI for fetching and displaying stock analysis without starting the multi-agent graph.
- Updated AnalysisService to integrate AI analysis into the stock analysis workflow.
- Added tests for AI dashboard analysis and ensured proper functionality of new features.
parent 6132f5c0
...@@ -336,6 +336,24 @@ Environment overrides are applied to `DEFAULT_CONFIG` before this code runs. ...@@ -336,6 +336,24 @@ Environment overrides are applied to `DEFAULT_CONFIG` before this code runs.
## REST API and local dashboard ## REST API and local dashboard
To fetch the yearly DSE evidence, run one grounded AI synthesis for the score,
valuation weighting, full research, and trader view, then open the UI without
running the long multi-agent graph, use:
```bash
dohasecuritiesstockai-dashboard GP --date 2026-08-10
```
The command sends only the collected, date-bounded evidence to the configured
deep-thinking model and requires that provider's API key. Numeric valuation
methods remain calculation-backed; the AI assigns reliability weights, writes
the research, and produces the trader view. The first UI launch may still need
time to install/build the Angular frontend. Use `--no-open-ui` to generate and
save the AI payload without starting the server, or `--no-ai` for the original
calculation-only fallback.
The equivalent source-tree command is
`python -m dohasecuritiesstockai.dashboard_cli GP --date 2026-08-10`.
Start the read-only analysis API: Start the read-only analysis API:
```bash ```bash
......
...@@ -67,6 +67,9 @@ ...@@ -67,6 +67,9 @@
<h1>{{ report.company_name }}</h1> <h1>{{ report.company_name }}</h1>
<div class="company-tags"> <div class="company-tags">
<span class="symbol-tag">{{ report.symbol }}</span> <span class="symbol-tag">{{ report.symbol }}</span>
@if (report.ai_research) {
<span class="ai-tag">AI-grounded</span>
}
<span>{{ report.sector }}</span> <span>{{ report.sector }}</span>
<span>{{ language() === 'bn' ? 'সর্বশেষ দাম' : 'Latest price' }}: {{ formatTaka(report.market.latest_price) }}</span> <span>{{ language() === 'bn' ? 'সর্বশেষ দাম' : 'Latest price' }}: {{ formatTaka(report.market.latest_price) }}</span>
@if (report.market.change_percent !== null) { @if (report.market.change_percent !== null) {
...@@ -84,7 +87,7 @@ ...@@ -84,7 +87,7 @@
<section class="score-overview"> <section class="score-overview">
<article class="score-card"> <article class="score-card">
<p class="section-label">{{ language() === 'bn' ? 'মৌলিক স্কোর' : 'Fundamental score' }}</p> <p class="section-label">{{ report.ai_research ? (language() === 'bn' ? 'এআই মৌলিক স্কোর' : 'AI fundamental score') : (language() === 'bn' ? 'মৌলিক স্কোর' : 'Fundamental score') }}</p>
<div class="score-chart"> <div class="score-chart">
<canvas #scoreCanvas aria-label="Fundamental score chart"></canvas> <canvas #scoreCanvas aria-label="Fundamental score chart"></canvas>
<div class="score-center"> <div class="score-center">
...@@ -103,6 +106,13 @@ ...@@ -103,6 +106,13 @@
<li>{{ copy(takeaway) }}</li> <li>{{ copy(takeaway) }}</li>
} }
</ul> </ul>
@if (report.ai_research; as ai) {
<p class="ai-rationale">{{ copy(ai.score_rationale) }}</p>
<small class="ai-provenance">
{{ language() === 'bn' ? 'এআই মডেল' : 'AI model' }}: {{ ai.provider }} / {{ ai.model }} ·
{{ language() === 'bn' ? 'আস্থা' : 'confidence' }}: {{ ai.score_confidence }}
</small>
}
</article> </article>
</section> </section>
...@@ -146,6 +156,13 @@ ...@@ -146,6 +156,13 @@
} }
<p class="valuation-summary">{{ copy(report.valuation.summary) }}</p> <p class="valuation-summary">{{ copy(report.valuation.summary) }}</p>
@if (report.ai_research) {
<p class="valuation-formula">
{{ language() === 'bn'
? 'এআই প্রতিটি হিসাব-পদ্ধতির নির্ভরযোগ্যতার ওজন নির্ধারণ করেছে; নিচের সংখ্যাগুলো যাচাইযোগ্য সূত্রে গণনা করা। ন্যায্য সীমা আনুমানিক মূল্যের ৮০%–১২০%।'
: 'AI assigned reliability weights to each method; the figures below remain calculation-backed. The fair range is 80%–120% of the rough estimate.' }}
</p>
}
<dl class="valuation-methods"> <dl class="valuation-methods">
@for (method of report.valuation.methods; track method.key) { @for (method of report.valuation.methods; track method.key) {
<div [class.unavailable]="!method.available"> <div [class.unavailable]="!method.available">
...@@ -203,6 +220,59 @@ ...@@ -203,6 +220,59 @@
} }
</div> </div>
@if (report.ai_research; as ai) {
<section class="ai-trader-report">
<header>
<div>
<p class="section-label">{{ language() === 'bn' ? 'এআই ট্রেডার ভিউ' : 'AI trader view' }}</p>
<h2>{{ copy(ai.trader_report.action) }}</h2>
</div>
<span [class]="'trader-rating ' + ai.trader_report.rating.toLowerCase()">
{{ ai.trader_report.rating }}
</span>
</header>
<p class="trader-summary">{{ copy(ai.trader_report.executive_summary) }}</p>
<div class="trader-grid">
<article>
<h3>{{ language() === 'bn' ? 'বিনিয়োগ যুক্তি' : 'Investment thesis' }}</h3>
<p>{{ copy(ai.trader_report.investment_thesis) }}</p>
</article>
<article>
<h3>{{ language() === 'bn' ? 'এন্ট্রি কৌশল' : 'Entry strategy' }}</h3>
<p>{{ copy(ai.trader_report.entry_strategy) }}</p>
</article>
<article>
<h3>{{ language() === 'bn' ? 'ঝুঁকি নিয়ন্ত্রণ' : 'Risk controls' }}</h3>
<p>{{ copy(ai.trader_report.risk_controls) }}</p>
</article>
<article>
<h3>{{ language() === 'bn' ? 'সময়সীমা' : 'Time horizon' }}</h3>
<p>{{ copy(ai.trader_report.time_horizon) }}</p>
</article>
</div>
<div class="trader-lists">
<div>
<h3>{{ language() === 'bn' ? 'সম্ভাব্য অনুঘটক' : 'Catalysts' }}</h3>
<ul>
@for (item of ai.trader_report.catalysts; track item.en) {
<li>{{ copy(item) }}</li>
}
</ul>
</div>
<div>
<h3>{{ language() === 'bn' ? 'যে অবস্থায় মত বদলাবে' : 'Invalidation conditions' }}</h3>
<ul>
@for (item of ai.trader_report.invalidation_conditions; track item.en) {
<li>{{ copy(item) }}</li>
}
</ul>
</div>
</div>
<p class="data-quality"><strong>{{ language() === 'bn' ? 'তথ্যের মান:' : 'Data quality:' }}</strong> {{ copy(ai.data_quality) }}</p>
</section>
}
@if (hasAgentEvidence(report)) {
<section class="agent-evidence"> <section class="agent-evidence">
<h2>{{ language() === 'bn' ? 'মূল এজেন্ট প্রমাণ' : 'Original agent evidence' }}</h2> <h2>{{ language() === 'bn' ? 'মূল এজেন্ট প্রমাণ' : 'Original agent evidence' }}</h2>
<p>{{ language() === 'bn' ? 'নিচের অংশগুলো মূল মাল্টি-এজেন্ট রান থেকে অপরিবর্তিত রাখা হয়েছে।' : 'These sections are retained unchanged from the multi-agent run.' }}</p> <p>{{ language() === 'bn' ? 'নিচের অংশগুলো মূল মাল্টি-এজেন্ট রান থেকে অপরিবর্তিত রাখা হয়েছে।' : 'These sections are retained unchanged from the multi-agent run.' }}</p>
...@@ -220,6 +290,7 @@ ...@@ -220,6 +290,7 @@
} }
<details><summary>{{ language() === 'bn' ? 'পূর্ণ স্টেট লগ JSON' : 'Full state log JSON' }}</summary><pre class="json-state">{{ rawState(report) }}</pre></details> <details><summary>{{ language() === 'bn' ? 'পূর্ণ স্টেট লগ JSON' : 'Full state log JSON' }}</summary><pre class="json-state">{{ rawState(report) }}</pre></details>
</section> </section>
}
</section> </section>
} }
......
...@@ -186,6 +186,12 @@ ...@@ -186,6 +186,12 @@
background: #f0f6f9; background: #f0f6f9;
} }
.company-tags .ai-tag {
border-color: #9bcab8;
color: var(--green);
background: #eef8f3;
}
.company-tags .negative-change { .company-tags .negative-change {
color: var(--red); color: var(--red);
} }
...@@ -275,6 +281,22 @@ ...@@ -275,6 +281,22 @@
color: var(--blue); color: var(--blue);
} }
.ai-rationale {
max-width: 62rem;
margin: 1.25rem 0 0;
padding: 1rem 1.15rem;
border-left: 0.24rem solid var(--blue);
color: var(--muted);
background: #f3f7f9;
line-height: 1.55;
}
.ai-provenance {
display: block;
margin-top: 0.65rem;
color: var(--muted);
}
.in-depth-card, .in-depth-card,
.value-card, .value-card,
.factor-card, .factor-card,
...@@ -388,6 +410,16 @@ ...@@ -388,6 +410,16 @@
line-height: 1.55; line-height: 1.55;
} }
.valuation-formula {
max-width: 72rem;
padding: 0.8rem 1rem;
border-radius: 0.8rem;
color: #456759;
background: #eef7f2;
font-size: 0.92rem;
line-height: 1.5;
}
.valuation-methods { .valuation-methods {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
...@@ -550,6 +582,76 @@ ...@@ -550,6 +582,76 @@
margin-top: 2.5rem; margin-top: 2.5rem;
} }
.ai-trader-report {
margin-top: 2.5rem;
padding: clamp(1.3rem, 2.5vw, 2rem);
border: 1px solid #b9d3c7;
border-radius: 1.25rem;
background: #f2f8f5;
}
.ai-trader-report > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.ai-trader-report h2,
.ai-trader-report h3 {
margin-top: 0;
}
.trader-rating {
padding: 0.45rem 1rem;
border-radius: 2rem;
color: #fff;
background: var(--green);
font-weight: 750;
}
.trader-rating.hold {
background: var(--copper);
}
.trader-rating.underweight,
.trader-rating.sell {
background: var(--red);
}
.trader-summary {
max-width: 72rem;
color: var(--ink);
font-size: 1.1rem;
line-height: 1.6;
}
.trader-grid,
.trader-lists {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.trader-grid article,
.trader-lists > div {
padding: 1.1rem;
border-radius: 0.9rem;
background: rgb(255 255 255 / 75%);
}
.trader-grid p,
.trader-lists li,
.data-quality {
color: var(--muted);
line-height: 1.55;
}
.data-quality {
margin: 1rem 0 0;
}
.agent-evidence > p { .agent-evidence > p {
color: var(--muted); color: var(--muted);
} }
...@@ -678,7 +780,9 @@ ...@@ -678,7 +780,9 @@
} }
.score-overview, .score-overview,
.report-section-grid { .report-section-grid,
.trader-grid,
.trader-lists {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
......
...@@ -155,6 +155,18 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy { ...@@ -155,6 +155,18 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
return JSON.stringify(report.agent_reports.raw_state, null, 2); return JSON.stringify(report.agent_reports.raw_state, null, 2);
} }
hasAgentEvidence(report: StockAnalysis): boolean {
const evidence = report.agent_reports;
return Boolean(
evidence.market_report ||
evidence.news_report ||
evidence.fundamentals_report ||
evidence.investment_plan ||
evidence.final_trade_decision ||
Object.keys(evidence.raw_state).length,
);
}
private loadStocks(): void { private loadStocks(): void {
this.loadingStocks.set(true); this.loadingStocks.set(true);
this.subscriptions.push( this.subscriptions.push(
......
...@@ -76,6 +76,31 @@ export interface AgentReports { ...@@ -76,6 +76,31 @@ export interface AgentReports {
raw_state: Record<string, unknown>; raw_state: Record<string, unknown>;
} }
export interface AITraderReport {
rating: 'Buy' | 'Overweight' | 'Hold' | 'Underweight' | 'Sell';
action: BilingualText;
confidence: 'low' | 'medium' | 'high';
executive_summary: BilingualText;
investment_thesis: BilingualText;
entry_strategy: BilingualText;
risk_controls: BilingualText;
catalysts: BilingualText[];
invalidation_conditions: BilingualText[];
time_horizon: BilingualText;
}
export interface AIResearch {
provider: string;
model: string;
mode: 'ai_fundamental' | 'multi_agent_synthesis';
generated_at: string;
score_confidence: 'low' | 'medium' | 'high';
score_rationale: BilingualText;
data_quality: BilingualText;
valuation_method_weights: Record<string, number>;
trader_report: AITraderReport;
}
export interface StockAnalysis { export interface StockAnalysis {
schema_version: '1.0'; schema_version: '1.0';
analysis_id: string; analysis_id: string;
...@@ -95,6 +120,7 @@ export interface StockAnalysis { ...@@ -95,6 +120,7 @@ export interface StockAnalysis {
factors: FactorCard[]; factors: FactorCard[];
report_sections: ReportSection[]; report_sections: ReportSection[];
agent_reports: AgentReports; agent_reports: AgentReports;
ai_research?: AIResearch | null;
disclaimer: BilingualText; disclaimer: BilingualText;
} }
......
This diff is collapsed.
This diff is collapsed.
...@@ -86,9 +86,34 @@ class AgentReports(APIModel): ...@@ -86,9 +86,34 @@ class AgentReports(APIModel):
raw_state: dict[str, Any] = Field(default_factory=dict) raw_state: dict[str, Any] = Field(default_factory=dict)
class AITraderReport(APIModel):
rating: Literal["Buy", "Overweight", "Hold", "Underweight", "Sell"]
action: BilingualText
confidence: Literal["low", "medium", "high"]
executive_summary: BilingualText
investment_thesis: BilingualText
entry_strategy: BilingualText
risk_controls: BilingualText
catalysts: list[BilingualText] = Field(default_factory=list)
invalidation_conditions: list[BilingualText] = Field(default_factory=list)
time_horizon: BilingualText
class AIResearch(APIModel):
provider: str
model: str
mode: Literal["ai_fundamental", "multi_agent_synthesis"]
generated_at: datetime
score_confidence: Literal["low", "medium", "high"]
score_rationale: BilingualText
data_quality: BilingualText
valuation_method_weights: dict[str, float] = Field(default_factory=dict)
trader_report: AITraderReport
class EvidenceSource(APIModel): class EvidenceSource(APIModel):
name: str name: str
source_type: Literal["dse_api", "agent_state", "calculation"] source_type: Literal["dse_api", "agent_state", "calculation", "ai_analysis"]
detail: str detail: str
...@@ -111,6 +136,7 @@ class StockAnalysis(APIModel): ...@@ -111,6 +136,7 @@ class StockAnalysis(APIModel):
factors: list[FactorCard] factors: list[FactorCard]
report_sections: list[ReportSection] report_sections: list[ReportSection]
agent_reports: AgentReports agent_reports: AgentReports
ai_research: AIResearch | None = None
sources: list[EvidenceSource] sources: list[EvidenceSource]
disclaimer: BilingualText disclaimer: BilingualText
......
"""Prompts used by the API-facing AI research workflow."""
"""Grounded prompts for the one-call dashboard research synthesizer."""
from __future__ import annotations
AI_STOCK_RESEARCH_SYSTEM_PROMPT = """You are the senior Dhaka Stock Exchange (DSE)
equity analyst and portfolio-risk reviewer for Doha Securities Stock AI.
Your job is to turn the supplied, date-bounded evidence into a clear educational
research report and a disciplined trader view. You are not a data-retrieval agent:
use only the JSON evidence in the user message, do not browse, call tools, or rely on
unstated memory.
NON-NEGOTIABLE EVIDENCE RULES
1. Never invent a figure, company fact, event, audit opinion, catalyst, price level,
peer, or financial statement line. If evidence is absent, explicitly say it is
unavailable and lower confidence.
2. Respect analysis_date as the hard information cutoff. Never use later data.
3. Distinguish reported facts from your inference. Cite years/periods and figures in
the prose whenever the evidence contains them.
4. Treat zero live price during a closed market as unavailable; the evidence builder
may supply the positive previous close as today's usable reference price.
5. Do not treat interim dividends as a full-year dividend. Do not annualize incomplete
quarters unless the evidence explicitly provides a trailing or annual figure.
6. Do not confuse revenue, operating profit, net profit, EPS, NAV, NOCFPS, cash,
equity, debt, or market price. Preserve the units present in the evidence.
7. A qualified/adverse audit opinion, missing cash-flow evidence, unusual related-party
exposure, weak free float, or dependence on non-core income must be discussed only
when supplied evidence supports it.
8. The raw gateway does not declare the scaling unit for company paid-up capital,
reserve/surplus, the annual `profit` field, or columnar balance-sheet amounts.
Never label those raw amounts as crore, million, billion, lakh, or a BDT amount.
Prefer per-share figures; otherwise call them "gateway-reported units".
AI FUNDAMENTAL SCORE
Return 0-10 judgments for exactly five factors. The application, not you, calculates
the final 0-100 score using: profitability 25%, financial health 25%, business quality
20%, valuation 15%, and dividend quality 15%.
- Profitability: profitable-year record, EPS/profit trend, consistency, and earnings quality.
- Financial health: debt, liquidity/cash cushion, equity, and operating cash conversion.
- Business quality: durability, operating consistency, ownership/governance, and evidence
of a defensible position. Do not award an imagined moat.
- Valuation: current price relative to the four supplied valuation anchors and the
reliability/dispersion of those anchors.
- Dividend quality: payment consistency, growth, payout support, and current-price context.
Score 5 when evidence is mixed or materially incomplete; do not turn missing data into 0 or 10.
The application calculates and displays the final weighted 0-100 score after your
response. Never state, estimate, or repeat an overall `/100` score in any narrative
field. Discuss only your five 0-10 factor judgments and their evidence.
VALUE TODAY SYSTEM
The evidence contains four pre-calculated, auditable value anchors:
historical_pe, peer_pe, historical_pb, and dividend_yield. Do not alter those numeric
anchors. Assign each method a 0-1 reliability weight. Give zero weight to unavailable
methods; down-weight weak peer sets, unstable earnings, stale NAV, irregular dividends,
or extreme outliers. The application normalizes your weights, calculates the weighted
rough estimate, creates an educational fair range of 80%-120% of that estimate, and
derives Looks cheap/Fair/Looks expensive from current price. Your prose must explain
which methods deserve trust and why. This is not a price target.
The application performs that calculation after your response. Never state a weighted
rough estimate, fair-range endpoints, or final cheap/fair/expensive verdict in any
narrative field. You may quote the four immutable method anchors and current price.
FULL RESEARCH
Produce exactly ten sections: company, business_model, profitability,
financial_safety, valuation, dividends, moat, bull_case, risks, and suitability.
Make the report useful to a trader: include concrete yearly trends, data limitations,
catalysts, invalidation conditions, entry discipline, risk controls, and time horizon.
Never promise returns. Never personalize position sizing because the user's holdings,
risk tolerance, and liquidity needs are unknown.
For entry strategy, do not invent technical support/resistance or use the not-yet-known
weighted fair range. Use conditional staged-entry language and only quote an exact price
level if it is explicitly present in raw price history. Use volume or moving-average
claims only when `technical_evidence` supplies them. A 52-week low is an observed range
endpoint, not automatically proven support. Return at least two concrete
catalysts and two concrete invalidation conditions; if company-specific evidence is
missing, make the condition a future evidence check rather than inventing an event.
LANGUAGE AND STYLE
Every user-facing text field must contain both natural English (en) and natural Bangla
(bn), conveying the same facts. Use plain language first, with financial terminology
where it improves precision. Be decisive but calibrated. Do not use marketing language,
claim perfection, or present the output as investment advice.
"""
AI_STOCK_RESEARCH_TASK_TEMPLATE = """Analyze the supplied evidence for {symbol} as of
{analysis_date}. The current mode is {mode}. If mode is multi_agent_synthesis, reconcile
the supplied analyst/trader reports with the raw DSE evidence; raw numeric evidence wins
when prose conflicts with it. If mode is ai_fundamental, state that the trader view is
based on fundamentals, price history, and disclosures only—not a full multi-agent debate.
Return the required structured object. Keep each long section substantive but concise;
across the ten sections, cover every material strength, weakness, valuation issue, and
decision condition present in the evidence.
AUTHORITATIVE EVIDENCE JSON:
{evidence_json}
"""
...@@ -11,6 +11,7 @@ from datetime import date, datetime, timezone ...@@ -11,6 +11,7 @@ from datetime import date, datetime, timezone
from dohasecuritiesstockai.default_config import DEFAULT_CONFIG from dohasecuritiesstockai.default_config import DEFAULT_CONFIG
from dohasecuritiesstockai.graph.trading_graph import TradingAgentsGraph from dohasecuritiesstockai.graph.trading_graph import TradingAgentsGraph
from .ai_analysis import AIStockAnalysisGenerator
from .analysis import StockAnalysisBuilder from .analysis import StockAnalysisBuilder
from .models import AnalysisJob, StockAnalysis from .models import AnalysisJob, StockAnalysis
from .repository import AnalysisRepository from .repository import AnalysisRepository
...@@ -62,7 +63,7 @@ class AnalysisService: ...@@ -62,7 +63,7 @@ class AnalysisService:
job = self._update(job, status="running", message="Collecting DSE evidence.") job = self._update(job, status="running", message="Collecting DSE evidence.")
if not force: if not force:
existing = self.repository.get_analysis(job.symbol, job.analysis_date) existing = self.repository.get_analysis(job.symbol, job.analysis_date)
if existing: if existing and existing.ai_research is not None:
self._update( self._update(
job, job,
status="completed", status="completed",
...@@ -90,10 +91,11 @@ class AnalysisService: ...@@ -90,10 +91,11 @@ class AnalysisService:
else: else:
state = self.repository.load_state(state_path) state = self.repository.load_state(state_path)
job = self._update(job, message="Calculating the presentation score and valuation.") job = self._update(
analysis = StockAnalysisBuilder().build( job,
job.symbol, job.analysis_date, agent_state=state message="Running the grounded AI score, valuation, and trader synthesis.",
) )
analysis = self._build_analysis(job.symbol, job.analysis_date, state)
self.repository.save_analysis(analysis) self.repository.save_analysis(analysis)
self._update( self._update(
job, job,
...@@ -119,6 +121,20 @@ class AnalysisService: ...@@ -119,6 +121,20 @@ class AnalysisService:
existing = self.repository.get_analysis(symbol, analysis_date) existing = self.repository.get_analysis(symbol, analysis_date)
if existing: if existing:
return existing return existing
analysis = StockAnalysisBuilder().build(symbol, analysis_date, state) analysis = self._build_analysis(symbol, analysis_date, state)
self.repository.save_analysis(analysis) self.repository.save_analysis(analysis)
return analysis return analysis
@staticmethod
def _build_analysis(
symbol: str,
analysis_date: date,
state: dict,
) -> StockAnalysis:
builder = StockAnalysisBuilder()
analysis = builder.build(symbol, analysis_date, state)
return AIStockAnalysisGenerator(DEFAULT_CONFIG).enhance(
analysis,
builder.last_evidence,
state,
)
...@@ -20,6 +20,7 @@ import uvicorn ...@@ -20,6 +20,7 @@ import uvicorn
from dohasecuritiesstockai.api.analysis import StockAnalysisBuilder from dohasecuritiesstockai.api.analysis import StockAnalysisBuilder
from dohasecuritiesstockai.api.models import StockAnalysis from dohasecuritiesstockai.api.models import StockAnalysis
from dohasecuritiesstockai.api.repository import AnalysisRepository from dohasecuritiesstockai.api.repository import AnalysisRepository
from dohasecuritiesstockai.default_config import DEFAULT_CONFIG
class DashboardLaunchError(RuntimeError): class DashboardLaunchError(RuntimeError):
...@@ -29,17 +30,34 @@ class DashboardLaunchError(RuntimeError): ...@@ -29,17 +30,34 @@ class DashboardLaunchError(RuntimeError):
def prepare_dashboard_analysis( def prepare_dashboard_analysis(
symbol: str, symbol: str,
analysis_date: date | str, analysis_date: date | str,
agent_state: dict[str, Any], agent_state: dict[str, Any] | None,
results_dir: str | Path, results_dir: str | Path,
*,
use_ai: bool = True,
config: dict[str, Any] | None = None,
) -> tuple[StockAnalysis, Path]: ) -> tuple[StockAnalysis, Path]:
"""Build and save the presentation model from the exact completed CLI state.""" """Build and save the UI payload, optionally enriching it with agent state.
Passing ``agent_state=None`` deliberately skips the multi-agent graph. When
``use_ai`` is true, one configured model call synthesizes the already-fetched
evidence into the score, valuation weighting, report, and trader view.
"""
parsed_date = ( parsed_date = (
analysis_date analysis_date
if isinstance(analysis_date, date) if isinstance(analysis_date, date)
else date.fromisoformat(str(analysis_date)) else date.fromisoformat(str(analysis_date))
) )
analysis = StockAnalysisBuilder().build(symbol, parsed_date, agent_state=agent_state) builder = StockAnalysisBuilder()
analysis = builder.build(symbol, parsed_date, agent_state=agent_state)
if use_ai:
from dohasecuritiesstockai.api.ai_analysis import AIStockAnalysisGenerator
analysis = AIStockAnalysisGenerator(config or DEFAULT_CONFIG).enhance(
analysis,
builder.last_evidence,
agent_state,
)
path = AnalysisRepository(results_dir).save_analysis(analysis) path = AnalysisRepository(results_dir).save_analysis(analysis)
return analysis, path return analysis, path
......
"""Lightweight dashboard command that never starts the multi-agent graph."""
from __future__ import annotations
from datetime import date
from typing import Annotated
import typer
from rich.console import Console
from rich.panel import Panel
from dohasecuritiesstockai.dashboard import (
dashboard_url,
launch_dashboard,
prepare_dashboard_analysis,
)
from dohasecuritiesstockai.default_config import DEFAULT_CONFIG
console = Console()
def show_dashboard(
symbol: Annotated[
str,
typer.Argument(help="DSE symbol, for example GP or BRACBANK."),
],
analysis_date: Annotated[
str | None,
typer.Option("--date", "-d", help="Dashboard data date (YYYY-MM-DD)."),
] = None,
use_ai: Annotated[
bool,
typer.Option(
"--ai/--no-ai",
help="Use the configured AI for score, valuation weighting, and trader report.",
),
] = True,
open_ui: Annotated[
bool,
typer.Option(
"--open-ui/--no-open-ui",
help="Serve and open the dashboard after fetching its data.",
),
] = True,
host: Annotated[
str,
typer.Option(help="Local dashboard/API bind host."),
] = "127.0.0.1",
port: Annotated[
int,
typer.Option(min=1, max=65535, help="Local dashboard/API port."),
] = 8000,
) -> None:
"""Fetch only the DSE score-page data, then open the local dashboard."""
try:
requested_date = date.fromisoformat(analysis_date) if analysis_date else date.today()
if requested_date > date.today():
raise ValueError("--date cannot be in the future.")
except ValueError as exc:
console.print(f"[bold red]Invalid dashboard date:[/bold red] {exc}")
raise typer.Exit(code=2) from None
console.print(
Panel.fit(
f"[bold]DSE score dashboard[/bold]\n"
f"{symbol.strip().upper()} · {requested_date.isoformat()} · "
f"{'grounded AI research' if use_ai else 'calculated data only'}",
border_style="cyan",
)
)
try:
status = (
"Collecting yearly DSE evidence and running the configured AI analyst…"
if use_ai
else "Requesting the dashboard fields from the DSE data APIs…"
)
with console.status(status):
analysis, saved_path = prepare_dashboard_analysis(
symbol,
requested_date,
agent_state=None,
results_dir=DEFAULT_CONFIG["results_dir"],
use_ai=use_ai,
config=DEFAULT_CONFIG,
)
except Exception as exc:
console.print(f"[bold red]Dashboard data request failed:[/bold red] {exc}")
raise typer.Exit(code=1) from None
console.print(
f"[green]✓ Dashboard data saved:[/green] {saved_path}\n"
f"[green]✓ Fundamental score:[/green] "
f"{analysis.fundamental_score}/100 ({analysis.score_label.en})"
)
if analysis.ai_research is not None:
console.print(
f"[green]✓ AI analyst:[/green] {analysis.ai_research.provider} / "
f"{analysis.ai_research.model}\n"
f"[green]✓ Trader view:[/green] "
f"{analysis.ai_research.trader_report.rating}"
)
if not open_ui:
return
url = dashboard_url(host, port, analysis.symbol, analysis.analysis_date)
console.print(
f"[green]✓ Opening:[/green] {url}\n"
"[dim]Keep this terminal open; press Ctrl+C to stop the UI.[/dim]"
)
try:
launch_dashboard(
analysis.symbol,
analysis.analysis_date,
host=host,
port=port,
)
except Exception as exc:
console.print(f"[bold red]Dashboard launch failed:[/bold red] {exc}")
raise typer.Exit(code=1) from None
def main() -> None:
typer.run(show_dashboard)
if __name__ == "__main__":
main()
...@@ -63,6 +63,7 @@ dohasecuritiesstockai = "cli.main:app" ...@@ -63,6 +63,7 @@ dohasecuritiesstockai = "cli.main:app"
tradingagents = "cli.main:app" tradingagents = "cli.main:app"
dohasecuritiesstockai-api = "dohasecuritiesstockai.api.__main__:main" dohasecuritiesstockai-api = "dohasecuritiesstockai.api.__main__:main"
tradingagents-api = "dohasecuritiesstockai.api.__main__:main" tradingagents-api = "dohasecuritiesstockai.api.__main__:main"
dohasecuritiesstockai-dashboard = "dohasecuritiesstockai.dashboard_cli:main"
dohasecuritiesstockai-predict = "dohasecuritiesstockai.timesfm_forecasting.cli:main" dohasecuritiesstockai-predict = "dohasecuritiesstockai.timesfm_forecasting.cli:main"
tradingagents-predict = "dohasecuritiesstockai.timesfm_forecasting.cli:main" tradingagents-predict = "dohasecuritiesstockai.timesfm_forecasting.cli:main"
dohasecuritiesstockai-dgt-predict = "dohasecuritiesstockai.dgt_forecasting.cli:main" dohasecuritiesstockai-dgt-predict = "dohasecuritiesstockai.dgt_forecasting.cli:main"
......
from __future__ import annotations
from datetime import date, datetime, timezone
from types import SimpleNamespace
from dohasecuritiesstockai.api.ai_analysis import (
AIStockAnalysisGenerator,
AIStockResearchOutput,
_narrative_violations,
)
from dohasecuritiesstockai.api.models import (
AgentReports,
BilingualText,
EvidenceSource,
FactorCard,
MarketSnapshot,
ScoreMetric,
StockAnalysis,
ValuationMethod,
ValuationSummary,
)
def _text(value: str) -> dict[str, str]:
return {"en": value, "bn": f"বাংলা {value}"}
def _ai_output() -> AIStockResearchOutput:
factors = {
key: {"score": 8, "rationale": _text(f"{key} evidence")}
for key in (
"profitability",
"financial_health",
"business_quality",
"valuation",
"dividend",
)
}
section_keys = (
"company",
"business_model",
"profitability",
"financial_safety",
"valuation",
"dividends",
"moat",
"bull_case",
"risks",
"suitability",
)
return AIStockResearchOutput.model_validate(
{
"factors": factors,
"score_confidence": "high",
"score_rationale": _text("Grounded score rationale"),
"headline": _text("Strong evidence-backed profile"),
"takeaways": [_text("One"), _text("Two"), _text("Three")],
"in_depth_title": _text("AI in-depth research"),
"in_depth_summary": _text("Yearly evidence was reviewed."),
"valuation": {
"weights": {
"historical_pe": 1,
"peer_pe": 0,
"historical_pb": 0,
"dividend_yield": 0,
},
"confidence": "medium",
"summary": _text("The earnings-history method is most reliable."),
},
"sections": [
{
"key": key,
"title": _text(key.replace("_", " ").title()),
"summary": _text(f"Detailed {key} analysis"),
"bullets": [_text(f"{key} evidence point")],
}
for key in section_keys
],
"trader": {
"rating": "Overweight",
"action": _text("Accumulate only with entry discipline"),
"confidence": "medium",
"executive_summary": _text("Use staged entries and defined risk."),
"investment_thesis": _text("Fundamentals are constructive."),
"entry_strategy": _text("Wait for confirmation."),
"risk_controls": _text("Limit exposure and define invalidation."),
"catalysts": [
_text("Improving earnings"),
_text("Stable dividend coverage"),
],
"invalidation_conditions": [
_text("Earnings deterioration"),
_text("Dividend coverage weakens"),
],
"time_horizon": _text("Six to twelve months"),
},
"data_quality": _text("Annual evidence is available; some fields are missing."),
}
)
def _baseline() -> StockAnalysis:
labels = {
"profitability": "Profitability",
"financial_health": "Financial health",
"business_quality": "Business quality",
"valuation": "Valuation",
"dividend": "Dividend",
}
factors = [
FactorCard(
key=key,
status="caution",
title=BilingualText(en=label, bn=label),
subtitle=BilingualText(en="Baseline", bn="Baseline"),
explanation=BilingualText(en="Calculated", bn="Calculated"),
metrics=[
ScoreMetric(
key="calculated",
label=BilingualText(en="Calculated", bn="Calculated"),
display_value="5/10",
score=5,
)
],
)
for key, label in labels.items()
]
methods = [
ValuationMethod(
key=key,
label=BilingualText(en=key, bn=key),
value=value,
available=True,
)
for key, value in zip(
("historical_pe", "peer_pe", "historical_pb", "dividend_yield"),
(100, 200, 300, 400),
strict=True,
)
]
return StockAnalysis(
analysis_id="GP-2026-08-10",
symbol="GP",
company_name="Grameenphone Ltd.",
sector="Telecom",
analysis_date=date(2026, 8, 10),
generated_at=datetime.now(timezone.utc),
market=MarketSnapshot(
latest_price=50,
as_of=datetime.now(timezone.utc),
),
fundamental_score=50,
score_label=BilingualText(en="Mixed", bn="মিশ্র"),
headline=BilingualText(en="Baseline", bn="Baseline"),
takeaways=[],
in_depth_title=BilingualText(en="Baseline", bn="Baseline"),
in_depth_snippet=BilingualText(en="Baseline", bn="Baseline"),
valuation=ValuationSummary(
verdict="fair",
verdict_label=BilingualText(en="Fair price", bn="ন্যায্য দাম"),
current_price=50,
rough_estimate=250,
fair_range_low=200,
fair_range_high=300,
confidence="high",
summary=BilingualText(en="Calculated", bn="Calculated"),
methods=methods,
),
factors=factors,
report_sections=[],
agent_reports=AgentReports(),
sources=[
EvidenceSource(name="DSE", source_type="dse_api", detail="read-only")
],
disclaimer=BilingualText(en="Educational only", bn="শিক্ষামূলক"),
)
def test_ai_enhancement_recomputes_score_and_weighted_value() -> None:
captured: dict[str, object] = {}
output = _ai_output()
structured = SimpleNamespace(
invoke=lambda messages: captured.setdefault("messages", messages) and output
)
llm = SimpleNamespace(with_structured_output=lambda schema: structured)
generator = AIStockAnalysisGenerator(
{"llm_provider": "test", "deep_think_llm": "test-model"},
llm=llm,
)
result = generator.enhance(
_baseline(),
{"annual_financial_performance": [{"year": 2025, "eps_basic": "12.4"}]},
)
assert result.fundamental_score == 80
assert result.score_label.en == "Very good"
assert result.valuation.rough_estimate == 100
assert result.valuation.fair_range_low == 80
assert result.valuation.fair_range_high == 120
assert result.valuation.verdict == "looks_cheap"
assert result.ai_research is not None
assert result.ai_research.mode == "ai_fundamental"
assert result.ai_research.trader_report.rating == "Overweight"
assert len(result.report_sections) == 10
assert all(factor.metrics[0].key == "ai_factor_assessment" for factor in result.factors)
messages = captured["messages"]
assert "Never invent" in messages[0]["content"]
assert "Never state, estimate, or repeat an overall" in messages[0]["content"]
assert "Never state a weighted" in messages[0]["content"]
assert '"year": 2025' in messages[1]["content"]
assert "ai_fundamental" in messages[1]["content"]
assert '"fundamental_score"' not in messages[1]["content"]
def test_multi_agent_state_changes_ai_research_mode() -> None:
output = _ai_output()
structured = SimpleNamespace(invoke=lambda messages: output)
llm = SimpleNamespace(with_structured_output=lambda schema: structured)
generator = AIStockAnalysisGenerator(
{"llm_provider": "test", "deep_think_llm": "test-model"},
llm=llm,
)
result = generator.enhance(
_baseline(),
{},
{"final_trade_decision": "**Rating**: Hold"},
)
assert result.ai_research is not None
assert result.ai_research.mode == "multi_agent_synthesis"
def test_narrative_guard_retries_structured_output_once() -> None:
bad_payload = _ai_output().model_dump()
bad_payload["headline"] = {"en": "Old score 72/100", "bn": "পুরোনো ৭২/১০০"}
bad = AIStockResearchOutput.model_validate(bad_payload)
good = _ai_output()
responses = iter([bad, good])
calls: list[object] = []
def invoke(messages):
calls.append(messages)
return next(responses)
structured = SimpleNamespace(invoke=invoke)
llm = SimpleNamespace(with_structured_output=lambda schema: structured)
generator = AIStockAnalysisGenerator(
{"llm_provider": "test", "deep_think_llm": "test-model"},
llm=llm,
)
result = generator.enhance(_baseline(), {})
assert result.headline.en == good.headline.en
assert len(calls) == 2
assert _narrative_violations(good) == []
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
import pandas as pd
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from dohasecuritiesstockai.api.analysis import StockAnalysisBuilder from dohasecuritiesstockai.api.analysis import (
StockAnalysisBuilder,
_market_price,
_rows_through,
)
from dohasecuritiesstockai.api.app import app from dohasecuritiesstockai.api.app import app
from dohasecuritiesstockai.api.models import AnalysisJob from dohasecuritiesstockai.api.models import AnalysisJob
from dohasecuritiesstockai.api.repository import AnalysisRepository from dohasecuritiesstockai.api.repository import AnalysisRepository
...@@ -39,6 +44,50 @@ def test_interim_dividend_is_not_treated_as_full_year() -> None: ...@@ -39,6 +44,50 @@ def test_interim_dividend_is_not_treated_as_full_year() -> None:
assert result == {2025: 20.0} assert result == {2025: 20.0}
def test_market_closed_quote_falls_back_to_previous_close() -> None:
quote = {"ltp": 0, "close": 0, "ycp": 258.7}
assert _market_price(quote) == 258.7
def test_ai_evidence_rows_respect_analysis_date() -> None:
rows = [
{"date": "2026-08-09", "eps_basic": "10"},
{"date": "2026-08-11", "eps_basic": "99"},
{"year": 2025, "eps_basic": "8"},
{"year": 2027, "eps_basic": "100"},
]
assert _rows_through(rows, date(2026, 8, 10)) == [rows[0], rows[2]]
def test_historical_snapshot_uses_last_candle_at_cutoff(monkeypatch) -> None:
frame = pd.DataFrame(
{
"Date": pd.to_datetime(["2026-08-09", "2026-08-10"]),
"Close": [256.6, 258.6],
"Open": [256.0, 257.0],
"High": [258.0, 259.0],
"Low": [255.5, 257.0],
"Volume": [160_000, 146_000],
}
)
monkeypatch.setattr(
"dohasecuritiesstockai.api.analysis.fetch_dse_ohlcv",
lambda symbol, start, end: frame,
)
snapshot = StockAnalysisBuilder._historical_snapshot("GP", date(2026, 8, 10))
assert snapshot["latest_price"] == 258.6
assert snapshot["previous_close"] == 256.6
assert snapshot["change_percent"] == 0.78
assert snapshot["as_of"] == datetime(2026, 8, 10, tzinfo=timezone.utc)
assert snapshot["technical_evidence"]["latest_volume"] == 146_000
assert snapshot["technical_evidence"]["average_volume_20"] == 153_000
assert snapshot["technical_evidence"]["recent_bars"][-1]["date"] == "2026-08-10"
def test_job_repository_round_trip(tmp_path: Path) -> None: def test_job_repository_round_trip(tmp_path: Path) -> None:
repository = AnalysisRepository(tmp_path) repository = AnalysisRepository(tmp_path)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
......
...@@ -27,6 +27,10 @@ def test_product_command_and_legacy_alias_share_the_cli_entrypoint(): ...@@ -27,6 +27,10 @@ def test_product_command_and_legacy_alias_share_the_cli_entrypoint():
'dohasecuritiesstockai-api = "dohasecuritiesstockai.api.__main__:main"' 'dohasecuritiesstockai-api = "dohasecuritiesstockai.api.__main__:main"'
in project in project
) )
assert (
'dohasecuritiesstockai-dashboard = "dohasecuritiesstockai.dashboard_cli:main"'
in project
)
def test_python_package_uses_dohasecuritiesstockai_directory(): def test_python_package_uses_dohasecuritiesstockai_directory():
......
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import date
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import cli.main as cli_main import cli.main as cli_main
import dohasecuritiesstockai.dashboard_cli as dashboard_cli
from dohasecuritiesstockai.api.app import create_app from dohasecuritiesstockai.api.app import create_app
from dohasecuritiesstockai.dashboard import dashboard_url from dohasecuritiesstockai.dashboard import dashboard_url, prepare_dashboard_analysis
from dohasecuritiesstockai.graph.trading_graph import TradingAgentsGraph from dohasecuritiesstockai.graph.trading_graph import TradingAgentsGraph
...@@ -17,6 +20,85 @@ def test_dashboard_url_targets_exact_cli_result() -> None: ...@@ -17,6 +20,85 @@ def test_dashboard_url_targets_exact_cli_result() -> None:
) )
def test_lightweight_dashboard_command_skips_agent_state(tmp_path: Path, monkeypatch) -> None:
calls: dict[str, object] = {}
analysis = SimpleNamespace(
symbol="GP",
analysis_date=date(2026, 8, 10),
fundamental_score=72,
score_label=SimpleNamespace(en="Good"),
ai_research=None,
)
def fake_prepare(
symbol, analysis_date, agent_state, results_dir, *, use_ai, config
):
calls["prepare"] = (
symbol,
analysis_date,
agent_state,
results_dir,
use_ai,
config,
)
return analysis, tmp_path / "GP.json"
def fake_launch(symbol, analysis_date, *, host, port):
calls["launch"] = (symbol, analysis_date, host, port)
monkeypatch.setattr(dashboard_cli, "prepare_dashboard_analysis", fake_prepare)
monkeypatch.setattr(dashboard_cli, "launch_dashboard", fake_launch)
monkeypatch.setitem(dashboard_cli.DEFAULT_CONFIG, "results_dir", str(tmp_path))
dashboard_cli.show_dashboard(
"gp",
analysis_date="2026-08-10",
use_ai=True,
open_ui=True,
host="0.0.0.0",
port=8123,
)
assert calls["prepare"] == (
"gp",
date(2026, 8, 10),
None,
str(tmp_path),
True,
dashboard_cli.DEFAULT_CONFIG,
)
assert calls["launch"] == ("GP", date(2026, 8, 10), "0.0.0.0", 8123)
def test_prepare_dashboard_analysis_accepts_lightweight_mode(tmp_path: Path, monkeypatch) -> None:
built = SimpleNamespace(symbol="GP", analysis_date=date(2026, 8, 10))
calls: dict[str, object] = {}
class FakeBuilder:
def build(self, symbol, analysis_date, agent_state):
calls["build"] = (symbol, analysis_date, agent_state)
return built
class FakeRepository:
def __init__(self, results_dir):
calls["results_dir"] = results_dir
def save_analysis(self, analysis):
calls["saved"] = analysis
return tmp_path / "analysis.json"
monkeypatch.setattr("dohasecuritiesstockai.dashboard.StockAnalysisBuilder", FakeBuilder)
monkeypatch.setattr("dohasecuritiesstockai.dashboard.AnalysisRepository", FakeRepository)
result, path = prepare_dashboard_analysis(
"GP", "2026-08-10", None, tmp_path, use_ai=False
)
assert result is built
assert path == tmp_path / "analysis.json"
assert calls["build"] == ("GP", date(2026, 8, 10), None)
def test_open_ui_env_flag_is_strict(monkeypatch) -> None: def test_open_ui_env_flag_is_strict(monkeypatch) -> None:
monkeypatch.setenv("TRADINGAGENTS_OPEN_UI_AFTER_ANALYSIS", "yes") monkeypatch.setenv("TRADINGAGENTS_OPEN_UI_AFTER_ANALYSIS", "yes")
assert cli_main._env_flag("TRADINGAGENTS_OPEN_UI_AFTER_ANALYSIS") is True assert cli_main._env_flag("TRADINGAGENTS_OPEN_UI_AFTER_ANALYSIS") is True
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment