From f71da8cc52df2684639cca69e9e6d1b2a410697c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 11 Jun 2026 14:11:32 +0900 Subject: [PATCH 01/45] Add EDINET data controls and security search --- .../investment/analysis.py | 9 + .../investment/candidates.py | 71 ++- src/investment_assistant/investment/detail.py | 11 + src/investment_assistant/investment/edinet.py | 64 ++ .../investment/reporting.py | 51 ++ src/investment_assistant/webapi/service.py | 171 ++++++ tests/unit/test_investment_samples_smoke.py | 5 + tests/unit/test_webapi.py | 94 +++ web/src/App.tsx | 548 +++++++++++++++++- web/src/styles.css | 98 ++++ 10 files changed, 1084 insertions(+), 38 deletions(-) create mode 100644 src/investment_assistant/investment/edinet.py diff --git a/src/investment_assistant/investment/analysis.py b/src/investment_assistant/investment/analysis.py index 17e1a17..c0a3188 100644 --- a/src/investment_assistant/investment/analysis.py +++ b/src/investment_assistant/investment/analysis.py @@ -7,6 +7,7 @@ from pathlib import Path from investment_assistant.financials.evidence import DEFAULT_FINANCIALS_CSV, load_comparison +from investment_assistant.investment.edinet import build_edinet_summary from investment_assistant.investment.models import DISCLAIMER, InvestmentHolding from investment_assistant.investment.provider_policy import ProviderPolicy, provider_policy @@ -73,6 +74,12 @@ def analyze_portfolio( if market_value else 0.0, } + if company is not None: + row["edinet_summary"] = build_edinet_summary( + company, + financials_csv=financials_csv, + generated_at=generated_at, + ) row_data_alerts = _holding_data_alerts( holding=holding, generated_at=generated_at_dt, @@ -171,6 +178,8 @@ def analyze_portfolio( "asset_mix": _share_map(asset_mix, total_market), "tax_wrapper_mix": _share_map(tax_wrapper_mix, total_market), "nisa": nisa, + "edinet_covered_holdings": sum(1 for row in rows if row.get("edinet_summary")), + "edinet_source_ref": str(financials_csv), "data_quality": _data_quality_summary(data_alerts), "income_quality": _income_quality_summary(income_alerts), } diff --git a/src/investment_assistant/investment/candidates.py b/src/investment_assistant/investment/candidates.py index d8ffcbe..dc7e271 100644 --- a/src/investment_assistant/investment/candidates.py +++ b/src/investment_assistant/investment/candidates.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections.abc import Sequence +from datetime import UTC, datetime from pathlib import Path -from investment_assistant.financials.evidence import DEFAULT_FINANCIALS_CSV +from investment_assistant.financials.evidence import DEFAULT_FINANCIALS_CSV, load_comparison +from investment_assistant.investment.edinet import build_edinet_summary from investment_assistant.investment.models import DISCLAIMER, CandidateScreen, FundProfile from investment_assistant.investment.provider_policy import provider_policy from investment_assistant.scoring.stock import run_stock_scoring @@ -23,6 +25,9 @@ def screen_candidates( items: list[dict[str, object]] = [] blocked_providers: list[dict[str, object]] = [] asset_types = set(screen.asset_types) + generated_at = datetime.now(UTC).isoformat() + financials_source_ref = str(financials_csv) + companies = _company_index(financials_csv) if "stock" in asset_types: stock_result = run_stock_scoring( @@ -34,7 +39,16 @@ def screen_candidates( limit=None, ) for row in _rows(stock_result.get("results")): - items.append(_stock_candidate(row, screen)) + code = str(row.get("ticker") or "") + items.append( + _stock_candidate( + row, + screen, + financials_source_ref=financials_source_ref, + generated_at=generated_at, + company=companies.get(code), + ) + ) if "fund" in asset_types: for fund in funds: @@ -62,6 +76,8 @@ def screen_candidates( items = items[: max(screen.limit, 0)] return { "available": True, + "generated_at": generated_at, + "financials_source_ref": financials_source_ref, "screen": { "asset_types": list(screen.asset_types), "exclude_dividend_cut": screen.exclude_dividend_cut, @@ -108,7 +124,14 @@ def screen_from_values( ) -def _stock_candidate(row: dict[str, object], screen: CandidateScreen) -> dict[str, object]: +def _stock_candidate( + row: dict[str, object], + screen: CandidateScreen, + *, + financials_source_ref: str, + generated_at: str, + company: dict[str, object] | None, +) -> dict[str, object]: metrics = row.get("metrics") metric_map = metrics if isinstance(metrics, dict) else {} conditions = ["EDINET財務データあり"] @@ -116,6 +139,16 @@ def _stock_candidate(row: dict[str, object], screen: CandidateScreen) -> dict[st conditions.append("減配履歴なし") if screen.min_equity_ratio is not None: conditions.append(f"自己資本比率 {screen.min_equity_ratio:g}% 以上") + evidence = [ + { + "claim_key": f"candidate.{row.get('ticker')}.edinet_financials", + "source_type": "edinet_financials", + "metric_key": "dividend/equity/operating_cf", + "source_ref": financials_source_ref, + "formula": "EDINET-derived financials CSV -> deterministic stock score inputs", + "last_updated": generated_at, + } + ] return { "asset_type": "stock", "code": row.get("ticker"), @@ -123,13 +156,14 @@ def _stock_candidate(row: dict[str, object], screen: CandidateScreen) -> dict[st "score": row.get("total_score"), "matched_conditions": conditions, "metrics": metric_map, - "evidence": [ - { - "source_type": "edinet_financials", - "metric_key": "dividend/equity/operating_cf", - "source_ref": str(DEFAULT_FINANCIALS_CSV), - } - ], + "edinet_summary": build_edinet_summary( + company, + financials_csv=financials_source_ref, + generated_at=generated_at, + ) + if company is not None + else None, + "evidence": evidence, } @@ -173,6 +207,23 @@ def _rows(value: object) -> list[dict[str, object]]: return [item for item in value if isinstance(item, dict)] +def _company_index(financials_csv: str | Path) -> dict[str, dict[str, object]]: + comparison = load_comparison(financials_csv) + if comparison is None: + return {} + companies = comparison.get("companies") + if not isinstance(companies, list): + return {} + out: dict[str, dict[str, object]] = {} + for company in companies: + if not isinstance(company, dict): + continue + ticker = str(company.get("ticker") or "").strip() + if ticker: + out[ticker] = company + return out + + def _sort(items: list[dict[str, object]], sort_by: str) -> list[dict[str, object]]: if sort_by == "expense_ratio": return sorted(items, key=lambda item: _expense_key(item)) diff --git a/src/investment_assistant/investment/detail.py b/src/investment_assistant/investment/detail.py index 74ecac9..e643e13 100644 --- a/src/investment_assistant/investment/detail.py +++ b/src/investment_assistant/investment/detail.py @@ -12,6 +12,7 @@ from investment_assistant.financials.evidence import DEFAULT_FINANCIALS_CSV, load_comparison from investment_assistant.investment.analysis import analyze_portfolio +from investment_assistant.investment.edinet import build_edinet_summary from investment_assistant.investment.models import DISCLAIMER, FundProfile, InvestmentHolding @@ -104,6 +105,15 @@ def build_investment_detail( } ) metrics.extend(_financial_metrics(company, claim_key, generated_at)) + edinet_summary = ( + build_edinet_summary( + company, + financials_csv=financials_csv, + generated_at=generated_at, + ) + if company is not None + else None + ) if fund is not None: claim_key = f"fund.{normalized_code}.profile" @@ -130,6 +140,7 @@ def build_investment_detail( "holding_summary": holding_summary, "holdings": holding_rows, "financials": company, + "edinet_summary": edinet_summary, "fund_profile": fund.to_dict() if fund is not None else None, "metrics": _dedupe_metrics(metrics), "sections": _sections( diff --git a/src/investment_assistant/investment/edinet.py b/src/investment_assistant/investment/edinet.py new file mode 100644 index 0000000..0768b6c --- /dev/null +++ b/src/investment_assistant/investment/edinet.py @@ -0,0 +1,64 @@ +"""Small EDINET summary helpers for investment MVP payloads.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path + +TREND_LABELS: dict[str, str] = { + "increasing": "増加傾向", + "declining": "減少傾向", + "flat": "横ばい", + "mixed": "増減混在", + "insufficient": "データ不足", +} + + +def trend_label(value: object) -> str: + """Return a Japanese label for a normalized financial trend value.""" + + key = str(value or "insufficient") + return TREND_LABELS.get(key, key) + + +def build_edinet_summary( + company: Mapping[str, object], + *, + financials_csv: str | Path, + generated_at: str, +) -> dict[str, object]: + """Build a compact, display-ready EDINET financial summary.""" + + years = _list(company.get("years")) + cut_years = _list(company.get("dividend_cut_years")) + dividend_series = _list(company.get("dividend_series")) + dividend_trend = str(company.get("dividend_trend") or "insufficient") + operating_cf_trend = str(company.get("operating_cf_trend") or "insufficient") + equity_ratio_trend = str(company.get("equity_ratio_trend") or "insufficient") + return { + "source_type": "edinet_financials", + "source_ref": str(financials_csv), + "ticker": str(company.get("ticker") or ""), + "name": str(company.get("name") or ""), + "latest_fiscal_year": company.get("latest_fiscal_year"), + "latest_operating_cf": company.get("latest_operating_cf"), + "latest_equity_ratio": company.get("latest_equity_ratio"), + "latest_dividend_per_share": company.get("latest_dividend_per_share"), + "dividend_cut_years": cut_years, + "dividend_trend": dividend_trend, + "dividend_trend_label": trend_label(dividend_trend), + "operating_cf_trend": operating_cf_trend, + "operating_cf_trend_label": trend_label(operating_cf_trend), + "equity_ratio_trend": equity_ratio_trend, + "equity_ratio_trend_label": trend_label(equity_ratio_trend), + "periods": len(years) if years else len(dividend_series), + "payout_policy": company.get("payout_policy"), + "last_updated": generated_at, + "note": "EDINET由来の取得済み財務CSVを機械集計した比較材料です。", + } + + +def _list(value: object) -> list[object]: + if not isinstance(value, Sequence) or isinstance(value, str | bytes): + return [] + return list(value) diff --git a/src/investment_assistant/investment/reporting.py b/src/investment_assistant/investment/reporting.py index 225fc63..1c51f27 100644 --- a/src/investment_assistant/investment/reporting.py +++ b/src/investment_assistant/investment/reporting.py @@ -55,6 +55,7 @@ def build_investment_monthly_report( "note": "条件一致の比較候補であり、推奨ではありません。", } ) + evidence.extend(_candidate_evidence_rows(item, generated_at)) target = _mapping(target_result.get("target")) if target_result is not None else None target_summary = _mapping(target_result.get("summary")) if target_result is not None else None target_concentration = ( @@ -409,6 +410,56 @@ def _evidence_rows(value: object) -> list[dict[str, object]]: return [item for item in value if isinstance(item, dict)] +def _candidate_evidence_rows( + candidate: Mapping[str, object], + generated_at: str, +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + raw_rows = candidate.get("evidence") + if isinstance(raw_rows, list): + for index, row in enumerate(raw_rows): + if not isinstance(row, Mapping): + continue + source_type = row.get("source_type") + if source_type != "edinet_financials": + continue + code = str(candidate.get("code") or f"row{index}") + rows.append( + { + "claim_key": str( + row.get("claim_key") or f"candidate.{code}.edinet_financials" + ), + "source_type": "edinet_financials", + "source_ref": row.get("source_ref"), + "metric_key": row.get("metric_key") or "dividend/equity/operating_cf", + "formula": row.get("formula") + or "EDINET-derived financials CSV used in candidate screen", + "last_updated": row.get("last_updated") or generated_at, + "note": "候補抽出で参照したEDINET由来財務データです。推奨ではありません。", + } + ) + summary = candidate.get("edinet_summary") + if isinstance(summary, Mapping): + code = str(candidate.get("code") or summary.get("ticker") or "") + rows.append( + { + "claim_key": f"candidate.{code}.edinet_summary", + "source_type": "edinet_financials", + "source_ref": summary.get("source_ref"), + "metric_key": "latest_fiscal_year/equity_ratio/dividend", + "formula": "latest EDINET financial row summarized for report evidence", + "last_updated": summary.get("last_updated") or generated_at, + "note": ( + f"FY{summary.get('latest_fiscal_year')}: " + f"自己資本比率 {summary.get('latest_equity_ratio')}%, " + f"1株配当 {summary.get('latest_dividend_per_share')}, " + f"営業CF {summary.get('operating_cf_trend_label')}" + ), + } + ) + return rows + + def _claim_keys( evidence: Sequence[dict[str, object]], *, diff --git a/src/investment_assistant/webapi/service.py b/src/investment_assistant/webapi/service.py index 47d3219..86b4bd9 100644 --- a/src/investment_assistant/webapi/service.py +++ b/src/investment_assistant/webapi/service.py @@ -63,6 +63,37 @@ def _health(_: JsonDict) -> JsonDict: return {"status": "ok", "service": "investment-assistant", "auto_trading": False} +def _edinet_status(_: JsonDict) -> JsonDict: + from investment_assistant.edinet.client import API_KEY_ENV_VAR + + _ensure_env_from_dotenv(API_KEY_ENV_VAR) + return { + "api_key_configured": bool(os.getenv(API_KEY_ENV_VAR, "").strip()), + "api_key_env_var": API_KEY_ENV_VAR, + "default_registry": "examples/source_registry_nikkei225_edinet.yaml", + "default_output_dir": "local_docs/edinet", + "default_financials_csv": DEFAULT_FINANCIALS_CSV, + "auto_trading": False, + "call_real_api": False, + } + + +def _edinet_api_key_set(body: JsonDict) -> JsonDict: + from investment_assistant.edinet.client import API_KEY_ENV_VAR + + value = str(body.get("api_key") or "").strip() + if value: + os.environ[API_KEY_ENV_VAR] = value + configured = bool(os.getenv(API_KEY_ENV_VAR, "").strip()) + return { + "api_key_configured": configured, + "api_key_env_var": API_KEY_ENV_VAR, + "request_api_key_applied": bool(value), + "auto_trading": False, + "call_real_api": False, + } + + def _budget(_: JsonDict) -> JsonDict: from dataclasses import asdict @@ -409,6 +440,9 @@ def _fetch_job_auto(body: JsonDict) -> JsonDict: def _edinet_ingest(body: JsonDict) -> JsonDict: + from investment_assistant.edinet.client import API_KEY_ENV_VAR + + _ensure_env_from_dotenv(API_KEY_ENV_VAR) registry_path = str( body.get("registry_path") or "examples/source_registry_edinet_sample.yaml" ) @@ -609,6 +643,116 @@ def _financials_compare(body: JsonDict) -> JsonDict: return compare_financials(load_financials(path)) +def _financials_import(body: JsonDict) -> JsonDict: + from investment_assistant.financials.models import FINANCIAL_COLUMNS + from investment_assistant.ingestion.fetcher import reject_path_traversal + + raw_csv_text = body.get("csv_text") + csv_text = raw_csv_text if isinstance(raw_csv_text, str) else "" + save = _as_bool(body.get("save"), False) + cleanup: Path | None = None + source = "path" + source_ref: str + normalized_csv: str + + if csv_text.strip(): + if len(csv_text) > _MAX_MANUAL_TEXT_CHARS: + raise ApiError(f"csv_text is too long: max {_MAX_MANUAL_TEXT_CHARS} characters") + normalized_csv = csv_text.strip() + "\n" + with tempfile.NamedTemporaryFile( + "w", + suffix=".csv", + delete=False, + encoding="utf-8", + ) as handle: + handle.write(normalized_csv) + source_ref = handle.name + cleanup = Path(source_ref) + source = "csv_text" + else: + source_ref = _require_str(body, "path") + normalized_csv = Path(source_ref).read_text(encoding="utf-8") + + try: + points = load_financials(source_ref) + finally: + if cleanup is not None: + cleanup.unlink(missing_ok=True) + + comparison = compare_financials(points) + saved_path: str | None = None + output_path = str(body.get("output_path") or DEFAULT_FINANCIALS_CSV) + if save: + target = reject_path_traversal(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(normalized_csv, encoding="utf-8") + saved_path = str(target) + + companies = comparison.get("companies") + company_count = len(companies) if isinstance(companies, list) else 0 + return { + "available": True, + "source": source, + "source_ref": None if source == "csv_text" else source_ref, + "saved": save, + "saved_path": saved_path, + "financials_csv": saved_path or (None if source == "csv_text" else source_ref), + "columns": list(FINANCIAL_COLUMNS), + "count": len(points), + "company_count": company_count, + "comparison": comparison, + "disclaimer": comparison.get("disclaimer"), + "auto_trading": False, + "call_real_api": False, + } + + +def _financials_securities(body: JsonDict) -> JsonDict: + path = str( + body.get("financials_csv") + or body.get("path") + or DEFAULT_FINANCIALS_CSV + ) + query = str(body.get("query") or "").strip().lower() + limit = max(_as_int(body.get("limit"), 20), 1) + comparison = compare_financials(load_financials(path)) + companies = comparison.get("companies") + rows = companies if isinstance(companies, list) else [] + matches: list[dict[str, object]] = [] + for company in rows: + if not isinstance(company, dict): + continue + ticker = str(company.get("ticker") or "") + name = str(company.get("name") or "") + haystack = f"{ticker} {name}".lower() + if query and query not in haystack: + continue + matches.append( + { + "ticker": ticker, + "code": ticker, + "name": name, + "latest_fiscal_year": company.get("latest_fiscal_year"), + "latest_equity_ratio": company.get("latest_equity_ratio"), + "latest_dividend_per_share": company.get("latest_dividend_per_share"), + "dividend_cut_years": company.get("dividend_cut_years"), + "operating_cf_trend": company.get("operating_cf_trend"), + "source_ref": path, + } + ) + if len(matches) >= limit: + break + return { + "available": True, + "query": query, + "source_ref": path, + "count": len(matches), + "securities": matches, + "auto_trading": False, + "call_real_api": False, + } + + def _holdings_import(body: JsonDict) -> JsonDict: from investment_assistant.investment.loader import ( holding_input_warnings, @@ -1127,6 +1271,29 @@ def _real_api_decision(body: JsonDict) -> tuple[bool, str | None]: return False, "real API is not enabled" +def _ensure_env_from_dotenv(key: str) -> bool: + if os.getenv(key, "").strip(): + return True + for env_path in (Path(".env"), Path(".env.local")): + if not env_path.is_file(): + continue + try: + for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + name, value = stripped.split("=", 1) + if name.strip() != key: + continue + cleaned = value.strip().strip('"').strip("'") + if cleaned: + os.environ[key] = cleaned + return True + except OSError: + continue + return False + + def _require_str(body: JsonDict, key: str) -> str: value = body.get(key) if not isinstance(value, str) or not value.strip(): @@ -1313,12 +1480,16 @@ def _yaml_scalar(value: object) -> str: ("POST", "/api/reports/investment-monthly/history/verify"): _investment_report_history_verify, ("POST", "/api/reports/investment-monthly/history/compare"): _investment_report_history_compare, ("POST", "/api/financials/compare"): _financials_compare, + ("POST", "/api/financials/import"): _financials_import, + ("POST", "/api/financials/securities"): _financials_securities, ("POST", "/api/cache/maintenance"): _cache_maintenance, ("POST", "/api/fetch-job/dry-run"): lambda body: _fetch_job(body, dry_run=True), ("POST", "/api/fetch-job/run"): lambda body: _fetch_job(body, dry_run=False), ("POST", "/api/fetch-job/auto"): _fetch_job_auto, ("POST", "/api/edinet/ingest"): _edinet_ingest, ("POST", "/api/edinet/ingest-async"): _edinet_ingest_async, + ("GET", "/api/edinet/status"): _edinet_status, + ("POST", "/api/edinet/api-key"): _edinet_api_key_set, ("POST", "/api/jobs/status"): _job_status, ("POST", "/api/storage/prune"): _storage_prune, ("POST", "/api/knowledge/diff"): _knowledge_diff, diff --git a/tests/unit/test_investment_samples_smoke.py b/tests/unit/test_investment_samples_smoke.py index f73ae56..6554cf1 100644 --- a/tests/unit/test_investment_samples_smoke.py +++ b/tests/unit/test_investment_samples_smoke.py @@ -31,6 +31,8 @@ def test_investment_sample_csvs_drive_full_api_smoke() -> None: assert analysis["summary"]["market_value"] == 2_514_000.0 assert analysis["summary"]["cost_basis"] == 2_160_000.0 assert analysis["summary"]["annual_income_estimate"] == 12_450.0 + assert analysis["summary"]["edinet_covered_holdings"] == 2 + assert analysis["summary"]["edinet_source_ref"] == str(FINANCIALS_SAMPLE) status, candidates = handle_api( "POST", @@ -53,6 +55,9 @@ def test_investment_sample_csvs_drive_full_api_smoke() -> None: assert "9999" not in codes assert "FND999" not in codes assert candidates["auto_trading"] is False + stock_rows = [item for item in candidates["results"] if item["asset_type"] == "stock"] + assert stock_rows[0]["edinet_summary"]["source_ref"] == str(FINANCIALS_SAMPLE) + assert stock_rows[0]["evidence"][0]["source_ref"] == str(FINANCIALS_SAMPLE) status, report = handle_api( "POST", diff --git a/tests/unit/test_webapi.py b/tests/unit/test_webapi.py index 6fa2d8a..16986b5 100644 --- a/tests/unit/test_webapi.py +++ b/tests/unit/test_webapi.py @@ -39,6 +39,7 @@ def test_edinet_ingest_route_is_registered_and_routed(monkeypatch) -> None: from investment_assistant.webapi import service assert "POST /api/edinet/ingest" in available_routes() + assert "GET /api/edinet/status" in available_routes() captured: dict[str, object] = {} @@ -58,6 +59,87 @@ def fake_ingest(**kwargs: object) -> dict[str, object]: assert captured["days"] == 5 +def test_edinet_status_reports_api_key_configuration(monkeypatch) -> None: + monkeypatch.setenv("EDINET_API_KEY", "dummy-key") + + status, payload = handle_api("GET", "/api/edinet/status") + + assert status == 200 + assert payload["api_key_configured"] is True + assert payload["default_financials_csv"] == "local_docs/edinet/financials.csv" + assert payload["auto_trading"] is False + + +def test_edinet_api_key_can_be_set_for_runtime_without_echo(monkeypatch) -> None: + monkeypatch.delenv("EDINET_API_KEY", raising=False) + + status, payload = handle_api( + "POST", + "/api/edinet/api-key", + {"api_key": "runtime-secret"}, + ) + + assert status == 200 + assert payload["api_key_configured"] is True + assert payload["request_api_key_applied"] is True + assert "runtime-secret" not in str(payload) + + +def test_edinet_status_reads_local_dotenv_without_exposing_key( + tmp_path: Path, + monkeypatch, +) -> None: + monkeypatch.delenv("EDINET_API_KEY", raising=False) + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("EDINET_API_KEY=from-dotenv\n", encoding="utf-8") + + status, payload = handle_api("GET", "/api/edinet/status") + + assert status == 200 + assert payload["api_key_configured"] is True + assert "from-dotenv" not in str(payload) + + +def test_financials_import_saves_manual_csv_and_searches_securities( + tmp_path: Path, +) -> None: + csv_text = ( + "ticker,name,fiscal_year,operating_cf,equity_ratio,dividend_per_share,payout_policy\n" + "8306,MUFG,2023,1000,45,40,stable\n" + "8306,MUFG,2024,1200,48,45,stable\n" + "7203,Toyota,2024,3000,55,60,stable\n" + ) + output_path = tmp_path / "financials.csv" + + status, imported = handle_api( + "POST", + "/api/financials/import", + { + "csv_text": csv_text, + "save": True, + "output_path": str(output_path), + }, + ) + + assert status == 200 + assert imported["saved_path"] == str(output_path) + assert imported["count"] == 3 + assert imported["company_count"] == 2 + assert imported["auto_trading"] is False + assert output_path.is_file() + + status, searched = handle_api( + "POST", + "/api/financials/securities", + {"financials_csv": str(output_path), "query": "8306"}, + ) + + assert status == 200 + assert searched["count"] == 1 + assert searched["securities"][0]["ticker"] == "8306" + assert searched["securities"][0]["name"] == "MUFG" + + def test_rag_stats_endpoint_reports_db_contents(tmp_path) -> None: db = tmp_path / "rag.sqlite" @@ -402,6 +484,8 @@ def test_investment_mvp_routes_import_analyze_screen_and_report(tmp_path: Path) ) assert status == 200 assert analysis["summary"]["market_value"] == 670000.0 + assert analysis["summary"]["edinet_covered_holdings"] == 1 + assert analysis["summary"]["edinet_source_ref"] == str(financials) assert analysis["summary"]["nisa"]["status"] == "ok" assert analysis["summary"]["nisa"]["alerts"] == [] assert analysis["summary"]["data_quality"]["missing_timestamp_count"] == 2 @@ -441,6 +525,12 @@ def test_investment_mvp_routes_import_analyze_screen_and_report(tmp_path: Path) assert status == 200 assert candidates["count"] >= 2 assert candidates["auto_trading"] is False + stock_candidate = next( + item for item in candidates["results"] if item["asset_type"] == "stock" + ) + assert stock_candidate["edinet_summary"]["source_ref"] == str(financials) + assert stock_candidate["edinet_summary"]["latest_dividend_per_share"] == 45.0 + assert stock_candidate["evidence"][0]["source_ref"] == str(financials) status, detail = handle_api( "POST", @@ -458,6 +548,8 @@ def test_investment_mvp_routes_import_analyze_screen_and_report(tmp_path: Path) assert detail["asset_type"] == "stock" assert detail["metrics"] assert detail["evidence"] + assert detail["edinet_summary"]["source_ref"] == str(financials) + assert detail["edinet_summary"]["latest_fiscal_year"] == 2024 assert detail["auto_trading"] is False status, report = handle_api( @@ -498,6 +590,8 @@ def test_investment_mvp_routes_import_analyze_screen_and_report(tmp_path: Path) } assert "portfolio.target.required_budget" in evidence_keys assert "portfolio.concentration.current" in evidence_keys + assert "candidate.8306.edinet_financials" in evidence_keys + assert "candidate.8306.edinet_summary" in evidence_keys status, audit = handle_api( "POST", diff --git a/web/src/App.tsx b/web/src/App.tsx index 6478eae..dfac09e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -90,6 +90,8 @@ const TABS = [ { id: "detail", label: "Detail" }, { id: "simulate", label: "Simulate" }, { id: "report", label: "Report" }, + { id: "answer", label: "AI Chat" }, + { id: "data", label: "Data" }, { id: "evidence", label: "Evidence" }, ] as const; @@ -198,6 +200,19 @@ const SAMPLE_FUNDS_CSV = "FND999,高コストテーマ型,theme,1.20,distribution,false,user_csv,0.40\n"; const SAMPLE_FINANCIALS_PATH = "examples/financials_sample.csv"; +const DEFAULT_FINANCIALS_PATH = "local_docs/edinet/financials.csv"; +const SAMPLE_FINANCIALS_CSV = + "ticker,name,fiscal_year,operating_cf,equity_ratio,dividend_per_share,payout_policy\n" + + "7203,安定配当ホールディングス,2021,820000,58.2,42,連結配当性向30%目安・累進配当を志向\n" + + "7203,安定配当ホールディングス,2022,910000,59.1,46,連結配当性向30%目安・累進配当を志向\n" + + "7203,安定配当ホールディングス,2023,985000,60.4,52,連結配当性向30%目安・累進配当を志向\n" + + "7203,安定配当ホールディングス,2024,1040000,61.0,58,連結配当性向30%目安・累進配当を志向\n" + + "7203,安定配当ホールディングス,2025,1105000,62.3,64,連結配当性向30%目安・累進配当を志向\n" + + "9999,景気連動マテリアル,2021,310000,38.5,80,業績連動・配当性向40%(下限なし)\n" + + "9999,景気連動マテリアル,2022,420000,41.2,100,業績連動・配当性向40%(下限なし)\n" + + "9999,景気連動マテリアル,2023,150000,36.8,40,業績連動・配当性向40%(下限なし)\n" + + "9999,景気連動マテリアル,2024,260000,39.0,55,業績連動・配当性向40%(下限なし)\n" + + "9999,景気連動マテリアル,2025,180000,37.4,45,業績連動・配当性向40%(下限なし)\n"; const HOLDING_CSV_COLUMNS = [ "asset_type", @@ -307,6 +322,7 @@ type TabId = (typeof TABS)[number]["id"]; export function App() { const [tab, setTab] = useState("dashboard"); + const [financialsCsvPath, setFinancialsCsvPath] = useState(DEFAULT_FINANCIALS_PATH); const [detailSeed, setDetailSeed] = useState({ code: "7203", assetType: "stock", @@ -354,13 +370,34 @@ export function App() { ))} +
{tab === "dashboard" && } - {tab === "holdings" && } - {tab === "candidates" && } - {tab === "detail" && } + {tab === "holdings" && } + {tab === "candidates" && ( + + )} + {tab === "detail" && ( + + )} {tab === "simulate" && } - {tab === "report" && } + {tab === "report" && } + {tab === "answer" && } + {tab === "data" && ( + + )} {tab === "evidence" && }