Baseline AI investment platform through Phase 3 foundation

prakhar82 committed Aug 19, 2026 at 14:23 UTC 01ed7019ec7f615a50e32027e6714be29e9d1c54
294 files changed +17775
.dockerignore new
+16
@@ -0,0 +1,16 @@
1 +.git
2 +.idea
3 +.vscode
4 +node_modules
5 +.next
6 +target
7 +__pycache__
8 +.pytest_cache
9 +.venv
10 +.terraform
11 +*.tfstate
12 +*.tfstate.*
13 +*.log
14 +.env
15 +.env.*
16 +secrets
.editorconfig new
+18
@@ -0,0 +1,18 @@
1 +root = true
2 +
3 +[*]
4 +charset = utf-8
5 +end_of_line = crlf
6 +insert_final_newline = true
7 +indent_style = space
8 +indent_size = 2
9 +trim_trailing_whitespace = true
10 +
11 +[*.java]
12 +indent_size = 4
13 +
14 +[*.py]
15 +indent_size = 4
16 +
17 +[*.md]
18 +trim_trailing_whitespace = false
.gitignore new
+68
@@ -0,0 +1,68 @@
1 +# IDEs
2 +.idea/
3 +.vscode/
4 +*.iml
5 +
6 +# OS
7 +.DS_Store
8 +Thumbs.db
9 +
10 +# Logs
11 +*.log
12 +logs/
13 +
14 +# Java / Maven
15 +target/
16 +*.class
17 +.mvn/wrapper/maven-wrapper.jar
18 +
19 +# Node / Next.js
20 +node_modules/
21 +.next/
22 +out/
23 +npm-debug.log*
24 +yarn-debug.log*
25 +yarn-error.log*
26 +pnpm-debug.log*
27 +
28 +# Python
29 +__pycache__/
30 +*.py[cod]
31 +.pytest_cache/
32 +.ruff_cache/
33 +.mypy_cache/
34 +.venv/
35 +.venv*/
36 +venv/
37 +dist/
38 +build/
39 +*.egg-info/
40 +
41 +# Docker / local runtime
42 +.env
43 +.env.*
44 +!.env.example
45 +docker-compose.override.yml
46 +
47 +# Terraform
48 +.terraform/
49 +*.tfstate
50 +*.tfstate.*
51 +*.tfvars
52 +!*.tfvars.example
53 +crash.log
54 +crash.*.log
55 +override.tf
56 +override.tf.json
57 +*_override.tf
58 +*_override.tf.json
59 +
60 +# Kubernetes / Helm generated output
61 +*.tgz
62 +
63 +# Secrets and certificates
64 +*.pem
65 +*.key
66 +*.pfx
67 +*.p12
68 +secrets/
README.md new
+62
@@ -0,0 +1,62 @@
1 +# AI Investment Intelligence Platform
2 +
3 +Production-grade monorepo foundation for an AI-powered investment intelligence platform.
4 +
5 +This repository is independent and does not use or modify any existing user-management project.
6 +
7 +## Stack
8 +
9 +- Backend: Java 17, Spring Boot, Maven
10 +- AI services: Python 3.12+, FastAPI
11 +- Frontend: React / Next.js
12 +- Data platform: PostgreSQL, Redis, Kafka
13 +- Infrastructure: Docker, k3d, Kubernetes, Helm, Terraform
14 +- PRD target: Microsoft Azure AKS in `westeurope`
15 +- Observability target: OpenTelemetry, Prometheus, Grafana
16 +
17 +## Repository Layout
18 +
19 +```text
20 +frontend/
21 +services/
22 +ai/
23 +shared/
24 +infrastructure/
25 +config/
26 +scripts/
27 +docs/
28 +platform.ps1
29 +```
30 +
31 +## Platform Commands
32 +
33 +```powershell
34 +.\platform.ps1 up DEV
35 +.\platform.ps1 status DEV
36 +.\platform.ps1 down DEV
37 +
38 +.\platform.ps1 up PRD
39 +.\platform.ps1 status PRD
40 +.\platform.ps1 down PRD
41 +```
42 +
43 +The first foundation iteration does not apply Azure infrastructure. `up PRD` validates the Azure subscription and creates a Terraform plan only. `down PRD` requires an explicit typed confirmation before running `terraform destroy` against this project's PRD Terraform state.
44 +
45 +## Safe Local Validation
46 +
47 +```powershell
48 +mvn clean verify
49 +python -m compileall ai/research-engine/app
50 +helm lint infrastructure/helm/ai-investment-platform
51 +terraform -chdir=infrastructure/terraform/environments/prd fmt -check
52 +```
53 +
54 +Foundation validation creates no Azure resources. Use Terraform plan/apply only after an explicit deployment review.
55 +
56 +## Phase 3 Research Intelligence
57 +
58 +The research foundation lives in `ai/research-engine` and exposes `/api/v1/research/*` APIs for company profiles, documents, extracted events, source providers, schedule rules, refresh, and deterministic catalyst summaries. It uses demo fixtures by default and does not perform Google result scraping, CAPTCHA bypass, paywall bypass, paid API calls, or BUY/SELL recommendations.
59 +
60 +## Security Baseline
61 +
62 +No broker credentials, Azure credentials, API keys, or LLM provider secrets belong in source control. Use Azure CLI context for development authentication and Key Vault / Kubernetes secrets for production runtime secrets.
ai/portfolio-optimizer/.dockerignore new
+6
@@ -0,0 +1,6 @@
1 +__pycache__
2 +.pytest_cache
3 +.venv
4 +*.pyc
5 +*.log
6 +.env
ai/portfolio-optimizer/Dockerfile new
+9
@@ -0,0 +1,9 @@
1 +FROM python:3.12-slim
2 +WORKDIR /app
3 +COPY pyproject.toml .
4 +COPY app ./app
5 +RUN pip install --no-cache-dir .
6 +EXPOSE 8000
7 +RUN useradd --create-home --uid 10001 appuser
8 +USER appuser
9 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ai/portfolio-optimizer/app/__init__.py new
+1
@@ -0,0 +1 @@
1 +
ai/portfolio-optimizer/app/main.py new
+11
@@ -0,0 +1,11 @@
1 +from fastapi import FastAPI
2 +
3 +from app.settings import Settings
4 +
5 +settings = Settings()
6 +app = FastAPI(title="Portfolio Optimizer", version="0.1.0")
7 +
8 +
9 +@app.get("/health")
10 +def health() -> dict[str, str]:
11 + return {"status": "ok", "service": settings.service_name}
ai/portfolio-optimizer/app/settings.py new
+7
@@ -0,0 +1,7 @@
1 +from pydantic_settings import BaseSettings, SettingsConfigDict
2 +
3 +
4 +class Settings(BaseSettings):
5 + model_config = SettingsConfigDict(env_prefix="AIP_", env_file=".env", extra="ignore")
6 +
7 + service_name: str = "portfolio-optimizer"
ai/portfolio-optimizer/pyproject.toml new
+17
@@ -0,0 +1,17 @@
1 +[project]
2 +name = "portfolio-optimizer"
3 +version = "0.1.0"
4 +requires-python = ">=3.11"
5 +dependencies = [
6 + "fastapi>=0.115.0",
7 + "uvicorn[standard]>=0.30.0",
8 + "pydantic>=2.8.0",
9 + "pydantic-settings>=2.4.0"
10 +]
11 +
12 +[tool.ruff]
13 +line-length = 100
14 +
15 +[build-system]
16 +requires = ["setuptools>=68"]
17 +build-backend = "setuptools.build_meta"
ai/ranking-engine/.dockerignore new
+6
@@ -0,0 +1,6 @@
1 +__pycache__
2 +.pytest_cache
3 +.venv
4 +*.pyc
5 +*.log
6 +.env
ai/ranking-engine/Dockerfile new
+9
@@ -0,0 +1,9 @@
1 +FROM python:3.12-slim
2 +WORKDIR /app
3 +COPY pyproject.toml .
4 +COPY app ./app
5 +RUN pip install --no-cache-dir .
6 +EXPOSE 8000
7 +RUN useradd --create-home --uid 10001 appuser
8 +USER appuser
9 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ai/ranking-engine/app/__init__.py new
+1
@@ -0,0 +1 @@
1 +
ai/ranking-engine/app/main.py new
+11
@@ -0,0 +1,11 @@
1 +from fastapi import FastAPI
2 +
3 +from app.settings import Settings
4 +
5 +settings = Settings()
6 +app = FastAPI(title="Ranking Engine", version="0.1.0")
7 +
8 +
9 +@app.get("/health")
10 +def health() -> dict[str, str]:
11 + return {"status": "ok", "service": settings.service_name}
ai/ranking-engine/app/settings.py new
+7
@@ -0,0 +1,7 @@
1 +from pydantic_settings import BaseSettings, SettingsConfigDict
2 +
3 +
4 +class Settings(BaseSettings):
5 + model_config = SettingsConfigDict(env_prefix="AIP_", env_file=".env", extra="ignore")
6 +
7 + service_name: str = "ranking-engine"
ai/ranking-engine/pyproject.toml new
+17
@@ -0,0 +1,17 @@
1 +[project]
2 +name = "ranking-engine"
3 +version = "0.1.0"
4 +requires-python = ">=3.11"
5 +dependencies = [
6 + "fastapi>=0.115.0",
7 + "uvicorn[standard]>=0.30.0",
8 + "pydantic>=2.8.0",
9 + "pydantic-settings>=2.4.0"
10 +]
11 +
12 +[tool.ruff]
13 +line-length = 100
14 +
15 +[build-system]
16 +requires = ["setuptools>=68"]
17 +build-backend = "setuptools.build_meta"
ai/research-engine/.dockerignore new
+6
@@ -0,0 +1,6 @@
1 +__pycache__
2 +.pytest_cache
3 +.venv
4 +*.pyc
5 +*.log
6 +.env
ai/research-engine/Dockerfile new
+10
@@ -0,0 +1,10 @@
1 +FROM python:3.12-slim
2 +WORKDIR /app
3 +COPY pyproject.toml .
4 +COPY app ./app
5 +COPY db ./db
6 +RUN pip install --no-cache-dir .
7 +EXPOSE 8000
8 +RUN useradd --create-home --uid 10001 appuser
9 +USER appuser
10 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ai/research-engine/app/__init__.py new
+1
@@ -0,0 +1 @@
1 +
ai/research-engine/app/deduplication.py new
+20
@@ -0,0 +1,20 @@
1 +from __future__ import annotations
2 +
3 +from app.models import ResearchDocument
4 +
5 +
6 +class DocumentDeduplicator:
7 + def __init__(self) -> None:
8 + self._by_url: dict[str, ResearchDocument] = {}
9 + self._by_hash: dict[str, ResearchDocument] = {}
10 +
11 + def find_duplicate(self, document: ResearchDocument) -> ResearchDocument | None:
12 + return self._by_url.get(document.canonical_url) or self._by_hash.get(document.content_hash)
13 +
14 + def add(self, document: ResearchDocument) -> ResearchDocument | None:
15 + duplicate = self.find_duplicate(document)
16 + if duplicate:
17 + return duplicate
18 + self._by_url[document.canonical_url] = document
19 + self._by_hash[document.content_hash] = document
20 + return None
ai/research-engine/app/entity_resolution.py new
+47
@@ -0,0 +1,47 @@
1 +from __future__ import annotations
2 +
3 +from urllib.parse import urlparse
4 +
5 +from app.models import CompanyResearchProfile, EntityResolution
6 +
7 +
8 +class EntityResolver:
9 + def __init__(self, profiles: list[CompanyResearchProfile]):
10 + self.profiles = profiles
11 +
12 + def resolve(self, title: str | None, text: str, url: str) -> EntityResolution:
13 + haystack = f"{title or ''} {text}".lower()
14 + host = (urlparse(url).hostname or "").lower()
15 + best = EntityResolution(instrument_id=None, company_id=None, confidence=0.0, matched_on=[])
16 + for profile in self.profiles:
17 + score = 0.0
18 + matched: list[str] = []
19 + if profile.isin and profile.isin.lower() in haystack:
20 + score += 0.45
21 + matched.append("isin")
22 + if profile.company_name.lower() in haystack:
23 + score += 0.30
24 + matched.append("company_name")
25 + for alias in profile.aliases:
26 + if alias.lower() in haystack:
27 + score += 0.18
28 + matched.append("alias")
29 + break
30 + if profile.ticker.lower() in haystack and profile.exchange.lower() in haystack:
31 + score += 0.18
32 + matched.append("ticker_exchange")
33 + if profile.country.lower() in haystack:
34 + score += 0.04
35 + matched.append("country")
36 + if any(host.endswith(domain.lower()) for domain in profile.known_domains):
37 + score += 0.35
38 + matched.append("known_domain")
39 + score = min(score, 1.0)
40 + if score > best.confidence:
41 + best = EntityResolution(
42 + instrument_id=profile.instrument_id,
43 + company_id=profile.company_id,
44 + confidence=score,
45 + matched_on=matched,
46 + )
47 + return best
ai/research-engine/app/events.py new
+42
@@ -0,0 +1,42 @@
1 +from __future__ import annotations
2 +
3 +from uuid import UUID
4 +
5 +from app.models import PlatformEvent, ResearchDocument, ResearchEvent
6 +
7 +
8 +def document_event(event_type: str, document: ResearchDocument, correlation_id: str | None = None) -> PlatformEvent:
9 + return PlatformEvent(
10 + event_type=event_type,
11 + correlation_id=correlation_id,
12 + payload={
13 + "documentId": str(document.document_id),
14 + "instrumentId": str(document.instrument_id) if document.instrument_id else None,
15 + "sourceType": document.source_type,
16 + "status": document.status,
17 + "canonicalUrl": document.canonical_url,
18 + },
19 + )
20 +
21 +
22 +def research_event_extracted(event: ResearchEvent, correlation_id: str | None = None) -> PlatformEvent:
23 + return PlatformEvent(
24 + event_type="research.event.extracted",
25 + correlation_id=correlation_id,
26 + payload={
27 + "eventId": str(event.event_id),
28 + "instrumentId": str(event.instrument_id),
29 + "eventType": event.event_type,
30 + "impact": event.impact,
31 + "confidence": event.confidence,
32 + "sourceDocumentId": str(event.source_document_id),
33 + },
34 + )
35 +
36 +
37 +def company_updated(instrument_id: UUID, correlation_id: str | None = None) -> PlatformEvent:
38 + return PlatformEvent(
39 + event_type="research.company.updated",
40 + correlation_id=correlation_id,
41 + payload={"instrumentId": str(instrument_id)},
42 + )
ai/research-engine/app/extraction.py new
+144
@@ -0,0 +1,144 @@
1 +from __future__ import annotations
2 +
3 +import re
4 +from datetime import datetime, timezone
5 +from decimal import Decimal
6 +
7 +from app.models import (
8 + EventImpact,
9 + ReliabilityLevel,
10 + ResearchDocument,
11 + ResearchEvent,
12 + ResearchEventType,
13 + SourceType,
14 + TimeHorizon,
15 +)
16 +from app.normalization import normalize_numbers
17 +
18 +
19 +class ResearchEventExtractor:
20 + def extract(self, document: ResearchDocument) -> list[ResearchEvent]:
21 + raise NotImplementedError
22 +
23 +
24 +class RuleBasedEventExtractor(ResearchEventExtractor):
25 + def extract(self, document: ResearchDocument) -> list[ResearchEvent]:
26 + if not document.instrument_id or not document.company_id or not document.normalized_text:
27 + return []
28 + text = document.normalized_text
29 + lower = text.lower()
30 + events: list[ResearchEvent] = []
31 + if any(term in lower for term in ["new order", "order worth", "contract worth", "framework agreement", "purchase order"]):
32 + events.append(self._event(document, ResearchEventType.NEW_ORDER, "Order or contract announcement", EventImpact.POSITIVE, TimeHorizon.MEDIUM_TERM))
33 + if "backlog" in lower:
34 + impact = EventImpact.POSITIVE if any(term in lower for term in ["increase", "grew", "growth", "+"]) else EventImpact.NEUTRAL
35 + events.append(self._event(document, ResearchEventType.ORDER_BACKLOG_CHANGE, "Order backlog update", impact, TimeHorizon.SHORT_TERM))
36 + if any(term in lower for term in ["capex", "capital expenditure", "investment of", "invests", "will invest"]):
37 + events.append(self._event(document, ResearchEventType.CAPEX, "CAPEX or investment announcement", EventImpact.UNCERTAIN, TimeHorizon.LONG_TERM))
38 + if any(term in lower for term in ["capacity expansion", "expand capacity", "production capacity", "mw"]):
39 + events.append(self._event(document, ResearchEventType.CAPACITY_EXPANSION, "Capacity expansion signal", EventImpact.POSITIVE, TimeHorizon.LONG_TERM))
40 + if any(term in lower for term in ["new facility", "new factory", "factory expansion", "new plant"]):
41 + events.append(self._event(document, ResearchEventType.NEW_FACILITY, "Facility expansion signal", EventImpact.POSITIVE, TimeHorizon.LONG_TERM))
42 + if any(term in lower for term in ["new customer", "customer win", "selected by"]):
43 + events.append(self._event(document, ResearchEventType.NEW_CUSTOMER, "Customer win signal", EventImpact.POSITIVE, TimeHorizon.MEDIUM_TERM))
44 + if any(term in lower for term in ["guidance raised", "raises guidance", "increased guidance"]):
45 + events.append(self._event(document, ResearchEventType.GUIDANCE_RAISED, "Guidance raised", EventImpact.POSITIVE, TimeHorizon.SHORT_TERM))
46 + if any(term in lower for term in ["guidance lowered", "cuts guidance", "lowered guidance", "withdraws guidance"]):
47 + events.append(self._event(document, ResearchEventType.GUIDANCE_LOWERED, "Guidance lowered", EventImpact.NEGATIVE, TimeHorizon.SHORT_TERM))
48 + if any(term in lower for term in ["cancelled", "canceled", "delay", "delayed", "customer loss"]):
49 + events.append(self._event(document, ResearchEventType.OTHER, "Negative execution signal", EventImpact.NEGATIVE, TimeHorizon.SHORT_TERM))
50 + if "annual report" in lower:
51 + events.append(self._event(document, ResearchEventType.ANNUAL_REPORT, "Annual report published", EventImpact.NEUTRAL, TimeHorizon.UNKNOWN))
52 + if "earnings" in lower or "quarterly results" in lower:
53 + events.append(self._event(document, ResearchEventType.EARNINGS_RELEASE, "Earnings release", EventImpact.NEUTRAL, TimeHorizon.IMMEDIATE))
54 + return _deduplicate_events(events)
55 +
56 + def _event(
57 + self,
58 + document: ResearchDocument,
59 + event_type: ResearchEventType,
60 + title: str,
61 + impact: EventImpact,
62 + horizon: TimeHorizon,
63 + ) -> ResearchEvent:
64 + assert document.instrument_id is not None
65 + assert document.company_id is not None
66 + text = document.normalized_text or ""
67 + numbers = normalize_numbers(text)
68 + money = next((n for n in numbers if n.currency), None)
69 + pct = next((n for n in numbers if n.unit == "PERCENT"), None)
70 + capacity = next((n for n in numbers if n.unit in {"MW", "GW", "UNITS"}), None)
71 + customer = _extract_customer(text)
72 + confidence = _confidence(document.reliability_level, document.entity_resolution_confidence, bool(numbers), document.published_at is not None)
73 + return ResearchEvent(
74 + instrument_id=document.instrument_id,
75 + company_id=document.company_id,
76 + event_type=event_type,
77 + event_date=document.published_at,
78 + detected_at=datetime.now(timezone.utc),
79 + title=title,
80 + summary=_summary(text),
81 + source_document_id=document.document_id,
82 + source_url=document.canonical_url,
83 + source_type=document.source_type,
84 + reliability=document.reliability_level,
85 + confidence=confidence,
86 + impact=impact,
87 + time_horizon=horizon,
88 + currency=money.currency if money else None,
89 + monetary_value=money.value if money else None,
90 + monetary_original=money.original if money else None,
91 + percentage_value=pct.value if pct else None,
92 + percentage_original=pct.original if pct else None,
93 + customer=customer,
94 + counterparty=customer,
95 + capacity_value=capacity.value if capacity else None,
96 + capacity_unit=capacity.unit if capacity else None,
97 + raw_evidence_reference=_evidence(text, event_type),
98 + )
99 +
100 +
101 +class LLMEventExtractor(ResearchEventExtractor):
102 + def extract(self, document: ResearchDocument) -> list[ResearchEvent]:
103 + return []
104 +
105 +
106 +def _confidence(reliability: ReliabilityLevel, entity_confidence: float, has_number: bool, has_date: bool) -> float:
107 + reliability_score = {
108 + ReliabilityLevel.LEVEL_A: 0.95,
109 + ReliabilityLevel.LEVEL_B: 0.85,
110 + ReliabilityLevel.LEVEL_C: 0.70,
111 + ReliabilityLevel.LEVEL_D: 0.55,
112 + ReliabilityLevel.LEVEL_E: 0.35,
113 + }[reliability]
114 + score = reliability_score * 0.45 + entity_confidence * 0.35 + (0.10 if has_number else 0.0) + (0.10 if has_date else 0.0)
115 + return float(min(1.0, round(score, 4)))
116 +
117 +
118 +def _summary(text: str) -> str:
119 + return text[:240].strip()
120 +
121 +
122 +def _evidence(text: str, event_type: ResearchEventType) -> str:
123 + sentences = re.split(r"(?<=[.!?])\s+", text)
124 + keywords = event_type.value.lower().split("_")
125 + for sentence in sentences:
126 + if any(keyword in sentence.lower() for keyword in keywords):
127 + return sentence[:500]
128 + return text[:500]
129 +
130 +
131 +def _extract_customer(text: str) -> str | None:
132 + match = re.search(r"(?:with|from|by|for)\s+([A-Z][A-Za-z0-9&.\- ]{2,60})(?:\.|,| for | to | worth | valued)", text)
133 + return match.group(1).strip() if match else None
134 +
135 +
136 +def _deduplicate_events(events: list[ResearchEvent]) -> list[ResearchEvent]:
137 + seen: set[tuple[ResearchEventType, Decimal | None, str | None]] = set()
138 + unique: list[ResearchEvent] = []
139 + for event in events:
140 + key = (event.event_type, event.monetary_value, event.customer)
141 + if key not in seen:
142 + seen.add(key)
143 + unique.append(event)
144 + return unique
ai/research-engine/app/llm.py new
+30
@@ -0,0 +1,30 @@
1 +from typing import Any, Protocol
2 +
3 +from pydantic import BaseModel, ValidationError
4 +
5 +
6 +class LlmProvider(Protocol):
7 + def structured_extract(self, prompt: str, schema: type[BaseModel]) -> BaseModel | None:
8 + """Return schema-validated extraction output or None without fabricating missing data."""
9 +
10 +
11 +class OllamaProvider:
12 + def structured_extract(self, prompt: str, schema: type[BaseModel]) -> BaseModel | None:
13 + return None
14 +
15 +
16 +class FutureOpenAIProvider:
17 + def structured_extract(self, prompt: str, schema: type[BaseModel]) -> BaseModel | None:
18 + return None
19 +
20 +
21 +class FutureAzureOpenAIProvider:
22 + def structured_extract(self, prompt: str, schema: type[BaseModel]) -> BaseModel | None:
23 + return None
24 +
25 +
26 +def validate_structured_output(payload: dict[str, Any], schema: type[BaseModel]) -> BaseModel | None:
27 + try:
28 + return schema.model_validate(payload)
29 + except ValidationError:
30 + return None
ai/research-engine/app/main.py new
+84
@@ -0,0 +1,84 @@
1 +from uuid import UUID
2 +
3 +from fastapi import FastAPI, Header, HTTPException, Query
4 +
5 +from app.models import ReliabilityLevel, ResearchEventType
6 +from app.repository import ResearchRepository
7 +from app.scheduler import default_schedule_rules
8 +from app.settings import Settings
9 +from app.sources import default_source_providers
10 +
11 +settings = Settings()
12 +repository = ResearchRepository()
13 +app = FastAPI(title="Research Engine", version="0.3.0")
14 +
15 +
16 +@app.get("/health")
17 +def health() -> dict[str, str]:
18 + return {"status": "ok", "service": settings.service_name}
19 +
20 +
21 +@app.get("/providers/llm")
22 +def llm_provider() -> dict[str, str]:
23 + return {"provider": settings.llm_provider, "mode": "optional-not-called"}
24 +
25 +
26 +@app.get("/api/v1/research/sources")
27 +def sources():
28 + return default_source_providers()
29 +
30 +
31 +@app.get("/api/v1/research/schedule")
32 +def schedule():
33 + return default_schedule_rules()
34 +
35 +
36 +@app.get("/api/v1/research/companies")
37 +def companies():
38 + return repository.list_profiles()
39 +
40 +
41 +@app.get("/api/v1/research/companies/{instrument_id}")
42 +def company(instrument_id: UUID):
43 + try:
44 + return repository.profile(instrument_id)
45 + except StopIteration as exc:
46 + raise HTTPException(status_code=404, detail="Research profile not found") from exc
47 +
48 +
49 +@app.get("/api/v1/research/companies/{instrument_id}/events")
50 +def events(
51 + instrument_id: UUID,
52 + event_type: ResearchEventType | None = Query(default=None, alias="eventType"),
53 + impact: str | None = None,
54 + reliability: ReliabilityLevel | None = None,
55 +):
56 + _require_profile(instrument_id)
57 + return repository.events_for(instrument_id, event_type=event_type, impact=impact, reliability=reliability)
58 +
59 +
60 +@app.get("/api/v1/research/companies/{instrument_id}/documents")
61 +def documents(instrument_id: UUID):
62 + _require_profile(instrument_id)
63 + return repository.documents_for(instrument_id)
64 +
65 +
66 +@app.get("/api/v1/research/companies/{instrument_id}/summary")
67 +def summary(instrument_id: UUID):
68 + _require_profile(instrument_id)
69 + return repository.summary(instrument_id)
70 +
71 +
72 +@app.post("/api/v1/research/companies/{instrument_id}/refresh")
73 +def refresh(instrument_id: UUID, x_correlation_id: str | None = Header(default=None)):
74 + _require_profile(instrument_id)
75 + # Phase 3 refresh is fixture-backed unless AIP_RESEARCH_LIVE_ENABLED is explicitly enabled later.
76 + result = repository.refresh(instrument_id)
77 + return result.model_copy(update={"data_freshness": "DEMO" if settings.research_demo_enabled else "UNAVAILABLE"})
78 +
79 +
80 +def _require_profile(instrument_id: UUID) -> None:
81 + try:
82 + repository.profile(instrument_id)
83 + except StopIteration as exc:
84 + raise HTTPException(status_code=404, detail="Research profile not found") from exc
ai/research-engine/app/models.py new
+264
@@ -0,0 +1,264 @@
1 +from __future__ import annotations
2 +
3 +from datetime import datetime, timezone
4 +from decimal import Decimal
5 +from enum import StrEnum
6 +from typing import Any
7 +from uuid import UUID, uuid4
8 +
9 +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, field_validator
10 +
11 +
12 +def _to_camel(value: str) -> str:
13 + head, *tail = value.split("_")
14 + return head + "".join(part.capitalize() for part in tail)
15 +
16 +
17 +class ResearchBaseModel(BaseModel):
18 + model_config = ConfigDict(alias_generator=_to_camel, populate_by_name=True, use_enum_values=True)
19 +
20 +
21 +class SourceType(StrEnum):
22 + COMPANY_WEBSITE = "COMPANY_WEBSITE"
23 + INVESTOR_RELATIONS = "INVESTOR_RELATIONS"
24 + EXCHANGE_ANNOUNCEMENT = "EXCHANGE_ANNOUNCEMENT"
25 + REGULATORY_FILING = "REGULATORY_FILING"
26 + GOVERNMENT_PROCUREMENT = "GOVERNMENT_PROCUREMENT"
27 + RSS = "RSS"
28 + NEWS = "NEWS"
29 + SEARCH_DISCOVERY = "SEARCH_DISCOVERY"
30 +
31 +
32 +class FetchStrategy(StrEnum):
33 + HTTP = "HTTP"
34 + PLAYWRIGHT = "PLAYWRIGHT"
35 + MANUAL = "MANUAL"
36 +
37 +
38 +class SourceAccessStatus(StrEnum):
39 + AVAILABLE = "AVAILABLE"
40 + UNAVAILABLE = "UNAVAILABLE"
41 + RESTRICTED = "RESTRICTED"
42 + MANUAL_ONLY = "MANUAL_ONLY"
43 +
44 +
45 +class ReliabilityLevel(StrEnum):
46 + LEVEL_A = "LEVEL_A"
47 + LEVEL_B = "LEVEL_B"
48 + LEVEL_C = "LEVEL_C"
49 + LEVEL_D = "LEVEL_D"
50 + LEVEL_E = "LEVEL_E"
51 +
52 +
53 +class DocumentStatus(StrEnum):
54 + DISCOVERED = "DISCOVERED"
55 + FETCHED = "FETCHED"
56 + PARSED = "PARSED"
57 + DUPLICATE = "DUPLICATE"
58 + REJECTED = "REJECTED"
59 + FAILED = "FAILED"
60 + PROCESSED = "PROCESSED"
61 +
62 +
63 +class DocumentType(StrEnum):
64 + HTML = "HTML"
65 + TEXT = "TEXT"
66 + RSS_XML = "RSS_XML"
67 + PDF_REFERENCE = "PDF_REFERENCE"
68 + UNKNOWN = "UNKNOWN"
69 +
70 +
71 +class ResearchEventType(StrEnum):
72 + NEW_ORDER = "NEW_ORDER"
73 + ORDER_BACKLOG_CHANGE = "ORDER_BACKLOG_CHANGE"
74 + NEW_CUSTOMER = "NEW_CUSTOMER"
75 + CUSTOMER_EXPANSION = "CUSTOMER_EXPANSION"
76 + MAJOR_CUSTOMER = "MAJOR_CUSTOMER"
77 + CUSTOMER_LOSS = "CUSTOMER_LOSS"
78 + MAJOR_CONTRACT = "MAJOR_CONTRACT"
79 + GOVERNMENT_CONTRACT = "GOVERNMENT_CONTRACT"
80 + CAPEX = "CAPEX"
81 + FACTORY_EXPANSION = "FACTORY_EXPANSION"
82 + CAPACITY_EXPANSION = "CAPACITY_EXPANSION"
83 + NEW_FACILITY = "NEW_FACILITY"
84 + GEOGRAPHIC_EXPANSION = "GEOGRAPHIC_EXPANSION"
85 + ACQUISITION = "ACQUISITION"
86 + PARTNERSHIP = "PARTNERSHIP"
87 + PRODUCT_LAUNCH = "PRODUCT_LAUNCH"
88 + GUIDANCE_RAISED = "GUIDANCE_RAISED"
89 + GUIDANCE_LOWERED = "GUIDANCE_LOWERED"
90 + REVENUE_GUIDANCE = "REVENUE_GUIDANCE"
91 + MARGIN_GUIDANCE = "MARGIN_GUIDANCE"
92 + INVESTMENT = "INVESTMENT"
93 + DEBT_CHANGE = "DEBT_CHANGE"
94 + FUNDING = "FUNDING"
95 + MANAGEMENT_CHANGE = "MANAGEMENT_CHANGE"
96 + REGULATORY_EVENT = "REGULATORY_EVENT"
97 + EARNINGS_RELEASE = "EARNINGS_RELEASE"
98 + ANNUAL_REPORT = "ANNUAL_REPORT"
99 + OTHER = "OTHER"
100 +
101 +
102 +class EventImpact(StrEnum):
103 + STRONG_POSITIVE = "STRONG_POSITIVE"
104 + POSITIVE = "POSITIVE"
105 + NEUTRAL = "NEUTRAL"
106 + NEGATIVE = "NEGATIVE"
107 + STRONG_NEGATIVE = "STRONG_NEGATIVE"
108 + UNCERTAIN = "UNCERTAIN"
109 +
110 +
111 +class TimeHorizon(StrEnum):
112 + IMMEDIATE = "IMMEDIATE"
113 + SHORT_TERM = "SHORT_TERM"
114 + MEDIUM_TERM = "MEDIUM_TERM"
115 + LONG_TERM = "LONG_TERM"
116 + UNKNOWN = "UNKNOWN"
117 +
118 +
119 +class ResearchLifecycleStatus(StrEnum):
120 + DETECTED = "DETECTED"
121 + VALIDATED = "VALIDATED"
122 + REJECTED = "REJECTED"
123 +
124 +
125 +class SourceRateLimitPolicy(ResearchBaseModel):
126 + requests_per_minute: int = 6
127 + min_delay_seconds: float = 10.0
128 +
129 +
130 +class ResearchSourceProvider(ResearchBaseModel):
131 + source_id: str
132 + source_type: SourceType
133 + source_name: str
134 + supported_countries: list[str] = Field(default_factory=list)
135 + supported_markets: list[str] = Field(default_factory=list)
136 + fetch_strategy: FetchStrategy
137 + reliability_level: ReliabilityLevel
138 + rate_limit_policy: SourceRateLimitPolicy = Field(default_factory=SourceRateLimitPolicy)
139 + javascript_required: bool = False
140 + automatic_access: SourceAccessStatus = SourceAccessStatus.MANUAL_ONLY
141 + base_url: AnyHttpUrl | None = None
142 +
143 +
144 +class CompanyResearchProfile(ResearchBaseModel):
145 + instrument_id: UUID
146 + company_id: UUID
147 + company_name: str
148 + aliases: list[str] = Field(default_factory=list)
149 + isin: str | None = None
150 + ticker: str
151 + exchange: str
152 + mic: str
153 + country: str
154 + currency: str
155 + known_domains: list[str] = Field(default_factory=list)
156 + official_website: AnyHttpUrl | None = None
157 + investor_relations_url: AnyHttpUrl | None = None
158 + press_release_url: AnyHttpUrl | None = None
159 + annual_reports_url: AnyHttpUrl | None = None
160 + exchange_announcements_url: AnyHttpUrl | None = None
161 + regulatory_filings_url: AnyHttpUrl | None = None
162 + rss_feeds: list[AnyHttpUrl] = Field(default_factory=list)
163 +
164 +
165 +class EntityResolution(ResearchBaseModel):
166 + instrument_id: UUID | None
167 + company_id: UUID | None
168 + confidence: float = Field(ge=0.0, le=1.0)
169 + matched_on: list[str] = Field(default_factory=list)
170 +
171 +
172 +class ResearchDocument(ResearchBaseModel):
173 + document_id: UUID = Field(default_factory=uuid4)
174 + canonical_url: str
175 + original_url: str
176 + title: str | None = None
177 + source_type: SourceType
178 + source_name: str
179 + publisher: str | None = None
180 + published_at: datetime | None = None
181 + retrieved_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
182 + language: str | None = None
183 + content_type: str
184 + document_type: DocumentType
185 + raw_text: str | None = Field(default=None, exclude=True)
186 + normalized_text: str | None = Field(default=None, exclude=True)
187 + content_hash: str
188 + instrument_id: UUID | None = None
189 + company_id: UUID | None = None
190 + country: str | None = None
191 + exchange: str | None = None
192 + status: DocumentStatus = DocumentStatus.DISCOVERED
193 + reliability_level: ReliabilityLevel
194 + entity_resolution_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
195 +
196 +
197 +class NormalizedNumber(ResearchBaseModel):
198 + original: str
199 + value: Decimal
200 + unit: str | None = None
201 + currency: str | None = None
202 +
203 +
204 +class ResearchEvent(ResearchBaseModel):
205 + event_id: UUID = Field(default_factory=uuid4)
206 + instrument_id: UUID
207 + company_id: UUID
208 + event_type: ResearchEventType
209 + event_date: datetime | None = None
210 + detected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
211 + title: str
212 + summary: str
213 + source_document_id: UUID
214 + source_url: str
215 + source_type: SourceType
216 + reliability: ReliabilityLevel
217 + confidence: float = Field(ge=0.0, le=1.0)
218 + impact: EventImpact
219 + time_horizon: TimeHorizon
220 + currency: str | None = None
221 + monetary_value: Decimal | None = None
222 + monetary_original: str | None = None
223 + percentage_value: Decimal | None = None
224 + percentage_original: str | None = None
225 + customer: str | None = None
226 + counterparty: str | None = None
227 + location: str | None = None
228 + capacity_value: Decimal | None = None
229 + capacity_unit: str | None = None
230 + status: ResearchLifecycleStatus = ResearchLifecycleStatus.VALIDATED
231 + raw_evidence_reference: str
232 +
233 + @field_validator("raw_evidence_reference")
234 + @classmethod
235 + def limit_evidence(cls, value: str) -> str:
236 + return value[:500]
237 +
238 +
239 +class CatalystScore(ResearchBaseModel):
240 + instrument_id: UUID
241 + overall_score: int = Field(ge=0, le=100)
242 + buckets: dict[str, int]
243 + research_confidence: int = Field(ge=0, le=100)
244 + generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
245 +
246 +
247 +class ResearchSummary(ResearchBaseModel):
248 + profile: CompanyResearchProfile
249 + catalyst_score: CatalystScore
250 + recent_events: list[ResearchEvent]
251 + documents: list[ResearchDocument]
252 + last_refresh_at: datetime | None = None
253 + data_freshness: str
254 + demo: bool
255 + source_mix: dict[str, int]
256 +
257 +
258 +class PlatformEvent(ResearchBaseModel):
259 + event_type: str
260 + version: int = 1
261 + event_id: UUID = Field(default_factory=uuid4)
262 + correlation_id: str | None = None
263 + occurred_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
264 + payload: dict[str, Any]
ai/research-engine/app/normalization.py new
+94
@@ -0,0 +1,94 @@
1 +from __future__ import annotations
2 +
3 +import hashlib
4 +import re
5 +from decimal import Decimal
6 +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
7 +
8 +from bs4 import BeautifulSoup
9 +
10 +from app.models import DocumentType, NormalizedNumber
11 +
12 +
13 +TRACKING_PARAMS = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid"}
14 +
15 +
16 +def canonicalize_url(url: str) -> str:
17 + parts = urlsplit(url.strip())
18 + scheme = parts.scheme.lower()
19 + host = (parts.hostname or "").lower()
20 + port = f":{parts.port}" if parts.port and parts.port not in {80, 443} else ""
21 + path = re.sub(r"/+", "/", parts.path or "/")
22 + if path != "/" and path.endswith("/"):
23 + path = path[:-1]
24 + query = urlencode(sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k not in TRACKING_PARAMS))
25 + return urlunsplit((scheme, host + port, path, query, ""))
26 +
27 +
28 +def normalize_text(text: str) -> str:
29 + return re.sub(r"\s+", " ", text).strip()
30 +
31 +
32 +def content_hash(text: str) -> str:
33 + return hashlib.sha256(normalize_text(text).lower().encode("utf-8")).hexdigest()
34 +
35 +
36 +def detect_document_type(content_type: str, url: str = "") -> DocumentType:
37 + lower = content_type.lower()
38 + if "pdf" in lower or url.lower().endswith(".pdf"):
39 + return DocumentType.PDF_REFERENCE
40 + if "html" in lower:
41 + return DocumentType.HTML
42 + if "xml" in lower or "rss" in lower:
43 + return DocumentType.RSS_XML
44 + if "text/plain" in lower:
45 + return DocumentType.TEXT
46 + return DocumentType.UNKNOWN
47 +
48 +
49 +def extract_text(content: str, content_type: str) -> tuple[str | None, str | None]:
50 + doc_type = detect_document_type(content_type)
51 + if doc_type == DocumentType.PDF_REFERENCE:
52 + return None, None
53 + if doc_type == DocumentType.HTML:
54 + soup = BeautifulSoup(content, "html.parser")
55 + title = normalize_text(soup.title.get_text(" ")) if soup.title else None
56 + for tag in soup(["script", "style", "noscript"]):
57 + tag.decompose()
58 + return title, normalize_text(soup.get_text(" "))
59 + return None, normalize_text(content)
60 +
61 +
62 +_MONEY_PATTERN = re.compile(
63 + r"(?P<prefix>₹|€|\$|INR|EUR|USD)?\s*(?P<number>[+-]?\d+(?:,\d{2,3})*(?:\.\d+)?)\s*(?P<scale>crore|lakh|million|billion|mn|bn)?",
64 + re.IGNORECASE,
65 +)
66 +_CAPACITY_PATTERN = re.compile(r"(?P<number>\d+(?:\.\d+)?)\s*(?P<unit>MW|GW|units?)", re.IGNORECASE)
67 +_PERCENT_PATTERN = re.compile(r"(?P<number>[+-]?\d+(?:\.\d+)?)\s*%")
68 +
69 +
70 +def normalize_numbers(text: str) -> list[NormalizedNumber]:
71 + values: list[NormalizedNumber] = []
72 + for match in _MONEY_PATTERN.finditer(text):
73 + original = match.group(0).strip()
74 + prefix = (match.group("prefix") or "").upper()
75 + scale = (match.group("scale") or "").lower()
76 + if not prefix and scale not in {"crore", "lakh", "million", "billion", "mn", "bn"}:
77 + continue
78 + number = Decimal(match.group("number").replace(",", ""))
79 + multiplier = {
80 + "lakh": Decimal("100000"),
81 + "crore": Decimal("10000000"),
82 + "million": Decimal("1000000"),
83 + "mn": Decimal("1000000"),
84 + "billion": Decimal("1000000000"),
85 + "bn": Decimal("1000000000"),
86 + "": Decimal("1"),
87 + }[scale]
88 + currency = {"₹": "INR", "€": "EUR", "$": "USD"}.get(prefix, prefix or None)
89 + values.append(NormalizedNumber(original=original, value=number * multiplier, currency=currency))
90 + for match in _CAPACITY_PATTERN.finditer(text):
91 + values.append(NormalizedNumber(original=match.group(0), value=Decimal(match.group("number")), unit=match.group("unit").upper()))
92 + for match in _PERCENT_PATTERN.finditer(text):
93 + values.append(NormalizedNumber(original=match.group(0), value=Decimal(match.group("number")), unit="PERCENT"))
94 + return values
ai/research-engine/app/repository.py new
+237
@@ -0,0 +1,237 @@
1 +from __future__ import annotations
2 +
3 +from datetime import datetime, timezone
4 +from uuid import UUID
5 +
6 +from app.deduplication import DocumentDeduplicator
7 +from app.entity_resolution import EntityResolver
8 +from app.events import company_updated, document_event, research_event_extracted
9 +from app.extraction import RuleBasedEventExtractor
10 +from app.models import (
11 + CompanyResearchProfile,
12 + DocumentStatus,
13 + DocumentType,
14 + PlatformEvent,
15 + ReliabilityLevel,
16 + ResearchDocument,
17 + ResearchEvent,
18 + ResearchEventType,
19 + ResearchSummary,
20 + SourceType,
21 +)
22 +from app.normalization import canonicalize_url, content_hash, extract_text, normalize_text
23 +from app.scoring import CatalystScorer
24 +
25 +
26 +class ResearchRepository:
27 + def __init__(self) -> None:
28 + self.profiles = _demo_profiles()
29 + self.documents: dict[UUID, ResearchDocument] = {}
30 + self.events: dict[UUID, ResearchEvent] = {}
31 + self.platform_events: list[PlatformEvent] = []
32 + self.last_refresh: dict[UUID, datetime] = {}
33 + self._deduplicator = DocumentDeduplicator()
34 + self._resolver = EntityResolver(self.profiles)
35 + self._extractor = RuleBasedEventExtractor()
36 + self._scorer = CatalystScorer()
37 + self._seed_demo_data()
38 +
39 + def list_profiles(self) -> list[CompanyResearchProfile]:
40 + return self.profiles
41 +
42 + def profile(self, instrument_id: UUID) -> CompanyResearchProfile:
43 + return next(profile for profile in self.profiles if profile.instrument_id == instrument_id)
44 +
45 + def documents_for(self, instrument_id: UUID) -> list[ResearchDocument]:
46 + return sorted(
47 + [doc for doc in self.documents.values() if doc.instrument_id == instrument_id],
48 + key=lambda doc: doc.published_at or doc.retrieved_at,
49 + reverse=True,
50 + )
51 +
52 + def events_for(
53 + self,
54 + instrument_id: UUID,
55 + event_type: ResearchEventType | None = None,
56 + impact: str | None = None,
57 + reliability: ReliabilityLevel | None = None,
58 + ) -> list[ResearchEvent]:
59 + values = [event for event in self.events.values() if event.instrument_id == instrument_id]
60 + if event_type:
61 + values = [event for event in values if event.event_type == event_type]
62 + if impact:
63 + values = [event for event in values if event.impact == impact]
64 + if reliability:
65 + values = [event for event in values if event.reliability == reliability]
66 + return sorted(values, key=lambda event: event.event_date or event.detected_at, reverse=True)
67 +
68 + def summary(self, instrument_id: UUID) -> ResearchSummary:
69 + profile = self.profile(instrument_id)
70 + events = self.events_for(instrument_id)
71 + documents = self.documents_for(instrument_id)
72 + score = self._scorer.score(instrument_id, events)
73 + source_mix: dict[str, int] = {}
74 + for document in documents:
75 + key = str(document.source_type)
76 + source_mix[key] = source_mix.get(key, 0) + 1
77 + return ResearchSummary(
78 + profile=profile,
79 + catalyst_score=score,
80 + recent_events=events[:10],
81 + documents=documents[:10],
82 + last_refresh_at=self.last_refresh.get(instrument_id),
83 + data_freshness="DEMO",
84 + demo=True,
85 + source_mix=source_mix,
86 + )
87 +
88 + def ingest_fixture(
89 + self,
90 + *,
91 + original_url: str,
92 + source_type: SourceType,
93 + source_name: str,
94 + publisher: str,
95 + content_type: str,
96 + body: str,
97 + reliability: ReliabilityLevel,
98 + published_at: datetime | None = None,
99 + ) -> ResearchDocument:
100 + canonical = canonicalize_url(original_url)
101 + title, extracted = extract_text(body, content_type)
102 + normalized = normalize_text(extracted or "")
103 + resolution = self._resolver.resolve(title, normalized, canonical)
104 + document = ResearchDocument(
105 + canonical_url=canonical,
106 + original_url=original_url,
107 + title=title,
108 + source_type=source_type,
109 + source_name=source_name,
110 + publisher=publisher,
111 + published_at=published_at,
112 + content_type=content_type,
113 + document_type=DocumentType.PDF_REFERENCE if canonical.lower().endswith(".pdf") else DocumentType.HTML,
114 + raw_text=body if len(body) < 20_000 else None,
115 + normalized_text=normalized,
116 + content_hash=content_hash(normalized or canonical),
117 + instrument_id=resolution.instrument_id,
118 + company_id=resolution.company_id,
119 + status=DocumentStatus.PARSED,
120 + reliability_level=reliability,
121 + entity_resolution_confidence=resolution.confidence,
122 + )
123 + duplicate = self._deduplicator.add(document)
124 + if duplicate:
125 + document.status = DocumentStatus.DUPLICATE
126 + self.documents[document.document_id] = document
127 + return document
128 + self.documents[document.document_id] = document
129 + self.platform_events.append(document_event("research.document.processed", document))
130 + for event in self._extractor.extract(document):
131 + self.events[event.event_id] = event
132 + self.platform_events.append(research_event_extracted(event))
133 + if document.instrument_id:
134 + self.last_refresh[document.instrument_id] = datetime.now(timezone.utc)
135 + self.platform_events.append(company_updated(document.instrument_id))
136 + document.status = DocumentStatus.PROCESSED
137 + return document
138 +
139 + def refresh(self, instrument_id: UUID) -> ResearchSummary:
140 + self.last_refresh[instrument_id] = datetime.now(timezone.utc)
141 + return self.summary(instrument_id)
142 +
143 + def _seed_demo_data(self) -> None:
144 + fixtures = [
145 + (
146 + "https://ir.aixtron.example/releases/order-capacity?utm_source=test",
147 + "AIXTRON SE announced a new order worth €350 million from a leading power electronics customer. The XETR AIXA order supports silicon-carbide equipment demand and increases backlog by +42%.",
148 + SourceType.INVESTOR_RELATIONS,
149 + ReliabilityLevel.LEVEL_B,
150 + ),
151 + (
152 + "https://exchange.example/xams/besi-capacity",
153 + "BE Semiconductor Industries BESI XAMS announced capacity expansion of 73.15 MW equivalent production capability and a new facility in the Netherlands. CAPEX is €120 million and the project is under construction.",
154 + SourceType.EXCHANGE_ANNOUNCEMENT,
155 + ReliabilityLevel.LEVEL_A,
156 + ),
157 + (
158 + "https://nse.example/reliance-filing",
159 + "RELIANCE XNSE INE002A01018 disclosed an investment of ₹2,000 crore in new energy manufacturing capacity in India. Management maintained revenue guidance.",
160 + SourceType.REGULATORY_FILING,
161 + ReliabilityLevel.LEVEL_A,
162 + ),
163 + (
164 + "https://news.example/nvda-delay",
165 + "NVIDIA Corporation NVDA XNAS reported that a factory ramp was delayed by one quarter while demand remains strong.",
166 + SourceType.NEWS,
167 + ReliabilityLevel.LEVEL_C,
168 + ),
169 + ]
170 + for url, body, source_type, reliability in fixtures:
171 + self.ingest_fixture(
172 + original_url=url,
173 + source_type=source_type,
174 + source_name="DEMO fixture source",
175 + publisher="DEMO",
176 + content_type="text/html",
177 + body=f"<html><head><title>DEMO research fixture</title></head><body>{body}</body></html>",
178 + reliability=reliability,
179 + published_at=datetime(2026, 1, 15, tzinfo=timezone.utc),
180 + )
181 +
182 +
183 +def _demo_profiles() -> list[CompanyResearchProfile]:
184 + return [
185 + CompanyResearchProfile(
186 + instrument_id=UUID("11111111-1111-1111-1111-111111111111"),
187 + company_id=UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"),
188 + company_name="AIXTRON SE",
189 + aliases=["AIXTRON", "AIXA"],
190 + isin="DE000A0WMPJ6",
191 + ticker="AIXA",
192 + exchange="XETR",
193 + mic="XETR",
194 + country="DE",
195 + currency="EUR",
196 + known_domains=["aixtron.example"],
197 + ),
198 + CompanyResearchProfile(
199 + instrument_id=UUID("22222222-2222-2222-2222-222222222222"),
200 + company_id=UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"),
201 + company_name="BE Semiconductor Industries",
202 + aliases=["BESI", "BE Semiconductor"],
203 + isin="NL0012866412",
204 + ticker="BESI",
205 + exchange="XAMS",
206 + mic="XAMS",
207 + country="NL",
208 + currency="EUR",
209 + known_domains=["besi.example"],
210 + ),
211 + CompanyResearchProfile(
212 + instrument_id=UUID("33333333-3333-3333-3333-333333333333"),
213 + company_id=UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"),
214 + company_name="NVIDIA Corporation",
215 + aliases=["NVIDIA", "NVDA"],
216 + isin="US67066G1040",
217 + ticker="NVDA",
218 + exchange="XNAS",
219 + mic="XNAS",
220 + country="US",
221 + currency="USD",
222 + known_domains=["nvidia.example"],
223 + ),
224 + CompanyResearchProfile(
225 + instrument_id=UUID("44444444-4444-4444-4444-444444444444"),
226 + company_id=UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4"),
227 + company_name="Reliance Industries Limited",
228 + aliases=["RELIANCE", "Reliance Industries"],
229 + isin="INE002A01018",
230 + ticker="RELIANCE",
231 + exchange="XNSE",
232 + mic="XNSE",
233 + country="IN",
234 + currency="INR",
235 + known_domains=["ril.example"],
236 + ),
237 + ]
ai/research-engine/app/research_fetching.py new
+136
@@ -0,0 +1,136 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +from dataclasses import dataclass
5 +from email.utils import parsedate_to_datetime
6 +from typing import Protocol
7 +
8 +import httpx
9 +
10 +from app.settings import Settings
11 +from app.url_security import validate_public_http_url
12 +
13 +
14 +class ResearchFetcher(Protocol):
15 + async def fetch(self, url: str) -> "FetchResult":
16 + """Fetch permitted public content while respecting source limits and SSRF controls."""
17 +
18 +
19 +@dataclass(frozen=True)
20 +class FetchResult:
21 + final_url: str
22 + status_code: int
23 + content_type: str
24 + text: str
25 + bytes_read: int
26 +
27 +
28 +class FetchError(RuntimeError):
29 + pass
30 +
31 +
32 +class RestrictedFetchError(FetchError):
33 + pass
34 +
35 +
36 +class HttpResearchFetcher:
37 + def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None):
38 + self.settings = settings
39 + timeout = httpx.Timeout(
40 + timeout=settings.research_request_timeout_seconds,
41 + connect=settings.research_connect_timeout_seconds,
42 + )
43 + self._client = client or httpx.AsyncClient(
44 + timeout=timeout,
45 + follow_redirects=False,
46 + headers={"User-Agent": settings.research_user_agent, "Accept-Encoding": "gzip, deflate, br"},
47 + )
48 +
49 + async def fetch(self, url: str) -> FetchResult:
50 + validate_public_http_url(url)
51 + current_url = url
52 + for redirect_count in range(self.settings.research_max_redirects + 1):
53 + response = await self._request_with_retries(current_url)
54 + if response.is_redirect:
55 + if redirect_count >= self.settings.research_max_redirects:
56 + raise FetchError("Maximum redirects exceeded")
57 + location = response.headers.get("location")
58 + if not location:
59 + raise FetchError("Redirect without Location header")
60 + current_url = str(response.url.join(location))
61 + validate_public_http_url(current_url)
62 + continue
63 + return await self._response_to_result(response)
64 + raise FetchError("Maximum redirects exceeded")
65 +
66 + async def _request_with_retries(self, url: str) -> httpx.Response:
67 + attempt = 0
68 + while True:
69 + try:
70 + response = await self._client.get(url)
71 + except httpx.TimeoutException as exception:
72 + response = None
73 + last_error: Exception | None = exception
74 + else:
75 + last_error = None
76 + if response.status_code in {401, 403, 407, 451}:
77 + raise RestrictedFetchError(f"Source returned restricted status {response.status_code}")
78 + if response.status_code not in {408, 429, 500, 502, 503, 504}:
79 + return response
80 + if attempt >= self.settings.research_max_retries:
81 + if response is not None:
82 + return response
83 + raise FetchError("Fetch timed out") from last_error
84 + retry_after = _retry_after_seconds(response.headers.get("retry-after")) if response else None
85 + delay = retry_after if retry_after is not None else min(2**attempt, 8)
86 + await asyncio.sleep(delay)
87 + attempt += 1
88 +
89 + async def _response_to_result(self, response: httpx.Response) -> FetchResult:
90 + content_type = response.headers.get("content-type", "").split(";")[0].lower()
91 + allowed = content_type in {
92 + "text/html",
93 + "text/plain",
94 + "application/xml",
95 + "text/xml",
96 + "application/rss+xml",
97 + "application/pdf",
98 + }
99 + if not allowed:
100 + raise FetchError(f"Unsupported content type: {content_type or 'unknown'}")
101 + content = response.content
102 + if len(content) > self.settings.research_max_content_bytes:
103 + raise FetchError("Maximum content size exceeded")
104 + return FetchResult(
105 + final_url=str(response.url),
106 + status_code=response.status_code,
107 + content_type=content_type,
108 + text=response.text if content_type != "application/pdf" else "",
109 + bytes_read=len(content),
110 + )
111 +
112 +
113 +class PlaywrightResearchFetcher:
114 + def __init__(self, settings: Settings):
115 + self.settings = settings
116 + self._semaphore = asyncio.Semaphore(settings.research_playwright_concurrency)
117 +
118 + async def fetch(self, url: str) -> FetchResult:
119 + validate_public_http_url(url)
120 + if not self.settings.research_playwright_enabled:
121 + raise RestrictedFetchError("Playwright fallback is disabled")
122 + async with self._semaphore:
123 + raise RestrictedFetchError("Playwright runtime is not bundled in Phase 3 default image")
124 +
125 +
126 +def _retry_after_seconds(value: str | None) -> float | None:
127 + if not value:
128 + return None
129 + try:
130 + return max(float(value), 0.0)
131 + except ValueError:
132 + try:
133 + delta = parsedate_to_datetime(value).timestamp()
134 + except (TypeError, ValueError):
135 + return None
136 + return max(delta, 0.0)
ai/research-engine/app/scheduler.py new
+24
@@ -0,0 +1,24 @@
1 +from __future__ import annotations
2 +
3 +from dataclasses import dataclass
4 +from datetime import timedelta
5 +
6 +from app.models import SourceType
7 +
8 +
9 +@dataclass(frozen=True)
10 +class ResearchScheduleRule:
11 + source_type: SourceType
12 + interval: timedelta
13 + max_documents_per_run: int
14 +
15 +
16 +def default_schedule_rules() -> list[ResearchScheduleRule]:
17 + return [
18 + ResearchScheduleRule(SourceType.EXCHANGE_ANNOUNCEMENT, timedelta(hours=2), 20),
19 + ResearchScheduleRule(SourceType.REGULATORY_FILING, timedelta(hours=4), 20),
20 + ResearchScheduleRule(SourceType.INVESTOR_RELATIONS, timedelta(days=1), 10),
21 + ResearchScheduleRule(SourceType.COMPANY_WEBSITE, timedelta(days=3), 10),
22 + ResearchScheduleRule(SourceType.RSS, timedelta(hours=6), 20),
23 + ResearchScheduleRule(SourceType.NEWS, timedelta(hours=12), 20),
24 + ]
ai/research-engine/app/scoring.py new
+85
@@ -0,0 +1,85 @@
1 +from __future__ import annotations
2 +
3 +import math
4 +from datetime import datetime, timezone
5 +
6 +from app.models import CatalystScore, EventImpact, ResearchEvent, ResearchEventType
7 +
8 +
9 +DEFAULT_WEIGHTS: dict[ResearchEventType, float] = {
10 + ResearchEventType.NEW_ORDER: 16,
11 + ResearchEventType.ORDER_BACKLOG_CHANGE: 14,
12 + ResearchEventType.NEW_CUSTOMER: 10,
13 + ResearchEventType.MAJOR_CONTRACT: 14,
14 + ResearchEventType.GOVERNMENT_CONTRACT: 12,
15 + ResearchEventType.CAPEX: 8,
16 + ResearchEventType.FACTORY_EXPANSION: 10,
17 + ResearchEventType.CAPACITY_EXPANSION: 12,
18 + ResearchEventType.NEW_FACILITY: 10,
19 + ResearchEventType.GEOGRAPHIC_EXPANSION: 8,
20 + ResearchEventType.PARTNERSHIP: 6,
21 + ResearchEventType.PRODUCT_LAUNCH: 8,
22 + ResearchEventType.GUIDANCE_RAISED: 14,
23 + ResearchEventType.GUIDANCE_LOWERED: 16,
24 + ResearchEventType.DEBT_CHANGE: 8,
25 + ResearchEventType.REGULATORY_EVENT: 8,
26 + ResearchEventType.OTHER: 4,
27 +}
28 +
29 +IMPACT_MULTIPLIER = {
30 + EventImpact.STRONG_POSITIVE: 1.0,
31 + EventImpact.POSITIVE: 0.65,
32 + EventImpact.NEUTRAL: 0.0,
33 + EventImpact.UNCERTAIN: 0.15,
34 + EventImpact.NEGATIVE: -0.65,
35 + EventImpact.STRONG_NEGATIVE: -1.0,
36 +}
37 +
38 +
39 +class CatalystScorer:
40 + def __init__(self, weights: dict[ResearchEventType, float] | None = None, half_life_days: float = 365.0):
41 + self.weights = weights or DEFAULT_WEIGHTS
42 + self.half_life_days = half_life_days
43 +
44 + def score(self, instrument_id, events: list[ResearchEvent]) -> CatalystScore:
45 + buckets = {
46 + "New Orders": 0.0,
47 + "CAPEX": 0.0,
48 + "Capacity Expansion": 0.0,
49 + "New Customers": 0.0,
50 + "Guidance": 0.0,
51 + }
52 + total = 50.0
53 + confidence_sum = 0.0
54 + for event in events:
55 + contribution = self._contribution(event)
56 + total += contribution
57 + confidence_sum += event.confidence
58 + if event.event_type in {ResearchEventType.NEW_ORDER, ResearchEventType.MAJOR_CONTRACT, ResearchEventType.GOVERNMENT_CONTRACT, ResearchEventType.ORDER_BACKLOG_CHANGE}:
59 + buckets["New Orders"] += max(contribution, 0)
60 + elif event.event_type == ResearchEventType.CAPEX:
61 + buckets["CAPEX"] += max(contribution, 0)
62 + elif event.event_type in {ResearchEventType.CAPACITY_EXPANSION, ResearchEventType.NEW_FACILITY, ResearchEventType.FACTORY_EXPANSION}:
63 + buckets["Capacity Expansion"] += max(contribution, 0)
64 + elif event.event_type in {ResearchEventType.NEW_CUSTOMER, ResearchEventType.CUSTOMER_EXPANSION, ResearchEventType.MAJOR_CUSTOMER}:
65 + buckets["New Customers"] += max(contribution, 0)
66 + elif event.event_type in {ResearchEventType.GUIDANCE_RAISED, ResearchEventType.GUIDANCE_LOWERED, ResearchEventType.REVENUE_GUIDANCE, ResearchEventType.MARGIN_GUIDANCE}:
67 + buckets["Guidance"] += max(contribution, 0)
68 + bucket_scores = {key: int(max(0, min(100, 50 + value))) for key, value in buckets.items()}
69 + research_confidence = int(round((confidence_sum / len(events)) * 100)) if events else 0
70 + return CatalystScore(
71 + instrument_id=instrument_id,
72 + overall_score=int(max(0, min(100, round(total)))),
73 + buckets=bucket_scores,
74 + research_confidence=research_confidence,
75 + )
76 +
77 + def _contribution(self, event: ResearchEvent) -> float:
78 + weight = self.weights.get(event.event_type, 3.0)
79 + impact = IMPACT_MULTIPLIER[event.impact]
80 + return weight * impact * event.confidence * self._decay(event)
81 +
82 + def _decay(self, event: ResearchEvent) -> float:
83 + event_date = event.event_date or event.detected_at
84 + age_days = max((datetime.now(timezone.utc) - event_date).days, 0)
85 + return math.pow(0.5, age_days / self.half_life_days)
ai/research-engine/app/settings.py new
+20
@@ -0,0 +1,20 @@
1 +from pydantic_settings import BaseSettings, SettingsConfigDict
2 +
3 +
4 +class Settings(BaseSettings):
5 + model_config = SettingsConfigDict(env_prefix="AIP_", env_file=".env", extra="ignore")
6 +
7 + service_name: str = "research-engine"
8 + environment: str = "DEV"
9 + llm_provider: str = "ollama"
10 + ollama_base_url: str = "http://ollama:11434"
11 + research_live_enabled: bool = False
12 + research_demo_enabled: bool = True
13 + research_user_agent: str = "AIInvestmentResearchBot/0.1 contact=research-compliance@example.invalid"
14 + research_request_timeout_seconds: float = 10.0
15 + research_connect_timeout_seconds: float = 3.0
16 + research_max_content_bytes: int = 1_500_000
17 + research_max_redirects: int = 5
18 + research_max_retries: int = 2
19 + research_playwright_enabled: bool = False
20 + research_playwright_concurrency: int = 1
ai/research-engine/app/sources.py new
+82
@@ -0,0 +1,82 @@
1 +from __future__ import annotations
2 +
3 +from app.models import (
4 + FetchStrategy,
5 + ReliabilityLevel,
6 + ResearchSourceProvider,
7 + SourceAccessStatus,
8 + SourceRateLimitPolicy,
9 + SourceType,
10 +)
11 +
12 +
13 +def default_source_providers() -> list[ResearchSourceProvider]:
14 + return [
15 + ResearchSourceProvider(
16 + source_id="official-company-website",
17 + source_type=SourceType.COMPANY_WEBSITE,
18 + source_name="Company website",
19 + fetch_strategy=FetchStrategy.HTTP,
20 + reliability_level=ReliabilityLevel.LEVEL_B,
21 + automatic_access=SourceAccessStatus.MANUAL_ONLY,
22 + rate_limit_policy=SourceRateLimitPolicy(requests_per_minute=2, min_delay_seconds=30),
23 + ),
24 + ResearchSourceProvider(
25 + source_id="investor-relations",
26 + source_type=SourceType.INVESTOR_RELATIONS,
27 + source_name="Investor relations",
28 + fetch_strategy=FetchStrategy.HTTP,
29 + reliability_level=ReliabilityLevel.LEVEL_B,
30 + automatic_access=SourceAccessStatus.MANUAL_ONLY,
31 + rate_limit_policy=SourceRateLimitPolicy(requests_per_minute=3, min_delay_seconds=20),
32 + ),
33 + ResearchSourceProvider(
34 + source_id="exchange-announcements",
35 + source_type=SourceType.EXCHANGE_ANNOUNCEMENT,
36 + source_name="Exchange announcements",
37 + fetch_strategy=FetchStrategy.HTTP,
38 + reliability_level=ReliabilityLevel.LEVEL_A,
39 + automatic_access=SourceAccessStatus.MANUAL_ONLY,
40 + rate_limit_policy=SourceRateLimitPolicy(requests_per_minute=6, min_delay_seconds=10),
41 + ),
42 + ResearchSourceProvider(
43 + source_id="regulatory-filings",
44 + source_type=SourceType.REGULATORY_FILING,
45 + source_name="Regulatory filings",
46 + fetch_strategy=FetchStrategy.HTTP,
47 + reliability_level=ReliabilityLevel.LEVEL_A,
48 + automatic_access=SourceAccessStatus.MANUAL_ONLY,
49 + ),
50 + ResearchSourceProvider(
51 + source_id="government-procurement",
52 + source_type=SourceType.GOVERNMENT_PROCUREMENT,
53 + source_name="Government procurement",
54 + fetch_strategy=FetchStrategy.HTTP,
55 + reliability_level=ReliabilityLevel.LEVEL_A,
56 + automatic_access=SourceAccessStatus.MANUAL_ONLY,
57 + ),
58 + ResearchSourceProvider(
59 + source_id="rss",
60 + source_type=SourceType.RSS,
61 + source_name="Permitted RSS feed",
62 + fetch_strategy=FetchStrategy.HTTP,
63 + reliability_level=ReliabilityLevel.LEVEL_C,
64 + automatic_access=SourceAccessStatus.AVAILABLE,
65 + ),
66 + ResearchSourceProvider(
67 + source_id="news",
68 + source_type=SourceType.NEWS,
69 + source_name="Permitted financial news",
70 + fetch_strategy=FetchStrategy.HTTP,
71 + reliability_level=ReliabilityLevel.LEVEL_C,
72 + automatic_access=SourceAccessStatus.MANUAL_ONLY,
73 + ),
74 + ResearchSourceProvider(
75 + source_id="search-discovery-disabled",
76 + source_type=SourceType.SEARCH_DISCOVERY,
77 + source_name="Search discovery provider",
78 + fetch_strategy=FetchStrategy.MANUAL,
79 + reliability_level=ReliabilityLevel.LEVEL_E,
80 + automatic_access=SourceAccessStatus.UNAVAILABLE,
81 + ),
82 + ]
ai/research-engine/app/url_security.py new
+51
@@ -0,0 +1,51 @@
1 +from __future__ import annotations
2 +
3 +import ipaddress
4 +import socket
5 +from urllib.parse import urlparse
6 +
7 +
8 +BLOCKED_HOSTS = {"localhost", "metadata.google.internal"}
9 +BLOCKED_IPS = {
10 + ipaddress.ip_address("169.254.169.254"),
11 + ipaddress.ip_address("100.100.100.200"),
12 +}
13 +
14 +
15 +class UnsafeUrlError(ValueError):
16 + pass
17 +
18 +
19 +def validate_public_http_url(url: str, *, resolve_dns: bool = False) -> str:
20 + parsed = urlparse(url)
21 + if parsed.scheme not in {"http", "https"}:
22 + raise UnsafeUrlError("Only http and https URLs are permitted")
23 + if not parsed.hostname:
24 + raise UnsafeUrlError("URL host is required")
25 + host = parsed.hostname.lower()
26 + if host in BLOCKED_HOSTS or host.endswith(".localhost"):
27 + raise UnsafeUrlError("Localhost and metadata hosts are not permitted")
28 + try:
29 + ip = ipaddress.ip_address(host)
30 + except ValueError:
31 + if resolve_dns:
32 + for _, _, _, _, sockaddr in socket.getaddrinfo(host, parsed.port or 443):
33 + ip = ipaddress.ip_address(sockaddr[0])
34 + if _is_blocked_ip(ip):
35 + raise UnsafeUrlError("Resolved private or metadata IP is not permitted")
36 + else:
37 + if _is_blocked_ip(ip):
38 + raise UnsafeUrlError("Private, local, link-local, multicast, and metadata IPs are not permitted")
39 + return url
40 +
41 +
42 +def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
43 + return (
44 + ip in BLOCKED_IPS
45 + or ip.is_private
46 + or ip.is_loopback
47 + or ip.is_link_local
48 + or ip.is_multicast
49 + or ip.is_reserved
50 + or ip.is_unspecified
51 + )
ai/research-engine/db/migration/V1__research_intelligence.sql new
+124
@@ -0,0 +1,124 @@
1 +CREATE TABLE research_sources (
2 + source_id VARCHAR(120) PRIMARY KEY,
3 + source_type VARCHAR(60) NOT NULL,
4 + source_name VARCHAR(240) NOT NULL,
5 + reliability_level VARCHAR(20) NOT NULL,
6 + fetch_strategy VARCHAR(40) NOT NULL,
7 + automatic_access VARCHAR(40) NOT NULL,
8 + javascript_required BOOLEAN NOT NULL DEFAULT FALSE,
9 + requests_per_minute INTEGER NOT NULL,
10 + min_delay_seconds DECIMAL(12, 3) NOT NULL
11 +);
12 +
13 +CREATE TABLE company_research_profiles (
14 + company_id UUID PRIMARY KEY,
15 + instrument_id UUID NOT NULL UNIQUE,
16 + company_name VARCHAR(240) NOT NULL,
17 + isin VARCHAR(20),
18 + ticker VARCHAR(40) NOT NULL,
19 + exchange VARCHAR(40) NOT NULL,
20 + mic VARCHAR(12),
21 + country VARCHAR(2) NOT NULL,
22 + currency VARCHAR(3) NOT NULL,
23 + official_website VARCHAR(600),
24 + investor_relations_url VARCHAR(600),
25 + press_release_url VARCHAR(600),
26 + annual_reports_url VARCHAR(600),
27 + exchange_announcements_url VARCHAR(600),
28 + regulatory_filings_url VARCHAR(600)
29 +);
30 +
31 +CREATE TABLE research_documents (
32 + document_id UUID PRIMARY KEY,
33 + canonical_url VARCHAR(1000) NOT NULL,
34 + original_url VARCHAR(1000) NOT NULL,
35 + title VARCHAR(500),
36 + source_type VARCHAR(60) NOT NULL,
37 + source_name VARCHAR(240) NOT NULL,
38 + publisher VARCHAR(240),
39 + published_at TIMESTAMP,
40 + retrieved_at TIMESTAMP NOT NULL,
41 + language VARCHAR(16),
42 + content_type VARCHAR(120) NOT NULL,
43 + document_type VARCHAR(40) NOT NULL,
44 + content_hash CHAR(64) NOT NULL,
45 + instrument_id UUID,
46 + company_id UUID,
47 + country VARCHAR(2),
48 + exchange VARCHAR(40),
49 + status VARCHAR(40) NOT NULL,
50 + reliability_level VARCHAR(20) NOT NULL,
51 + entity_resolution_confidence DECIMAL(5, 4) NOT NULL
52 +);
53 +
54 +CREATE TABLE research_document_sources (
55 + document_source_id UUID PRIMARY KEY,
56 + document_id UUID NOT NULL,
57 + source_id VARCHAR(120),
58 + source_url VARCHAR(1000) NOT NULL,
59 + discovered_at TIMESTAMP NOT NULL,
60 + CONSTRAINT fk_research_document_sources_document FOREIGN KEY (document_id) REFERENCES research_documents (document_id)
61 +);
62 +
63 +CREATE TABLE research_events (
64 + event_id UUID PRIMARY KEY,
65 + instrument_id UUID NOT NULL,
66 + company_id UUID NOT NULL,
67 + event_type VARCHAR(80) NOT NULL,
68 + event_date TIMESTAMP,
69 + detected_at TIMESTAMP NOT NULL,
70 + title VARCHAR(300) NOT NULL,
71 + summary VARCHAR(1200) NOT NULL,
72 + source_document_id UUID NOT NULL,
73 + source_url VARCHAR(1000) NOT NULL,
74 + source_type VARCHAR(60) NOT NULL,
75 + reliability VARCHAR(20) NOT NULL,
76 + confidence DECIMAL(5, 4) NOT NULL,
77 + impact VARCHAR(40) NOT NULL,
78 + time_horizon VARCHAR(40) NOT NULL,
79 + currency VARCHAR(3),
80 + monetary_value DECIMAL(28, 4),
81 + monetary_original VARCHAR(80),
82 + percentage_value DECIMAL(12, 4),
83 + percentage_original VARCHAR(80),
84 + customer VARCHAR(240),
85 + counterparty VARCHAR(240),
86 + location VARCHAR(240),
87 + capacity_value DECIMAL(28, 4),
88 + capacity_unit VARCHAR(40),
89 + status VARCHAR(40) NOT NULL,
90 + CONSTRAINT fk_research_events_document FOREIGN KEY (source_document_id) REFERENCES research_documents (document_id)
91 +);
92 +
93 +CREATE TABLE research_event_evidence (
94 + evidence_id UUID PRIMARY KEY,
95 + event_id UUID NOT NULL,
96 + source_document_id UUID NOT NULL,
97 + evidence_reference VARCHAR(600) NOT NULL,
98 + CONSTRAINT fk_research_event_evidence_event FOREIGN KEY (event_id) REFERENCES research_events (event_id)
99 +);
100 +
101 +CREATE TABLE research_fetch_history (
102 + fetch_id UUID PRIMARY KEY,
103 + source_id VARCHAR(120),
104 + url VARCHAR(1000) NOT NULL,
105 + attempted_at TIMESTAMP NOT NULL,
106 + status VARCHAR(40) NOT NULL,
107 + http_status INTEGER,
108 + failure_code VARCHAR(120),
109 + bytes_read BIGINT,
110 + latency_ms BIGINT
111 +);
112 +
113 +CREATE INDEX idx_research_documents_instrument_id ON research_documents (instrument_id);
114 +CREATE INDEX idx_research_documents_company_id ON research_documents (company_id);
115 +CREATE INDEX idx_research_documents_published_at ON research_documents (published_at);
116 +CREATE UNIQUE INDEX idx_research_documents_canonical_url ON research_documents (canonical_url);
117 +CREATE INDEX idx_research_documents_content_hash ON research_documents (content_hash);
118 +CREATE INDEX idx_research_documents_source_type ON research_documents (source_type);
119 +CREATE INDEX idx_research_documents_status ON research_documents (status);
120 +CREATE INDEX idx_research_events_instrument_id ON research_events (instrument_id);
121 +CREATE INDEX idx_research_events_company_id ON research_events (company_id);
122 +CREATE INDEX idx_research_events_event_type ON research_events (event_type);
123 +CREATE INDEX idx_research_events_event_date ON research_events (event_date);
124 +CREATE INDEX idx_research_events_impact ON research_events (impact);
ai/research-engine/pyproject.toml new
+29
@@ -0,0 +1,29 @@
1 +[project]
2 +name = "research-engine"
3 +version = "0.1.0"
4 +requires-python = ">=3.11"
5 +dependencies = [
6 + "fastapi>=0.115.0",
7 + "uvicorn[standard]>=0.30.0",
8 + "pydantic>=2.8.0",
9 + "pydantic-settings>=2.4.0",
10 + "httpx>=0.27.0",
11 + "beautifulsoup4>=4.12.0"
12 +]
13 +
14 +[project.optional-dependencies]
15 +test = [
16 + "pytest>=8.3.0",
17 + "pytest-asyncio>=0.24.0",
18 + "respx>=0.21.0"
19 +]
20 +
21 +[tool.ruff]
22 +line-length = 100
23 +
24 +[tool.setuptools.packages.find]
25 +include = ["app*"]
26 +
27 +[build-system]
28 +requires = ["setuptools>=68"]
29 +build-backend = "setuptools.build_meta"
ai/research-engine/tests/test_research_api.py new
+34
@@ -0,0 +1,34 @@
1 +from fastapi.testclient import TestClient
2 +
3 +from app.main import app
4 +
5 +
6 +def test_research_company_summary_api_returns_demo_evidence_without_raw_bodies() -> None:
7 + client = TestClient(app)
8 +
9 + companies = client.get("/api/v1/research/companies").json()
10 + assert companies
11 + instrument_id = companies[0]["instrumentId"]
12 +
13 + summary = client.get(f"/api/v1/research/companies/{instrument_id}/summary")
14 +
15 + assert summary.status_code == 200
16 + body = summary.json()
17 + assert body["demo"] is True
18 + assert "overallScore" in body["catalystScore"]
19 + assert body["recentEvents"]
20 + assert "rawText" not in str(body)
21 + assert "normalizedText" not in str(body)
22 +
23 +
24 +def test_research_refresh_is_safe_fixture_backed() -> None:
25 + client = TestClient(app)
26 + instrument_id = client.get("/api/v1/research/companies").json()[0]["instrumentId"]
27 +
28 + response = client.post(
29 + f"/api/v1/research/companies/{instrument_id}/refresh",
30 + headers={"X-Correlation-Id": "phase3-api-test"},
31 + )
32 +
33 + assert response.status_code == 200
34 + assert response.json()["dataFreshness"] == "DEMO"
ai/research-engine/tests/test_research_engine.py new
+161
@@ -0,0 +1,161 @@
1 +from __future__ import annotations
2 +
3 +from datetime import datetime, timezone
4 +from decimal import Decimal
5 +from uuid import UUID
6 +
7 +import httpx
8 +import pytest
9 +import respx
10 +
11 +from app.deduplication import DocumentDeduplicator
12 +from app.entity_resolution import EntityResolver
13 +from app.extraction import RuleBasedEventExtractor
14 +from app.models import DocumentStatus, EventImpact, ReliabilityLevel, ResearchDocument, SourceType
15 +from app.normalization import canonicalize_url, content_hash, normalize_numbers
16 +from app.repository import ResearchRepository
17 +from app.research_fetching import HttpResearchFetcher, RestrictedFetchError
18 +from app.scoring import CatalystScorer
19 +from app.settings import Settings
20 +from app.url_security import UnsafeUrlError, validate_public_http_url
21 +
22 +
23 +def test_url_canonicalization_and_hashing_are_deterministic() -> None:
24 + url = canonicalize_url("HTTPS://Example.COM/a//b/?utm_source=x&b=2&a=1#frag")
25 + assert url == "https://example.com/a/b?a=1&b=2"
26 + assert content_hash(" Hello World ") == content_hash("hello world")
27 +
28 +
29 +def test_ssrf_rejects_private_local_and_non_http_urls() -> None:
30 + for url in ["http://localhost/test", "http://127.0.0.1/test", "file:///etc/passwd", "http://169.254.169.254/latest"]:
31 + with pytest.raises(UnsafeUrlError):
32 + validate_public_http_url(url)
33 +
34 +
35 +def test_numeric_normalization_supports_money_capacity_and_percentages() -> None:
36 + values = normalize_numbers("₹2,000 crore, €350 million, $1.2 billion, 73.15 MW, +42% backlog")
37 + assert any(v.currency == "INR" and v.value == Decimal("20000000000") for v in values)
38 + assert any(v.currency == "EUR" and v.value == Decimal("350000000") for v in values)
39 + assert any(v.currency == "USD" and v.value == Decimal("1200000000.0") for v in values)
40 + assert any(v.unit == "MW" and v.value == Decimal("73.15") for v in values)
41 + assert any(v.unit == "PERCENT" and v.value == Decimal("42") for v in values)
42 +
43 +
44 +def test_entity_resolution_uses_more_than_ticker() -> None:
45 + repo = ResearchRepository()
46 + resolver = EntityResolver(repo.profiles)
47 + resolution = resolver.resolve(
48 + "AIXTRON order",
49 + "AIXTRON SE AIXA XETR DE000A0WMPJ6 receives a new order.",
50 + "https://ir.aixtron.example/releases/order",
51 + )
52 + assert resolution.instrument_id == UUID("11111111-1111-1111-1111-111111111111")
53 + assert resolution.confidence > 0.8
54 + assert {"isin", "company_name", "known_domain"}.issubset(set(resolution.matched_on))
55 +
56 +
57 +def test_document_deduplication_uses_url_and_hash() -> None:
58 + dedupe = DocumentDeduplicator()
59 + doc = _document("https://example.com/a", "same text")
60 + duplicate_url = _document("https://example.com/a", "different text")
61 + duplicate_hash = _document("https://example.com/b", "same text")
62 + assert dedupe.add(doc) is None
63 + assert dedupe.add(duplicate_url) == doc
64 + assert dedupe.add(duplicate_hash) == doc
65 +
66 +
67 +def test_rule_based_extraction_sets_confidence_and_negative_events() -> None:
68 + doc = _document(
69 + "https://example.com/a",
70 + "AIXTRON SE AIXA XETR announced a new order worth €350 million. The factory ramp was delayed.",
71 + )
72 + doc.instrument_id = UUID("11111111-1111-1111-1111-111111111111")
73 + doc.company_id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1")
74 + doc.entity_resolution_confidence = 0.9
75 + extractor = RuleBasedEventExtractor()
76 +
77 + events = extractor.extract(doc)
78 +
79 + assert any(event.event_type == "NEW_ORDER" and event.monetary_value == Decimal("350000000") for event in events)
80 + assert any(event.impact == EventImpact.NEGATIVE for event in events)
81 + assert all(0 < event.confidence <= 1 for event in events)
82 +
83 +
84 +def test_catalyst_score_uses_negative_events_and_temporal_decay() -> None:
85 + repo = ResearchRepository()
86 + instrument_id = UUID("33333333-3333-3333-3333-333333333333")
87 + events = repo.events_for(instrument_id)
88 + score = CatalystScorer().score(instrument_id, events)
89 + assert 0 <= score.overall_score <= 100
90 + assert score.overall_score < 60
91 +
92 +
93 +def test_repository_ingestion_marks_duplicate_and_preserves_demo_summary() -> None:
94 + repo = ResearchRepository()
95 + first = repo.ingest_fixture(
96 + original_url="https://ir.aixtron.example/releases/new-order",
97 + source_type=SourceType.INVESTOR_RELATIONS,
98 + source_name="Fixture",
99 + publisher="DEMO",
100 + content_type="text/html",
101 + body="<html><title>AIXTRON order</title><body>AIXTRON SE AIXA XETR wins a new order worth €10 million.</body></html>",
102 + reliability=ReliabilityLevel.LEVEL_B,
103 + published_at=datetime(2026, 2, 1, tzinfo=timezone.utc),
104 + )
105 + second = repo.ingest_fixture(
106 + original_url="https://ir.aixtron.example/releases/new-order?utm_source=feed",
107 + source_type=SourceType.RSS,
108 + source_name="Fixture RSS",
109 + publisher="DEMO",
110 + content_type="text/html",
111 + body="<html><title>AIXTRON order</title><body>AIXTRON SE AIXA XETR wins a new order worth €10 million.</body></html>",
112 + reliability=ReliabilityLevel.LEVEL_C,
113 + )
114 + assert first.status == DocumentStatus.PROCESSED
115 + assert second.status == DocumentStatus.DUPLICATE
116 + assert repo.summary(UUID("11111111-1111-1111-1111-111111111111")).demo is True
117 +
118 +
119 +@pytest.mark.asyncio
120 +@respx.mock
121 +async def test_http_fetch_validates_content_type_retries_and_size() -> None:
122 + settings = Settings(research_max_content_bytes=50, research_max_retries=1)
123 + fetcher = HttpResearchFetcher(settings)
124 + route = respx.get("https://example.com/release").mock(
125 + side_effect=[
126 + httpx.Response(429, headers={"Retry-After": "0"}),
127 + httpx.Response(200, headers={"content-type": "text/html"}, text="<html>ok</html>"),
128 + ]
129 + )
130 + result = await fetcher.fetch("https://example.com/release")
131 + assert route.call_count == 2
132 + assert result.content_type == "text/html"
133 +
134 +
135 +@pytest.mark.asyncio
136 +@respx.mock
137 +async def test_http_fetch_does_not_retry_restricted_sources() -> None:
138 + settings = Settings(research_max_retries=2)
139 + fetcher = HttpResearchFetcher(settings)
140 + route = respx.get("https://example.com/private").mock(return_value=httpx.Response(403))
141 + with pytest.raises(RestrictedFetchError):
142 + await fetcher.fetch("https://example.com/private")
143 + assert route.call_count == 1
144 +
145 +
146 +def _document(url: str, text: str) -> ResearchDocument:
147 + return ResearchDocument(
148 + canonical_url=canonicalize_url(url),
149 + original_url=url,
150 + title="Fixture",
151 + source_type=SourceType.INVESTOR_RELATIONS,
152 + source_name="Fixture",
153 + publisher="DEMO",
154 + published_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
155 + content_type="text/html",
156 + document_type="HTML",
157 + normalized_text=text,
158 + content_hash=content_hash(text),
159 + status=DocumentStatus.PARSED,
160 + reliability_level=ReliabilityLevel.LEVEL_B,
161 + )
ai/valuation-engine/.dockerignore new
+6
@@ -0,0 +1,6 @@
1 +__pycache__
2 +.pytest_cache
3 +.venv
4 +*.pyc
5 +*.log
6 +.env
ai/valuation-engine/Dockerfile new
+9
@@ -0,0 +1,9 @@
1 +FROM python:3.12-slim
2 +WORKDIR /app
3 +COPY pyproject.toml .
4 +COPY app ./app
5 +RUN pip install --no-cache-dir .
6 +EXPOSE 8000
7 +RUN useradd --create-home --uid 10001 appuser
8 +USER appuser
9 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ai/valuation-engine/app/__init__.py new
+1
@@ -0,0 +1 @@
1 +
ai/valuation-engine/app/main.py new
+11
@@ -0,0 +1,11 @@
1 +from fastapi import FastAPI
2 +
3 +from app.settings import Settings
4 +
5 +settings = Settings()
6 +app = FastAPI(title="Valuation Engine", version="0.1.0")
7 +
8 +
9 +@app.get("/health")
10 +def health() -> dict[str, str]:
11 + return {"status": "ok", "service": settings.service_name}
ai/valuation-engine/app/settings.py new
+7
@@ -0,0 +1,7 @@
1 +from pydantic_settings import BaseSettings, SettingsConfigDict
2 +
3 +
4 +class Settings(BaseSettings):
5 + model_config = SettingsConfigDict(env_prefix="AIP_", env_file=".env", extra="ignore")
6 +
7 + service_name: str = "valuation-engine"
ai/valuation-engine/pyproject.toml new
+17
@@ -0,0 +1,17 @@
1 +[project]
2 +name = "valuation-engine"
3 +version = "0.1.0"
4 +requires-python = ">=3.11"
5 +dependencies = [
6 + "fastapi>=0.115.0",
7 + "uvicorn[standard]>=0.30.0",
8 + "pydantic>=2.8.0",
9 + "pydantic-settings>=2.4.0"
10 +]
11 +
12 +[tool.ruff]
13 +line-length = 100
14 +
15 +[build-system]
16 +requires = ["setuptools>=68"]
17 +build-backend = "setuptools.build_meta"
config/dev/application.env.example new
+27
@@ -0,0 +1,27 @@
1 +AIP_ENVIRONMENT=DEV
2 +AIP_POSTGRES_HOST=postgres
3 +AIP_POSTGRES_PORT=5432
4 +AIP_REDIS_HOST=redis
5 +AIP_REDIS_PORT=6379
6 +AIP_KAFKA_BOOTSTRAP_SERVERS=kafka:9092
7 +AIP_LLM_PROVIDER=ollama
8 +AIP_OLLAMA_BASE_URL=http://ollama:11434
9 +AIP_RESEARCH_LIVE_ENABLED=false
10 +AIP_RESEARCH_DEMO_ENABLED=true
11 +# Broker integration defaults stay mock-safe.
12 +IBKR_ENABLED=false
13 +IBKR_BASE_URL=
14 +IBKR_CLIENT_ID=
15 +IBKR_CALLBACK_URL=
16 +IBKR_AUTH_METHOD=
17 +IBKR_OFFICIAL_DOCUMENTATION_VERIFIED=false
18 +
19 +ICICI_DIRECT_ENABLED=false
20 +ICICI_DIRECT_BASE_URL=
21 +ICICI_DIRECT_CLIENT_ID=
22 +ICICI_DIRECT_CALLBACK_URL=
23 +ICICI_DIRECT_AUTH_METHOD=
24 +ICICI_DIRECT_OFFICIAL_DOCUMENTATION_VERIFIED=false
25 +
26 +MARKET_DATA_DEMO_MODE=true
27 +RESEARCH_ENGINE_BASE_URL=http://localhost:8000
config/prd/application.env.example new
+26
@@ -0,0 +1,26 @@
1 +AIP_ENVIRONMENT=PRD
2 +AIP_POSTGRES_HOST=<azure-postgresql-host>
3 +AIP_REDIS_HOST=<azure-redis-host>
4 +AIP_KAFKA_BOOTSTRAP_SERVERS=<managed-or-self-hosted-kafka-bootstrap>
5 +AIP_LLM_PROVIDER=<configured-provider>
6 +AIP_RESEARCH_LIVE_ENABLED=false
7 +AIP_RESEARCH_DEMO_ENABLED=false
8 +
9 +# Broker credentials and tokens must come from secure runtime configuration.
10 +IBKR_ENABLED=false
11 +IBKR_BASE_URL=
12 +IBKR_CLIENT_ID=
13 +IBKR_CALLBACK_URL=
14 +IBKR_AUTH_METHOD=
15 +IBKR_OFFICIAL_DOCUMENTATION_VERIFIED=false
16 +
17 +ICICI_DIRECT_ENABLED=false
18 +ICICI_DIRECT_BASE_URL=
19 +ICICI_DIRECT_CLIENT_ID=
20 +ICICI_DIRECT_CALLBACK_URL=
21 +ICICI_DIRECT_AUTH_METHOD=
22 +ICICI_DIRECT_OFFICIAL_DOCUMENTATION_VERIFIED=false
23 +
24 +MARKET_DATA_DEMO_MODE=false
25 +RESEARCH_ENGINE_BASE_URL=http://research-engine
26 +# Store real values in Azure Key Vault / Kubernetes secrets, not in this file.
config/prd/azure.json new
+5
@@ -0,0 +1,5 @@
1 +{
2 + "subscriptionId": "2dccba84-7038-4126-b0b2-32f8f29bcbd4",
3 + "tenantId": "ee868f5c-6f21-48fa-b329-3e114d8d229d",
4 + "location": "westeurope"
5 +}
docs/architecture.md new
+82
@@ -0,0 +1,82 @@
1 +# Architecture
2 +
3 +## System Shape
4 +
5 +The platform is a monorepo with independently containerisable services:
6 +
7 +- Java Spring Boot services own application APIs and business workflows.
8 +- Python FastAPI services own AI, research extraction, scoring assistance, ranking, valuation, and optimization engines.
9 +- Next.js owns the browser-facing application shell.
10 +- Shared Java modules hold common domain and web concerns that would otherwise be duplicated.
11 +
12 +## Service Boundaries
13 +
14 +- `api-gateway`: future external API entrypoint and routing policy.
15 +- `auth-service`: authentication and authorization boundary.
16 +- `portfolio-service`: portfolio, positions, holdings, and exposure model.
17 +- `broker-service`: broker provider orchestration through `BrokerProvider`.
18 +- `company-service`: company and instrument reference data.
19 +- `research-service`: orchestration of public-source research workflows.
20 +- `recommendation-service`: deterministic recommendation calculation and explanation assembly.
21 +- `risk-service`: portfolio risk, concentration, currency exposure, and diversification checks.
22 +- `notification-service`: alerts and asynchronous user notifications.
23 +
24 +## Shared Domain
25 +
26 +Securities are not identified by ticker alone. The initial `Instrument` model includes:
27 +
28 +- internal instrument ID
29 +- ISIN
30 +- ticker
31 +- exchange
32 +- MIC
33 +- currency
34 +- country
35 +- asset type
36 +- company name
37 +
38 +Broker-specific instrument identity additionally carries broker security ID and broker contract ID where providers expose them. Stable normalization uses the broker identifier plus ISIN, exchange, ticker, and currency; ticker alone is never globally unique.
39 +
40 +The model is deliberately shared from `shared/java/domain` to avoid duplicated incompatible security identifiers across services.
41 +
42 +## Broker Provider Architecture
43 +
44 +Broker integrations must implement `BrokerProvider`.
45 +
46 +Initial placeholders:
47 +
48 +- `IBKRBrokerProvider`
49 +- `ICICIDirectBrokerProvider`
50 +
51 +The real-provider adapters fail safely because official provider documentation is not present locally. Disabled providers report `NOT_CONFIGURED`; enabled but unverified providers report `DOCUMENTATION_REQUIRED`. The placeholders throw `UnsupportedOperationException` for read calls until supported authentication, session handling, and market data APIs are verified from official documentation. Browser automation that stores broker usernames or passwords is out of scope and prohibited.
52 +
53 +Phase 2B adds broker-neutral session, token-reference, rate-limit, retry, circuit-breaker, audit, and instrument-normalization boundaries. These are architecture boundaries only; they do not make IBKR or ICICI Direct connected providers.
54 +
55 +Market data is routed through a fallback provider: verified source, stale cache, demo mock only when explicitly enabled, otherwise `UNAVAILABLE`. Mock quotes are marked `MOCK` and displayed as `DEMO`.
56 +
57 +## AI Provider Architecture
58 +
59 +The Python research engine defines an `LlmProvider` protocol. DEV can use Ollama/local models; PRD can configure a cloud LLM provider later without changing callers.
60 +
61 +LLMs should extract, summarize, and explain. Deterministic recommendation scoring remains owned by application logic.
62 +
63 +## Research Access
64 +
65 +Research fetching must start with normal HTTP fetching and HTML parsing. Playwright is reserved for permitted JavaScript-rendered public pages. The platform must respect robots.txt, website terms, rate limits, licensing, authentication walls, paywalls, CAPTCHAs, and other access controls.
66 +
67 +Phase 3 research intelligence is implemented in `ai/research-engine`. It creates structured evidence from source documents, performs deduplication and entity resolution, extracts typed research events, and computes deterministic catalyst scores. The LLM boundary is optional and schema-constrained; final BUY/SELL recommendations are not implemented.
68 +
69 +## Environments
70 +
71 +The same source and images should run in DEV and PRD.
72 +
73 +- DEV: Docker Desktop plus k3d, local PostgreSQL, local Redis, local Kafka, optional Ollama.
74 +- PRD: Azure AKS, ACR, VNet, AKS subnet, Key Vault, Helm deployments, Terraform-owned infrastructure.
75 +
76 +DEV and PRD use the same source code, Dockerfile strategy, and Helm templates. Environment differences are supplied through Helm values, Terraform variables, and runtime environment variables.
77 +
78 +## Frontend Experience
79 +
80 +Frontend work must follow the mandatory requirements in `docs/frontend-ui-ux-requirements.md`.
81 +
82 +The browser application should look and behave like a production-grade investment intelligence platform: premium financial SaaS, not a generic admin dashboard. Phase 2B uses actual portfolio and broker-readiness APIs where available and clearly labels mock broker output as demo data.
docs/azure-deployment.md new
+73
@@ -0,0 +1,73 @@
1 +# Azure Deployment
2 +
3 +## Azure Context
4 +
5 +Expected Azure environment:
6 +
7 +- Subscription name: Azure subscription 1
8 +- Subscription ID: `2dccba84-7038-4126-b0b2-32f8f29bcbd4`
9 +- Tenant ID: `ee868f5c-6f21-48fa-b329-3e114d8d229d`
10 +- Region: `westeurope`
11 +
12 +The canonical checked-in PRD Azure account configuration is `config/prd/azure.json`. `platform.ps1` reads this file and passes those values into Terraform. `prd.auto.tfvars.example` is a human-readable Terraform template for direct Terraform use; if values differ, treat `config/prd/azure.json` as authoritative for platform commands.
13 +
14 +Development authentication uses the currently authenticated Azure CLI context. Credentials must not be stored in this repository.
15 +
16 +## Subscription Guardrail
17 +
18 +Before PRD deployment, validate:
19 +
20 +```powershell
21 +az account show
22 +```
23 +
24 +If the active subscription is wrong, set it manually:
25 +
26 +```powershell
27 +az account set --subscription "2dccba84-7038-4126-b0b2-32f8f29bcbd4"
28 +```
29 +
30 +`platform.ps1` fails safely when the active Azure subscription does not match the expected subscription ID.
31 +
32 +## PRD Terraform
33 +
34 +The first Terraform environment is located at:
35 +
36 +```text
37 +infrastructure/terraform/environments/prd
38 +```
39 +
40 +It defines Terraform-owned PRD resources:
41 +
42 +- Resource group
43 +- Azure Container Registry
44 +- VNet
45 +- AKS subnet
46 +- Azure Key Vault
47 +- AKS cluster
48 +
49 +For this foundation iteration:
50 +
51 +```powershell
52 +.\platform.ps1 up PRD
53 +```
54 +
55 +validates Azure and creates a Terraform plan, but intentionally stops before `terraform apply`.
56 +
57 +## Disposable PRD
58 +
59 +When real PRD resources have been created from this Terraform state, destruction must be limited to Terraform-owned resources:
60 +
61 +```powershell
62 +.\platform.ps1 down PRD
63 +```
64 +
65 +The script validates the active Azure subscription and requires typing `DESTROY ai-investment-platform PRD` before it invokes `terraform destroy`. After destruction, audit remaining tagged resources:
66 +
67 +```powershell
68 +az resource list --tag project=ai-investment-platform --output table
69 +```
70 +
71 +Do not manually delete unrelated Azure resources.
72 +
73 +Foundation validation must not run `terraform plan`, `terraform apply`, or `terraform destroy`, and must not create Azure resources.
docs/broker-security.md new
+27
@@ -0,0 +1,27 @@
1 +# Broker Security
2 +
3 +Broker integrations are read-only in Phase 2B. The platform must not store broker usernames, passwords, OTP values, CAPTCHA answers, MFA answers, or raw access tokens in source, logs, Kafka messages, frontend bundles, or Docker images.
4 +
5 +## Secret Boundary
6 +
7 +- `SecretProvider` reads secrets from the environment in DEV.
8 +- `AzureKeyVaultSecretProvider` is a production boundary only and is not required for local development.
9 +- `BrokerTokenStore` stores token references and session state, not broker passwords or OTP values.
10 +- Token storage is intentionally abstracted so production storage can become encrypted or Key Vault backed without changing broker callers.
11 +
12 +## Authentication Boundary
13 +
14 +Provider authentication can only be implemented from official provider documentation. If a provider requires OAuth, gateway software, a callback, local service, user approval, or a session refresh endpoint, the adapter must model those operational prerequisites exactly.
15 +
16 +## Audit Boundary
17 +
18 +Broker operation audit logging records provider, operation, timestamp-derived duration, success or failure, correlation ID, and status code strings. It must not log request or response payloads that may contain tokens, account details, OTP values, or credentials.
19 +
20 +## Prohibited
21 +
22 +- Browser automation against broker login pages.
23 +- Scraping OTP, CAPTCHA, or MFA screens.
24 +- Persisting raw broker credentials.
25 +- Exposing auth values to frontend code.
26 +- Retrying authentication aggressively.
27 +- Executing trades.
docs/brokers/ibkr.md new
+64
@@ -0,0 +1,64 @@
1 +# Interactive Brokers Provider
2 +
3 +## SUPPORTED
4 +
5 +- Provider registration and frontend status exposure.
6 +- Fail-safe configuration model.
7 +- Read-only capability contract placeholders.
8 +- Provider-specific instrument normalization using contract ID, ISIN, ticker, exchange, MIC, currency, country, and asset type.
9 +
10 +## NOT SUPPORTED
11 +
12 +- Order placement.
13 +- Browser automation.
14 +- Stored broker usernames or passwords.
15 +- Real account, position, cash, metadata, session, or market-data calls.
16 +
17 +## AUTH METHOD
18 +
19 +Official IBKR authentication documentation is not available in this repository. The adapter must not assume OAuth, Client Portal Gateway, TWS, IB Gateway, callbacks, headers, token formats, session refresh, or account identifiers until official docs are added and reviewed.
20 +
21 +## CONFIG REQUIRED
22 +
23 +Configuration is read from environment-backed Spring properties only:
24 +
25 +- `IBKR_ENABLED`
26 +- `IBKR_BASE_URL`
27 +- `IBKR_CLIENT_ID`
28 +- `IBKR_CALLBACK_URL`
29 +- `IBKR_AUTH_METHOD`
30 +- `IBKR_OFFICIAL_DOCUMENTATION_VERIFIED`
31 +
32 +These names are local integration placeholders and must be reconciled with official IBKR documentation before real connectivity is enabled.
33 +
34 +## READ-ONLY CAPABILITIES
35 +
36 +No real IBKR read capability is currently enabled. Future verified read-only targets are accounts, positions, cash balances, account metadata, provider session status, and permitted market data.
37 +
38 +## CAPABILITIES
39 +
40 +Current advertised capabilities: none.
41 +
42 +## KNOWN LIMITATIONS
43 +
44 +- Provider reports `NOT_CONFIGURED` by default.
45 +- Provider reports `DOCUMENTATION_REQUIRED` when enabled without locally verified official documentation.
46 +- No network requests are made.
47 +
48 +## OFFICIAL DOCUMENTATION VERIFIED
49 +
50 +NO
51 +
52 +## REAL CONNECTION VALIDATED
53 +
54 +NO
55 +
56 +## CURRENT PROVIDER STATUS
57 +
58 +- Default: `NOT_CONFIGURED`
59 +- Enabled without locally verified official docs: `DOCUMENTATION_REQUIRED`
60 +- Real trades executed: NO
61 +
62 +## DOCUMENTATION SOURCE
63 +
64 +No current official IBKR API documentation was found in repository-local docs or source files during Phase 2B.
docs/brokers/icici-direct.md new
+65
@@ -0,0 +1,65 @@
1 +# ICICI Direct Provider
2 +
3 +## SUPPORTED
4 +
5 +- Provider registration and frontend status exposure.
6 +- Fail-safe configuration model.
7 +- Read-only capability contract placeholders.
8 +- Provider-specific Indian instrument normalization using broker security ID, ISIN, ticker, exchange, MIC, INR currency, country, and asset type.
9 +
10 +## NOT SUPPORTED
11 +
12 +- Order placement.
13 +- Login page scraping.
14 +- OTP, CAPTCHA, or MFA screen automation.
15 +- Stored passwords, OTP values, or MFA answers.
16 +- Real account, holdings, positions, cash, margin, session, or market-data calls.
17 +
18 +## AUTH METHOD
19 +
20 +Official ICICI Direct integration documentation is not available in this repository. The adapter must not assume API keys, OAuth, session-token exchange, headers, token refresh behavior, or account identifiers until official docs are added and reviewed.
21 +
22 +## CONFIG REQUIRED
23 +
24 +Configuration is read from environment-backed Spring properties only:
25 +
26 +- `ICICI_DIRECT_ENABLED`
27 +- `ICICI_DIRECT_BASE_URL`
28 +- `ICICI_DIRECT_CLIENT_ID`
29 +- `ICICI_DIRECT_CALLBACK_URL`
30 +- `ICICI_DIRECT_AUTH_METHOD`
31 +- `ICICI_DIRECT_OFFICIAL_DOCUMENTATION_VERIFIED`
32 +
33 +These names are local integration placeholders and must be reconciled with official ICICI Direct documentation before real connectivity is enabled.
34 +
35 +## READ-ONLY CAPABILITIES
36 +
37 +No real ICICI Direct read capability is currently enabled. Future verified read-only targets are accounts, holdings, positions, cash or margin information, provider session status, and permitted market data.
38 +
39 +## CAPABILITIES
40 +
41 +Current advertised capabilities: none.
42 +
43 +## KNOWN LIMITATIONS
44 +
45 +- Provider reports `NOT_CONFIGURED` by default.
46 +- Provider reports `DOCUMENTATION_REQUIRED` when enabled without locally verified official documentation.
47 +- No network requests are made.
48 +
49 +## OFFICIAL DOCUMENTATION VERIFIED
50 +
51 +NO
52 +
53 +## REAL CONNECTION VALIDATED
54 +
55 +NO
56 +
57 +## CURRENT PROVIDER STATUS
58 +
59 +- Default: `NOT_CONFIGURED`
60 +- Enabled without locally verified official docs: `DOCUMENTATION_REQUIRED`
61 +- Real trades executed: NO
62 +
63 +## DOCUMENTATION SOURCE
64 +
65 +No current official ICICI Direct API documentation was found in repository-local docs or source files during Phase 2B.
docs/frontend-ui-ux-requirements.md new
+100
@@ -0,0 +1,100 @@
1 +# Frontend UI/UX Requirements
2 +
3 +All frontend work must preserve a production-grade investment intelligence experience. The product must feel like premium financial SaaS, not a generic admin dashboard or raw CRUD interface.
4 +
5 +## Design Goals
6 +
7 +- Clean, modern, responsive desktop/tablet/mobile layouts.
8 +- Strong information hierarchy for portfolio and research workflows.
9 +- Accessible typography, keyboard navigation, semantic HTML, visible focus states, and adequate contrast.
10 +- Consistent spacing, terminology, component behavior, and data presentation.
11 +- Professional financial tables, charts, risk indicators, and BUY / HOLD / SELL status presentation.
12 +- Minimal animation, restrained color, and no visually noisy dashboard composition.
13 +- Financial readability takes priority over decorative visuals.
14 +
15 +## Design System
16 +
17 +Frontend implementation must use reusable components for:
18 +
19 +- typography scale
20 +- spacing scale
21 +- cards
22 +- buttons
23 +- inputs
24 +- badges
25 +- tabs
26 +- tables
27 +- drawers
28 +- dialogs
29 +- tooltips
30 +- skeleton loading
31 +- empty states
32 +- error states
33 +- status indicators
34 +- responsive navigation
35 +- chart containers
36 +
37 +Tailwind CSS and shadcn/ui may be introduced only when compatible with the current Next.js setup and when the added dependency weight is justified. Large UI libraries should not be introduced by default.
38 +
39 +## Application Shell
40 +
41 +The application must use a professional shell with:
42 +
43 +- collapsible sidebar navigation
44 +- top command/search area
45 +- notification area
46 +- user/account menu
47 +- responsive mobile navigation
48 +
49 +Primary navigation should include Dashboard, Portfolio, Research, Screener, Watchlist, Insights, Brokers, and Settings as the product matures.
50 +
51 +## Phase 1 Frontend Scope
52 +
53 +Phase 1 frontend must provide professional UI for:
54 +
55 +- application shell
56 +- sidebar navigation
57 +- dashboard
58 +- portfolio list/client selection
59 +- portfolio detail
60 +- holdings table
61 +- portfolio summary cards
62 +- allocation visualization
63 +- mock broker information
64 +- demo-data indicator
65 +- loading, error, and empty states
66 +
67 +Use the actual Phase 1 Portfolio API wherever endpoints exist. Do not hard-code portfolio results when backend endpoints are available. Mock data must be clearly labelled as demo data and never presented as live market data.
68 +
69 +## Data Presentation
70 +
71 +Important financial areas should be designed to display:
72 +
73 +- last updated time
74 +- source
75 +- REAL-TIME, DELAYED, END-OF-DAY, or RESEARCH UPDATE freshness labels when real data exists
76 +
77 +Phase 1 must clearly display Demo data.
78 +
79 +## Future Pages
80 +
81 +The stock detail architecture must be able to support:
82 +
83 +- company header
84 +- current price, daily movement, market cap
85 +- AI Opportunity Score
86 +- BUY / ADD / HOLD / TRIM / SELL
87 +- tabs for Overview, Financials, Valuation, Growth, Orders & Backlog, News, Analysts, Institutions, Insiders, and Risks
88 +- valuation, growth, quality, catalyst, analyst, institutional, and risk scores
89 +
90 +Do not implement fake analysis before the relevant backend logic exists.
91 +
92 +## Error And Loading Experience
93 +
94 +Never show blank pages while loading. Use skeleton cards, skeleton tables, and loading indicators.
95 +
96 +API errors must show professional user-safe messages. Do not expose stack traces, SQL errors, internal service names, secrets, or broker credentials. Show correlation IDs when available.
97 +
98 +## Security Constraints
99 +
100 +Never request, display, or store broker passwords, Azure credentials, API keys, access tokens, or other secrets in the frontend. Broker integration screens must remain placeholder/demo-only until real provider flows are designed.
docs/local-development.md new
+64
@@ -0,0 +1,64 @@
1 +# Local Development
2 +
3 +## Prerequisites
4 +
5 +- Java 17
6 +- Maven
7 +- Python 3.12+
8 +- Node.js 20+
9 +- Docker Desktop
10 +- k3d
11 +- kubectl
12 +- Helm
13 +
14 +## Validate the Foundation
15 +
16 +```powershell
17 +mvn clean verify
18 +
19 +python -m py_compile ai/research-engine/app/main.py
20 +python -m py_compile ai/valuation-engine/app/main.py
21 +python -m py_compile ai/ranking-engine/app/main.py
22 +python -m py_compile ai/portfolio-optimizer/app/main.py
23 +
24 +helm lint infrastructure/helm/ai-investment-platform
25 +```
26 +
27 +## Run DEV Platform
28 +
29 +```powershell
30 +.\platform.ps1 up DEV
31 +.\platform.ps1 status DEV
32 +.\platform.ps1 down DEV
33 +```
34 +
35 +The DEV command creates a local k3d cluster named `ai-investment-dev` and installs the Helm chart into the `ai-investment` namespace.
36 +
37 +## Spring Boot Local Ports
38 +
39 +When services are run directly from an IDE or Maven without overriding `SERVER_PORT`, their local defaults are:
40 +
41 +| Service | Port |
42 +| --- | ---: |
43 +| api-gateway | 8080 |
44 +| auth-service | 8081 |
45 +| portfolio-service | 8082 |
46 +| broker-service | 8083 |
47 +| company-service | 8084 |
48 +| research-service | 8085 |
49 +| recommendation-service | 8086 |
50 +| risk-service | 8087 |
51 +| notification-service | 8088 |
52 +
53 +Container and Helm deployments can override these through `SERVER_PORT`.
54 +
55 +## Local LLM Option
56 +
57 +DEV configuration supports Ollama via:
58 +
59 +```text
60 +AIP_LLM_PROVIDER=ollama
61 +AIP_OLLAMA_BASE_URL=http://ollama:11434
62 +```
63 +
64 +No model is pulled or called in the foundation iteration.
docs/market-data.md new
+40
@@ -0,0 +1,40 @@
1 +# Market Data
2 +
3 +Phase 2B defines the real market-data connectivity boundary without claiming a live quote source.
4 +
5 +## Provider Order
6 +
7 +1. Broker-supported market data after official provider docs and read-only validation exist.
8 +2. Secondary documented and permitted source.
9 +3. Stale cache.
10 +4. Mock quotes only when demo mode is enabled.
11 +
12 +## Freshness Vocabulary
13 +
14 +- `REAL_TIME`
15 +- `DELAYED`
16 +- `END_OF_DAY`
17 +- `STALE`
18 +- `MOCK`, displayed as `DEMO`
19 +- `UNAVAILABLE`
20 +
21 +## Current Implementation
22 +
23 +`FallbackMarketDataProvider` is the primary portfolio-service provider. It checks usable cached quotes first, exposes expired cache entries as `STALE`, returns mock quotes only when `MARKET_DATA_DEMO_MODE=true`, and otherwise returns `UNAVAILABLE` with source `NoVerifiedMarketDataProvider`.
24 +
25 +The base runtime default is `MARKET_DATA_DEMO_MODE=false`. DEV and test profiles may explicitly enable demo mode. Cached `MOCK` quotes are ignored when demo mode is disabled, so a real/non-demo runtime cannot silently reuse fake prices.
26 +
27 +Quotes carry `source`, `sourceTimestamp`, `receivedAt`, and `freshness`. The older `timestamp` field remains the quote/source timestamp for API compatibility.
28 +
29 +Redis stores deterministic fresh and stale keys:
30 +
31 +- `market:quote:{instrumentId}`
32 +- `market:quote:stale:{instrumentId}`
33 +
34 +No arbitrary quote website scraping is implemented. No paid API is configured.
35 +
36 +## Validation Status
37 +
38 +- Real market-data source validated: NO
39 +- Broker market data validated: NO
40 +- Mock fallback available in DEV/demo mode: YES
docs/research-intelligence.md new
+53
@@ -0,0 +1,53 @@
1 +# Research Intelligence Engine
2 +
3 +Phase 3 builds the research evidence foundation. It does not produce final BUY/SELL recommendations.
4 +
5 +## Pipeline
6 +
7 +Public source -> document discovery -> fetch -> extraction -> normalization -> deduplication -> entity resolution -> structured event extraction -> validation -> research fact store -> deterministic catalyst scoring -> future AI explanation.
8 +
9 +LLMs are optional and may only produce schema-constrained extraction candidates. Invalid enum values, dates, numbers, or unsupported claims are rejected before persistence.
10 +
11 +## Source Providers
12 +
13 +The source abstraction records source type, supported countries and markets, fetch strategy, reliability level, rate-limit policy, JavaScript requirement, and automatic access status.
14 +
15 +Default providers cover company websites, investor relations, exchange announcements, regulatory filings, government procurement, permitted RSS, permitted news, and a disabled search-discovery placeholder. Google search result scraping is not implemented.
16 +
17 +## Source Safety
18 +
19 +The fetcher must not bypass CAPTCHA, paywalls, authentication, robots restrictions, anti-bot controls, or source terms. Sources that are not permitted for automation must be marked `UNAVAILABLE`, `RESTRICTED`, or `MANUAL_ONLY`.
20 +
21 +Default live fetching is disabled with `AIP_RESEARCH_LIVE_ENABLED=false`. Demo fixtures are labelled `DEMO`.
22 +
23 +## Fetching
24 +
25 +Normal HTTP fetching is the first strategy. The implementation supports configurable user agent, connect/request timeouts, bounded retries, exponential backoff, `Retry-After`, redirect limits, redirect target validation, content-type validation, maximum content size, canonical URL normalization, and SSRF protection.
26 +
27 +Playwright fallback is represented but disabled by default and not bundled in the default image. It may only be enabled for sources that permit JavaScript automation.
28 +
29 +## Documents
30 +
31 +`ResearchDocument` supports canonical URL, original URL, title, source type/name, publisher, publication/retrieval dates, language, content type, document type, content hash, instrument/company IDs, status, and reliability. Raw and normalized bodies are excluded from API responses.
32 +
33 +PDFs are supported as metadata/reference documents in Phase 3. OCR is not implemented.
34 +
35 +## Entity Resolution
36 +
37 +Resolution does not use ticker alone. It combines ISIN, company name, aliases, ticker plus exchange, country, and known domains, and returns a confidence score.
38 +
39 +## Events And Scoring
40 +
41 +`ResearchEvent` captures event type, date, source document, source URL, reliability, confidence, impact, time horizon, monetary values, percentages, customer/counterparty, location, capacity, and a short evidence reference.
42 +
43 +The deterministic `CatalystScore` is 0-100 and uses event weights, source reliability, confidence, impact, and temporal decay. It is not a recommendation engine.
44 +
45 +## Persistence And Events
46 +
47 +The Phase 3 schema artifact is in `ai/research-engine/db/migration/V1__research_intelligence.sql`.
48 +
49 +Versioned event contracts exist for `research.document.discovered`, `research.document.fetched`, `research.document.processed`, `research.event.extracted`, `research.event.rejected`, and `research.company.updated`. Events contain IDs and metadata, not raw page bodies or secrets.
50 +
51 +## Security
52 +
53 +The fetcher rejects localhost, loopback, private ranges, link-local addresses, cloud metadata endpoints, non-http(s) schemes, and unsafe redirect targets. No paid API keys or credentials are required.
docs/security.md new
+54
@@ -0,0 +1,54 @@
1 +# Security
2 +
3 +## Credential Rules
4 +
5 +Do not commit:
6 +
7 +- broker usernames or passwords
8 +- Azure credentials
9 +- API keys
10 +- LLM provider secrets
11 +- database passwords
12 +- private keys or certificates
13 +
14 +Use `.env.example` files only for non-secret configuration shape. Store PRD secrets in Azure Key Vault and expose them to workloads through approved Kubernetes secret mechanisms.
15 +
16 +## Broker Security
17 +
18 +Broker integration must use provider-supported authentication and session mechanisms. Do not implement browser automation that stores or replays broker credentials.
19 +
20 +Initial broker classes are placeholders only:
21 +
22 +- `IBKRBrokerProvider`
23 +- `ICICIDirectBrokerProvider`
24 +
25 +They must not be treated as working integrations until official/supported API research is complete.
26 +
27 +## Research Compliance
28 +
29 +Automated research must:
30 +
31 +- respect robots.txt and website terms
32 +- obey rate limits
33 +- use normal HTTP fetching first
34 +- use Playwright only for permitted JavaScript-rendered public pages
35 +- avoid CAPTCHA bypass
36 +- avoid paywall bypass
37 +- avoid authentication bypass
38 +- respect licensing and redistribution restrictions
39 +
40 +## Application Security Defaults
41 +
42 +- Correlation IDs are propagated through `X-Correlation-Id`.
43 +- Health endpoints are present for orchestration.
44 +- Configuration is environment-driven.
45 +- Source code contains no default production credentials.
46 +- PRD Azure commands validate subscription context before planning or destroying.
47 +
48 +## Future Required Controls
49 +
50 +- Authentication and authorization design review.
51 +- Tenant and user data isolation model.
52 +- Secret rotation process.
53 +- Audit logging for broker sessions and recommendation generation.
54 +- Formal threat model before broker connectivity is enabled.
frontend/.dockerignore new
+6
@@ -0,0 +1,6 @@
1 +node_modules
2 +.next
3 +out
4 +*.log
5 +.env
6 +.env.*
frontend/.env.example new
+1
@@ -0,0 +1 @@
1 +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
frontend/AGENTS.md new
+9
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +# This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
frontend/CLAUDE.md new
+1
@@ -0,0 +1 @@
1 +@AGENTS.md
frontend/Dockerfile new
+20
@@ -0,0 +1,20 @@
1 +FROM node:24-alpine AS deps
2 +WORKDIR /app
3 +COPY package.json package-lock.json ./
4 +RUN npm ci --ignore-scripts
5 +
6 +FROM node:24-alpine AS builder
7 +WORKDIR /app
8 +COPY --from=deps /app/node_modules ./node_modules
9 +COPY . .
10 +RUN npm run build
11 +
12 +FROM node:24-alpine AS runner
13 +WORKDIR /app
14 +ENV NODE_ENV=production
15 +COPY --from=builder /app/.next/standalone ./
16 +COPY --from=builder /app/.next/static ./.next/static
17 +COPY --from=builder /app/public ./public
18 +EXPOSE 3000
19 +USER node
20 +CMD ["node", "server.js"]
frontend/app/api/health/route.ts new
+5
@@ -0,0 +1,5 @@
1 +import { NextResponse } from "next/server";
2 +
3 +export function GET() {
4 + return NextResponse.json({ status: "ok", service: "frontend" });
5 +}
frontend/app/components/investment-workspace.tsx new
+1306
@@ -0,0 +1,1306 @@
1 +"use client";
2 +
3 +import {
4 + Bell,
5 + BriefcaseBusiness,
6 + Building2,
7 + ChevronDown,
8 + Command,
9 + Gauge,
10 + LineChart,
11 + Menu,
12 + Moon,
13 + RefreshCw,
14 + Search,
15 + Settings,
16 + ShieldCheck,
17 + SlidersHorizontal,
18 + Sun,
19 + TrendingDown,
20 + TrendingUp,
21 + WalletCards,
22 + X
23 +} from "lucide-react";
24 +import { useEffect, useMemo, useState } from "react";
25 +import { frontendConfig } from "../config";
26 +import {
27 + type ApiFailure,
28 + type BrokerConnection,
29 + type BrokerProviderInfo,
30 + type Portfolio,
31 + type PortfolioListItem,
32 + type PortfolioPosition,
33 + type PortfolioSummary,
34 + type ResearchProfile,
35 + type ResearchSummary,
36 + brokerApi,
37 + portfolioApi,
38 + researchApi
39 +} from "../lib/portfolio-api";
40 +import { Badge, Button, Card, EmptyState, ErrorState, Field, MetricCard, Skeleton } from "./ui";
41 +
42 +type View = "dashboard" | "portfolio" | "research" | "brokers" | "settings";
43 +type Theme = "system" | "light" | "dark";
44 +type SortKey = "company" | "ticker" | "marketValue" | "profitLoss" | "allocation";
45 +
46 +const navItems: Array<{ id: View; label: string; icon: typeof Gauge }> = [
47 + { id: "dashboard", label: "Dashboard", icon: Gauge },
48 + { id: "portfolio", label: "Portfolio", icon: BriefcaseBusiness },
49 + { id: "research", label: "Research", icon: Search },
50 + { id: "brokers", label: "Brokers", icon: WalletCards },
51 + { id: "settings", label: "Settings", icon: Settings }
52 +];
53 +
54 +function formatMoney(amount?: number, currency?: string) {
55 + if (amount === undefined || !currency) {
56 + return "--";
57 + }
58 +
59 + return new Intl.NumberFormat("en", {
60 + style: "currency",
61 + currency,
62 + maximumFractionDigits: 2
63 + }).format(amount);
64 +}
65 +
66 +function formatPercent(value?: number) {
67 + if (value === undefined || Number.isNaN(value)) {
68 + return "--";
69 + }
70 +
71 + return `${value.toFixed(2)}%`;
72 +}
73 +
74 +function getAllocationValue(summary: PortfolioSummary | null, position: PortfolioPosition) {
75 + if (!summary || summary.totalMarketValue.amount === 0) {
76 + return 0;
77 + }
78 +
79 + return (position.marketValue.amount / summary.totalMarketValue.amount) * 100;
80 +}
81 +
82 +function getApiFailure(error: unknown): ApiFailure {
83 + if (typeof error === "object" && error !== null && "message" in error) {
84 + return error as ApiFailure;
85 + }
86 +
87 + return { message: "The portfolio API is not reachable. Confirm the gateway or portfolio service is running." };
88 +}
89 +
90 +export function InvestmentWorkspace() {
91 + const [view, setView] = useState<View>("dashboard");
92 + const [theme, setTheme] = useState<Theme>("system");
93 + const [sidebarOpen, setSidebarOpen] = useState(false);
94 + const [portfolios, setPortfolios] = useState<PortfolioListItem[]>([]);
95 + const [selectedPortfolio, setSelectedPortfolio] = useState<Portfolio | undefined>();
96 + const [selectedPortfolioId, setSelectedPortfolioId] = useState<string>("");
97 + const [summary, setSummary] = useState<PortfolioSummary | null>(null);
98 + const [positions, setPositions] = useState<PortfolioPosition[]>([]);
99 + const [loading, setLoading] = useState(false);
100 + const [syncing, setSyncing] = useState(false);
101 + const [creating, setCreating] = useState(false);
102 + const [error, setError] = useState<ApiFailure | null>(null);
103 + const [newPortfolioName, setNewPortfolioName] = useState("My Global Portfolio");
104 + const [newPortfolioCurrency, setNewPortfolioCurrency] = useState("EUR");
105 + const [searchText, setSearchText] = useState("");
106 + const [sortKey, setSortKey] = useState<SortKey>("marketValue");
107 + const [brokerProviders, setBrokerProviders] = useState<BrokerProviderInfo[]>([]);
108 + const [brokerConnections, setBrokerConnections] = useState<BrokerConnection[]>([]);
109 + const [brokerLoading, setBrokerLoading] = useState(false);
110 + const [researchCompanies, setResearchCompanies] = useState<ResearchProfile[]>([]);
111 + const [selectedResearchInstrumentId, setSelectedResearchInstrumentId] = useState("");
112 + const [researchSummary, setResearchSummary] = useState<ResearchSummary | null>(null);
113 + const [researchLoading, setResearchLoading] = useState(false);
114 + const [researchEventType, setResearchEventType] = useState("");
115 + const [researchImpact, setResearchImpact] = useState("");
116 +
117 + useEffect(() => {
118 + document.documentElement.dataset.theme = theme;
119 + }, [theme]);
120 +
121 + useEffect(() => {
122 + let cancelled = false;
123 +
124 + async function loadPortfolios() {
125 + setLoading(true);
126 + setError(null);
127 + try {
128 + const loaded = await portfolioApi.listPortfolios();
129 + if (cancelled) {
130 + return;
131 + }
132 + setPortfolios(loaded);
133 + setSelectedPortfolioId((current) => current || loaded[0]?.portfolioId || "");
134 + } catch (err) {
135 + if (!cancelled) {
136 + setError(getApiFailure(err));
137 + }
138 + } finally {
139 + if (!cancelled) {
140 + setLoading(false);
141 + }
142 + }
143 + }
144 +
145 + void loadPortfolios();
146 + return () => {
147 + cancelled = true;
148 + };
149 + }, []);
150 +
151 + useEffect(() => {
152 + let cancelled = false;
153 +
154 + async function loadResearchCompanies() {
155 + try {
156 + const companies = await researchApi.listCompanies();
157 + if (!cancelled) {
158 + setResearchCompanies(companies);
159 + setSelectedResearchInstrumentId((current) => current || companies[0]?.instrumentId || "");
160 + }
161 + } catch {
162 + if (!cancelled) {
163 + setResearchCompanies([]);
164 + }
165 + }
166 + }
167 +
168 + void loadResearchCompanies();
169 + return () => {
170 + cancelled = true;
171 + };
172 + }, []);
173 +
174 + useEffect(() => {
175 + let cancelled = false;
176 +
177 + async function loadResearchSummary() {
178 + if (!selectedResearchInstrumentId) {
179 + return;
180 + }
181 + setResearchLoading(true);
182 + try {
183 + const loaded = await researchApi.getSummary(selectedResearchInstrumentId);
184 + if (!cancelled) {
185 + setResearchSummary(loaded);
186 + }
187 + } catch {
188 + if (!cancelled) {
189 + setResearchSummary(null);
190 + }
191 + } finally {
192 + if (!cancelled) {
193 + setResearchLoading(false);
194 + }
195 + }
196 + }
197 +
198 + void loadResearchSummary();
199 + return () => {
200 + cancelled = true;
201 + };
202 + }, [selectedResearchInstrumentId]);
203 +
204 + useEffect(() => {
205 + let cancelled = false;
206 +
207 + async function loadPortfolioDetail() {
208 + setLoading(true);
209 + setError(null);
210 + try {
211 + const [loadedPortfolio, loadedSummary, loadedPositions] = await Promise.all([
212 + portfolioApi.getPortfolio(selectedPortfolioId),
213 + portfolioApi.getSummary(selectedPortfolioId),
214 + portfolioApi.getPositions(selectedPortfolioId)
215 + ]);
216 + if (!cancelled) {
217 + setSelectedPortfolio(loadedPortfolio);
218 + setSummary(loadedSummary);
219 + setPositions(loadedPositions);
220 + }
221 + } catch (err) {
222 + if (!cancelled) {
223 + setError(getApiFailure(err));
224 + setSummary(null);
225 + setPositions([]);
226 + }
227 + } finally {
228 + if (!cancelled) {
229 + setLoading(false);
230 + }
231 + }
232 + }
233 +
234 + if (!selectedPortfolioId) {
235 + return;
236 + }
237 +
238 + void loadPortfolioDetail();
239 + return () => {
240 + cancelled = true;
241 + };
242 + }, [selectedPortfolioId]);
243 +
244 + useEffect(() => {
245 + let cancelled = false;
246 +
247 + async function loadBrokers() {
248 + setBrokerLoading(true);
249 + try {
250 + const [providers, connections] = await Promise.all([brokerApi.listBrokers(), brokerApi.listConnections()]);
251 + if (!cancelled) {
252 + setBrokerProviders(providers);
253 + setBrokerConnections(connections);
254 + }
255 + } catch {
256 + if (!cancelled) {
257 + setBrokerProviders([]);
258 + setBrokerConnections([]);
259 + }
260 + } finally {
261 + if (!cancelled) {
262 + setBrokerLoading(false);
263 + }
264 + }
265 + }
266 +
267 + void loadBrokers();
268 + return () => {
269 + cancelled = true;
270 + };
271 + }, []);
272 +
273 + const sortedPositions = useMemo(() => {
274 + const normalizedSearch = searchText.trim().toLowerCase();
275 + return positions
276 + .filter((position) => {
277 + if (!normalizedSearch) {
278 + return true;
279 + }
280 + return [
281 + position.instrument.companyName,
282 + position.instrument.ticker,
283 + position.instrument.isin,
284 + position.instrument.exchange,
285 + position.instrument.country
286 + ]
287 + .filter(Boolean)
288 + .some((value) => String(value).toLowerCase().includes(normalizedSearch));
289 + })
290 + .sort((a, b) => {
291 + switch (sortKey) {
292 + case "company":
293 + return a.instrument.companyName.localeCompare(b.instrument.companyName);
294 + case "ticker":
295 + return a.instrument.ticker.localeCompare(b.instrument.ticker);
296 + case "profitLoss":
297 + return b.unrealizedProfitLoss.amount - a.unrealizedProfitLoss.amount;
298 + case "allocation":
299 + return getAllocationValue(summary, b) - getAllocationValue(summary, a);
300 + case "marketValue":
301 + default:
302 + return b.marketValue.amount - a.marketValue.amount;
303 + }
304 + });
305 + }, [positions, searchText, sortKey, summary]);
306 +
307 + async function createPortfolio() {
308 + setCreating(true);
309 + setError(null);
310 + try {
311 + const created = await portfolioApi.createPortfolio({
312 + name: newPortfolioName.trim(),
313 + baseCurrency: newPortfolioCurrency.trim().toUpperCase()
314 + });
315 + const loaded = await portfolioApi.listPortfolios();
316 + setPortfolios(loaded);
317 + setSelectedPortfolioId(created.portfolioId);
318 + setView("portfolio");
319 + } catch (err) {
320 + setError(getApiFailure(err));
321 + } finally {
322 + setCreating(false);
323 + }
324 + }
325 +
326 + async function syncPortfolio() {
327 + if (!selectedPortfolioId) {
328 + return;
329 + }
330 +
331 + setSyncing(true);
332 + setError(null);
333 + try {
334 + const syncedSummary = await portfolioApi.syncPortfolio(selectedPortfolioId);
335 + const loadedPositions = await portfolioApi.getPositions(selectedPortfolioId);
336 + const loaded = await portfolioApi.listPortfolios();
337 + setPortfolios(loaded);
338 + setSummary(syncedSummary);
339 + setPositions(loadedPositions);
340 + } catch (err) {
341 + setError(getApiFailure(err));
342 + } finally {
343 + setSyncing(false);
344 + }
345 + }
346 +
347 + return (
348 + <main className="app-shell">
349 + <aside className={`sidebar ${sidebarOpen ? "sidebar-open" : ""}`}>
350 + <div className="brand-lockup">
351 + <div className="brand-mark">AI</div>
352 + <div>
353 + <strong>AI Investment</strong>
354 + <span>Intelligence</span>
355 + </div>
356 + </div>
357 +
358 + <nav aria-label="Primary">
359 + {navItems.map((item) => {
360 + const Icon = item.icon;
361 + return (
362 + <button
363 + className={`nav-item ${view === item.id ? "nav-item-active" : ""}`}
364 + key={item.id}
365 + onClick={() => {
366 + setView(item.id);
367 + setSidebarOpen(false);
368 + }}
369 + type="button"
370 + >
371 + <Icon size={18} aria-hidden="true" />
372 + {item.label}
373 + </button>
374 + );
375 + })}
376 + </nav>
377 +
378 + <div className="sidebar-panel">
379 + <Badge tone="info">Demo data</Badge>
380 + <p>Phase 2B validates backend portfolio and broker APIs. Real providers remain not configured.</p>
381 + </div>
382 + </aside>
383 +
384 + <section className="workspace">
385 + <header className="topbar">
386 + <Button className="mobile-menu" variant="ghost" onClick={() => setSidebarOpen(true)} aria-label="Open navigation">
387 + <Menu size={20} />
388 + </Button>
389 + <div className="command-bar">
390 + <Command size={17} aria-hidden="true" />
391 + <input
392 + aria-label="Global security search"
393 + placeholder="Search ticker, company, or ISIN"
394 + type="search"
395 + />
396 + <kbd>/</kbd>
397 + </div>
398 + <div className="topbar-actions">
399 + <Badge tone="warning">Demo data</Badge>
400 + <button className="icon-button" type="button" aria-label="Notifications">
401 + <Bell size={18} />
402 + </button>
403 + <label className="theme-switch">
404 + <span className="sr-only">Theme</span>
405 + <Sun size={16} aria-hidden="true" />
406 + <select value={theme} onChange={(event) => setTheme(event.target.value as Theme)}>
407 + <option value="system">System</option>
408 + <option value="light">Light</option>
409 + <option value="dark">Dark</option>
410 + </select>
411 + <Moon size={16} aria-hidden="true" />
412 + </label>
413 + <button className="account-button" type="button">
414 + <span>DEV</span>
415 + <ChevronDown size={16} aria-hidden="true" />
416 + </button>
417 + </div>
418 + </header>
419 +
420 + {sidebarOpen ? (
421 + <button className="sidebar-scrim" onClick={() => setSidebarOpen(false)} aria-label="Close navigation" type="button">
422 + <X size={20} />
423 + </button>
424 + ) : null}
425 +
426 + <div className="content">
427 + <section className="page-header">
428 + <div>
429 + <p className="eyebrow">Phase 2B broker readiness</p>
430 + <h1>{view === "dashboard" ? "Portfolio command center" : navItems.find((item) => item.id === view)?.label}</h1>
431 + <p>
432 + Backend: <code>{frontendConfig.apiBaseUrl}</code>
433 + </p>
434 + </div>
435 + <PortfolioSelector
436 + portfolios={portfolios}
437 + selectedPortfolioId={selectedPortfolioId}
438 + onChange={setSelectedPortfolioId}
439 + />
440 + </section>
441 +
442 + {error ? (
443 + <ErrorState
444 + message={error.message}
445 + correlationId={error.correlationId}
446 + action={
447 + <Button variant="secondary" onClick={() => window.location.reload()}>
448 + Retry
449 + </Button>
450 + }
451 + />
452 + ) : null}
453 +
454 + {loading ? <LoadingView /> : null}
455 +
456 + {!loading && !error ? (
457 + <>
458 + {view === "dashboard" ? (
459 + <DashboardView
460 + portfolio={selectedPortfolio}
461 + summary={summary}
462 + positions={positions}
463 + onCreate={createPortfolio}
464 + onSync={syncPortfolio}
465 + creating={creating}
466 + syncing={syncing}
467 + newPortfolioName={newPortfolioName}
468 + newPortfolioCurrency={newPortfolioCurrency}
469 + setNewPortfolioName={setNewPortfolioName}
470 + setNewPortfolioCurrency={setNewPortfolioCurrency}
471 + />
472 + ) : null}
473 + {view === "portfolio" ? (
474 + <PortfolioView
475 + portfolio={selectedPortfolio}
476 + summary={summary}
477 + positions={sortedPositions}
478 + rawPositions={positions}
479 + searchText={searchText}
480 + sortKey={sortKey}
481 + onSearch={setSearchText}
482 + onSort={setSortKey}
483 + onCreate={createPortfolio}
484 + onSync={syncPortfolio}
485 + creating={creating}
486 + syncing={syncing}
487 + newPortfolioName={newPortfolioName}
488 + newPortfolioCurrency={newPortfolioCurrency}
489 + setNewPortfolioName={setNewPortfolioName}
490 + setNewPortfolioCurrency={setNewPortfolioCurrency}
491 + />
492 + ) : null}
493 + {view === "brokers" ? (
494 + <BrokerView
495 + providers={brokerProviders}
496 + connections={brokerConnections}
497 + loading={brokerLoading}
498 + onConnectDemo={async () => {
499 + setBrokerLoading(true);
500 + try {
501 + await brokerApi.connectMock();
502 + setBrokerConnections(await brokerApi.listConnections());
503 + } finally {
504 + setBrokerLoading(false);
505 + }
506 + }}
507 + onSyncConnection={async (connectionId) => {
508 + setBrokerLoading(true);
509 + try {
510 + await brokerApi.syncConnection(connectionId);
511 + setBrokerConnections(await brokerApi.listConnections());
512 + } finally {
513 + setBrokerLoading(false);
514 + }
515 + }}
516 + />
517 + ) : null}
518 + {view === "research" ? (
519 + <ResearchView
520 + companies={researchCompanies}
521 + selectedInstrumentId={selectedResearchInstrumentId}
522 + onSelectInstrument={setSelectedResearchInstrumentId}
523 + summary={researchSummary}
524 + loading={researchLoading}
525 + eventType={researchEventType}
526 + impact={researchImpact}
527 + onEventType={setResearchEventType}
528 + onImpact={setResearchImpact}
529 + onRefresh={async () => {
530 + if (!selectedResearchInstrumentId) {
531 + return;
532 + }
533 + setResearchLoading(true);
534 + try {
535 + setResearchSummary(await researchApi.refresh(selectedResearchInstrumentId));
536 + } finally {
537 + setResearchLoading(false);
538 + }
539 + }}
540 + />
541 + ) : null}
542 + {view === "settings" ? <SettingsView /> : null}
543 + </>
544 + ) : null}
545 + </div>
546 + </section>
547 + </main>
548 + );
549 +}
550 +
551 +function PortfolioSelector({
552 + portfolios,
553 + selectedPortfolioId,
554 + onChange
555 +}: {
556 + portfolios: PortfolioListItem[];
557 + selectedPortfolioId: string;
558 + onChange: (value: string) => void;
559 +}) {
560 + if (portfolios.length === 0) {
561 + return null;
562 + }
563 +
564 + return (
565 + <label className="portfolio-select">
566 + <span>Portfolio</span>
567 + <select value={selectedPortfolioId} onChange={(event) => onChange(event.target.value)}>
568 + {portfolios.map((portfolio) => (
569 + <option value={portfolio.portfolioId} key={portfolio.portfolioId}>
570 + {portfolio.name}
571 + </option>
572 + ))}
573 + </select>
574 + </label>
575 + );
576 +}
577 +
578 +function LoadingView() {
579 + return (
580 + <div className="loading-grid">
581 + <Skeleton rows={4} />
582 + <Skeleton rows={4} />
583 + <Skeleton rows={8} />
584 + </div>
585 + );
586 +}
587 +
588 +function PortfolioCreatePanel({
589 + onCreate,
590 + creating,
591 + newPortfolioName,
592 + newPortfolioCurrency,
593 + setNewPortfolioName,
594 + setNewPortfolioCurrency
595 +}: {
596 + onCreate: () => void;
597 + creating: boolean;
598 + newPortfolioName: string;
599 + newPortfolioCurrency: string;
600 + setNewPortfolioName: (value: string) => void;
601 + setNewPortfolioCurrency: (value: string) => void;
602 +}) {
603 + return (
604 + <Card className="create-panel">
605 + <div>
606 + <h2>Create a portfolio</h2>
607 + <p>Connect a broker or create a portfolio to get started.</p>
608 + </div>
609 + <div className="form-grid">
610 + <Field label="Name">
611 + <input value={newPortfolioName} onChange={(event) => setNewPortfolioName(event.target.value)} />
612 + </Field>
613 + <Field label="Base currency" hint="ISO 4217 code">
614 + <input
615 + value={newPortfolioCurrency}
616 + maxLength={3}
617 + onChange={(event) => setNewPortfolioCurrency(event.target.value.toUpperCase())}
618 + />
619 + </Field>
620 + </div>
621 + <Button onClick={onCreate} disabled={creating || newPortfolioName.trim().length === 0}>
622 + {creating ? "Creating..." : "Create portfolio"}
623 + </Button>
624 + </Card>
625 + );
626 +}
627 +
628 +function DashboardView({
629 + portfolio,
630 + summary,
631 + positions,
632 + onCreate,
633 + onSync,
634 + creating,
635 + syncing,
636 + newPortfolioName,
637 + newPortfolioCurrency,
638 + setNewPortfolioName,
639 + setNewPortfolioCurrency
640 +}: {
641 + portfolio?: Portfolio;
642 + summary: PortfolioSummary | null;
643 + positions: PortfolioPosition[];
644 + onCreate: () => void;
645 + onSync: () => void;
646 + creating: boolean;
647 + syncing: boolean;
648 + newPortfolioName: string;
649 + newPortfolioCurrency: string;
650 + setNewPortfolioName: (value: string) => void;
651 + setNewPortfolioCurrency: (value: string) => void;
652 +}) {
653 + if (!portfolio) {
654 + return (
655 + <PortfolioCreatePanel
656 + onCreate={onCreate}
657 + creating={creating}
658 + newPortfolioName={newPortfolioName}
659 + newPortfolioCurrency={newPortfolioCurrency}
660 + setNewPortfolioName={setNewPortfolioName}
661 + setNewPortfolioCurrency={setNewPortfolioCurrency}
662 + />
663 + );
664 + }
665 +
666 + const topGainers = [...positions].sort((a, b) => b.unrealizedProfitLossPercent - a.unrealizedProfitLossPercent).slice(0, 3);
667 + const topLosers = [...positions].sort((a, b) => a.unrealizedProfitLossPercent - b.unrealizedProfitLossPercent).slice(0, 3);
668 +
669 + return (
670 + <div className="dashboard-grid">
671 + <section className="metrics-grid" aria-label="Portfolio summary">
672 + <MetricCard
673 + label="Portfolio value"
674 + value={formatMoney(summary?.totalMarketValue.amount, summary?.baseCurrency)}
675 + meta="Demo broker sync"
676 + />
677 + <MetricCard label="Total P/L" value={formatMoney(summary?.unrealizedProfitLoss.amount, summary?.baseCurrency)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
678 + <MetricCard label="Return" value={formatPercent(summary?.unrealizedProfitLossPercent)} tone={(summary?.unrealizedProfitLossPercent ?? 0) >= 0 ? "positive" : "negative"} />
679 + <MetricCard label="Cash" value={formatMoney(summary?.cash.amount, summary?.baseCurrency)} />
680 + <MetricCard label="Holdings" value={String(summary?.positions ?? 0)} />
681 + <MetricCard label="Portfolio risk" value="Pending" meta="Risk service not implemented" tone="warning" />
682 + </section>
683 +
684 + <Card className="wide-panel">
685 + <div className="panel-header">
686 + <div>
687 + <h2>{portfolio.name}</h2>
688 + <p>Last updated: {summary ? "from latest mock sync" : "not synced"} - Source: Broker demo data</p>
689 + </div>
690 + <Button onClick={onSync} disabled={syncing} variant="secondary">
691 + <RefreshCw size={16} />
692 + {syncing ? "Syncing..." : "Sync mock broker"}
693 + </Button>
694 + </div>
695 + {summary ? <AllocationCharts summary={summary} /> : <EmptyState title="No positions" message="This portfolio currently has no positions." />}
696 + </Card>
697 +
698 + <MovementPanel title="Top gainers" icon={TrendingUp} positions={topGainers} />
699 + <MovementPanel title="Top losers" icon={TrendingDown} positions={topLosers} />
700 +
701 + <Card className="wide-panel">
702 + <div className="panel-header">
703 + <div>
704 + <h2>AI opportunities</h2>
705 + <p>Recommendation logic is intentionally out of scope for Phase 2B.</p>
706 + </div>
707 + <Badge tone="neutral">Future ready</Badge>
708 + </div>
709 + <EmptyState title="No AI ratings yet" message="BUY, HOLD, SELL, and opportunity scores will appear after recommendation services are approved and implemented." />
710 + </Card>
711 + </div>
712 + );
713 +}
714 +
715 +function MovementPanel({
716 + title,
717 + icon: Icon,
718 + positions
719 +}: {
720 + title: string;
721 + icon: typeof TrendingUp;
722 + positions: PortfolioPosition[];
723 +}) {
724 + return (
725 + <Card>
726 + <div className="panel-header compact">
727 + <h2>{title}</h2>
728 + <Icon size={18} aria-hidden="true" />
729 + </div>
730 + {positions.length === 0 ? (
731 + <EmptyState title="No synced positions" message="Sync a mock broker account to populate this view." />
732 + ) : (
733 + <div className="movement-list">
734 + {positions.map((position) => (
735 + <div key={position.positionId}>
736 + <span>{position.instrument.ticker}</span>
737 + <strong className={position.unrealizedProfitLossPercent >= 0 ? "positive-text" : "negative-text"}>
738 + {formatPercent(position.unrealizedProfitLossPercent)}
739 + </strong>
740 + </div>
741 + ))}
742 + </div>
743 + )}
744 + </Card>
745 + );
746 +}
747 +
748 +function PortfolioView({
749 + portfolio,
750 + summary,
751 + positions,
752 + rawPositions,
753 + searchText,
754 + sortKey,
755 + onSearch,
756 + onSort,
757 + onCreate,
758 + onSync,
759 + creating,
760 + syncing,
761 + newPortfolioName,
762 + newPortfolioCurrency,
763 + setNewPortfolioName,
764 + setNewPortfolioCurrency
765 +}: {
766 + portfolio?: Portfolio;
767 + summary: PortfolioSummary | null;
768 + positions: PortfolioPosition[];
769 + rawPositions: PortfolioPosition[];
770 + searchText: string;
771 + sortKey: SortKey;
772 + onSearch: (value: string) => void;
773 + onSort: (value: SortKey) => void;
774 + onCreate: () => void;
775 + onSync: () => void;
776 + creating: boolean;
777 + syncing: boolean;
778 + newPortfolioName: string;
779 + newPortfolioCurrency: string;
780 + setNewPortfolioName: (value: string) => void;
781 + setNewPortfolioCurrency: (value: string) => void;
782 +}) {
783 + if (!portfolio) {
784 + return (
785 + <PortfolioCreatePanel
786 + onCreate={onCreate}
787 + creating={creating}
788 + newPortfolioName={newPortfolioName}
789 + newPortfolioCurrency={newPortfolioCurrency}
790 + setNewPortfolioName={setNewPortfolioName}
791 + setNewPortfolioCurrency={setNewPortfolioCurrency}
792 + />
793 + );
794 + }
795 +
796 + return (
797 + <div className="portfolio-layout">
798 + <section className="metrics-grid" aria-label="Portfolio totals">
799 + <MetricCard label="Total market value" value={formatMoney(summary?.totalMarketValue.amount, summary?.baseCurrency)} />
800 + <MetricCard label="Total cost" value={formatMoney(summary?.totalCostBasis.amount, summary?.baseCurrency)} />
801 + <MetricCard label="Unrealized P/L" value={formatMoney(summary?.unrealizedProfitLoss.amount, summary?.baseCurrency)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
802 + <MetricCard label="Return" value={formatPercent(summary?.unrealizedProfitLossPercent)} />
803 + <MetricCard label="Cash" value={formatMoney(summary?.cash.amount, summary?.baseCurrency)} />
804 + <MetricCard label="Position count" value={String(summary?.positions ?? 0)} />
805 + </section>
806 +
807 + <Card className="wide-panel">
808 + <div className="panel-header">
809 + <div>
810 + <h2>Holdings</h2>
811 + <p>Search respects ticker, company, ISIN, exchange, and country.</p>
812 + </div>
813 + <Button onClick={onSync} disabled={syncing} variant="secondary">
814 + <RefreshCw size={16} />
815 + {syncing ? "Syncing..." : "Sync mock broker"}
816 + </Button>
817 + </div>
818 + <div className="table-toolbar">
819 + <div className="table-search">
820 + <Search size={16} aria-hidden="true" />
821 + <input
822 + aria-label="Filter holdings"
823 + placeholder="Filter holdings"
824 + value={searchText}
825 + onChange={(event) => onSearch(event.target.value)}
826 + />
827 + </div>
828 + <label className="sort-control">
829 + <SlidersHorizontal size={16} aria-hidden="true" />
830 + <span>Sort</span>
831 + <select value={sortKey} onChange={(event) => onSort(event.target.value as SortKey)}>
832 + <option value="marketValue">Market value</option>
833 + <option value="profitLoss">P/L</option>
834 + <option value="allocation">Allocation</option>
835 + <option value="company">Company</option>
836 + <option value="ticker">Ticker</option>
837 + </select>
838 + </label>
839 + </div>
840 + {rawPositions.length === 0 ? (
841 + <EmptyState title="No positions" message="This portfolio currently has no positions." action={<Button onClick={onSync}>Sync mock broker</Button>} />
842 + ) : (
843 + <HoldingsTable positions={positions} summary={summary} />
844 + )}
845 + </Card>
846 +
847 + {summary ? (
848 + <Card className="wide-panel">
849 + <div className="panel-header">
850 + <div>
851 + <h2>Allocation</h2>
852 + <p>Normalized into {summary.baseCurrency}; charts include textual values for accessibility.</p>
853 + </div>
854 + <LineChart size={20} aria-hidden="true" />
855 + </div>
856 + <AllocationCharts summary={summary} />
857 + </Card>
858 + ) : null}
859 + </div>
860 + );
861 +}
862 +
863 +function FreshnessBadge({ freshness }: { freshness: string }) {
864 + const label = freshness === "END_OF_DAY" ? "EOD" : freshness === "MOCK" ? "DEMO" : freshness.replaceAll("_", "-");
865 + const tone = freshness === "REAL_TIME" ? "positive" : freshness === "STALE" ? "warning" : freshness === "MOCK" ? "info" : freshness === "UNAVAILABLE" ? "negative" : "neutral";
866 + return <Badge tone={tone}>{label}</Badge>;
867 +}
868 +
869 +function HoldingsTable({ positions, summary }: { positions: PortfolioPosition[]; summary: PortfolioSummary | null }) {
870 + return (
871 + <div className="table-frame">
872 + <table>
873 + <thead>
874 + <tr>
875 + <th>Company</th>
876 + <th>Ticker</th>
877 + <th>Exchange</th>
878 + <th>Quantity</th>
879 + <th>Average cost</th>
880 + <th>Current price</th>
881 + <th>Last price</th>
882 + <th>Bid</th>
883 + <th>Ask</th>
884 + <th>Market value</th>
885 + <th>P/L</th>
886 + <th>P/L %</th>
887 + <th>Allocation</th>
888 + <th>Currency</th>
889 + <th>Data status</th>
890 + <th>Last updated</th>
891 + <th>Source</th>
892 + <th>Broker</th>
893 + <th>AI rating</th>
894 + </tr>
895 + </thead>
896 + <tbody>
897 + {positions.map((position) => {
898 + const allocation = getAllocationValue(summary, position);
899 + return (
900 + <tr key={position.positionId}>
901 + <td>
902 + <button className="security-button" type="button">
903 + <strong>{position.instrument.companyName}</strong>
904 + <span>{position.instrument.isin ?? "No ISIN"}</span>
905 + </button>
906 + </td>
907 + <td>{position.instrument.ticker}</td>
908 + <td>{position.instrument.exchange}</td>
909 + <td>{position.quantity.toLocaleString("en")}</td>
910 + <td>{formatMoney(position.averageCost.amount, position.averageCost.currency)}</td>
911 + <td>{formatMoney(position.currentPrice.amount, position.currentPrice.currency)}</td>
912 + <td>{formatMoney(position.quote?.last?.amount, position.quote?.last?.currency)}</td>
913 + <td>{formatMoney(position.quote?.bid?.amount, position.quote?.bid?.currency)}</td>
914 + <td>{formatMoney(position.quote?.ask?.amount, position.quote?.ask?.currency)}</td>
915 + <td>{formatMoney(position.marketValue.amount, position.marketValue.currency)}</td>
916 + <td className={position.unrealizedProfitLoss.amount >= 0 ? "positive-text" : "negative-text"}>
917 + {formatMoney(position.unrealizedProfitLoss.amount, position.unrealizedProfitLoss.currency)}
918 + </td>
919 + <td className={position.unrealizedProfitLossPercent >= 0 ? "positive-text" : "negative-text"}>
920 + {formatPercent(position.unrealizedProfitLossPercent)}
921 + </td>
922 + <td>{formatPercent(allocation)}</td>
923 + <td>{position.instrument.tradingCurrency}</td>
924 + <td>{position.quote?.freshness ? <FreshnessBadge freshness={position.quote.freshness} /> : <FreshnessBadge freshness="UNAVAILABLE" />}</td>
925 + <td>{position.quote?.receivedAt ? new Date(position.quote.receivedAt).toLocaleString() : position.quote?.timestamp ? new Date(position.quote.timestamp).toLocaleString() : "Unavailable"}</td>
926 + <td>{position.quote?.source ?? "--"}</td>
927 + <td>{position.brokerAccountId}</td>
928 + <td>
929 + <Badge tone="neutral">Not rated</Badge>
930 + </td>
931 + </tr>
932 + );
933 + })}
934 + </tbody>
935 + </table>
936 + </div>
937 + );
938 +}
939 +
940 +function AllocationCharts({ summary }: { summary: PortfolioSummary }) {
941 + return (
942 + <div className="allocation-grid">
943 + <AllocationGroup title="Country" values={summary.allocation.country} />
944 + <AllocationGroup title="Sector" values={summary.allocation.sector} />
945 + <AllocationGroup title="Currency" values={summary.allocation.currency} />
946 + <AllocationGroup title="Asset type" values={summary.allocation.assetType} />
947 + <AllocationGroup title="Broker" values={summary.allocation.broker} />
948 + </div>
949 + );
950 +}
951 +
952 +function AllocationGroup({ title, values }: { title: string; values: Record<string, number> }) {
953 + const entries = Object.entries(values).sort(([, a], [, b]) => b - a).slice(0, 5);
954 +
955 + return (
956 + <section className="allocation-group" aria-label={`${title} allocation`}>
957 + <h3>{title}</h3>
958 + {entries.length === 0 ? (
959 + <p>No allocation data.</p>
960 + ) : (
961 + entries.map(([label, value]) => (
962 + <div className="allocation-row" key={label}>
963 + <div>
964 + <span>{label}</span>
965 + <strong>{formatPercent(value)}</strong>
966 + </div>
967 + <div className="bar-track" aria-hidden="true">
968 + <span style={{ width: `${Math.min(value, 100)}%` }} />
969 + </div>
970 + </div>
971 + ))
972 + )}
973 + </section>
974 + );
975 +}
976 +
977 +function BrokerView({
978 + providers,
979 + connections,
980 + loading,
981 + onConnectDemo,
982 + onSyncConnection
983 +}: {
984 + providers: BrokerProviderInfo[];
985 + connections: BrokerConnection[];
986 + loading: boolean;
987 + onConnectDemo: () => Promise<void>;
988 + onSyncConnection: (connectionId: string) => Promise<void>;
989 +}) {
990 + return (
991 + <div className="broker-grid">
992 + {providers.map((provider) => {
993 + const providerConnections = connections.filter((connection) => connection.brokerType === provider.brokerType);
994 + const isMock = provider.brokerType === "MOCK";
995 + const activeConnection = providerConnections[0];
996 + const providerTone = isMock ? "info" : provider.officialProviderSetupRequired ? "warning" : provider.providerStatus === "CONNECTED" ? "positive" : "neutral";
997 + return (
998 + <Card className="broker-card" as="article" key={provider.brokerType}>
999 + <div className="broker-icon">
1000 + <Building2 size={22} aria-hidden="true" />
1001 + </div>
1002 + <div>
1003 + <div className="panel-header compact">
1004 + <h2>{brokerDisplayName(provider.brokerType)}</h2>
1005 + <Badge tone={providerTone}>{isMock ? "DEMO" : provider.providerStatus}</Badge>
1006 + <Badge tone="positive">Read-only</Badge>
1007 + </div>
1008 + <dl className="broker-facts">
1009 + <div>
1010 + <dt>Connection status</dt>
1011 + <dd>{activeConnection?.status ?? provider.code}</dd>
1012 + </div>
1013 + <div>
1014 + <dt>Connection method</dt>
1015 + <dd>{provider.connectionMethod}</dd>
1016 + </div>
1017 + <div>
1018 + <dt>Last sync</dt>
1019 + <dd>{activeConnection?.lastSuccessfulSyncAt ? new Date(activeConnection.lastSuccessfulSyncAt).toLocaleString() : "Not synced"}</dd>
1020 + </div>
1021 + <div>
1022 + <dt>Account reference</dt>
1023 + <dd>{activeConnection?.externalAccountReference ?? "Unavailable"}</dd>
1024 + </div>
1025 + <div>
1026 + <dt>Data freshness</dt>
1027 + <dd>{activeConnection?.dataFreshness ?? provider.dataFreshness}</dd>
1028 + </div>
1029 + <div>
1030 + <dt>Capabilities</dt>
1031 + <dd>{provider.capabilities.length > 0 ? provider.capabilities.join(", ") : "None"}</dd>
1032 + </div>
1033 + </dl>
1034 + {provider.officialProviderSetupRequired ? (
1035 + <p className="broker-note">{provider.providerStatus === "DOCUMENTATION_REQUIRED" ? "Documentation required" : "Not configured"}</p>
1036 + ) : null}
1037 + {isMock && providerConnections.length === 0 ? (
1038 + <Button variant="secondary" onClick={onConnectDemo} disabled={loading}>
1039 + {loading ? "Connecting..." : "Connect Demo Broker"}
1040 + </Button>
1041 + ) : null}
1042 + {isMock && providerConnections[0] ? (
1043 + <Button variant="secondary" onClick={() => onSyncConnection(providerConnections[0].connectionId)} disabled={loading}>
1044 + {loading ? "Syncing..." : "Sync connection"}
1045 + </Button>
1046 + ) : null}
1047 + </div>
1048 + </Card>
1049 + );
1050 + })}
1051 + <Card className="wide-panel">
1052 + <EmptyState title="Real broker connections are not enabled" message="Interactive Brokers and ICICI Direct explicitly report provider not configured. The UI never asks for broker passwords." />
1053 + </Card>
1054 + </div>
1055 + );
1056 +}
1057 +
1058 +function ResearchView({
1059 + companies,
1060 + selectedInstrumentId,
1061 + onSelectInstrument,
1062 + summary,
1063 + loading,
1064 + eventType,
1065 + impact,
1066 + onEventType,
1067 + onImpact,
1068 + onRefresh
1069 +}: {
1070 + companies: ResearchProfile[];
1071 + selectedInstrumentId: string;
1072 + onSelectInstrument: (value: string) => void;
1073 + summary: ResearchSummary | null;
1074 + loading: boolean;
1075 + eventType: string;
1076 + impact: string;
1077 + onEventType: (value: string) => void;
1078 + onImpact: (value: string) => void;
1079 + onRefresh: () => Promise<void>;
1080 +}) {
1081 + const filteredEvents = (summary?.recentEvents ?? []).filter((event) => {
1082 + return (!eventType || event.eventType === eventType) && (!impact || event.impact === impact);
1083 + });
1084 + const eventTypes = [...new Set((summary?.recentEvents ?? []).map((event) => event.eventType))].sort();
1085 + const impacts = [...new Set((summary?.recentEvents ?? []).map((event) => event.impact))].sort();
1086 +
1087 + return (
1088 + <div className="research-layout">
1089 + <Card className="wide-panel">
1090 + <div className="panel-header">
1091 + <div>
1092 + <h2>Research intelligence</h2>
1093 + <p>Structured evidence, catalyst scoring, and source citations. Final BUY/SELL recommendations are not implemented.</p>
1094 + </div>
1095 + <div className="research-actions">
1096 + {summary?.demo ? <Badge tone="info">DEMO</Badge> : null}
1097 + <Button variant="secondary" onClick={onRefresh} disabled={loading || !selectedInstrumentId}>
1098 + <RefreshCw size={16} />
1099 + {loading ? "Refreshing..." : "Refresh research"}
1100 + </Button>
1101 + </div>
1102 + </div>
1103 + <div className="research-controls">
1104 + <label className="sort-control">
1105 + <Search size={16} aria-hidden="true" />
1106 + <span>Company</span>
1107 + <select value={selectedInstrumentId} onChange={(event) => onSelectInstrument(event.target.value)}>
1108 + {companies.map((company) => (
1109 + <option key={company.instrumentId} value={company.instrumentId}>
1110 + {company.ticker} · {company.exchange} · {company.companyName}
1111 + </option>
1112 + ))}
1113 + </select>
1114 + </label>
1115 + <label className="sort-control">
1116 + <span>Event</span>
1117 + <select value={eventType} onChange={(event) => onEventType(event.target.value)}>
1118 + <option value="">All events</option>
1119 + {eventTypes.map((value) => (
1120 + <option key={value} value={value}>
1121 + {value.replaceAll("_", " ")}
1122 + </option>
1123 + ))}
1124 + </select>
1125 + </label>
1126 + <label className="sort-control">
1127 + <span>Impact</span>
1128 + <select value={impact} onChange={(event) => onImpact(event.target.value)}>
1129 + <option value="">All impacts</option>
1130 + {impacts.map((value) => (
1131 + <option key={value} value={value}>
1132 + {value.replaceAll("_", " ")}
1133 + </option>
1134 + ))}
1135 + </select>
1136 + </label>
1137 + </div>
1138 + </Card>
1139 +
1140 + {loading ? <Skeleton rows={5} /> : null}
1141 +
1142 + {!loading && summary ? (
1143 + <>
1144 + <section className="metrics-grid" aria-label="Research scores">
1145 + <MetricCard label="Catalyst score" value={String(summary.catalystScore.overallScore)} meta="0-100 deterministic" />
1146 + <MetricCard label="Research confidence" value={`${summary.catalystScore.researchConfidence}%`} />
1147 + <MetricCard label="Recent events" value={String(summary.recentEvents.length)} />
1148 + <MetricCard label="Documents" value={String(summary.documents.length)} />
1149 + <MetricCard label="Freshness" value={summary.dataFreshness} tone={summary.demo ? "warning" : "neutral"} />
1150 + <MetricCard label="Last refresh" value={summary.lastRefreshAt ? new Date(summary.lastRefreshAt).toLocaleDateString() : "Not refreshed"} />
1151 + </section>
1152 +
1153 + <Card className="wide-panel">
1154 + <div className="panel-header">
1155 + <div>
1156 + <h2>{summary.profile.companyName}</h2>
1157 + <p>
1158 + {summary.profile.ticker} · {summary.profile.exchange} · {summary.profile.country} · {summary.profile.isin ?? "No ISIN"}
1159 + </p>
1160 + </div>
1161 + <Badge tone="neutral">No BUY/SELL rating</Badge>
1162 + </div>
1163 + <div className="research-tabs" role="tablist" aria-label="Research sections">
1164 + {["Overview", "Growth", "Orders & Backlog", "CAPEX & Capacity", "Customers", "Guidance", "News / Events", "Sources"].map((tab) => (
1165 + <span role="tab" aria-selected={tab === "Overview"} key={tab}>
1166 + {tab}
1167 + </span>
1168 + ))}
1169 + </div>
1170 + <div className="score-grid">
1171 + {Object.entries(summary.catalystScore.buckets).map(([label, value]) => (
1172 + <div className="score-row" key={label}>
1173 + <span>{label}</span>
1174 + <strong>{value}</strong>
1175 + <div className="bar-track" aria-hidden="true">
1176 + <span style={{ width: `${value}%` }} />
1177 + </div>
1178 + </div>
1179 + ))}
1180 + </div>
1181 + </Card>
1182 +
1183 + <section className="event-grid" aria-label="Research events">
1184 + {filteredEvents.length === 0 ? (
1185 + <Card className="wide-panel">
1186 + <EmptyState title="No matching events" message="Change the filters or refresh research fixtures." />
1187 + </Card>
1188 + ) : (
1189 + filteredEvents.map((event) => <ResearchEventCard event={event} key={event.eventId} />)
1190 + )}
1191 + </section>
1192 +
1193 + <Card className="wide-panel">
1194 + <div className="panel-header">
1195 + <div>
1196 + <h2>Sources</h2>
1197 + <p>Traceable citations for the extracted facts. Raw page bodies are not displayed.</p>
1198 + </div>
1199 + </div>
1200 + <div className="source-list">
1201 + {summary.documents.map((document) => (
1202 + <a href={document.canonicalUrl} target="_blank" rel="noreferrer" key={document.documentId}>
1203 + <strong>{document.title ?? document.publisher ?? document.sourceName}</strong>
1204 + <span>{document.sourceType} · {document.reliabilityLevel} · {document.publishedAt ? new Date(document.publishedAt).toLocaleDateString() : "No publication date"}</span>
1205 + </a>
1206 + ))}
1207 + </div>
1208 + </Card>
1209 + </>
1210 + ) : null}
1211 +
1212 + {!loading && !summary ? (
1213 + <Card className="wide-panel">
1214 + <EmptyState title="No research profile loaded" message="The research API is unavailable or has no fixture profiles." />
1215 + </Card>
1216 + ) : null}
1217 + </div>
1218 + );
1219 +}
1220 +
1221 +function ResearchEventCard({ event }: { event: import("../lib/portfolio-api").ResearchEvent }) {
1222 + const impactTone = event.impact.includes("NEGATIVE") ? "negative" : event.impact.includes("POSITIVE") ? "positive" : event.impact === "UNCERTAIN" ? "warning" : "neutral";
1223 + return (
1224 + <Card className="research-event-card" as="article">
1225 + <div className="panel-header compact">
1226 + <div>
1227 + <Badge tone="neutral">{event.eventType.replaceAll("_", " ")}</Badge>
1228 + <h2>{event.title}</h2>
1229 + </div>
1230 + <Badge tone={impactTone}>{event.impact.replaceAll("_", " ")}</Badge>
1231 + </div>
1232 + <div className="event-value-line">
1233 + <strong>{event.monetaryOriginal ?? (event.capacityValue ? `${event.capacityValue} ${event.capacityUnit}` : event.percentageOriginal ?? "Value undisclosed")}</strong>
1234 + <span>{event.customer ?? event.counterparty ?? event.location ?? event.timeHorizon.replaceAll("_", " ")}</span>
1235 + </div>
1236 + <p>{event.summary}</p>
1237 + <dl className="event-facts">
1238 + <div>
1239 + <dt>Confidence</dt>
1240 + <dd>{Math.round(event.confidence * 100)}%</dd>
1241 + </div>
1242 + <div>
1243 + <dt>Reliability</dt>
1244 + <dd>{event.reliability}</dd>
1245 + </div>
1246 + <div>
1247 + <dt>Published</dt>
1248 + <dd>{event.eventDate ? new Date(event.eventDate).toLocaleDateString() : "Unknown"}</dd>
1249 + </div>
1250 + <div>
1251 + <dt>Source</dt>
1252 + <dd>{event.sourceType}</dd>
1253 + </div>
1254 + </dl>
1255 + <blockquote>{event.rawEvidenceReference}</blockquote>
1256 + <a className="source-link" href={event.sourceUrl} target="_blank" rel="noreferrer">
1257 + Open source
1258 + </a>
1259 + </Card>
1260 + );
1261 +}
1262 +
1263 +function SettingsView() {
1264 + return (
1265 + <Card className="wide-panel">
1266 + <div className="panel-header">
1267 + <div>
1268 + <h2>Platform preferences</h2>
1269 + <p>Light, dark, and system theme support is wired for frontend work.</p>
1270 + </div>
1271 + <ShieldCheck size={20} aria-hidden="true" />
1272 + </div>
1273 + <div className="settings-grid">
1274 + <div>
1275 + <h3>Data freshness vocabulary</h3>
1276 + <p>REAL-TIME, DELAYED, EOD, STALE, DEMO, and UNAVAILABLE labels are supported. DEMO is never presented as live market data.</p>
1277 + </div>
1278 + <div>
1279 + <h3>Error handling</h3>
1280 + <p>API errors show a user-safe message and correlation ID without stack traces or internal secrets.</p>
1281 + </div>
1282 + <div>
1283 + <h3>Risk indicators</h3>
1284 + <p>Portfolio risk presentation is prepared, but risk scoring is not implemented in Phase 2B.</p>
1285 + </div>
1286 + <div>
1287 + <h3>Recommendation states</h3>
1288 + <p>BUY, ADD, HOLD, TRIM, and SELL badges are design-system ready for future recommendation logic.</p>
1289 + </div>
1290 + </div>
1291 + </Card>
1292 + );
1293 +}
1294 +
1295 +function brokerDisplayName(brokerType: string) {
1296 + if (brokerType === "ICICI_DIRECT") {
1297 + return "ICICI Direct";
1298 + }
1299 + if (brokerType === "IBKR") {
1300 + return "Interactive Brokers";
1301 + }
1302 + if (brokerType === "MOCK") {
1303 + return "Demo Broker";
1304 + }
1305 + return brokerType;
1306 +}
frontend/app/components/ui.tsx new
+119
@@ -0,0 +1,119 @@
1 +import type { ReactNode } from "react";
2 +
3 +type Tone = "neutral" | "positive" | "negative" | "warning" | "info";
4 +
5 +export function Card({
6 + children,
7 + className = "",
8 + as: Component = "section"
9 +}: {
10 + children: ReactNode;
11 + className?: string;
12 + as?: "section" | "article" | "div";
13 +}) {
14 + return <Component className={`card ${className}`}>{children}</Component>;
15 +}
16 +
17 +export function Badge({ children, tone = "neutral" }: { children: ReactNode; tone?: Tone }) {
18 + return <span className={`badge badge-${tone}`}>{children}</span>;
19 +}
20 +
21 +export function Button({
22 + children,
23 + variant = "primary",
24 + className = "",
25 + ...props
26 +}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
27 + variant?: "primary" | "secondary" | "ghost" | "danger";
28 +}) {
29 + return (
30 + <button className={`button button-${variant} ${className}`} {...props}>
31 + {children}
32 + </button>
33 + );
34 +}
35 +
36 +export function Field({
37 + label,
38 + children,
39 + hint
40 +}: {
41 + label: string;
42 + children: ReactNode;
43 + hint?: string;
44 +}) {
45 + return (
46 + <label className="field">
47 + <span>{label}</span>
48 + {children}
49 + {hint ? <small>{hint}</small> : null}
50 + </label>
51 + );
52 +}
53 +
54 +export function MetricCard({
55 + label,
56 + value,
57 + meta,
58 + tone = "neutral"
59 +}: {
60 + label: string;
61 + value: string;
62 + meta?: string;
63 + tone?: Tone;
64 +}) {
65 + return (
66 + <Card className="metric-card" as="article">
67 + <span className="metric-label">{label}</span>
68 + <strong className={`metric-value metric-${tone}`}>{value}</strong>
69 + {meta ? <span className="metric-meta">{meta}</span> : null}
70 + </Card>
71 + );
72 +}
73 +
74 +export function Skeleton({ rows = 1 }: { rows?: number }) {
75 + return (
76 + <div className="skeleton-stack" aria-label="Loading">
77 + {Array.from({ length: rows }, (_, index) => (
78 + <div className="skeleton-line" key={index} />
79 + ))}
80 + </div>
81 + );
82 +}
83 +
84 +export function EmptyState({
85 + title,
86 + message,
87 + action
88 +}: {
89 + title: string;
90 + message: string;
91 + action?: ReactNode;
92 +}) {
93 + return (
94 + <div className="empty-state">
95 + <h3>{title}</h3>
96 + <p>{message}</p>
97 + {action}
98 + </div>
99 + );
100 +}
101 +
102 +export function ErrorState({
103 + message,
104 + correlationId,
105 + action
106 +}: {
107 + message: string;
108 + correlationId?: string;
109 + action?: ReactNode;
110 +}) {
111 + return (
112 + <div className="error-state" role="alert">
113 + <h3>Unable to load data</h3>
114 + <p>{message}</p>
115 + {correlationId ? <small>Correlation ID: {correlationId}</small> : null}
116 + {action}
117 + </div>
118 + );
119 +}
frontend/app/config.ts new
+3
@@ -0,0 +1,3 @@
1 +export const frontendConfig = {
2 + apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"
3 +};
frontend/app/layout.tsx new
+15
@@ -0,0 +1,15 @@
1 +import type { Metadata } from "next";
2 +import "./styles.css";
3 +
4 +export const metadata: Metadata = {
5 + title: "AI Investment Intelligence Platform",
6 + description: "Production-grade investment intelligence workspace"
7 +};
8 +
9 +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
10 + return (
11 + <html lang="en" data-theme="system">
12 + <body>{children}</body>
13 + </html>
14 + );
15 +}
frontend/app/lib/portfolio-api.ts new
+294
@@ -0,0 +1,294 @@
1 +import { frontendConfig } from "../config";
2 +
3 +export type Money = {
4 + amount: number;
5 + currency: string;
6 +};
7 +
8 +export type Portfolio = {
9 + portfolioId: string;
10 + userId: string;
11 + name: string;
12 + baseCurrency: string;
13 + createdAt: string;
14 + updatedAt: string;
15 +};
16 +
17 +export type PortfolioListItem = {
18 + portfolioId: string;
19 + name: string;
20 + baseCurrency: string;
21 + totalMarketValue: Money;
22 + unrealizedProfitLoss: Money;
23 + unrealizedProfitLossPercent: number;
24 + positions: number;
25 + updatedAt: string;
26 +};
27 +
28 +export type Instrument = {
29 + instrumentId: string;
30 + isin?: string | null;
31 + ticker: string;
32 + exchange: string;
33 + mic?: string | null;
34 + companyName: string;
35 + assetType: string;
36 + country?: string | null;
37 + tradingCurrency: string;
38 + sector?: string | null;
39 + industry?: string | null;
40 +};
41 +
42 +export type Quote = {
43 + bid?: Money;
44 + ask?: Money;
45 + last?: Money;
46 + previousClose?: Money;
47 + currency?: string;
48 + timestamp?: string;
49 + source?: string;
50 + freshness?: "REAL_TIME" | "DELAYED" | "END_OF_DAY" | "STALE" | "MOCK" | "UNAVAILABLE";
51 + marketStatus?: string;
52 + sourceTimestamp?: string;
53 + receivedAt?: string;
54 +};
55 +
56 +export type PortfolioPosition = {
57 + positionId: string;
58 + portfolioId: string;
59 + instrument: Instrument;
60 + quantity: number;
61 + averageCost: Money;
62 + currentPrice: Money;
63 + marketValue: Money;
64 + costBasis: Money;
65 + unrealizedProfitLoss: Money;
66 + unrealizedProfitLossPercent: number;
67 + brokerAccountId: string;
68 + lastUpdated: string;
69 + quote?: Quote | null;
70 +};
71 +
72 +export type Allocation = {
73 + country: Record<string, number>;
74 + currency: Record<string, number>;
75 + sector: Record<string, number>;
76 + assetType: Record<string, number>;
77 + broker: Record<string, number>;
78 +};
79 +
80 +export type PortfolioSummary = {
81 + portfolioId: string;
82 + baseCurrency: string;
83 + totalMarketValue: Money;
84 + totalCostBasis: Money;
85 + unrealizedProfitLoss: Money;
86 + unrealizedProfitLossPercent: number;
87 + cash: Money;
88 + positions: number;
89 + allocation: Allocation;
90 +};
91 +
92 +export type BrokerProviderInfo = {
93 + brokerType: string;
94 + status: string;
95 + providerStatus: string;
96 + code: string;
97 + message: string;
98 + capabilities: string[];
99 + connectionMethod: string;
100 + dataFreshness: string;
101 + readOnly: boolean;
102 + officialProviderSetupRequired: boolean;
103 +};
104 +
105 +export type BrokerConnection = {
106 + connectionId: string;
107 + userId: string;
108 + brokerType: string;
109 + externalAccountReference?: string | null;
110 + displayName: string;
111 + status: string;
112 + connectedAt?: string | null;
113 + lastSuccessfulSyncAt?: string | null;
114 + lastSyncAttemptAt?: string | null;
115 + lastErrorCode?: string | null;
116 + createdAt: string;
117 + updatedAt: string;
118 + capabilities: string[];
119 + providerStatus: string;
120 + dataFreshness: string;
121 + readOnly: boolean;
122 +};
123 +
124 +export type ApiFailure = {
125 + message: string;
126 + correlationId?: string;
127 + status?: number;
128 +};
129 +
130 +export type ResearchProfile = {
131 + instrumentId: string;
132 + companyId: string;
133 + companyName: string;
134 + aliases: string[];
135 + isin?: string | null;
136 + ticker: string;
137 + exchange: string;
138 + mic: string;
139 + country: string;
140 + currency: string;
141 +};
142 +
143 +export type ResearchEvent = {
144 + eventId: string;
145 + instrumentId: string;
146 + companyId: string;
147 + eventType: string;
148 + eventDate?: string | null;
149 + detectedAt: string;
150 + title: string;
151 + summary: string;
152 + sourceDocumentId: string;
153 + sourceUrl: string;
154 + sourceType: string;
155 + reliability: string;
156 + confidence: number;
157 + impact: string;
158 + timeHorizon: string;
159 + currency?: string | null;
160 + monetaryValue?: number | null;
161 + monetaryOriginal?: string | null;
162 + percentageValue?: number | null;
163 + percentageOriginal?: string | null;
164 + customer?: string | null;
165 + counterparty?: string | null;
166 + location?: string | null;
167 + capacityValue?: number | null;
168 + capacityUnit?: string | null;
169 + status: string;
170 + rawEvidenceReference: string;
171 +};
172 +
173 +export type ResearchDocument = {
174 + documentId: string;
175 + canonicalUrl: string;
176 + originalUrl: string;
177 + title?: string | null;
178 + sourceType: string;
179 + sourceName: string;
180 + publisher?: string | null;
181 + publishedAt?: string | null;
182 + retrievedAt: string;
183 + language?: string | null;
184 + contentType: string;
185 + documentType: string;
186 + contentHash: string;
187 + instrumentId?: string | null;
188 + companyId?: string | null;
189 + country?: string | null;
190 + exchange?: string | null;
191 + status: string;
192 + reliabilityLevel: string;
193 + entityResolutionConfidence: number;
194 +};
195 +
196 +export type CatalystScore = {
197 + instrumentId: string;
198 + overallScore: number;
199 + buckets: Record<string, number>;
200 + researchConfidence: number;
201 + generatedAt: string;
202 +};
203 +
204 +export type ResearchSummary = {
205 + profile: ResearchProfile;
206 + catalystScore: CatalystScore;
207 + recentEvents: ResearchEvent[];
208 + documents: ResearchDocument[];
209 + lastRefreshAt?: string | null;
210 + dataFreshness: string;
211 + demo: boolean;
212 + sourceMix: Record<string, number>;
213 +};
214 +
215 +type ApiErrorBody = {
216 + message?: string;
217 + correlationId?: string;
218 +};
219 +
220 +async function request<T>(path: string, init?: RequestInit): Promise<T> {
221 + const response = await fetch(`${frontendConfig.apiBaseUrl}${path}`, {
222 + ...init,
223 + headers: {
224 + "Content-Type": "application/json",
225 + "X-Correlation-Id": crypto.randomUUID(),
226 + ...init?.headers
227 + }
228 + });
229 +
230 + if (!response.ok) {
231 + let body: ApiErrorBody = {};
232 + try {
233 + body = (await response.json()) as ApiErrorBody;
234 + } catch {
235 + body = {};
236 + }
237 +
238 + const failure: ApiFailure = {
239 + message: body.message ?? "The request could not be completed.",
240 + correlationId: body.correlationId ?? response.headers.get("X-Correlation-Id") ?? undefined,
241 + status: response.status
242 + };
243 + throw failure;
244 + }
245 +
246 + if (response.status === 204) {
247 + return undefined as T;
248 + }
249 +
250 + return (await response.json()) as T;
251 +}
252 +
253 +export const portfolioApi = {
254 + listPortfolios: () => request<PortfolioListItem[]>("/api/v1/portfolios"),
255 + createPortfolio: (payload: { name: string; baseCurrency: string }) =>
256 + request<Portfolio>("/api/v1/portfolios", {
257 + method: "POST",
258 + body: JSON.stringify(payload)
259 + }),
260 + getPortfolio: (portfolioId: string) => request<Portfolio>(`/api/v1/portfolios/${portfolioId}`),
261 + getPositions: (portfolioId: string) =>
262 + request<PortfolioPosition[]>(`/api/v1/portfolios/${portfolioId}/positions`),
263 + getSummary: (portfolioId: string) =>
264 + request<PortfolioSummary>(`/api/v1/portfolios/${portfolioId}/summary`),
265 + syncPortfolio: (portfolioId: string) =>
266 + request<PortfolioSummary>(`/api/v1/portfolios/${portfolioId}/sync`, { method: "POST" })
267 +};
268 +
269 +export const brokerApi = {
270 + listBrokers: () => request<BrokerProviderInfo[]>("/api/v1/brokers"),
271 + listConnections: () => request<BrokerConnection[]>("/api/v1/broker-connections"),
272 + connectMock: () => request<BrokerConnection>("/api/v1/broker-connections/mock", { method: "POST" }),
273 + syncConnection: (connectionId: string) =>
274 + request<BrokerConnection>(`/api/v1/broker-connections/${connectionId}/sync`, { method: "POST" }),
275 + disconnectConnection: (connectionId: string) =>
276 + request<void>(`/api/v1/broker-connections/${connectionId}`, { method: "DELETE" })
277 +};
278 +
279 +export const researchApi = {
280 + listCompanies: () => request<ResearchProfile[]>("/api/v1/research/companies"),
281 + getSummary: (instrumentId: string) => request<ResearchSummary>(`/api/v1/research/companies/${instrumentId}/summary`),
282 + getEvents: (instrumentId: string, filters?: { eventType?: string; impact?: string; reliability?: string }) => {
283 + const params = new URLSearchParams();
284 + if (filters?.eventType) params.set("eventType", filters.eventType);
285 + if (filters?.impact) params.set("impact", filters.impact);
286 + if (filters?.reliability) params.set("reliability", filters.reliability);
287 + const suffix = params.toString() ? `?${params}` : "";
288 + return request<ResearchEvent[]>(`/api/v1/research/companies/${instrumentId}/events${suffix}`);
289 + },
290 + getDocuments: (instrumentId: string) =>
291 + request<ResearchDocument[]>(`/api/v1/research/companies/${instrumentId}/documents`),
292 + refresh: (instrumentId: string) =>
293 + request<ResearchSummary>(`/api/v1/research/companies/${instrumentId}/refresh`, { method: "POST" })
294 +};
frontend/app/page.tsx new
+5
@@ -0,0 +1,5 @@
1 +import { InvestmentWorkspace } from "./components/investment-workspace";
2 +
3 +export default function Home() {
4 + return <InvestmentWorkspace />;
5 +}
frontend/app/styles.css new
+1152
@@ -0,0 +1,1152 @@
1 +:root {
2 + color-scheme: light;
3 + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
4 + --bg: #f5f7f9;
5 + --bg-subtle: #edf1f4;
6 + --surface: #ffffff;
7 + --surface-raised: #ffffff;
8 + --text: #111827;
9 + --text-muted: #5f6f83;
10 + --text-soft: #8390a3;
11 + --line: #dce3ea;
12 + --line-strong: #c4ced9;
13 + --primary: #0f766e;
14 + --primary-strong: #115e59;
15 + --positive: #087443;
16 + --negative: #b42318;
17 + --warning: #a15c07;
18 + --info: #2459a6;
19 + --focus: #2563eb;
20 + --shadow: 0 16px 42px rgba(15, 23, 42, 0.08);
21 + --space-1: 4px;
22 + --space-2: 8px;
23 + --space-3: 12px;
24 + --space-4: 16px;
25 + --space-5: 20px;
26 + --space-6: 24px;
27 + --space-8: 32px;
28 + --radius: 8px;
29 +}
30 +
31 +@media (prefers-color-scheme: dark) {
32 + :root[data-theme="system"] {
33 + color-scheme: dark;
34 + --bg: #0e141b;
35 + --bg-subtle: #151e28;
36 + --surface: #141c26;
37 + --surface-raised: #192330;
38 + --text: #eef3f8;
39 + --text-muted: #a9b6c5;
40 + --text-soft: #7f8ea1;
41 + --line: #263342;
42 + --line-strong: #354457;
43 + --primary: #2dd4bf;
44 + --primary-strong: #5eead4;
45 + --positive: #4ade80;
46 + --negative: #fb7185;
47 + --warning: #facc15;
48 + --info: #93c5fd;
49 + --shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
50 + }
51 +}
52 +
53 +:root[data-theme="dark"] {
54 + color-scheme: dark;
55 + --bg: #0e141b;
56 + --bg-subtle: #151e28;
57 + --surface: #141c26;
58 + --surface-raised: #192330;
59 + --text: #eef3f8;
60 + --text-muted: #a9b6c5;
61 + --text-soft: #7f8ea1;
62 + --line: #263342;
63 + --line-strong: #354457;
64 + --primary: #2dd4bf;
65 + --primary-strong: #5eead4;
66 + --positive: #4ade80;
67 + --negative: #fb7185;
68 + --warning: #facc15;
69 + --info: #93c5fd;
70 + --shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
71 +}
72 +
73 +:root[data-theme="light"] {
74 + color-scheme: light;
75 +}
76 +
77 +* {
78 + box-sizing: border-box;
79 +}
80 +
81 +html {
82 + min-width: 320px;
83 +}
84 +
85 +body {
86 + margin: 0;
87 + background: var(--bg);
88 + color: var(--text);
89 + font-family: var(--font-sans);
90 + font-size: 15px;
91 + line-height: 1.5;
92 +}
93 +
94 +button,
95 +input,
96 +select {
97 + font: inherit;
98 +}
99 +
100 +button,
101 +select {
102 + cursor: pointer;
103 +}
104 +
105 +button:focus-visible,
106 +input:focus-visible,
107 +select:focus-visible {
108 + outline: 3px solid color-mix(in srgb, var(--focus) 35%, transparent);
109 + outline-offset: 2px;
110 +}
111 +
112 +code,
113 +kbd {
114 + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
115 +}
116 +
117 +.sr-only {
118 + position: absolute;
119 + width: 1px;
120 + height: 1px;
121 + padding: 0;
122 + margin: -1px;
123 + overflow: hidden;
124 + clip: rect(0, 0, 0, 0);
125 + white-space: nowrap;
126 + border: 0;
127 +}
128 +
129 +.app-shell {
130 + min-height: 100vh;
131 + display: grid;
132 + grid-template-columns: 248px minmax(0, 1fr);
133 +}
134 +
135 +.sidebar {
136 + position: sticky;
137 + top: 0;
138 + height: 100vh;
139 + display: flex;
140 + flex-direction: column;
141 + gap: var(--space-6);
142 + padding: var(--space-5);
143 + border-right: 1px solid var(--line);
144 + background: var(--surface);
145 +}
146 +
147 +.brand-lockup {
148 + display: flex;
149 + align-items: center;
150 + gap: var(--space-3);
151 + min-height: 44px;
152 +}
153 +
154 +.brand-mark {
155 + width: 40px;
156 + height: 40px;
157 + display: grid;
158 + place-items: center;
159 + border: 1px solid var(--line-strong);
160 + border-radius: var(--radius);
161 + background: var(--bg-subtle);
162 + color: var(--primary-strong);
163 + font-weight: 800;
164 +}
165 +
166 +.brand-lockup strong,
167 +.brand-lockup span {
168 + display: block;
169 +}
170 +
171 +.brand-lockup span {
172 + color: var(--text-muted);
173 + font-size: 13px;
174 +}
175 +
176 +.sidebar nav {
177 + display: grid;
178 + gap: var(--space-1);
179 +}
180 +
181 +.nav-item {
182 + width: 100%;
183 + display: flex;
184 + align-items: center;
185 + gap: var(--space-3);
186 + border: 0;
187 + border-radius: var(--radius);
188 + padding: 11px 12px;
189 + background: transparent;
190 + color: var(--text-muted);
191 + text-align: left;
192 +}
193 +
194 +.nav-item:hover,
195 +.nav-item-active {
196 + background: var(--bg-subtle);
197 + color: var(--text);
198 +}
199 +
200 +.nav-item-active {
201 + box-shadow: inset 3px 0 0 var(--primary);
202 +}
203 +
204 +.sidebar-panel {
205 + margin-top: auto;
206 + border: 1px solid var(--line);
207 + border-radius: var(--radius);
208 + padding: var(--space-4);
209 + background: var(--bg-subtle);
210 +}
211 +
212 +.sidebar-panel p {
213 + margin: var(--space-3) 0 0;
214 + color: var(--text-muted);
215 + font-size: 13px;
216 +}
217 +
218 +.workspace {
219 + min-width: 0;
220 +}
221 +
222 +.topbar {
223 + position: sticky;
224 + top: 0;
225 + z-index: 20;
226 + display: flex;
227 + align-items: center;
228 + gap: var(--space-4);
229 + min-height: 72px;
230 + padding: 0 var(--space-6);
231 + border-bottom: 1px solid var(--line);
232 + background: color-mix(in srgb, var(--surface) 92%, transparent);
233 + backdrop-filter: blur(14px);
234 +}
235 +
236 +.command-bar {
237 + flex: 1;
238 + min-width: 220px;
239 + max-width: 680px;
240 + display: flex;
241 + align-items: center;
242 + gap: var(--space-2);
243 + border: 1px solid var(--line);
244 + border-radius: var(--radius);
245 + padding: 0 var(--space-3);
246 + background: var(--surface-raised);
247 +}
248 +
249 +.command-bar input,
250 +.table-search input,
251 +.research-search input,
252 +.field input {
253 + width: 100%;
254 + min-width: 0;
255 + border: 0;
256 + background: transparent;
257 + color: var(--text);
258 + outline: 0;
259 +}
260 +
261 +.command-bar input {
262 + height: 42px;
263 +}
264 +
265 +.command-bar kbd {
266 + border: 1px solid var(--line);
267 + border-radius: 6px;
268 + padding: 1px 7px;
269 + color: var(--text-soft);
270 + font-size: 12px;
271 +}
272 +
273 +.topbar-actions {
274 + display: flex;
275 + align-items: center;
276 + gap: var(--space-2);
277 +}
278 +
279 +.icon-button,
280 +.account-button,
281 +.mobile-menu {
282 + border: 1px solid var(--line);
283 + border-radius: var(--radius);
284 + background: var(--surface-raised);
285 + color: var(--text);
286 +}
287 +
288 +.icon-button {
289 + width: 40px;
290 + height: 40px;
291 + display: grid;
292 + place-items: center;
293 +}
294 +
295 +.account-button,
296 +.theme-switch {
297 + height: 40px;
298 + display: inline-flex;
299 + align-items: center;
300 + gap: var(--space-2);
301 +}
302 +
303 +.account-button {
304 + padding: 0 var(--space-3);
305 +}
306 +
307 +.theme-switch {
308 + border: 1px solid var(--line);
309 + border-radius: var(--radius);
310 + padding: 0 var(--space-2);
311 + background: var(--surface-raised);
312 + color: var(--text-muted);
313 +}
314 +
315 +.theme-switch select,
316 +.portfolio-select select,
317 +.sort-control select {
318 + border: 0;
319 + background: transparent;
320 + color: var(--text);
321 + outline: 0;
322 +}
323 +
324 +.mobile-menu,
325 +.sidebar-scrim {
326 + display: none;
327 +}
328 +
329 +.content {
330 + width: min(100% - 48px, 1480px);
331 + margin: 0 auto;
332 + padding: var(--space-8) 0 56px;
333 +}
334 +
335 +.page-header {
336 + display: flex;
337 + align-items: flex-end;
338 + justify-content: space-between;
339 + gap: var(--space-6);
340 + margin-bottom: var(--space-6);
341 +}
342 +
343 +.page-header h1 {
344 + margin: 0;
345 + font-size: 32px;
346 + line-height: 1.15;
347 + letter-spacing: 0;
348 +}
349 +
350 +.page-header p,
351 +.panel-header p,
352 +.create-panel p,
353 +.empty-state p,
354 +.error-state p,
355 +.settings-grid p {
356 + margin: var(--space-1) 0 0;
357 + color: var(--text-muted);
358 +}
359 +
360 +.eyebrow {
361 + margin: 0 0 var(--space-2);
362 + color: var(--primary-strong);
363 + font-size: 12px;
364 + font-weight: 800;
365 + letter-spacing: 0;
366 + text-transform: uppercase;
367 +}
368 +
369 +.portfolio-select,
370 +.field {
371 + display: grid;
372 + gap: 6px;
373 +}
374 +
375 +.portfolio-select span,
376 +.field span {
377 + color: var(--text-muted);
378 + font-size: 12px;
379 + font-weight: 700;
380 + text-transform: uppercase;
381 +}
382 +
383 +.portfolio-select select,
384 +.field input {
385 + height: 42px;
386 + border: 1px solid var(--line);
387 + border-radius: var(--radius);
388 + padding: 0 var(--space-3);
389 + background: var(--surface-raised);
390 +}
391 +
392 +.field small {
393 + color: var(--text-soft);
394 +}
395 +
396 +.button {
397 + min-height: 40px;
398 + display: inline-flex;
399 + align-items: center;
400 + justify-content: center;
401 + gap: var(--space-2);
402 + border: 1px solid transparent;
403 + border-radius: var(--radius);
404 + padding: 0 var(--space-4);
405 + font-weight: 700;
406 +}
407 +
408 +.button:disabled {
409 + cursor: not-allowed;
410 + opacity: 0.62;
411 +}
412 +
413 +.button-primary {
414 + background: var(--primary);
415 + color: #ffffff;
416 +}
417 +
418 +.button-secondary {
419 + border-color: var(--line);
420 + background: var(--surface-raised);
421 + color: var(--text);
422 +}
423 +
424 +.button-ghost {
425 + background: transparent;
426 + color: var(--text);
427 +}
428 +
429 +.button-danger {
430 + background: var(--negative);
431 + color: #ffffff;
432 +}
433 +
434 +.badge {
435 + display: inline-flex;
436 + align-items: center;
437 + width: max-content;
438 + min-height: 24px;
439 + border: 1px solid var(--line);
440 + border-radius: 999px;
441 + padding: 2px 9px;
442 + font-size: 12px;
443 + font-weight: 800;
444 + line-height: 1.2;
445 +}
446 +
447 +.badge-neutral {
448 + color: var(--text-muted);
449 + background: var(--bg-subtle);
450 +}
451 +
452 +.badge-positive {
453 + color: var(--positive);
454 + background: color-mix(in srgb, var(--positive) 10%, transparent);
455 +}
456 +
457 +.badge-negative {
458 + color: var(--negative);
459 + background: color-mix(in srgb, var(--negative) 10%, transparent);
460 +}
461 +
462 +.badge-warning {
463 + color: var(--warning);
464 + background: color-mix(in srgb, var(--warning) 12%, transparent);
465 +}
466 +
467 +.badge-info {
468 + color: var(--info);
469 + background: color-mix(in srgb, var(--info) 10%, transparent);
470 +}
471 +
472 +.card {
473 + border: 1px solid var(--line);
474 + border-radius: var(--radius);
475 + background: var(--surface);
476 + box-shadow: var(--shadow);
477 +}
478 +
479 +.metric-card {
480 + min-height: 122px;
481 + display: grid;
482 + align-content: start;
483 + gap: var(--space-2);
484 + padding: var(--space-5);
485 +}
486 +
487 +.metric-label,
488 +.metric-meta {
489 + color: var(--text-muted);
490 +}
491 +
492 +.metric-label {
493 + font-size: 13px;
494 + font-weight: 700;
495 +}
496 +
497 +.metric-value {
498 + font-size: 25px;
499 + line-height: 1.15;
500 + letter-spacing: 0;
501 +}
502 +
503 +.metric-positive,
504 +.positive-text {
505 + color: var(--positive);
506 +}
507 +
508 +.metric-negative,
509 +.negative-text {
510 + color: var(--negative);
511 +}
512 +
513 +.metric-warning {
514 + color: var(--warning);
515 +}
516 +
517 +.dashboard-grid,
518 +.portfolio-layout,
519 +.broker-grid {
520 + display: grid;
521 + grid-template-columns: repeat(2, minmax(0, 1fr));
522 + gap: var(--space-5);
523 +}
524 +
525 +.metrics-grid {
526 + grid-column: 1 / -1;
527 + display: grid;
528 + grid-template-columns: repeat(6, minmax(0, 1fr));
529 + gap: var(--space-4);
530 +}
531 +
532 +.wide-panel,
533 +.create-panel {
534 + grid-column: 1 / -1;
535 + padding: var(--space-5);
536 +}
537 +
538 +.create-panel {
539 + display: grid;
540 + gap: var(--space-5);
541 + max-width: 720px;
542 +}
543 +
544 +.form-grid {
545 + display: grid;
546 + grid-template-columns: 1fr 160px;
547 + gap: var(--space-4);
548 +}
549 +
550 +.panel-header {
551 + display: flex;
552 + align-items: flex-start;
553 + justify-content: space-between;
554 + gap: var(--space-4);
555 + margin-bottom: var(--space-5);
556 +}
557 +
558 +.panel-header.compact {
559 + align-items: center;
560 + margin-bottom: var(--space-3);
561 +}
562 +
563 +.panel-header h2,
564 +.allocation-group h3,
565 +.empty-state h3,
566 +.error-state h3,
567 +.settings-grid h3 {
568 + margin: 0;
569 + letter-spacing: 0;
570 +}
571 +
572 +.panel-header h2 {
573 + font-size: 18px;
574 +}
575 +
576 +.movement-list {
577 + display: grid;
578 + gap: var(--space-3);
579 +}
580 +
581 +.movement-list div {
582 + display: flex;
583 + justify-content: space-between;
584 + gap: var(--space-3);
585 + padding-top: var(--space-3);
586 + border-top: 1px solid var(--line);
587 +}
588 +
589 +.allocation-grid {
590 + display: grid;
591 + grid-template-columns: repeat(5, minmax(180px, 1fr));
592 + gap: var(--space-4);
593 + overflow-x: auto;
594 + padding-bottom: 2px;
595 +}
596 +
597 +.allocation-group {
598 + display: grid;
599 + gap: var(--space-3);
600 + min-width: 0;
601 + border: 1px solid var(--line);
602 + border-radius: var(--radius);
603 + padding: var(--space-4);
604 + background: var(--bg-subtle);
605 +}
606 +
607 +.allocation-group h3 {
608 + font-size: 14px;
609 +}
610 +
611 +.allocation-row {
612 + display: grid;
613 + gap: 6px;
614 +}
615 +
616 +.allocation-row div:first-child {
617 + display: flex;
618 + justify-content: space-between;
619 + gap: var(--space-3);
620 + color: var(--text-muted);
621 + font-size: 13px;
622 +}
623 +
624 +.bar-track {
625 + height: 8px;
626 + overflow: hidden;
627 + border-radius: 999px;
628 + background: color-mix(in srgb, var(--line) 70%, transparent);
629 +}
630 +
631 +.bar-track span {
632 + display: block;
633 + height: 100%;
634 + border-radius: inherit;
635 + background: var(--primary);
636 +}
637 +
638 +.table-toolbar {
639 + display: flex;
640 + align-items: center;
641 + justify-content: space-between;
642 + gap: var(--space-4);
643 + margin-bottom: var(--space-4);
644 +}
645 +
646 +.table-search,
647 +.research-search,
648 +.sort-control {
649 + min-height: 42px;
650 + display: flex;
651 + align-items: center;
652 + gap: var(--space-2);
653 + border: 1px solid var(--line);
654 + border-radius: var(--radius);
655 + padding: 0 var(--space-3);
656 + background: var(--surface-raised);
657 +}
658 +
659 +.table-search {
660 + flex: 1;
661 + max-width: 480px;
662 +}
663 +
664 +.sort-control {
665 + color: var(--text-muted);
666 +}
667 +
668 +.table-frame {
669 + overflow-x: auto;
670 + border: 1px solid var(--line);
671 + border-radius: var(--radius);
672 +}
673 +
674 +table {
675 + width: 100%;
676 + min-width: 1560px;
677 + border-collapse: collapse;
678 +}
679 +
680 +th,
681 +td {
682 + padding: 13px 14px;
683 + border-bottom: 1px solid var(--line);
684 + text-align: left;
685 + vertical-align: middle;
686 + white-space: nowrap;
687 +}
688 +
689 +th {
690 + background: var(--bg-subtle);
691 + color: var(--text-muted);
692 + font-size: 12px;
693 + font-weight: 800;
694 + text-transform: uppercase;
695 +}
696 +
697 +tbody tr:hover {
698 + background: color-mix(in srgb, var(--primary) 5%, transparent);
699 +}
700 +
701 +tbody tr:last-child td {
702 + border-bottom: 0;
703 +}
704 +
705 +.security-button {
706 + display: grid;
707 + gap: 2px;
708 + border: 0;
709 + padding: 0;
710 + background: transparent;
711 + color: var(--text);
712 + text-align: left;
713 +}
714 +
715 +.security-button span {
716 + color: var(--text-muted);
717 + font-size: 12px;
718 +}
719 +
720 +.broker-card {
721 + display: grid;
722 + grid-template-columns: 52px minmax(0, 1fr);
723 + gap: var(--space-4);
724 + padding: var(--space-5);
725 +}
726 +
727 +.broker-icon {
728 + width: 48px;
729 + height: 48px;
730 + display: grid;
731 + place-items: center;
732 + border: 1px solid var(--line);
733 + border-radius: var(--radius);
734 + background: var(--bg-subtle);
735 + color: var(--primary-strong);
736 +}
737 +
738 +.broker-facts {
739 + display: grid;
740 + gap: var(--space-3);
741 + margin: 0 0 var(--space-5);
742 +}
743 +
744 +.broker-facts div {
745 + display: flex;
746 + justify-content: space-between;
747 + gap: var(--space-3);
748 + border-top: 1px solid var(--line);
749 + padding-top: var(--space-3);
750 +}
751 +
752 +.broker-facts dt {
753 + color: var(--text-muted);
754 +}
755 +
756 +.broker-facts dd {
757 + margin: 0;
758 + font-weight: 700;
759 + overflow-wrap: anywhere;
760 + text-align: right;
761 +}
762 +
763 +.broker-note {
764 + color: var(--warning);
765 + font-size: 0.88rem;
766 + font-weight: 650;
767 + margin: 0 0 var(--space-4);
768 +}
769 +
770 +.research-search {
771 + max-width: 680px;
772 + margin-bottom: var(--space-5);
773 +}
774 +
775 +.research-layout {
776 + display: grid;
777 + gap: var(--space-5);
778 +}
779 +
780 +.research-actions,
781 +.research-controls {
782 + display: flex;
783 + align-items: center;
784 + gap: var(--space-3);
785 + flex-wrap: wrap;
786 +}
787 +
788 +.research-controls {
789 + justify-content: space-between;
790 +}
791 +
792 +.research-controls .sort-control {
793 + flex: 1;
794 + min-width: 220px;
795 +}
796 +
797 +.research-tabs {
798 + display: flex;
799 + gap: var(--space-2);
800 + overflow-x: auto;
801 + margin-bottom: var(--space-5);
802 + padding-bottom: var(--space-1);
803 +}
804 +
805 +.research-tabs span {
806 + flex: 0 0 auto;
807 + border: 1px solid var(--line);
808 + border-radius: var(--radius);
809 + padding: 7px 10px;
810 + color: var(--text-muted);
811 + background: var(--surface-raised);
812 + font-size: 13px;
813 + font-weight: 700;
814 +}
815 +
816 +.research-tabs span[aria-selected="true"] {
817 + border-color: color-mix(in srgb, var(--primary) 45%, var(--line));
818 + color: var(--primary-strong);
819 + background: color-mix(in srgb, var(--primary) 8%, transparent);
820 +}
821 +
822 +.score-grid {
823 + display: grid;
824 + grid-template-columns: repeat(5, minmax(160px, 1fr));
825 + gap: var(--space-4);
826 +}
827 +
828 +.score-row {
829 + display: grid;
830 + gap: var(--space-2);
831 + border: 1px solid var(--line);
832 + border-radius: var(--radius);
833 + padding: var(--space-4);
834 + background: var(--bg-subtle);
835 +}
836 +
837 +.score-row span {
838 + color: var(--text-muted);
839 + font-size: 13px;
840 +}
841 +
842 +.score-row strong {
843 + font-size: 24px;
844 +}
845 +
846 +.event-grid {
847 + display: grid;
848 + grid-template-columns: repeat(2, minmax(0, 1fr));
849 + gap: var(--space-5);
850 +}
851 +
852 +.research-event-card {
853 + display: grid;
854 + gap: var(--space-4);
855 + padding: var(--space-5);
856 +}
857 +
858 +.research-event-card h2 {
859 + margin: var(--space-2) 0 0;
860 + font-size: 18px;
861 +}
862 +
863 +.research-event-card p,
864 +.research-event-card blockquote {
865 + margin: 0;
866 + color: var(--text-muted);
867 +}
868 +
869 +.research-event-card blockquote {
870 + border-left: 3px solid var(--line-strong);
871 + padding-left: var(--space-3);
872 + font-size: 13px;
873 +}
874 +
875 +.event-value-line {
876 + display: flex;
877 + align-items: baseline;
878 + justify-content: space-between;
879 + gap: var(--space-3);
880 + border-top: 1px solid var(--line);
881 + padding-top: var(--space-3);
882 +}
883 +
884 +.event-value-line strong {
885 + font-size: 24px;
886 +}
887 +
888 +.event-value-line span {
889 + color: var(--text-muted);
890 +}
891 +
892 +.event-facts {
893 + display: grid;
894 + grid-template-columns: repeat(4, minmax(0, 1fr));
895 + gap: var(--space-3);
896 + margin: 0;
897 +}
898 +
899 +.event-facts div {
900 + border: 1px solid var(--line);
901 + border-radius: var(--radius);
902 + padding: var(--space-3);
903 + background: var(--bg-subtle);
904 +}
905 +
906 +.event-facts dt {
907 + color: var(--text-muted);
908 + font-size: 12px;
909 + font-weight: 800;
910 + text-transform: uppercase;
911 +}
912 +
913 +.event-facts dd {
914 + margin: 4px 0 0;
915 + font-weight: 700;
916 +}
917 +
918 +.source-link,
919 +.source-list a {
920 + color: var(--primary-strong);
921 + font-weight: 800;
922 + text-decoration: none;
923 +}
924 +
925 +.source-list {
926 + display: grid;
927 + gap: var(--space-3);
928 +}
929 +
930 +.source-list a {
931 + display: grid;
932 + gap: 3px;
933 + border: 1px solid var(--line);
934 + border-radius: var(--radius);
935 + padding: var(--space-4);
936 + background: var(--bg-subtle);
937 +}
938 +
939 +.source-list span {
940 + color: var(--text-muted);
941 + font-weight: 500;
942 +}
943 +
944 +.settings-grid {
945 + display: grid;
946 + grid-template-columns: repeat(2, minmax(0, 1fr));
947 + gap: var(--space-4);
948 +}
949 +
950 +.settings-grid div {
951 + border: 1px solid var(--line);
952 + border-radius: var(--radius);
953 + padding: var(--space-4);
954 + background: var(--bg-subtle);
955 +}
956 +
957 +.empty-state,
958 +.error-state {
959 + display: grid;
960 + gap: var(--space-3);
961 + place-items: start;
962 + border: 1px dashed var(--line-strong);
963 + border-radius: var(--radius);
964 + padding: var(--space-6);
965 + background: var(--surface);
966 +}
967 +
968 +.error-state {
969 + margin-bottom: var(--space-5);
970 + border-color: color-mix(in srgb, var(--negative) 45%, var(--line));
971 +}
972 +
973 +.error-state small {
974 + color: var(--text-muted);
975 +}
976 +
977 +.loading-grid {
978 + display: grid;
979 + gap: var(--space-4);
980 +}
981 +
982 +.skeleton-stack {
983 + display: grid;
984 + gap: var(--space-3);
985 + border: 1px solid var(--line);
986 + border-radius: var(--radius);
987 + padding: var(--space-5);
988 + background: var(--surface);
989 +}
990 +
991 +.skeleton-line {
992 + height: 18px;
993 + border-radius: 999px;
994 + background: linear-gradient(90deg, var(--bg-subtle), var(--line), var(--bg-subtle));
995 + background-size: 200% 100%;
996 + animation: skeleton-pulse 1.4s ease-in-out infinite;
997 +}
998 +
999 +.skeleton-line:nth-child(2n) {
1000 + width: 76%;
1001 +}
1002 +
1003 +.skeleton-line:nth-child(3n) {
1004 + width: 54%;
1005 +}
1006 +
1007 +@keyframes skeleton-pulse {
1008 + 0% {
1009 + background-position: 100% 0;
1010 + }
1011 + 100% {
1012 + background-position: -100% 0;
1013 + }
1014 +}
1015 +
1016 +@media (prefers-reduced-motion: reduce) {
1017 + .skeleton-line {
1018 + animation: none;
1019 + }
1020 +}
1021 +
1022 +@media (max-width: 1180px) {
1023 + .metrics-grid {
1024 + grid-template-columns: repeat(3, minmax(0, 1fr));
1025 + }
1026 +
1027 + .dashboard-grid,
1028 + .broker-grid,
1029 + .event-grid {
1030 + grid-template-columns: 1fr;
1031 + }
1032 +
1033 + .score-grid {
1034 + grid-template-columns: repeat(2, minmax(0, 1fr));
1035 + }
1036 +}
1037 +
1038 +@media (max-width: 860px) {
1039 + .app-shell {
1040 + grid-template-columns: 1fr;
1041 + }
1042 +
1043 + .sidebar {
1044 + position: fixed;
1045 + z-index: 40;
1046 + left: 0;
1047 + transform: translateX(-100%);
1048 + width: min(82vw, 300px);
1049 + transition: transform 160ms ease;
1050 + }
1051 +
1052 + .sidebar-open {
1053 + transform: translateX(0);
1054 + }
1055 +
1056 + .mobile-menu {
1057 + display: inline-flex;
1058 + width: 40px;
1059 + height: 40px;
1060 + align-items: center;
1061 + justify-content: center;
1062 + }
1063 +
1064 + .sidebar-scrim {
1065 + position: fixed;
1066 + z-index: 35;
1067 + inset: 0;
1068 + display: flex;
1069 + justify-content: flex-end;
1070 + padding: var(--space-5);
1071 + border: 0;
1072 + background: rgba(15, 23, 42, 0.52);
1073 + color: #ffffff;
1074 + }
1075 +
1076 + .topbar {
1077 + padding: 0 var(--space-4);
1078 + }
1079 +
1080 + .topbar-actions .badge,
1081 + .command-bar kbd {
1082 + display: none;
1083 + }
1084 +
1085 + .content {
1086 + width: min(100% - 32px, 1480px);
1087 + padding-top: var(--space-6);
1088 + }
1089 +
1090 + .page-header,
1091 + .table-toolbar {
1092 + align-items: stretch;
1093 + flex-direction: column;
1094 + }
1095 +
1096 + .portfolio-select,
1097 + .table-search {
1098 + max-width: none;
1099 + }
1100 +
1101 + .metrics-grid {
1102 + grid-template-columns: repeat(2, minmax(0, 1fr));
1103 + }
1104 +
1105 + .form-grid,
1106 + .settings-grid,
1107 + .event-facts {
1108 + grid-template-columns: 1fr;
1109 + }
1110 +}
1111 +
1112 +@media (max-width: 620px) {
1113 + .topbar {
1114 + flex-wrap: wrap;
1115 + min-height: 112px;
1116 + align-content: center;
1117 + }
1118 +
1119 + .command-bar {
1120 + order: 3;
1121 + flex-basis: 100%;
1122 + max-width: none;
1123 + }
1124 +
1125 + .topbar-actions {
1126 + margin-left: auto;
1127 + }
1128 +
1129 + .theme-switch {
1130 + display: none;
1131 + }
1132 +
1133 + .page-header h1 {
1134 + font-size: 26px;
1135 + }
1136 +
1137 + .metrics-grid {
1138 + grid-template-columns: 1fr;
1139 + }
1140 +
1141 + .metric-card {
1142 + min-height: 104px;
1143 + }
1144 +
1145 + .panel-header {
1146 + flex-direction: column;
1147 + }
1148 +
1149 + .score-grid {
1150 + grid-template-columns: 1fr;
1151 + }
1152 +}
frontend/eslint.config.mjs new
+10
@@ -0,0 +1,10 @@
1 +import nextVitals from "eslint-config-next/core-web-vitals";
2 +
3 +const eslintConfig = [
4 + ...nextVitals,
5 + {
6 + ignores: [".next/**", "next-env.d.ts"]
7 + }
8 +];
9 +
10 +export default eslintConfig;
frontend/next-env.d.ts new
+7
@@ -0,0 +1,7 @@
1 +/// <reference types="next" />
2 +/// <reference types="next/image-types/global" />
3 +import "./.next/types/routes.d.ts";
4 +import "./.next/types/root-params.d.ts";
5 +
6 +// NOTE: This file should not be edited
7 +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
frontend/next.config.mjs new
+6
@@ -0,0 +1,6 @@
1 +/** @type {import('next').NextConfig} */
2 +const nextConfig = {
3 + output: "standalone"
4 +};
5 +
6 +export default nextConfig;
frontend/package-lock.json new
+6142
@@ -0,0 +1,6142 @@
1 +{
2 + "name": "ai-investment-platform-frontend",
3 + "version": "0.1.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "ai-investment-platform-frontend",
9 + "version": "0.1.0",
10 + "dependencies": {
11 + "lucide-react": "^0.468.0",
12 + "next": "^16.3.0",
13 + "react": "18.3.1",
14 + "react-dom": "18.3.1"
15 + },
16 + "devDependencies": {
17 + "@types/node": "^22.7.5",
18 + "@types/react": "^18.3.11",
19 + "@types/react-dom": "^18.3.1",
20 + "eslint": "^9.7.0",
21 + "eslint-config-next": "^16.3.0",
22 + "typescript": "^5.6.3"
23 + }
24 + },
25 + "node_modules/@babel/code-frame": {
26 + "version": "7.29.7",
27 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
28 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
29 + "dev": true,
30 + "license": "MIT",
31 + "dependencies": {
32 + "@babel/helper-validator-identifier": "^7.29.7",
33 + "js-tokens": "^4.0.0",
34 + "picocolors": "^1.1.1"
35 + },
36 + "engines": {
37 + "node": ">=6.9.0"
38 + }
39 + },
40 + "node_modules/@babel/compat-data": {
41 + "version": "7.29.7",
42 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
43 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
44 + "dev": true,
45 + "license": "MIT",
46 + "engines": {
47 + "node": ">=6.9.0"
48 + }
49 + },
50 + "node_modules/@babel/core": {
51 + "version": "7.29.7",
52 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
53 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
54 + "dev": true,
55 + "license": "MIT",
56 + "dependencies": {
57 + "@babel/code-frame": "^7.29.7",
58 + "@babel/generator": "^7.29.7",
59 + "@babel/helper-compilation-targets": "^7.29.7",
60 + "@babel/helper-module-transforms": "^7.29.7",
61 + "@babel/helpers": "^7.29.7",
62 + "@babel/parser": "^7.29.7",
63 + "@babel/template": "^7.29.7",
64 + "@babel/traverse": "^7.29.7",
65 + "@babel/types": "^7.29.7",
66 + "@jridgewell/remapping": "^2.3.5",
67 + "convert-source-map": "^2.0.0",
68 + "debug": "^4.1.0",
69 + "gensync": "^1.0.0-beta.2",
70 + "json5": "^2.2.3",
71 + "semver": "^6.3.1"
72 + },
73 + "engines": {
74 + "node": ">=6.9.0"
75 + },
76 + "funding": {
77 + "type": "opencollective",
78 + "url": "https://opencollective.com/babel"
79 + }
80 + },
81 + "node_modules/@babel/core/node_modules/json5": {
82 + "version": "2.2.3",
83 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
84 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
85 + "dev": true,
86 + "license": "MIT",
87 + "bin": {
88 + "json5": "lib/cli.js"
89 + },
90 + "engines": {
91 + "node": ">=6"
92 + }
93 + },
94 + "node_modules/@babel/core/node_modules/semver": {
95 + "version": "6.3.1",
96 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
97 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
98 + "dev": true,
99 + "license": "ISC",
100 + "bin": {
101 + "semver": "bin/semver.js"
102 + }
103 + },
104 + "node_modules/@babel/generator": {
105 + "version": "7.29.8",
106 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
107 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
108 + "dev": true,
109 + "license": "MIT",
110 + "dependencies": {
111 + "@babel/parser": "^7.29.8",
112 + "@babel/types": "^7.29.8",
113 + "@jridgewell/gen-mapping": "^0.3.12",
114 + "@jridgewell/trace-mapping": "^0.3.28",
115 + "jsesc": "^3.0.2"
116 + },
117 + "engines": {
118 + "node": ">=6.9.0"
119 + }
120 + },
121 + "node_modules/@babel/helper-compilation-targets": {
122 + "version": "7.29.7",
123 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
124 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
125 + "dev": true,
126 + "license": "MIT",
127 + "dependencies": {
128 + "@babel/compat-data": "^7.29.7",
129 + "@babel/helper-validator-option": "^7.29.7",
130 + "browserslist": "^4.24.0",
131 + "lru-cache": "^5.1.1",
132 + "semver": "^6.3.1"
133 + },
134 + "engines": {
135 + "node": ">=6.9.0"
136 + }
137 + },
138 + "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
139 + "version": "6.3.1",
140 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
141 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
142 + "dev": true,
143 + "license": "ISC",
144 + "bin": {
145 + "semver": "bin/semver.js"
146 + }
147 + },
148 + "node_modules/@babel/helper-globals": {
149 + "version": "7.29.7",
150 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
151 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
152 + "dev": true,
153 + "license": "MIT",
154 + "engines": {
155 + "node": ">=6.9.0"
156 + }
157 + },
158 + "node_modules/@babel/helper-module-imports": {
159 + "version": "7.29.7",
160 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
161 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
162 + "dev": true,
163 + "license": "MIT",
164 + "dependencies": {
165 + "@babel/traverse": "^7.29.7",
166 + "@babel/types": "^7.29.7"
167 + },
168 + "engines": {
169 + "node": ">=6.9.0"
170 + }
171 + },
172 + "node_modules/@babel/helper-module-transforms": {
173 + "version": "7.29.7",
174 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
175 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
176 + "dev": true,
177 + "license": "MIT",
178 + "dependencies": {
179 + "@babel/helper-module-imports": "^7.29.7",
180 + "@babel/helper-validator-identifier": "^7.29.7",
181 + "@babel/traverse": "^7.29.7"
182 + },
183 + "engines": {
184 + "node": ">=6.9.0"
185 + },
186 + "peerDependencies": {
187 + "@babel/core": "^7.0.0"
188 + }
189 + },
190 + "node_modules/@babel/helper-string-parser": {
191 + "version": "7.29.7",
192 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
193 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
194 + "dev": true,
195 + "license": "MIT",
196 + "engines": {
197 + "node": ">=6.9.0"
198 + }
199 + },
200 + "node_modules/@babel/helper-validator-identifier": {
201 + "version": "7.29.7",
202 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
203 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
204 + "dev": true,
205 + "license": "MIT",
206 + "engines": {
207 + "node": ">=6.9.0"
208 + }
209 + },
210 + "node_modules/@babel/helper-validator-option": {
211 + "version": "7.29.7",
212 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
213 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
214 + "dev": true,
215 + "license": "MIT",
216 + "engines": {
217 + "node": ">=6.9.0"
218 + }
219 + },
220 + "node_modules/@babel/helpers": {
221 + "version": "7.29.7",
222 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
223 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
224 + "dev": true,
225 + "license": "MIT",
226 + "dependencies": {
227 + "@babel/template": "^7.29.7",
228 + "@babel/types": "^7.29.7"
229 + },
230 + "engines": {
231 + "node": ">=6.9.0"
232 + }
233 + },
234 + "node_modules/@babel/parser": {
235 + "version": "7.29.8",
236 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
237 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
238 + "dev": true,
239 + "license": "MIT",
240 + "dependencies": {
241 + "@babel/types": "^7.29.8"
242 + },
243 + "bin": {
244 + "parser": "bin/babel-parser.js"
245 + },
246 + "engines": {
247 + "node": ">=6.0.0"
248 + }
249 + },
250 + "node_modules/@babel/template": {
251 + "version": "7.29.7",
252 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
253 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
254 + "dev": true,
255 + "license": "MIT",
256 + "dependencies": {
257 + "@babel/code-frame": "^7.29.7",
258 + "@babel/parser": "^7.29.7",
259 + "@babel/types": "^7.29.7"
260 + },
261 + "engines": {
262 + "node": ">=6.9.0"
263 + }
264 + },
265 + "node_modules/@babel/traverse": {
266 + "version": "7.29.8",
267 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
268 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
269 + "dev": true,
270 + "license": "MIT",
271 + "dependencies": {
272 + "@babel/code-frame": "^7.29.7",
273 + "@babel/generator": "^7.29.8",
274 + "@babel/helper-globals": "^7.29.7",
275 + "@babel/parser": "^7.29.8",
276 + "@babel/template": "^7.29.7",
277 + "@babel/types": "^7.29.8",
278 + "debug": "^4.3.1"
279 + },
280 + "engines": {
281 + "node": ">=6.9.0"
282 + }
283 + },
284 + "node_modules/@babel/types": {
285 + "version": "7.29.8",
286 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
287 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
288 + "dev": true,
289 + "license": "MIT",
290 + "dependencies": {
291 + "@babel/helper-string-parser": "^7.29.7",
292 + "@babel/helper-validator-identifier": "^7.29.7"
293 + },
294 + "engines": {
295 + "node": ">=6.9.0"
296 + }
297 + },
298 + "node_modules/@emnapi/core": {
299 + "version": "1.11.3",
300 + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
301 + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
302 + "dev": true,
303 + "license": "MIT",
304 + "optional": true,
305 + "peer": true,
306 + "dependencies": {
307 + "@emnapi/wasi-threads": "1.2.3",
308 + "tslib": "^2.4.0"
309 + }
310 + },
311 + "node_modules/@emnapi/runtime": {
312 + "version": "1.11.3",
313 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
314 + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
315 + "license": "MIT",
316 + "optional": true,
317 + "dependencies": {
318 + "tslib": "^2.4.0"
319 + }
320 + },
321 + "node_modules/@emnapi/wasi-threads": {
322 + "version": "1.2.3",
323 + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
324 + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
325 + "dev": true,
326 + "license": "MIT",
327 + "optional": true,
328 + "peer": true,
329 + "dependencies": {
330 + "tslib": "^2.4.0"
331 + }
332 + },
333 + "node_modules/@eslint-community/eslint-utils": {
334 + "version": "4.10.1",
335 + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
336 + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
337 + "dev": true,
338 + "license": "MIT",
339 + "dependencies": {
340 + "eslint-visitor-keys": "^3.4.3"
341 + },
342 + "engines": {
343 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
344 + },
345 + "funding": {
346 + "url": "https://opencollective.com/eslint"
347 + },
348 + "peerDependencies": {
349 + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
350 + }
351 + },
352 + "node_modules/@eslint-community/regexpp": {
353 + "version": "4.12.2",
354 + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
355 + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
356 + "dev": true,
357 + "license": "MIT",
358 + "engines": {
359 + "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
360 + }
361 + },
362 + "node_modules/@eslint/config-array": {
363 + "version": "0.17.1",
364 + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz",
365 + "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==",
366 + "dev": true,
367 + "license": "Apache-2.0",
368 + "dependencies": {
369 + "@eslint/object-schema": "^2.1.4",
370 + "debug": "^4.3.1",
371 + "minimatch": "^3.1.2"
372 + },
373 + "engines": {
374 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
375 + }
376 + },
377 + "node_modules/@eslint/eslintrc": {
378 + "version": "3.3.6",
379 + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
380 + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
381 + "dev": true,
382 + "license": "MIT",
383 + "dependencies": {
384 + "ajv": "^6.14.0",
385 + "debug": "^4.3.2",
386 + "espree": "^10.0.1",
387 + "globals": "^14.0.0",
388 + "ignore": "^5.2.0",
389 + "import-fresh": "^3.2.1",
390 + "js-yaml": "^4.3.0",
391 + "minimatch": "^3.1.5",
392 + "strip-json-comments": "^3.1.1"
393 + },
394 + "engines": {
395 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
396 + },
397 + "funding": {
398 + "url": "https://opencollective.com/eslint"
399 + }
400 + },
401 + "node_modules/@eslint/js": {
402 + "version": "9.7.0",
403 + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.7.0.tgz",
404 + "integrity": "sha512-ChuWDQenef8OSFnvuxv0TCVxEwmu3+hPNKvM9B34qpM0rDRbjL8t5QkQeHHeAfsKQjuH9wS82WeCi1J/owatng==",
405 + "dev": true,
406 + "license": "MIT",
407 + "engines": {
408 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
409 + }
410 + },
411 + "node_modules/@eslint/object-schema": {
412 + "version": "2.1.7",
413 + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
414 + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
415 + "dev": true,
416 + "license": "Apache-2.0",
417 + "engines": {
418 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
419 + }
420 + },
421 + "node_modules/@humanwhocodes/module-importer": {
422 + "version": "1.0.1",
423 + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
424 + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
425 + "dev": true,
426 + "license": "Apache-2.0",
427 + "engines": {
428 + "node": ">=12.22"
429 + },
430 + "funding": {
431 + "type": "github",
432 + "url": "https://github.com/sponsors/nzakas"
433 + }
434 + },
435 + "node_modules/@humanwhocodes/retry": {
436 + "version": "0.3.1",
437 + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
438 + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
439 + "dev": true,
440 + "license": "Apache-2.0",
441 + "engines": {
442 + "node": ">=18.18"
443 + },
444 + "funding": {
445 + "type": "github",
446 + "url": "https://github.com/sponsors/nzakas"
447 + }
448 + },
449 + "node_modules/@img/colour": {
450 + "version": "1.1.0",
451 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
452 + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
453 + "license": "MIT",
454 + "optional": true,
455 + "engines": {
456 + "node": ">=18"
457 + }
458 + },
459 + "node_modules/@img/sharp-darwin-arm64": {
460 + "version": "0.35.3",
461 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
462 + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
463 + "cpu": [
464 + "arm64"
465 + ],
466 + "license": "Apache-2.0",
467 + "optional": true,
468 + "os": [
469 + "darwin"
470 + ],
471 + "engines": {
472 + "node": ">=20.9.0"
473 + },
474 + "funding": {
475 + "url": "https://opencollective.com/libvips"
476 + },
477 + "optionalDependencies": {
478 + "@img/sharp-libvips-darwin-arm64": "1.3.2"
479 + }
480 + },
481 + "node_modules/@img/sharp-darwin-x64": {
482 + "version": "0.35.3",
483 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
484 + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
485 + "cpu": [
486 + "x64"
487 + ],
488 + "license": "Apache-2.0",
489 + "optional": true,
490 + "os": [
491 + "darwin"
492 + ],
493 + "engines": {
494 + "node": ">=20.9.0"
495 + },
496 + "funding": {
497 + "url": "https://opencollective.com/libvips"
498 + },
499 + "optionalDependencies": {
500 + "@img/sharp-libvips-darwin-x64": "1.3.2"
501 + }
502 + },
503 + "node_modules/@img/sharp-freebsd-wasm32": {
504 + "version": "0.35.3",
505 + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
506 + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
507 + "license": "Apache-2.0",
508 + "optional": true,
509 + "os": [
510 + "freebsd"
511 + ],
512 + "dependencies": {
513 + "@img/sharp-wasm32": "0.35.3"
514 + },
515 + "engines": {
516 + "node": ">=20.9.0"
517 + },
518 + "funding": {
519 + "url": "https://opencollective.com/libvips"
520 + }
521 + },
522 + "node_modules/@img/sharp-libvips-darwin-arm64": {
523 + "version": "1.3.2",
524 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
525 + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
526 + "cpu": [
527 + "arm64"
528 + ],
529 + "license": "LGPL-3.0-or-later",
530 + "optional": true,
531 + "os": [
532 + "darwin"
533 + ],
534 + "funding": {
535 + "url": "https://opencollective.com/libvips"
536 + }
537 + },
538 + "node_modules/@img/sharp-libvips-darwin-x64": {
539 + "version": "1.3.2",
540 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
541 + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
542 + "cpu": [
543 + "x64"
544 + ],
545 + "license": "LGPL-3.0-or-later",
546 + "optional": true,
547 + "os": [
548 + "darwin"
549 + ],
550 + "funding": {
551 + "url": "https://opencollective.com/libvips"
552 + }
553 + },
554 + "node_modules/@img/sharp-libvips-linux-arm": {
555 + "version": "1.3.2",
556 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
557 + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
558 + "cpu": [
559 + "arm"
560 + ],
561 + "license": "LGPL-3.0-or-later",
562 + "optional": true,
563 + "os": [
564 + "linux"
565 + ],
566 + "funding": {
567 + "url": "https://opencollective.com/libvips"
568 + }
569 + },
570 + "node_modules/@img/sharp-libvips-linux-arm64": {
571 + "version": "1.3.2",
572 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
573 + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
574 + "cpu": [
575 + "arm64"
576 + ],
577 + "license": "LGPL-3.0-or-later",
578 + "optional": true,
579 + "os": [
580 + "linux"
581 + ],
582 + "funding": {
583 + "url": "https://opencollective.com/libvips"
584 + }
585 + },
586 + "node_modules/@img/sharp-libvips-linux-ppc64": {
587 + "version": "1.3.2",
588 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
589 + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
590 + "cpu": [
591 + "ppc64"
592 + ],
593 + "license": "LGPL-3.0-or-later",
594 + "optional": true,
595 + "os": [
596 + "linux"
597 + ],
598 + "funding": {
599 + "url": "https://opencollective.com/libvips"
600 + }
601 + },
602 + "node_modules/@img/sharp-libvips-linux-riscv64": {
603 + "version": "1.3.2",
604 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
605 + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
606 + "cpu": [
607 + "riscv64"
608 + ],
609 + "license": "LGPL-3.0-or-later",
610 + "optional": true,
611 + "os": [
612 + "linux"
613 + ],
614 + "funding": {
615 + "url": "https://opencollective.com/libvips"
616 + }
617 + },
618 + "node_modules/@img/sharp-libvips-linux-s390x": {
619 + "version": "1.3.2",
620 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
621 + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
622 + "cpu": [
623 + "s390x"
624 + ],
625 + "license": "LGPL-3.0-or-later",
626 + "optional": true,
627 + "os": [
628 + "linux"
629 + ],
630 + "funding": {
631 + "url": "https://opencollective.com/libvips"
632 + }
633 + },
634 + "node_modules/@img/sharp-libvips-linux-x64": {
635 + "version": "1.3.2",
636 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
637 + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
638 + "cpu": [
639 + "x64"
640 + ],
641 + "license": "LGPL-3.0-or-later",
642 + "optional": true,
643 + "os": [
644 + "linux"
645 + ],
646 + "funding": {
647 + "url": "https://opencollective.com/libvips"
648 + }
649 + },
650 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
651 + "version": "1.3.2",
652 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
653 + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
654 + "cpu": [
655 + "arm64"
656 + ],
657 + "license": "LGPL-3.0-or-later",
658 + "optional": true,
659 + "os": [
660 + "linux"
661 + ],
662 + "funding": {
663 + "url": "https://opencollective.com/libvips"
664 + }
665 + },
666 + "node_modules/@img/sharp-libvips-linuxmusl-x64": {
667 + "version": "1.3.2",
668 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
669 + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
670 + "cpu": [
671 + "x64"
672 + ],
673 + "license": "LGPL-3.0-or-later",
674 + "optional": true,
675 + "os": [
676 + "linux"
677 + ],
678 + "funding": {
679 + "url": "https://opencollective.com/libvips"
680 + }
681 + },
682 + "node_modules/@img/sharp-linux-arm": {
683 + "version": "0.35.3",
684 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
685 + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
686 + "cpu": [
687 + "arm"
688 + ],
689 + "license": "Apache-2.0",
690 + "optional": true,
691 + "os": [
692 + "linux"
693 + ],
694 + "engines": {
695 + "node": ">=20.9.0"
696 + },
697 + "funding": {
698 + "url": "https://opencollective.com/libvips"
699 + },
700 + "optionalDependencies": {
701 + "@img/sharp-libvips-linux-arm": "1.3.2"
702 + }
703 + },
704 + "node_modules/@img/sharp-linux-arm64": {
705 + "version": "0.35.3",
706 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
707 + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
708 + "cpu": [
709 + "arm64"
710 + ],
711 + "license": "Apache-2.0",
712 + "optional": true,
713 + "os": [
714 + "linux"
715 + ],
716 + "engines": {
717 + "node": ">=20.9.0"
718 + },
719 + "funding": {
720 + "url": "https://opencollective.com/libvips"
721 + },
722 + "optionalDependencies": {
723 + "@img/sharp-libvips-linux-arm64": "1.3.2"
724 + }
725 + },
726 + "node_modules/@img/sharp-linux-ppc64": {
727 + "version": "0.35.3",
728 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
729 + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
730 + "cpu": [
731 + "ppc64"
732 + ],
733 + "license": "Apache-2.0",
734 + "optional": true,
735 + "os": [
736 + "linux"
737 + ],
738 + "engines": {
739 + "node": ">=20.9.0"
740 + },
741 + "funding": {
742 + "url": "https://opencollective.com/libvips"
743 + },
744 + "optionalDependencies": {
745 + "@img/sharp-libvips-linux-ppc64": "1.3.2"
746 + }
747 + },
748 + "node_modules/@img/sharp-linux-riscv64": {
749 + "version": "0.35.3",
750 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
751 + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
752 + "cpu": [
753 + "riscv64"
754 + ],
755 + "license": "Apache-2.0",
756 + "optional": true,
757 + "os": [
758 + "linux"
759 + ],
760 + "engines": {
761 + "node": ">=20.9.0"
762 + },
763 + "funding": {
764 + "url": "https://opencollective.com/libvips"
765 + },
766 + "optionalDependencies": {
767 + "@img/sharp-libvips-linux-riscv64": "1.3.2"
768 + }
769 + },
770 + "node_modules/@img/sharp-linux-s390x": {
771 + "version": "0.35.3",
772 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
773 + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
774 + "cpu": [
775 + "s390x"
776 + ],
777 + "license": "Apache-2.0",
778 + "optional": true,
779 + "os": [
780 + "linux"
781 + ],
782 + "engines": {
783 + "node": ">=20.9.0"
784 + },
785 + "funding": {
786 + "url": "https://opencollective.com/libvips"
787 + },
788 + "optionalDependencies": {
789 + "@img/sharp-libvips-linux-s390x": "1.3.2"
790 + }
791 + },
792 + "node_modules/@img/sharp-linux-x64": {
793 + "version": "0.35.3",
794 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
795 + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
796 + "cpu": [
797 + "x64"
798 + ],
799 + "license": "Apache-2.0",
800 + "optional": true,
801 + "os": [
802 + "linux"
803 + ],
804 + "engines": {
805 + "node": ">=20.9.0"
806 + },
807 + "funding": {
808 + "url": "https://opencollective.com/libvips"
809 + },
810 + "optionalDependencies": {
811 + "@img/sharp-libvips-linux-x64": "1.3.2"
812 + }
813 + },
814 + "node_modules/@img/sharp-linuxmusl-arm64": {
815 + "version": "0.35.3",
816 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
817 + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
818 + "cpu": [
819 + "arm64"
820 + ],
821 + "license": "Apache-2.0",
822 + "optional": true,
823 + "os": [
824 + "linux"
825 + ],
826 + "engines": {
827 + "node": ">=20.9.0"
828 + },
829 + "funding": {
830 + "url": "https://opencollective.com/libvips"
831 + },
832 + "optionalDependencies": {
833 + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
834 + }
835 + },
836 + "node_modules/@img/sharp-linuxmusl-x64": {
837 + "version": "0.35.3",
838 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
839 + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
840 + "cpu": [
841 + "x64"
842 + ],
843 + "license": "Apache-2.0",
844 + "optional": true,
845 + "os": [
846 + "linux"
847 + ],
848 + "engines": {
849 + "node": ">=20.9.0"
850 + },
851 + "funding": {
852 + "url": "https://opencollective.com/libvips"
853 + },
854 + "optionalDependencies": {
855 + "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
856 + }
857 + },
858 + "node_modules/@img/sharp-wasm32": {
859 + "version": "0.35.3",
860 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
861 + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
862 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
863 + "optional": true,
864 + "dependencies": {
865 + "@emnapi/runtime": "^1.11.1"
866 + },
867 + "engines": {
868 + "node": ">=20.9.0"
869 + },
870 + "funding": {
871 + "url": "https://opencollective.com/libvips"
872 + }
873 + },
874 + "node_modules/@img/sharp-webcontainers-wasm32": {
875 + "version": "0.35.3",
876 + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
877 + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
878 + "cpu": [
879 + "wasm32"
880 + ],
881 + "license": "Apache-2.0",
882 + "optional": true,
883 + "dependencies": {
884 + "@img/sharp-wasm32": "0.35.3"
885 + },
886 + "engines": {
887 + "node": ">=20.9.0"
888 + },
889 + "funding": {
890 + "url": "https://opencollective.com/libvips"
891 + }
892 + },
893 + "node_modules/@img/sharp-win32-arm64": {
894 + "version": "0.35.3",
895 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
896 + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
897 + "cpu": [
898 + "arm64"
899 + ],
900 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
901 + "optional": true,
902 + "os": [
903 + "win32"
904 + ],
905 + "engines": {
906 + "node": ">=20.9.0"
907 + },
908 + "funding": {
909 + "url": "https://opencollective.com/libvips"
910 + }
911 + },
912 + "node_modules/@img/sharp-win32-ia32": {
913 + "version": "0.35.3",
914 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
915 + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
916 + "cpu": [
917 + "ia32"
918 + ],
919 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
920 + "optional": true,
921 + "os": [
922 + "win32"
923 + ],
924 + "engines": {
925 + "node": "^20.9.0"
926 + },
927 + "funding": {
928 + "url": "https://opencollective.com/libvips"
929 + }
930 + },
931 + "node_modules/@img/sharp-win32-x64": {
932 + "version": "0.35.3",
933 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
934 + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
935 + "cpu": [
936 + "x64"
937 + ],
938 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
939 + "optional": true,
940 + "os": [
941 + "win32"
942 + ],
943 + "engines": {
944 + "node": ">=20.9.0"
945 + },
946 + "funding": {
947 + "url": "https://opencollective.com/libvips"
948 + }
949 + },
950 + "node_modules/@jridgewell/gen-mapping": {
951 + "version": "0.3.13",
952 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
953 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
954 + "dev": true,
955 + "license": "MIT",
956 + "dependencies": {
957 + "@jridgewell/sourcemap-codec": "^1.5.0",
958 + "@jridgewell/trace-mapping": "^0.3.24"
959 + }
960 + },
961 + "node_modules/@jridgewell/remapping": {
962 + "version": "2.3.5",
963 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
964 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
965 + "dev": true,
966 + "license": "MIT",
967 + "dependencies": {
968 + "@jridgewell/gen-mapping": "^0.3.5",
969 + "@jridgewell/trace-mapping": "^0.3.24"
970 + }
971 + },
972 + "node_modules/@jridgewell/resolve-uri": {
973 + "version": "3.1.2",
974 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
975 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
976 + "dev": true,
977 + "license": "MIT",
978 + "engines": {
979 + "node": ">=6.0.0"
980 + }
981 + },
982 + "node_modules/@jridgewell/sourcemap-codec": {
983 + "version": "1.5.5",
984 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
985 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
986 + "dev": true,
987 + "license": "MIT"
988 + },
989 + "node_modules/@jridgewell/trace-mapping": {
990 + "version": "0.3.31",
991 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
992 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
993 + "dev": true,
994 + "license": "MIT",
995 + "dependencies": {
996 + "@jridgewell/resolve-uri": "^3.1.0",
997 + "@jridgewell/sourcemap-codec": "^1.4.14"
998 + }
999 + },
1000 + "node_modules/@napi-rs/wasm-runtime": {
1001 + "version": "1.2.2",
1002 + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
1003 + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
1004 + "dev": true,
1005 + "license": "MIT",
1006 + "optional": true,
1007 + "dependencies": {
1008 + "@tybys/wasm-util": "^0.10.3"
1009 + },
1010 + "engines": {
1011 + "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
1012 + },
1013 + "funding": {
1014 + "type": "github",
1015 + "url": "https://github.com/sponsors/Brooooooklyn"
1016 + },
1017 + "peerDependencies": {
1018 + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
1019 + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
1020 + }
1021 + },
1022 + "node_modules/@next/env": {
1023 + "version": "16.3.0",
1024 + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
1025 + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
1026 + "license": "MIT"
1027 + },
1028 + "node_modules/@next/eslint-plugin-next": {
1029 + "version": "16.3.0",
1030 + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz",
1031 + "integrity": "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==",
1032 + "dev": true,
1033 + "license": "MIT",
1034 + "dependencies": {
1035 + "@eslint-community/eslint-utils": "4.9.1",
1036 + "fast-glob": "3.3.1"
1037 + }
1038 + },
1039 + "node_modules/@next/eslint-plugin-next/node_modules/@eslint-community/eslint-utils": {
1040 + "version": "4.9.1",
1041 + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
1042 + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
1043 + "dev": true,
1044 + "license": "MIT",
1045 + "dependencies": {
1046 + "eslint-visitor-keys": "^3.4.3"
1047 + },
1048 + "engines": {
1049 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
1050 + },
1051 + "funding": {
1052 + "url": "https://opencollective.com/eslint"
1053 + },
1054 + "peerDependencies": {
1055 + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
1056 + }
1057 + },
1058 + "node_modules/@next/swc-darwin-arm64": {
1059 + "version": "16.3.0",
1060 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
1061 + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
1062 + "cpu": [
1063 + "arm64"
1064 + ],
1065 + "license": "MIT",
1066 + "optional": true,
1067 + "os": [
1068 + "darwin"
1069 + ],
1070 + "engines": {
1071 + "node": ">= 10"
1072 + }
1073 + },
1074 + "node_modules/@next/swc-darwin-x64": {
1075 + "version": "16.3.0",
1076 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
1077 + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
1078 + "cpu": [
1079 + "x64"
1080 + ],
1081 + "license": "MIT",
1082 + "optional": true,
1083 + "os": [
1084 + "darwin"
1085 + ],
1086 + "engines": {
1087 + "node": ">= 10"
1088 + }
1089 + },
1090 + "node_modules/@next/swc-linux-arm64-gnu": {
1091 + "version": "16.3.0",
1092 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
1093 + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
1094 + "cpu": [
1095 + "arm64"
1096 + ],
1097 + "license": "MIT",
1098 + "optional": true,
1099 + "os": [
1100 + "linux"
1101 + ],
1102 + "engines": {
1103 + "node": ">= 10"
1104 + }
1105 + },
1106 + "node_modules/@next/swc-linux-arm64-musl": {
1107 + "version": "16.3.0",
1108 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
1109 + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
1110 + "cpu": [
1111 + "arm64"
1112 + ],
1113 + "license": "MIT",
1114 + "optional": true,
1115 + "os": [
1116 + "linux"
1117 + ],
1118 + "engines": {
1119 + "node": ">= 10"
1120 + }
1121 + },
1122 + "node_modules/@next/swc-linux-x64-gnu": {
1123 + "version": "16.3.0",
1124 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
1125 + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
1126 + "cpu": [
1127 + "x64"
1128 + ],
1129 + "license": "MIT",
1130 + "optional": true,
1131 + "os": [
1132 + "linux"
1133 + ],
1134 + "engines": {
1135 + "node": ">= 10"
1136 + }
1137 + },
1138 + "node_modules/@next/swc-linux-x64-musl": {
1139 + "version": "16.3.0",
1140 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
1141 + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
1142 + "cpu": [
1143 + "x64"
1144 + ],
1145 + "license": "MIT",
1146 + "optional": true,
1147 + "os": [
1148 + "linux"
1149 + ],
1150 + "engines": {
1151 + "node": ">= 10"
1152 + }
1153 + },
1154 + "node_modules/@next/swc-win32-arm64-msvc": {
1155 + "version": "16.3.0",
1156 + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
1157 + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
1158 + "cpu": [
1159 + "arm64"
1160 + ],
1161 + "license": "MIT",
1162 + "optional": true,
1163 + "os": [
1164 + "win32"
1165 + ],
1166 + "engines": {
1167 + "node": ">= 10"
1168 + }
1169 + },
1170 + "node_modules/@next/swc-win32-x64-msvc": {
1171 + "version": "16.3.0",
1172 + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
1173 + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
1174 + "cpu": [
1175 + "x64"
1176 + ],
1177 + "license": "MIT",
1178 + "optional": true,
1179 + "os": [
1180 + "win32"
1181 + ],
1182 + "engines": {
1183 + "node": ">= 10"
1184 + }
1185 + },
1186 + "node_modules/@nodelib/fs.scandir": {
1187 + "version": "2.1.5",
1188 + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
1189 + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
1190 + "dev": true,
1191 + "license": "MIT",
1192 + "dependencies": {
1193 + "@nodelib/fs.stat": "2.0.5",
1194 + "run-parallel": "^1.1.9"
1195 + },
1196 + "engines": {
1197 + "node": ">= 8"
1198 + }
1199 + },
1200 + "node_modules/@nodelib/fs.stat": {
1201 + "version": "2.0.5",
1202 + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
1203 + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
1204 + "dev": true,
1205 + "license": "MIT",
1206 + "engines": {
1207 + "node": ">= 8"
1208 + }
1209 + },
1210 + "node_modules/@nodelib/fs.walk": {
1211 + "version": "1.2.8",
1212 + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
1213 + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
1214 + "dev": true,
1215 + "license": "MIT",
1216 + "dependencies": {
1217 + "@nodelib/fs.scandir": "2.1.5",
1218 + "fastq": "^1.6.0"
1219 + },
1220 + "engines": {
1221 + "node": ">= 8"
1222 + }
1223 + },
1224 + "node_modules/@nolyfill/is-core-module": {
1225 + "version": "1.0.39",
1226 + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
1227 + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
1228 + "dev": true,
1229 + "license": "MIT",
1230 + "engines": {
1231 + "node": ">=12.4.0"
1232 + }
1233 + },
1234 + "node_modules/@rtsao/scc": {
1235 + "version": "1.1.0",
1236 + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
1237 + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
1238 + "dev": true,
1239 + "license": "MIT"
1240 + },
1241 + "node_modules/@swc/helpers": {
1242 + "version": "0.5.15",
1243 + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
1244 + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
1245 + "license": "Apache-2.0",
1246 + "dependencies": {
1247 + "tslib": "^2.8.0"
1248 + }
1249 + },
1250 + "node_modules/@tybys/wasm-util": {
1251 + "version": "0.10.3",
1252 + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
1253 + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
1254 + "dev": true,
1255 + "license": "MIT",
1256 + "optional": true,
1257 + "dependencies": {
1258 + "tslib": "^2.4.0"
1259 + }
1260 + },
1261 + "node_modules/@types/json5": {
1262 + "version": "0.0.29",
1263 + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
1264 + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
1265 + "dev": true,
1266 + "license": "MIT"
1267 + },
1268 + "node_modules/@types/node": {
1269 + "version": "22.20.1",
1270 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
1271 + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
1272 + "dev": true,
1273 + "license": "MIT",
1274 + "dependencies": {
1275 + "undici-types": "~6.21.0"
1276 + }
1277 + },
1278 + "node_modules/@types/prop-types": {
1279 + "version": "15.7.15",
1280 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1281 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1282 + "dev": true,
1283 + "license": "MIT"
1284 + },
1285 + "node_modules/@types/react": {
1286 + "version": "18.3.31",
1287 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1288 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1289 + "dev": true,
1290 + "license": "MIT",
1291 + "dependencies": {
1292 + "@types/prop-types": "*",
1293 + "csstype": "^3.2.2"
1294 + }
1295 + },
1296 + "node_modules/@types/react-dom": {
1297 + "version": "18.3.7",
1298 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1299 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1300 + "dev": true,
1301 + "license": "MIT",
1302 + "peerDependencies": {
1303 + "@types/react": "^18.0.0"
1304 + }
1305 + },
1306 + "node_modules/@typescript-eslint/eslint-plugin": {
1307 + "version": "8.66.0",
1308 + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
1309 + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==",
1310 + "dev": true,
1311 + "license": "MIT",
1312 + "dependencies": {
1313 + "@eslint-community/regexpp": "^4.12.2",
1314 + "@typescript-eslint/scope-manager": "8.66.0",
1315 + "@typescript-eslint/type-utils": "8.66.0",
1316 + "@typescript-eslint/utils": "8.66.0",
1317 + "@typescript-eslint/visitor-keys": "8.66.0",
1318 + "ignore": "^7.0.5",
1319 + "natural-compare": "^1.4.0",
1320 + "ts-api-utils": "^2.5.0"
1321 + },
1322 + "engines": {
1323 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1324 + },
1325 + "funding": {
1326 + "type": "opencollective",
1327 + "url": "https://opencollective.com/typescript-eslint"
1328 + },
1329 + "peerDependencies": {
1330 + "@typescript-eslint/parser": "^8.66.0",
1331 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1332 + "typescript": ">=4.8.4 <6.1.0"
1333 + }
1334 + },
1335 + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
1336 + "version": "7.0.6",
1337 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
1338 + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
1339 + "dev": true,
1340 + "license": "MIT",
1341 + "engines": {
1342 + "node": ">= 4"
1343 + }
1344 + },
1345 + "node_modules/@typescript-eslint/parser": {
1346 + "version": "8.66.0",
1347 + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz",
1348 + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
1349 + "dev": true,
1350 + "license": "MIT",
1351 + "dependencies": {
1352 + "@typescript-eslint/scope-manager": "8.66.0",
1353 + "@typescript-eslint/types": "8.66.0",
1354 + "@typescript-eslint/typescript-estree": "8.66.0",
1355 + "@typescript-eslint/visitor-keys": "8.66.0",
1356 + "debug": "^4.4.3"
1357 + },
1358 + "engines": {
1359 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1360 + },
1361 + "funding": {
1362 + "type": "opencollective",
1363 + "url": "https://opencollective.com/typescript-eslint"
1364 + },
1365 + "peerDependencies": {
1366 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1367 + "typescript": ">=4.8.4 <6.1.0"
1368 + }
1369 + },
1370 + "node_modules/@typescript-eslint/project-service": {
1371 + "version": "8.66.0",
1372 + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
1373 + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
1374 + "dev": true,
1375 + "license": "MIT",
1376 + "dependencies": {
1377 + "@typescript-eslint/tsconfig-utils": "^8.66.0",
1378 + "@typescript-eslint/types": "^8.66.0",
1379 + "debug": "^4.4.3"
1380 + },
1381 + "engines": {
1382 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1383 + },
1384 + "funding": {
1385 + "type": "opencollective",
1386 + "url": "https://opencollective.com/typescript-eslint"
1387 + },
1388 + "peerDependencies": {
1389 + "typescript": ">=4.8.4 <6.1.0"
1390 + }
1391 + },
1392 + "node_modules/@typescript-eslint/scope-manager": {
1393 + "version": "8.66.0",
1394 + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
1395 + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
1396 + "dev": true,
1397 + "license": "MIT",
1398 + "dependencies": {
1399 + "@typescript-eslint/types": "8.66.0",
1400 + "@typescript-eslint/visitor-keys": "8.66.0"
1401 + },
1402 + "engines": {
1403 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1404 + },
1405 + "funding": {
1406 + "type": "opencollective",
1407 + "url": "https://opencollective.com/typescript-eslint"
1408 + }
1409 + },
1410 + "node_modules/@typescript-eslint/tsconfig-utils": {
1411 + "version": "8.66.0",
1412 + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
1413 + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
1414 + "dev": true,
1415 + "license": "MIT",
1416 + "engines": {
1417 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1418 + },
1419 + "funding": {
1420 + "type": "opencollective",
1421 + "url": "https://opencollective.com/typescript-eslint"
1422 + },
1423 + "peerDependencies": {
1424 + "typescript": ">=4.8.4 <6.1.0"
1425 + }
1426 + },
1427 + "node_modules/@typescript-eslint/type-utils": {
1428 + "version": "8.66.0",
1429 + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz",
1430 + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==",
1431 + "dev": true,
1432 + "license": "MIT",
1433 + "dependencies": {
1434 + "@typescript-eslint/types": "8.66.0",
1435 + "@typescript-eslint/typescript-estree": "8.66.0",
1436 + "@typescript-eslint/utils": "8.66.0",
1437 + "debug": "^4.4.3",
1438 + "ts-api-utils": "^2.5.0"
1439 + },
1440 + "engines": {
1441 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1442 + },
1443 + "funding": {
1444 + "type": "opencollective",
1445 + "url": "https://opencollective.com/typescript-eslint"
1446 + },
1447 + "peerDependencies": {
1448 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1449 + "typescript": ">=4.8.4 <6.1.0"
1450 + }
1451 + },
1452 + "node_modules/@typescript-eslint/types": {
1453 + "version": "8.66.0",
1454 + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
1455 + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
1456 + "dev": true,
1457 + "license": "MIT",
1458 + "engines": {
1459 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1460 + },
1461 + "funding": {
1462 + "type": "opencollective",
1463 + "url": "https://opencollective.com/typescript-eslint"
1464 + }
1465 + },
1466 + "node_modules/@typescript-eslint/typescript-estree": {
1467 + "version": "8.66.0",
1468 + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
1469 + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
1470 + "dev": true,
1471 + "license": "MIT",
1472 + "dependencies": {
1473 + "@typescript-eslint/project-service": "8.66.0",
1474 + "@typescript-eslint/tsconfig-utils": "8.66.0",
1475 + "@typescript-eslint/types": "8.66.0",
1476 + "@typescript-eslint/visitor-keys": "8.66.0",
1477 + "debug": "^4.4.3",
1478 + "minimatch": "^10.2.2",
1479 + "semver": "^7.7.3",
1480 + "tinyglobby": "^0.2.15",
1481 + "ts-api-utils": "^2.5.0"
1482 + },
1483 + "engines": {
1484 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1485 + },
1486 + "funding": {
1487 + "type": "opencollective",
1488 + "url": "https://opencollective.com/typescript-eslint"
1489 + },
1490 + "peerDependencies": {
1491 + "typescript": ">=4.8.4 <6.1.0"
1492 + }
1493 + },
1494 + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
1495 + "version": "4.0.4",
1496 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
1497 + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
1498 + "dev": true,
1499 + "license": "MIT",
1500 + "engines": {
1501 + "node": "18 || 20 || >=22"
1502 + }
1503 + },
1504 + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
1505 + "version": "5.0.9",
1506 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
1507 + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
1508 + "dev": true,
1509 + "license": "MIT",
1510 + "dependencies": {
1511 + "balanced-match": "^4.0.2"
1512 + },
1513 + "engines": {
1514 + "node": "20 || >=22"
1515 + }
1516 + },
1517 + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
1518 + "version": "10.2.6",
1519 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
1520 + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
1521 + "dev": true,
1522 + "license": "BlueOak-1.0.0",
1523 + "dependencies": {
1524 + "brace-expansion": "^5.0.8"
1525 + },
1526 + "engines": {
1527 + "node": "18 || 20 || >=22"
1528 + },
1529 + "funding": {
1530 + "url": "https://github.com/sponsors/isaacs"
1531 + }
1532 + },
1533 + "node_modules/@typescript-eslint/utils": {
1534 + "version": "8.66.0",
1535 + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
1536 + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
1537 + "dev": true,
1538 + "license": "MIT",
1539 + "dependencies": {
1540 + "@eslint-community/eslint-utils": "^4.9.1",
1541 + "@typescript-eslint/scope-manager": "8.66.0",
1542 + "@typescript-eslint/types": "8.66.0",
1543 + "@typescript-eslint/typescript-estree": "8.66.0"
1544 + },
1545 + "engines": {
1546 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1547 + },
1548 + "funding": {
1549 + "type": "opencollective",
1550 + "url": "https://opencollective.com/typescript-eslint"
1551 + },
1552 + "peerDependencies": {
1553 + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1554 + "typescript": ">=4.8.4 <6.1.0"
1555 + }
1556 + },
1557 + "node_modules/@typescript-eslint/visitor-keys": {
1558 + "version": "8.66.0",
1559 + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
1560 + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
1561 + "dev": true,
1562 + "license": "MIT",
1563 + "dependencies": {
1564 + "@typescript-eslint/types": "8.66.0",
1565 + "eslint-visitor-keys": "^5.0.0"
1566 + },
1567 + "engines": {
1568 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1569 + },
1570 + "funding": {
1571 + "type": "opencollective",
1572 + "url": "https://opencollective.com/typescript-eslint"
1573 + }
1574 + },
1575 + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
1576 + "version": "5.0.1",
1577 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
1578 + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
1579 + "dev": true,
1580 + "license": "Apache-2.0",
1581 + "engines": {
1582 + "node": "^20.19.0 || ^22.13.0 || >=24"
1583 + },
1584 + "funding": {
1585 + "url": "https://opencollective.com/eslint"
1586 + }
1587 + },
1588 + "node_modules/@unrs/resolver-binding-android-arm-eabi": {
1589 + "version": "1.12.2",
1590 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
1591 + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==",
1592 + "cpu": [
1593 + "arm"
1594 + ],
1595 + "dev": true,
1596 + "license": "MIT",
1597 + "optional": true,
1598 + "os": [
1599 + "android"
1600 + ]
1601 + },
1602 + "node_modules/@unrs/resolver-binding-android-arm64": {
1603 + "version": "1.12.2",
1604 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz",
1605 + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==",
1606 + "cpu": [
1607 + "arm64"
1608 + ],
1609 + "dev": true,
1610 + "license": "MIT",
1611 + "optional": true,
1612 + "os": [
1613 + "android"
1614 + ]
1615 + },
1616 + "node_modules/@unrs/resolver-binding-darwin-arm64": {
1617 + "version": "1.12.2",
1618 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz",
1619 + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==",
1620 + "cpu": [
1621 + "arm64"
1622 + ],
1623 + "dev": true,
1624 + "license": "MIT",
1625 + "optional": true,
1626 + "os": [
1627 + "darwin"
1628 + ]
1629 + },
1630 + "node_modules/@unrs/resolver-binding-darwin-x64": {
1631 + "version": "1.12.2",
1632 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz",
1633 + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==",
1634 + "cpu": [
1635 + "x64"
1636 + ],
1637 + "dev": true,
1638 + "license": "MIT",
1639 + "optional": true,
1640 + "os": [
1641 + "darwin"
1642 + ]
1643 + },
1644 + "node_modules/@unrs/resolver-binding-freebsd-x64": {
1645 + "version": "1.12.2",
1646 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz",
1647 + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==",
1648 + "cpu": [
1649 + "x64"
1650 + ],
1651 + "dev": true,
1652 + "license": "MIT",
1653 + "optional": true,
1654 + "os": [
1655 + "freebsd"
1656 + ]
1657 + },
1658 + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
1659 + "version": "1.12.2",
1660 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz",
1661 + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==",
1662 + "cpu": [
1663 + "arm"
1664 + ],
1665 + "dev": true,
1666 + "license": "MIT",
1667 + "optional": true,
1668 + "os": [
1669 + "linux"
1670 + ]
1671 + },
1672 + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
1673 + "version": "1.12.2",
1674 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz",
1675 + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==",
1676 + "cpu": [
1677 + "arm"
1678 + ],
1679 + "dev": true,
1680 + "license": "MIT",
1681 + "optional": true,
1682 + "os": [
1683 + "linux"
1684 + ]
1685 + },
1686 + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
1687 + "version": "1.12.2",
1688 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz",
1689 + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==",
1690 + "cpu": [
1691 + "arm64"
1692 + ],
1693 + "dev": true,
1694 + "license": "MIT",
1695 + "optional": true,
1696 + "os": [
1697 + "linux"
1698 + ]
1699 + },
1700 + "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
1701 + "version": "1.12.2",
1702 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz",
1703 + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==",
1704 + "cpu": [
1705 + "arm64"
1706 + ],
1707 + "dev": true,
1708 + "license": "MIT",
1709 + "optional": true,
1710 + "os": [
1711 + "linux"
1712 + ]
1713 + },
1714 + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": {
1715 + "version": "1.12.2",
1716 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz",
1717 + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==",
1718 + "cpu": [
1719 + "loong64"
1720 + ],
1721 + "dev": true,
1722 + "license": "MIT",
1723 + "optional": true,
1724 + "os": [
1725 + "linux"
1726 + ]
1727 + },
1728 + "node_modules/@unrs/resolver-binding-linux-loong64-musl": {
1729 + "version": "1.12.2",
1730 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz",
1731 + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==",
1732 + "cpu": [
1733 + "loong64"
1734 + ],
1735 + "dev": true,
1736 + "license": "MIT",
1737 + "optional": true,
1738 + "os": [
1739 + "linux"
1740 + ]
1741 + },
1742 + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
1743 + "version": "1.12.2",
1744 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz",
1745 + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==",
1746 + "cpu": [
1747 + "ppc64"
1748 + ],
1749 + "dev": true,
1750 + "license": "MIT",
1751 + "optional": true,
1752 + "os": [
1753 + "linux"
1754 + ]
1755 + },
1756 + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
1757 + "version": "1.12.2",
1758 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz",
1759 + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==",
1760 + "cpu": [
1761 + "riscv64"
1762 + ],
1763 + "dev": true,
1764 + "license": "MIT",
1765 + "optional": true,
1766 + "os": [
1767 + "linux"
1768 + ]
1769 + },
1770 + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
1771 + "version": "1.12.2",
1772 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz",
1773 + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==",
1774 + "cpu": [
1775 + "riscv64"
1776 + ],
1777 + "dev": true,
1778 + "license": "MIT",
1779 + "optional": true,
1780 + "os": [
1781 + "linux"
1782 + ]
1783 + },
1784 + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
1785 + "version": "1.12.2",
1786 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz",
1787 + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==",
1788 + "cpu": [
1789 + "s390x"
1790 + ],
1791 + "dev": true,
1792 + "license": "MIT",
1793 + "optional": true,
1794 + "os": [
1795 + "linux"
1796 + ]
1797 + },
1798 + "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
1799 + "version": "1.12.2",
1800 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz",
1801 + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==",
1802 + "cpu": [
1803 + "x64"
1804 + ],
1805 + "dev": true,
1806 + "license": "MIT",
1807 + "optional": true,
1808 + "os": [
1809 + "linux"
1810 + ]
1811 + },
1812 + "node_modules/@unrs/resolver-binding-linux-x64-musl": {
1813 + "version": "1.12.2",
1814 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz",
1815 + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==",
1816 + "cpu": [
1817 + "x64"
1818 + ],
1819 + "dev": true,
1820 + "license": "MIT",
1821 + "optional": true,
1822 + "os": [
1823 + "linux"
1824 + ]
1825 + },
1826 + "node_modules/@unrs/resolver-binding-openharmony-arm64": {
1827 + "version": "1.12.2",
1828 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
1829 + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
1830 + "cpu": [
1831 + "arm64"
1832 + ],
1833 + "dev": true,
1834 + "license": "MIT",
1835 + "optional": true,
1836 + "os": [
1837 + "openharmony"
1838 + ]
1839 + },
1840 + "node_modules/@unrs/resolver-binding-wasm32-wasi": {
1841 + "version": "1.12.2",
1842 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
1843 + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==",
1844 + "cpu": [
1845 + "wasm32"
1846 + ],
1847 + "dev": true,
1848 + "license": "MIT",
1849 + "optional": true,
1850 + "dependencies": {
1851 + "@emnapi/core": "1.10.0",
1852 + "@emnapi/runtime": "1.10.0",
1853 + "@napi-rs/wasm-runtime": "^1.1.4"
1854 + },
1855 + "engines": {
1856 + "node": ">=14.0.0"
1857 + }
1858 + },
1859 + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
1860 + "version": "1.10.0",
1861 + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
1862 + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
1863 + "dev": true,
1864 + "license": "MIT",
1865 + "optional": true,
1866 + "dependencies": {
1867 + "@emnapi/wasi-threads": "1.2.1",
1868 + "tslib": "^2.4.0"
1869 + }
1870 + },
1871 + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
1872 + "version": "1.10.0",
1873 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
1874 + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
1875 + "dev": true,
1876 + "license": "MIT",
1877 + "optional": true,
1878 + "dependencies": {
1879 + "tslib": "^2.4.0"
1880 + }
1881 + },
1882 + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
1883 + "version": "1.2.1",
1884 + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
1885 + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
1886 + "dev": true,
1887 + "license": "MIT",
1888 + "optional": true,
1889 + "dependencies": {
1890 + "tslib": "^2.4.0"
1891 + }
1892 + },
1893 + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
1894 + "version": "1.12.2",
1895 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
1896 + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==",
1897 + "cpu": [
1898 + "arm64"
1899 + ],
1900 + "dev": true,
1901 + "license": "MIT",
1902 + "optional": true,
1903 + "os": [
1904 + "win32"
1905 + ]
1906 + },
1907 + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
1908 + "version": "1.12.2",
1909 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
1910 + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
1911 + "cpu": [
1912 + "ia32"
1913 + ],
1914 + "dev": true,
1915 + "license": "MIT",
1916 + "optional": true,
1917 + "os": [
1918 + "win32"
1919 + ]
1920 + },
1921 + "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
1922 + "version": "1.12.2",
1923 + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
1924 + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
1925 + "cpu": [
1926 + "x64"
1927 + ],
1928 + "dev": true,
1929 + "license": "MIT",
1930 + "optional": true,
1931 + "os": [
1932 + "win32"
1933 + ]
1934 + },
1935 + "node_modules/acorn": {
1936 + "version": "8.18.0",
1937 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
1938 + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
1939 + "dev": true,
1940 + "license": "MIT",
1941 + "bin": {
1942 + "acorn": "bin/acorn"
1943 + },
1944 + "engines": {
1945 + "node": ">=0.4.0"
1946 + }
1947 + },
1948 + "node_modules/acorn-jsx": {
1949 + "version": "5.3.2",
1950 + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
1951 + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
1952 + "dev": true,
1953 + "license": "MIT",
1954 + "peerDependencies": {
1955 + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
1956 + }
1957 + },
1958 + "node_modules/ajv": {
1959 + "version": "6.15.0",
1960 + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
1961 + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
1962 + "dev": true,
1963 + "license": "MIT",
1964 + "dependencies": {
1965 + "fast-deep-equal": "^3.1.1",
1966 + "fast-json-stable-stringify": "^2.0.0",
1967 + "json-schema-traverse": "^0.4.1",
1968 + "uri-js": "^4.2.2"
1969 + },
1970 + "funding": {
1971 + "type": "github",
1972 + "url": "https://github.com/sponsors/epoberezkin"
1973 + }
1974 + },
1975 + "node_modules/ansi-regex": {
1976 + "version": "5.0.1",
1977 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
1978 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1979 + "dev": true,
1980 + "license": "MIT",
1981 + "engines": {
1982 + "node": ">=8"
1983 + }
1984 + },
1985 + "node_modules/ansi-styles": {
1986 + "version": "4.3.0",
1987 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
1988 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
1989 + "dev": true,
1990 + "license": "MIT",
1991 + "dependencies": {
1992 + "color-convert": "^2.0.1"
1993 + },
1994 + "engines": {
1995 + "node": ">=8"
1996 + },
1997 + "funding": {
1998 + "url": "https://github.com/chalk/ansi-styles?sponsor=1"
1999 + }
2000 + },
2001 + "node_modules/argparse": {
2002 + "version": "2.0.1",
2003 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
2004 + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
2005 + "dev": true,
2006 + "license": "Python-2.0"
2007 + },
2008 + "node_modules/aria-query": {
2009 + "version": "5.3.2",
2010 + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
2011 + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
2012 + "dev": true,
2013 + "license": "Apache-2.0",
2014 + "engines": {
2015 + "node": ">= 0.4"
2016 + }
2017 + },
2018 + "node_modules/array-buffer-byte-length": {
2019 + "version": "1.0.2",
2020 + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
2021 + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
2022 + "dev": true,
2023 + "license": "MIT",
2024 + "dependencies": {
2025 + "call-bound": "^1.0.3",
2026 + "is-array-buffer": "^3.0.5"
2027 + },
2028 + "engines": {
2029 + "node": ">= 0.4"
2030 + },
2031 + "funding": {
2032 + "url": "https://github.com/sponsors/ljharb"
2033 + }
2034 + },
2035 + "node_modules/array-includes": {
2036 + "version": "3.1.9",
2037 + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
2038 + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
2039 + "dev": true,
2040 + "license": "MIT",
2041 + "dependencies": {
2042 + "call-bind": "^1.0.8",
2043 + "call-bound": "^1.0.4",
2044 + "define-properties": "^1.2.1",
2045 + "es-abstract": "^1.24.0",
2046 + "es-object-atoms": "^1.1.1",
2047 + "get-intrinsic": "^1.3.0",
2048 + "is-string": "^1.1.1",
2049 + "math-intrinsics": "^1.1.0"
2050 + },
2051 + "engines": {
2052 + "node": ">= 0.4"
2053 + },
2054 + "funding": {
2055 + "url": "https://github.com/sponsors/ljharb"
2056 + }
2057 + },
2058 + "node_modules/array.prototype.findlast": {
2059 + "version": "1.2.5",
2060 + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
2061 + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
2062 + "dev": true,
2063 + "license": "MIT",
2064 + "dependencies": {
2065 + "call-bind": "^1.0.7",
2066 + "define-properties": "^1.2.1",
2067 + "es-abstract": "^1.23.2",
2068 + "es-errors": "^1.3.0",
2069 + "es-object-atoms": "^1.0.0",
2070 + "es-shim-unscopables": "^1.0.2"
2071 + },
2072 + "engines": {
2073 + "node": ">= 0.4"
2074 + },
2075 + "funding": {
2076 + "url": "https://github.com/sponsors/ljharb"
2077 + }
2078 + },
2079 + "node_modules/array.prototype.findlastindex": {
2080 + "version": "1.2.6",
2081 + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
2082 + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
2083 + "dev": true,
2084 + "license": "MIT",
2085 + "dependencies": {
2086 + "call-bind": "^1.0.8",
2087 + "call-bound": "^1.0.4",
2088 + "define-properties": "^1.2.1",
2089 + "es-abstract": "^1.23.9",
2090 + "es-errors": "^1.3.0",
2091 + "es-object-atoms": "^1.1.1",
2092 + "es-shim-unscopables": "^1.1.0"
2093 + },
2094 + "engines": {
2095 + "node": ">= 0.4"
2096 + },
2097 + "funding": {
2098 + "url": "https://github.com/sponsors/ljharb"
2099 + }
2100 + },
2101 + "node_modules/array.prototype.flat": {
2102 + "version": "1.3.3",
2103 + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
2104 + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
2105 + "dev": true,
2106 + "license": "MIT",
2107 + "dependencies": {
2108 + "call-bind": "^1.0.8",
2109 + "define-properties": "^1.2.1",
2110 + "es-abstract": "^1.23.5",
2111 + "es-shim-unscopables": "^1.0.2"
2112 + },
2113 + "engines": {
2114 + "node": ">= 0.4"
2115 + },
2116 + "funding": {
2117 + "url": "https://github.com/sponsors/ljharb"
2118 + }
2119 + },
2120 + "node_modules/array.prototype.flatmap": {
2121 + "version": "1.3.3",
2122 + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
2123 + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
2124 + "dev": true,
2125 + "license": "MIT",
2126 + "dependencies": {
2127 + "call-bind": "^1.0.8",
2128 + "define-properties": "^1.2.1",
2129 + "es-abstract": "^1.23.5",
2130 + "es-shim-unscopables": "^1.0.2"
2131 + },
2132 + "engines": {
2133 + "node": ">= 0.4"
2134 + },
2135 + "funding": {
2136 + "url": "https://github.com/sponsors/ljharb"
2137 + }
2138 + },
2139 + "node_modules/array.prototype.tosorted": {
2140 + "version": "1.1.4",
2141 + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
2142 + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
2143 + "dev": true,
2144 + "license": "MIT",
2145 + "dependencies": {
2146 + "call-bind": "^1.0.7",
2147 + "define-properties": "^1.2.1",
2148 + "es-abstract": "^1.23.3",
2149 + "es-errors": "^1.3.0",
2150 + "es-shim-unscopables": "^1.0.2"
2151 + },
2152 + "engines": {
2153 + "node": ">= 0.4"
2154 + }
2155 + },
2156 + "node_modules/arraybuffer.prototype.slice": {
2157 + "version": "1.0.4",
2158 + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
2159 + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
2160 + "dev": true,
2161 + "license": "MIT",
2162 + "dependencies": {
2163 + "array-buffer-byte-length": "^1.0.1",
2164 + "call-bind": "^1.0.8",
2165 + "define-properties": "^1.2.1",
2166 + "es-abstract": "^1.23.5",
2167 + "es-errors": "^1.3.0",
2168 + "get-intrinsic": "^1.2.6",
2169 + "is-array-buffer": "^3.0.4"
2170 + },
2171 + "engines": {
2172 + "node": ">= 0.4"
2173 + },
2174 + "funding": {
2175 + "url": "https://github.com/sponsors/ljharb"
2176 + }
2177 + },
2178 + "node_modules/ast-types-flow": {
2179 + "version": "0.0.8",
2180 + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
2181 + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
2182 + "dev": true,
2183 + "license": "MIT"
2184 + },
2185 + "node_modules/async-function": {
2186 + "version": "1.0.0",
2187 + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
2188 + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
2189 + "dev": true,
2190 + "license": "MIT",
2191 + "engines": {
2192 + "node": ">= 0.4"
2193 + }
2194 + },
2195 + "node_modules/available-typed-arrays": {
2196 + "version": "1.0.7",
2197 + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
2198 + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
2199 + "dev": true,
2200 + "license": "MIT",
2201 + "dependencies": {
2202 + "possible-typed-array-names": "^1.0.0"
2203 + },
2204 + "engines": {
2205 + "node": ">= 0.4"
2206 + },
2207 + "funding": {
2208 + "url": "https://github.com/sponsors/ljharb"
2209 + }
2210 + },
2211 + "node_modules/axe-core": {
2212 + "version": "4.13.0",
2213 + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
2214 + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
2215 + "dev": true,
2216 + "license": "MPL-2.0",
2217 + "engines": {
2218 + "node": ">=4"
2219 + }
2220 + },
2221 + "node_modules/axobject-query": {
2222 + "version": "4.1.0",
2223 + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
2224 + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
2225 + "dev": true,
2226 + "license": "Apache-2.0",
2227 + "engines": {
2228 + "node": ">= 0.4"
2229 + }
2230 + },
2231 + "node_modules/balanced-match": {
2232 + "version": "1.0.2",
2233 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
2234 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
2235 + "dev": true,
2236 + "license": "MIT"
2237 + },
2238 + "node_modules/baseline-browser-mapping": {
2239 + "version": "2.11.13",
2240 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
2241 + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
2242 + "license": "Apache-2.0",
2243 + "bin": {
2244 + "baseline-browser-mapping": "dist/cli.cjs"
2245 + },
2246 + "engines": {
2247 + "node": ">=6.0.0"
2248 + }
2249 + },
2250 + "node_modules/brace-expansion": {
2251 + "version": "1.1.18",
2252 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
2253 + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
2254 + "dev": true,
2255 + "license": "MIT",
2256 + "dependencies": {
2257 + "balanced-match": "^1.0.0",
2258 + "concat-map": "0.0.1"
2259 + }
2260 + },
2261 + "node_modules/braces": {
2262 + "version": "3.0.3",
2263 + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
2264 + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
2265 + "dev": true,
2266 + "license": "MIT",
2267 + "dependencies": {
2268 + "fill-range": "^7.1.1"
2269 + },
2270 + "engines": {
2271 + "node": ">=8"
2272 + }
2273 + },
2274 + "node_modules/browserslist": {
2275 + "version": "4.28.8",
2276 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
2277 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
2278 + "dev": true,
2279 + "funding": [
2280 + {
2281 + "type": "opencollective",
2282 + "url": "https://opencollective.com/browserslist"
2283 + },
2284 + {
2285 + "type": "tidelift",
2286 + "url": "https://tidelift.com/funding/github/npm/browserslist"
2287 + },
2288 + {
2289 + "type": "github",
2290 + "url": "https://github.com/sponsors/ai"
2291 + }
2292 + ],
2293 + "license": "MIT",
2294 + "dependencies": {
2295 + "baseline-browser-mapping": "^2.11.12",
2296 + "caniuse-lite": "^1.0.30001809",
2297 + "electron-to-chromium": "^1.5.402",
2298 + "node-releases": "^2.0.53",
2299 + "update-browserslist-db": "^1.3.0"
2300 + },
2301 + "bin": {
2302 + "browserslist": "cli.js"
2303 + },
2304 + "engines": {
2305 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
2306 + }
2307 + },
2308 + "node_modules/call-bind": {
2309 + "version": "1.0.9",
2310 + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
2311 + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
2312 + "dev": true,
2313 + "license": "MIT",
2314 + "dependencies": {
2315 + "call-bind-apply-helpers": "^1.0.2",
2316 + "es-define-property": "^1.0.1",
2317 + "get-intrinsic": "^1.3.0",
2318 + "set-function-length": "^1.2.2"
2319 + },
2320 + "engines": {
2321 + "node": ">= 0.4"
2322 + },
2323 + "funding": {
2324 + "url": "https://github.com/sponsors/ljharb"
2325 + }
2326 + },
2327 + "node_modules/call-bind-apply-helpers": {
2328 + "version": "1.0.2",
2329 + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
2330 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
2331 + "dev": true,
2332 + "license": "MIT",
2333 + "dependencies": {
2334 + "es-errors": "^1.3.0",
2335 + "function-bind": "^1.1.2"
2336 + },
2337 + "engines": {
2338 + "node": ">= 0.4"
2339 + }
2340 + },
2341 + "node_modules/call-bound": {
2342 + "version": "1.0.4",
2343 + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
2344 + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
2345 + "dev": true,
2346 + "license": "MIT",
2347 + "dependencies": {
2348 + "call-bind-apply-helpers": "^1.0.2",
2349 + "get-intrinsic": "^1.3.0"
2350 + },
2351 + "engines": {
2352 + "node": ">= 0.4"
2353 + },
2354 + "funding": {
2355 + "url": "https://github.com/sponsors/ljharb"
2356 + }
2357 + },
2358 + "node_modules/callsites": {
2359 + "version": "3.1.0",
2360 + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
2361 + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
2362 + "dev": true,
2363 + "license": "MIT",
2364 + "engines": {
2365 + "node": ">=6"
2366 + }
2367 + },
2368 + "node_modules/caniuse-lite": {
2369 + "version": "1.0.30001809",
2370 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
2371 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
2372 + "funding": [
2373 + {
2374 + "type": "opencollective",
2375 + "url": "https://opencollective.com/browserslist"
2376 + },
2377 + {
2378 + "type": "tidelift",
2379 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
2380 + },
2381 + {
2382 + "type": "github",
2383 + "url": "https://github.com/sponsors/ai"
2384 + }
2385 + ],
2386 + "license": "CC-BY-4.0"
2387 + },
2388 + "node_modules/chalk": {
2389 + "version": "4.1.2",
2390 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
2391 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
2392 + "dev": true,
2393 + "license": "MIT",
2394 + "dependencies": {
2395 + "ansi-styles": "^4.1.0",
2396 + "supports-color": "^7.1.0"
2397 + },
2398 + "engines": {
2399 + "node": ">=10"
2400 + },
2401 + "funding": {
2402 + "url": "https://github.com/chalk/chalk?sponsor=1"
2403 + }
2404 + },
2405 + "node_modules/client-only": {
2406 + "version": "0.0.1",
2407 + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
2408 + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
2409 + "license": "MIT"
2410 + },
2411 + "node_modules/color-convert": {
2412 + "version": "2.0.1",
2413 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
2414 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
2415 + "dev": true,
2416 + "license": "MIT",
2417 + "dependencies": {
2418 + "color-name": "~1.1.4"
2419 + },
2420 + "engines": {
2421 + "node": ">=7.0.0"
2422 + }
2423 + },
2424 + "node_modules/color-name": {
2425 + "version": "1.1.4",
2426 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
2427 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
2428 + "dev": true,
2429 + "license": "MIT"
2430 + },
2431 + "node_modules/concat-map": {
2432 + "version": "0.0.1",
2433 + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
2434 + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
2435 + "dev": true,
2436 + "license": "MIT"
2437 + },
2438 + "node_modules/convert-source-map": {
2439 + "version": "2.0.0",
2440 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
2441 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
2442 + "dev": true,
2443 + "license": "MIT"
2444 + },
2445 + "node_modules/cross-spawn": {
2446 + "version": "7.0.6",
2447 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
2448 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
2449 + "dev": true,
2450 + "license": "MIT",
2451 + "dependencies": {
2452 + "path-key": "^3.1.0",
2453 + "shebang-command": "^2.0.0",
2454 + "which": "^2.0.1"
2455 + },
2456 + "engines": {
2457 + "node": ">= 8"
2458 + }
2459 + },
2460 + "node_modules/csstype": {
2461 + "version": "3.2.3",
2462 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
2463 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2464 + "dev": true,
2465 + "license": "MIT"
2466 + },
2467 + "node_modules/damerau-levenshtein": {
2468 + "version": "1.0.8",
2469 + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
2470 + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
2471 + "dev": true,
2472 + "license": "BSD-2-Clause"
2473 + },
2474 + "node_modules/data-view-buffer": {
2475 + "version": "1.0.2",
2476 + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
2477 + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
2478 + "dev": true,
2479 + "license": "MIT",
2480 + "dependencies": {
2481 + "call-bound": "^1.0.3",
2482 + "es-errors": "^1.3.0",
2483 + "is-data-view": "^1.0.2"
2484 + },
2485 + "engines": {
2486 + "node": ">= 0.4"
2487 + },
2488 + "funding": {
2489 + "url": "https://github.com/sponsors/ljharb"
2490 + }
2491 + },
2492 + "node_modules/data-view-byte-length": {
2493 + "version": "1.0.2",
2494 + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
2495 + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
2496 + "dev": true,
2497 + "license": "MIT",
2498 + "dependencies": {
2499 + "call-bound": "^1.0.3",
2500 + "es-errors": "^1.3.0",
2501 + "is-data-view": "^1.0.2"
2502 + },
2503 + "engines": {
2504 + "node": ">= 0.4"
2505 + },
2506 + "funding": {
2507 + "url": "https://github.com/sponsors/inspect-js"
2508 + }
2509 + },
2510 + "node_modules/data-view-byte-offset": {
2511 + "version": "1.0.1",
2512 + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
2513 + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
2514 + "dev": true,
2515 + "license": "MIT",
2516 + "dependencies": {
2517 + "call-bound": "^1.0.2",
2518 + "es-errors": "^1.3.0",
2519 + "is-data-view": "^1.0.1"
2520 + },
2521 + "engines": {
2522 + "node": ">= 0.4"
2523 + },
2524 + "funding": {
2525 + "url": "https://github.com/sponsors/ljharb"
2526 + }
2527 + },
2528 + "node_modules/debug": {
2529 + "version": "4.4.3",
2530 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
2531 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
2532 + "dev": true,
2533 + "license": "MIT",
2534 + "dependencies": {
2535 + "ms": "^2.1.3"
2536 + },
2537 + "engines": {
2538 + "node": ">=6.0"
2539 + },
2540 + "peerDependenciesMeta": {
2541 + "supports-color": {
2542 + "optional": true
2543 + }
2544 + }
2545 + },
2546 + "node_modules/deep-is": {
2547 + "version": "0.1.4",
2548 + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
2549 + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
2550 + "dev": true,
2551 + "license": "MIT"
2552 + },
2553 + "node_modules/define-data-property": {
2554 + "version": "1.1.4",
2555 + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
2556 + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
2557 + "dev": true,
2558 + "license": "MIT",
2559 + "dependencies": {
2560 + "es-define-property": "^1.0.0",
2561 + "es-errors": "^1.3.0",
2562 + "gopd": "^1.0.1"
2563 + },
2564 + "engines": {
2565 + "node": ">= 0.4"
2566 + },
2567 + "funding": {
2568 + "url": "https://github.com/sponsors/ljharb"
2569 + }
2570 + },
2571 + "node_modules/define-properties": {
2572 + "version": "1.2.1",
2573 + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
2574 + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
2575 + "dev": true,
2576 + "license": "MIT",
2577 + "dependencies": {
2578 + "define-data-property": "^1.0.1",
2579 + "has-property-descriptors": "^1.0.0",
2580 + "object-keys": "^1.1.1"
2581 + },
2582 + "engines": {
2583 + "node": ">= 0.4"
2584 + },
2585 + "funding": {
2586 + "url": "https://github.com/sponsors/ljharb"
2587 + }
2588 + },
2589 + "node_modules/detect-libc": {
2590 + "version": "2.1.2",
2591 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
2592 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
2593 + "license": "Apache-2.0",
2594 + "optional": true,
2595 + "engines": {
2596 + "node": ">=8"
2597 + }
2598 + },
2599 + "node_modules/doctrine": {
2600 + "version": "2.1.0",
2601 + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
2602 + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
2603 + "dev": true,
2604 + "license": "Apache-2.0",
2605 + "dependencies": {
2606 + "esutils": "^2.0.2"
2607 + },
2608 + "engines": {
2609 + "node": ">=0.10.0"
2610 + }
2611 + },
2612 + "node_modules/dunder-proto": {
2613 + "version": "1.0.1",
2614 + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
2615 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
2616 + "dev": true,
2617 + "license": "MIT",
2618 + "dependencies": {
2619 + "call-bind-apply-helpers": "^1.0.1",
2620 + "es-errors": "^1.3.0",
2621 + "gopd": "^1.2.0"
2622 + },
2623 + "engines": {
2624 + "node": ">= 0.4"
2625 + }
2626 + },
2627 + "node_modules/electron-to-chromium": {
2628 + "version": "1.5.403",
2629 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz",
2630 + "integrity": "sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==",
2631 + "dev": true,
2632 + "license": "ISC"
2633 + },
2634 + "node_modules/emoji-regex": {
2635 + "version": "9.2.2",
2636 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
2637 + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
2638 + "dev": true,
2639 + "license": "MIT"
2640 + },
2641 + "node_modules/es-abstract": {
2642 + "version": "1.24.2",
2643 + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
2644 + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
2645 + "dev": true,
2646 + "license": "MIT",
2647 + "dependencies": {
2648 + "array-buffer-byte-length": "^1.0.2",
2649 + "arraybuffer.prototype.slice": "^1.0.4",
2650 + "available-typed-arrays": "^1.0.7",
2651 + "call-bind": "^1.0.8",
2652 + "call-bound": "^1.0.4",
2653 + "data-view-buffer": "^1.0.2",
2654 + "data-view-byte-length": "^1.0.2",
2655 + "data-view-byte-offset": "^1.0.1",
2656 + "es-define-property": "^1.0.1",
2657 + "es-errors": "^1.3.0",
2658 + "es-object-atoms": "^1.1.1",
2659 + "es-set-tostringtag": "^2.1.0",
2660 + "es-to-primitive": "^1.3.0",
2661 + "function.prototype.name": "^1.1.8",
2662 + "get-intrinsic": "^1.3.0",
2663 + "get-proto": "^1.0.1",
2664 + "get-symbol-description": "^1.1.0",
2665 + "globalthis": "^1.0.4",
2666 + "gopd": "^1.2.0",
2667 + "has-property-descriptors": "^1.0.2",
2668 + "has-proto": "^1.2.0",
2669 + "has-symbols": "^1.1.0",
2670 + "hasown": "^2.0.2",
2671 + "internal-slot": "^1.1.0",
2672 + "is-array-buffer": "^3.0.5",
2673 + "is-callable": "^1.2.7",
2674 + "is-data-view": "^1.0.2",
2675 + "is-negative-zero": "^2.0.3",
2676 + "is-regex": "^1.2.1",
2677 + "is-set": "^2.0.3",
2678 + "is-shared-array-buffer": "^1.0.4",
2679 + "is-string": "^1.1.1",
2680 + "is-typed-array": "^1.1.15",
2681 + "is-weakref": "^1.1.1",
2682 + "math-intrinsics": "^1.1.0",
2683 + "object-inspect": "^1.13.4",
2684 + "object-keys": "^1.1.1",
2685 + "object.assign": "^4.1.7",
2686 + "own-keys": "^1.0.1",
2687 + "regexp.prototype.flags": "^1.5.4",
2688 + "safe-array-concat": "^1.1.3",
2689 + "safe-push-apply": "^1.0.0",
2690 + "safe-regex-test": "^1.1.0",
2691 + "set-proto": "^1.0.0",
2692 + "stop-iteration-iterator": "^1.1.0",
2693 + "string.prototype.trim": "^1.2.10",
2694 + "string.prototype.trimend": "^1.0.9",
2695 + "string.prototype.trimstart": "^1.0.8",
2696 + "typed-array-buffer": "^1.0.3",
2697 + "typed-array-byte-length": "^1.0.3",
2698 + "typed-array-byte-offset": "^1.0.4",
2699 + "typed-array-length": "^1.0.7",
2700 + "unbox-primitive": "^1.1.0",
2701 + "which-typed-array": "^1.1.19"
2702 + },
2703 + "engines": {
2704 + "node": ">= 0.4"
2705 + },
2706 + "funding": {
2707 + "url": "https://github.com/sponsors/ljharb"
2708 + }
2709 + },
2710 + "node_modules/es-abstract-get": {
2711 + "version": "1.0.0",
2712 + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz",
2713 + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==",
2714 + "dev": true,
2715 + "license": "MIT",
2716 + "dependencies": {
2717 + "es-errors": "^1.3.0",
2718 + "es-object-atoms": "^1.1.2",
2719 + "is-callable": "^1.2.7",
2720 + "object-inspect": "^1.13.4"
2721 + },
2722 + "engines": {
2723 + "node": ">= 0.4"
2724 + },
2725 + "funding": {
2726 + "url": "https://github.com/sponsors/ljharb"
2727 + }
2728 + },
2729 + "node_modules/es-define-property": {
2730 + "version": "1.0.1",
2731 + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
2732 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
2733 + "dev": true,
2734 + "license": "MIT",
2735 + "engines": {
2736 + "node": ">= 0.4"
2737 + }
2738 + },
2739 + "node_modules/es-errors": {
2740 + "version": "1.3.0",
2741 + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
2742 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
2743 + "dev": true,
2744 + "license": "MIT",
2745 + "engines": {
2746 + "node": ">= 0.4"
2747 + }
2748 + },
2749 + "node_modules/es-iterator-helpers": {
2750 + "version": "1.4.0",
2751 + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz",
2752 + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==",
2753 + "dev": true,
2754 + "license": "MIT",
2755 + "dependencies": {
2756 + "call-bind": "^1.0.9",
2757 + "call-bound": "^1.0.4",
2758 + "define-properties": "^1.2.1",
2759 + "es-abstract": "^1.24.2",
2760 + "es-errors": "^1.3.0",
2761 + "es-set-tostringtag": "^2.1.0",
2762 + "function-bind": "^1.1.2",
2763 + "get-intrinsic": "^1.3.0",
2764 + "globalthis": "^1.0.4",
2765 + "gopd": "^1.2.0",
2766 + "has-property-descriptors": "^1.0.2",
2767 + "has-proto": "^1.2.0",
2768 + "has-symbols": "^1.1.0",
2769 + "internal-slot": "^1.1.0",
2770 + "iterator.prototype": "^1.1.5",
2771 + "math-intrinsics": "^1.1.0"
2772 + },
2773 + "engines": {
2774 + "node": ">= 0.4"
2775 + }
2776 + },
2777 + "node_modules/es-object-atoms": {
2778 + "version": "1.1.2",
2779 + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
2780 + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
2781 + "dev": true,
2782 + "license": "MIT",
2783 + "dependencies": {
2784 + "es-errors": "^1.3.0"
2785 + },
2786 + "engines": {
2787 + "node": ">= 0.4"
2788 + }
2789 + },
2790 + "node_modules/es-set-tostringtag": {
2791 + "version": "2.1.0",
2792 + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
2793 + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
2794 + "dev": true,
2795 + "license": "MIT",
2796 + "dependencies": {
2797 + "es-errors": "^1.3.0",
2798 + "get-intrinsic": "^1.2.6",
2799 + "has-tostringtag": "^1.0.2",
2800 + "hasown": "^2.0.2"
2801 + },
2802 + "engines": {
2803 + "node": ">= 0.4"
2804 + }
2805 + },
2806 + "node_modules/es-shim-unscopables": {
2807 + "version": "1.1.0",
2808 + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
2809 + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
2810 + "dev": true,
2811 + "license": "MIT",
2812 + "dependencies": {
2813 + "hasown": "^2.0.2"
2814 + },
2815 + "engines": {
2816 + "node": ">= 0.4"
2817 + }
2818 + },
2819 + "node_modules/es-to-primitive": {
2820 + "version": "1.3.4",
2821 + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz",
2822 + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==",
2823 + "dev": true,
2824 + "license": "MIT",
2825 + "dependencies": {
2826 + "es-abstract-get": "^1.0.0",
2827 + "es-define-property": "^1.0.1",
2828 + "es-errors": "^1.3.0",
2829 + "is-callable": "^1.2.7",
2830 + "is-date-object": "^1.1.0",
2831 + "is-symbol": "^1.1.1"
2832 + },
2833 + "engines": {
2834 + "node": ">= 0.4"
2835 + },
2836 + "funding": {
2837 + "url": "https://github.com/sponsors/ljharb"
2838 + }
2839 + },
2840 + "node_modules/escalade": {
2841 + "version": "3.2.0",
2842 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
2843 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
2844 + "dev": true,
2845 + "license": "MIT",
2846 + "engines": {
2847 + "node": ">=6"
2848 + }
2849 + },
2850 + "node_modules/escape-string-regexp": {
2851 + "version": "4.0.0",
2852 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
2853 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
2854 + "dev": true,
2855 + "license": "MIT",
2856 + "engines": {
2857 + "node": ">=10"
2858 + },
2859 + "funding": {
2860 + "url": "https://github.com/sponsors/sindresorhus"
2861 + }
2862 + },
2863 + "node_modules/eslint": {
2864 + "version": "9.7.0",
2865 + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.7.0.tgz",
2866 + "integrity": "sha512-FzJ9D/0nGiCGBf8UXO/IGLTgLVzIxze1zpfA8Ton2mjLovXdAPlYDv+MQDcqj3TmrhAGYfOpz9RfR+ent0AgAw==",
2867 + "dev": true,
2868 + "license": "MIT",
2869 + "dependencies": {
2870 + "@eslint-community/eslint-utils": "^4.2.0",
2871 + "@eslint-community/regexpp": "^4.11.0",
2872 + "@eslint/config-array": "^0.17.0",
2873 + "@eslint/eslintrc": "^3.1.0",
2874 + "@eslint/js": "9.7.0",
2875 + "@humanwhocodes/module-importer": "^1.0.1",
2876 + "@humanwhocodes/retry": "^0.3.0",
2877 + "@nodelib/fs.walk": "^1.2.8",
2878 + "ajv": "^6.12.4",
2879 + "chalk": "^4.0.0",
2880 + "cross-spawn": "^7.0.2",
2881 + "debug": "^4.3.2",
2882 + "escape-string-regexp": "^4.0.0",
2883 + "eslint-scope": "^8.0.2",
2884 + "eslint-visitor-keys": "^4.0.0",
2885 + "espree": "^10.1.0",
2886 + "esquery": "^1.5.0",
2887 + "esutils": "^2.0.2",
2888 + "fast-deep-equal": "^3.1.3",
2889 + "file-entry-cache": "^8.0.0",
2890 + "find-up": "^5.0.0",
2891 + "glob-parent": "^6.0.2",
2892 + "ignore": "^5.2.0",
2893 + "imurmurhash": "^0.1.4",
2894 + "is-glob": "^4.0.0",
2895 + "is-path-inside": "^3.0.3",
2896 + "json-stable-stringify-without-jsonify": "^1.0.1",
2897 + "levn": "^0.4.1",
2898 + "lodash.merge": "^4.6.2",
2899 + "minimatch": "^3.1.2",
2900 + "natural-compare": "^1.4.0",
2901 + "optionator": "^0.9.3",
2902 + "strip-ansi": "^6.0.1",
2903 + "text-table": "^0.2.0"
2904 + },
2905 + "bin": {
2906 + "eslint": "bin/eslint.js"
2907 + },
2908 + "engines": {
2909 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2910 + },
2911 + "funding": {
2912 + "url": "https://eslint.org/donate"
2913 + }
2914 + },
2915 + "node_modules/eslint-config-next": {
2916 + "version": "16.3.0",
2917 + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.0.tgz",
2918 + "integrity": "sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==",
2919 + "dev": true,
2920 + "license": "MIT",
2921 + "dependencies": {
2922 + "@next/eslint-plugin-next": "16.3.0",
2923 + "eslint-import-resolver-node": "^0.3.6",
2924 + "eslint-import-resolver-typescript": "^3.5.2",
2925 + "eslint-plugin-import": "^2.32.0",
2926 + "eslint-plugin-jsx-a11y": "^6.10.0",
2927 + "eslint-plugin-react": "^7.37.0",
2928 + "eslint-plugin-react-hooks": "^7.0.0",
2929 + "globals": "16.4.0",
2930 + "typescript-eslint": "^8.46.0"
2931 + },
2932 + "peerDependencies": {
2933 + "eslint": ">=9.0.0",
2934 + "typescript": ">=3.3.1"
2935 + },
2936 + "peerDependenciesMeta": {
2937 + "typescript": {
2938 + "optional": true
2939 + }
2940 + }
2941 + },
2942 + "node_modules/eslint-config-next/node_modules/eslint-plugin-react": {
2943 + "version": "7.37.5",
2944 + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
2945 + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
2946 + "dev": true,
2947 + "license": "MIT",
2948 + "dependencies": {
2949 + "array-includes": "^3.1.8",
2950 + "array.prototype.findlast": "^1.2.5",
2951 + "array.prototype.flatmap": "^1.3.3",
2952 + "array.prototype.tosorted": "^1.1.4",
2953 + "doctrine": "^2.1.0",
2954 + "es-iterator-helpers": "^1.2.1",
2955 + "estraverse": "^5.3.0",
2956 + "hasown": "^2.0.2",
2957 + "jsx-ast-utils": "^2.4.1 || ^3.0.0",
2958 + "minimatch": "^3.1.2",
2959 + "object.entries": "^1.1.9",
2960 + "object.fromentries": "^2.0.8",
2961 + "object.values": "^1.2.1",
2962 + "prop-types": "^15.8.1",
2963 + "resolve": "^2.0.0-next.5",
2964 + "semver": "^6.3.1",
2965 + "string.prototype.matchall": "^4.0.12",
2966 + "string.prototype.repeat": "^1.0.0"
2967 + },
2968 + "engines": {
2969 + "node": ">=4"
2970 + },
2971 + "peerDependencies": {
2972 + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
2973 + }
2974 + },
2975 + "node_modules/eslint-config-next/node_modules/globals": {
2976 + "version": "16.4.0",
2977 + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
2978 + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
2979 + "dev": true,
2980 + "license": "MIT",
2981 + "engines": {
2982 + "node": ">=18"
2983 + },
2984 + "funding": {
2985 + "url": "https://github.com/sponsors/sindresorhus"
2986 + }
2987 + },
2988 + "node_modules/eslint-config-next/node_modules/semver": {
2989 + "version": "6.3.1",
2990 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2991 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2992 + "dev": true,
2993 + "license": "ISC",
2994 + "bin": {
2995 + "semver": "bin/semver.js"
2996 + }
2997 + },
2998 + "node_modules/eslint-import-resolver-node": {
2999 + "version": "0.3.10",
3000 + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
3001 + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==",
3002 + "dev": true,
3003 + "license": "MIT",
3004 + "dependencies": {
3005 + "debug": "^3.2.7",
3006 + "is-core-module": "^2.16.1",
3007 + "resolve": "^2.0.0-next.6"
3008 + }
3009 + },
3010 + "node_modules/eslint-import-resolver-node/node_modules/debug": {
3011 + "version": "3.2.7",
3012 + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
3013 + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
3014 + "dev": true,
3015 + "license": "MIT",
3016 + "dependencies": {
3017 + "ms": "^2.1.1"
3018 + }
3019 + },
3020 + "node_modules/eslint-import-resolver-typescript": {
3021 + "version": "3.10.1",
3022 + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz",
3023 + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==",
3024 + "dev": true,
3025 + "license": "ISC",
3026 + "dependencies": {
3027 + "@nolyfill/is-core-module": "1.0.39",
3028 + "debug": "^4.4.0",
3029 + "get-tsconfig": "^4.10.0",
3030 + "is-bun-module": "^2.0.0",
3031 + "stable-hash": "^0.0.5",
3032 + "tinyglobby": "^0.2.13",
3033 + "unrs-resolver": "^1.6.2"
3034 + },
3035 + "engines": {
3036 + "node": "^14.18.0 || >=16.0.0"
3037 + },
3038 + "funding": {
3039 + "url": "https://opencollective.com/eslint-import-resolver-typescript"
3040 + },
3041 + "peerDependencies": {
3042 + "eslint": "*",
3043 + "eslint-plugin-import": "*",
3044 + "eslint-plugin-import-x": "*"
3045 + },
3046 + "peerDependenciesMeta": {
3047 + "eslint-plugin-import": {
3048 + "optional": true
3049 + },
3050 + "eslint-plugin-import-x": {
3051 + "optional": true
3052 + }
3053 + }
3054 + },
3055 + "node_modules/eslint-module-utils": {
3056 + "version": "2.14.0",
3057 + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz",
3058 + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==",
3059 + "dev": true,
3060 + "license": "MIT",
3061 + "dependencies": {
3062 + "debug": "^3.2.7"
3063 + },
3064 + "engines": {
3065 + "node": ">=4"
3066 + },
3067 + "peerDependenciesMeta": {
3068 + "eslint": {
3069 + "optional": true
3070 + }
3071 + }
3072 + },
3073 + "node_modules/eslint-module-utils/node_modules/debug": {
3074 + "version": "3.2.7",
3075 + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
3076 + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
3077 + "dev": true,
3078 + "license": "MIT",
3079 + "dependencies": {
3080 + "ms": "^2.1.1"
3081 + }
3082 + },
3083 + "node_modules/eslint-plugin-import": {
3084 + "version": "2.32.0",
3085 + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
3086 + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
3087 + "dev": true,
3088 + "license": "MIT",
3089 + "dependencies": {
3090 + "@rtsao/scc": "^1.1.0",
3091 + "array-includes": "^3.1.9",
3092 + "array.prototype.findlastindex": "^1.2.6",
3093 + "array.prototype.flat": "^1.3.3",
3094 + "array.prototype.flatmap": "^1.3.3",
3095 + "debug": "^3.2.7",
3096 + "doctrine": "^2.1.0",
3097 + "eslint-import-resolver-node": "^0.3.9",
3098 + "eslint-module-utils": "^2.12.1",
3099 + "hasown": "^2.0.2",
3100 + "is-core-module": "^2.16.1",
3101 + "is-glob": "^4.0.3",
3102 + "minimatch": "^3.1.2",
3103 + "object.fromentries": "^2.0.8",
3104 + "object.groupby": "^1.0.3",
3105 + "object.values": "^1.2.1",
3106 + "semver": "^6.3.1",
3107 + "string.prototype.trimend": "^1.0.9",
3108 + "tsconfig-paths": "^3.15.0"
3109 + },
3110 + "engines": {
3111 + "node": ">=4"
3112 + },
3113 + "peerDependencies": {
3114 + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
3115 + }
3116 + },
3117 + "node_modules/eslint-plugin-import/node_modules/debug": {
3118 + "version": "3.2.7",
3119 + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
3120 + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
3121 + "dev": true,
3122 + "license": "MIT",
3123 + "dependencies": {
3124 + "ms": "^2.1.1"
3125 + }
3126 + },
3127 + "node_modules/eslint-plugin-import/node_modules/semver": {
3128 + "version": "6.3.1",
3129 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
3130 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
3131 + "dev": true,
3132 + "license": "ISC",
3133 + "bin": {
3134 + "semver": "bin/semver.js"
3135 + }
3136 + },
3137 + "node_modules/eslint-plugin-jsx-a11y": {
3138 + "version": "6.10.2",
3139 + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
3140 + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
3141 + "dev": true,
3142 + "license": "MIT",
3143 + "dependencies": {
3144 + "aria-query": "^5.3.2",
3145 + "array-includes": "^3.1.8",
3146 + "array.prototype.flatmap": "^1.3.2",
3147 + "ast-types-flow": "^0.0.8",
3148 + "axe-core": "^4.10.0",
3149 + "axobject-query": "^4.1.0",
3150 + "damerau-levenshtein": "^1.0.8",
3151 + "emoji-regex": "^9.2.2",
3152 + "hasown": "^2.0.2",
3153 + "jsx-ast-utils": "^3.3.5",
3154 + "language-tags": "^1.0.9",
3155 + "minimatch": "^3.1.2",
3156 + "object.fromentries": "^2.0.8",
3157 + "safe-regex-test": "^1.0.3",
3158 + "string.prototype.includes": "^2.0.1"
3159 + },
3160 + "engines": {
3161 + "node": ">=4.0"
3162 + },
3163 + "peerDependencies": {
3164 + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
3165 + }
3166 + },
3167 + "node_modules/eslint-plugin-react-hooks": {
3168 + "version": "7.1.1",
3169 + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
3170 + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
3171 + "dev": true,
3172 + "license": "MIT",
3173 + "dependencies": {
3174 + "@babel/core": "^7.24.4",
3175 + "@babel/parser": "^7.24.4",
3176 + "hermes-parser": "^0.25.1",
3177 + "zod": "^3.25.0 || ^4.0.0",
3178 + "zod-validation-error": "^3.5.0 || ^4.0.0"
3179 + },
3180 + "engines": {
3181 + "node": ">=18"
3182 + },
3183 + "peerDependencies": {
3184 + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
3185 + }
3186 + },
3187 + "node_modules/eslint-scope": {
3188 + "version": "8.4.0",
3189 + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
3190 + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
3191 + "dev": true,
3192 + "license": "BSD-2-Clause",
3193 + "dependencies": {
3194 + "esrecurse": "^4.3.0",
3195 + "estraverse": "^5.2.0"
3196 + },
3197 + "engines": {
3198 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
3199 + },
3200 + "funding": {
3201 + "url": "https://opencollective.com/eslint"
3202 + }
3203 + },
3204 + "node_modules/eslint-visitor-keys": {
3205 + "version": "3.4.3",
3206 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
3207 + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
3208 + "dev": true,
3209 + "license": "Apache-2.0",
3210 + "engines": {
3211 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
3212 + },
3213 + "funding": {
3214 + "url": "https://opencollective.com/eslint"
3215 + }
3216 + },
3217 + "node_modules/eslint/node_modules/eslint-visitor-keys": {
3218 + "version": "4.2.1",
3219 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
3220 + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
3221 + "dev": true,
3222 + "license": "Apache-2.0",
3223 + "engines": {
3224 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
3225 + },
3226 + "funding": {
3227 + "url": "https://opencollective.com/eslint"
3228 + }
3229 + },
3230 + "node_modules/espree": {
3231 + "version": "10.4.0",
3232 + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
3233 + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
3234 + "dev": true,
3235 + "license": "BSD-2-Clause",
3236 + "dependencies": {
3237 + "acorn": "^8.15.0",
3238 + "acorn-jsx": "^5.3.2",
3239 + "eslint-visitor-keys": "^4.2.1"
3240 + },
3241 + "engines": {
3242 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
3243 + },
3244 + "funding": {
3245 + "url": "https://opencollective.com/eslint"
3246 + }
3247 + },
3248 + "node_modules/espree/node_modules/eslint-visitor-keys": {
3249 + "version": "4.2.1",
3250 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
3251 + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
3252 + "dev": true,
3253 + "license": "Apache-2.0",
3254 + "engines": {
3255 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
3256 + },
3257 + "funding": {
3258 + "url": "https://opencollective.com/eslint"
3259 + }
3260 + },
3261 + "node_modules/esquery": {
3262 + "version": "1.7.0",
3263 + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
3264 + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
3265 + "dev": true,
3266 + "license": "BSD-3-Clause",
3267 + "dependencies": {
3268 + "estraverse": "^5.1.0"
3269 + },
3270 + "engines": {
3271 + "node": ">=0.10"
3272 + }
3273 + },
3274 + "node_modules/esrecurse": {
3275 + "version": "4.3.0",
3276 + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
3277 + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
3278 + "dev": true,
3279 + "license": "BSD-2-Clause",
3280 + "dependencies": {
3281 + "estraverse": "^5.2.0"
3282 + },
3283 + "engines": {
3284 + "node": ">=4.0"
3285 + }
3286 + },
3287 + "node_modules/estraverse": {
3288 + "version": "5.3.0",
3289 + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
3290 + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
3291 + "dev": true,
3292 + "license": "BSD-2-Clause",
3293 + "engines": {
3294 + "node": ">=4.0"
3295 + }
3296 + },
3297 + "node_modules/esutils": {
3298 + "version": "2.0.3",
3299 + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
3300 + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
3301 + "dev": true,
3302 + "license": "BSD-2-Clause",
3303 + "engines": {
3304 + "node": ">=0.10.0"
3305 + }
3306 + },
3307 + "node_modules/fast-deep-equal": {
3308 + "version": "3.1.3",
3309 + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
3310 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
3311 + "dev": true,
3312 + "license": "MIT"
3313 + },
3314 + "node_modules/fast-glob": {
3315 + "version": "3.3.1",
3316 + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
3317 + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
3318 + "dev": true,
3319 + "license": "MIT",
3320 + "dependencies": {
3321 + "@nodelib/fs.stat": "^2.0.2",
3322 + "@nodelib/fs.walk": "^1.2.3",
3323 + "glob-parent": "^5.1.2",
3324 + "merge2": "^1.3.0",
3325 + "micromatch": "^4.0.4"
3326 + },
3327 + "engines": {
3328 + "node": ">=8.6.0"
3329 + }
3330 + },
3331 + "node_modules/fast-glob/node_modules/glob-parent": {
3332 + "version": "5.1.2",
3333 + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
3334 + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
3335 + "dev": true,
3336 + "license": "ISC",
3337 + "dependencies": {
3338 + "is-glob": "^4.0.1"
3339 + },
3340 + "engines": {
3341 + "node": ">= 6"
3342 + }
3343 + },
3344 + "node_modules/fast-json-stable-stringify": {
3345 + "version": "2.1.0",
3346 + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
3347 + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
3348 + "dev": true,
3349 + "license": "MIT"
3350 + },
3351 + "node_modules/fast-levenshtein": {
3352 + "version": "2.0.6",
3353 + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
3354 + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
3355 + "dev": true,
3356 + "license": "MIT"
3357 + },
3358 + "node_modules/fastq": {
3359 + "version": "1.20.1",
3360 + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
3361 + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
3362 + "dev": true,
3363 + "license": "ISC",
3364 + "dependencies": {
3365 + "reusify": "^1.0.4"
3366 + }
3367 + },
3368 + "node_modules/fdir": {
3369 + "version": "6.5.0",
3370 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
3371 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
3372 + "dev": true,
3373 + "license": "MIT",
3374 + "engines": {
3375 + "node": ">=12.0.0"
3376 + },
3377 + "peerDependencies": {
3378 + "picomatch": "^3 || ^4"
3379 + },
3380 + "peerDependenciesMeta": {
3381 + "picomatch": {
3382 + "optional": true
3383 + }
3384 + }
3385 + },
3386 + "node_modules/file-entry-cache": {
3387 + "version": "8.0.0",
3388 + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
3389 + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
3390 + "dev": true,
3391 + "license": "MIT",
3392 + "dependencies": {
3393 + "flat-cache": "^4.0.0"
3394 + },
3395 + "engines": {
3396 + "node": ">=16.0.0"
3397 + }
3398 + },
3399 + "node_modules/fill-range": {
3400 + "version": "7.1.1",
3401 + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
3402 + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
3403 + "dev": true,
3404 + "license": "MIT",
3405 + "dependencies": {
3406 + "to-regex-range": "^5.0.1"
3407 + },
3408 + "engines": {
3409 + "node": ">=8"
3410 + }
3411 + },
3412 + "node_modules/find-up": {
3413 + "version": "5.0.0",
3414 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
3415 + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
3416 + "dev": true,
3417 + "license": "MIT",
3418 + "dependencies": {
3419 + "locate-path": "^6.0.0",
3420 + "path-exists": "^4.0.0"
3421 + },
3422 + "engines": {
3423 + "node": ">=10"
3424 + },
3425 + "funding": {
3426 + "url": "https://github.com/sponsors/sindresorhus"
3427 + }
3428 + },
3429 + "node_modules/flat-cache": {
3430 + "version": "4.0.1",
3431 + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
3432 + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
3433 + "dev": true,
3434 + "license": "MIT",
3435 + "dependencies": {
3436 + "flatted": "^3.2.9",
3437 + "keyv": "^4.5.4"
3438 + },
3439 + "engines": {
3440 + "node": ">=16"
3441 + }
3442 + },
3443 + "node_modules/flatted": {
3444 + "version": "3.4.4",
3445 + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
3446 + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
3447 + "dev": true,
3448 + "license": "ISC"
3449 + },
3450 + "node_modules/for-each": {
3451 + "version": "0.3.5",
3452 + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
3453 + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
3454 + "dev": true,
3455 + "license": "MIT",
3456 + "dependencies": {
3457 + "is-callable": "^1.2.7"
3458 + },
3459 + "engines": {
3460 + "node": ">= 0.4"
3461 + },
3462 + "funding": {
3463 + "url": "https://github.com/sponsors/ljharb"
3464 + }
3465 + },
3466 + "node_modules/function-bind": {
3467 + "version": "1.1.2",
3468 + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
3469 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
3470 + "dev": true,
3471 + "license": "MIT",
3472 + "funding": {
3473 + "url": "https://github.com/sponsors/ljharb"
3474 + }
3475 + },
3476 + "node_modules/function.prototype.name": {
3477 + "version": "1.2.0",
3478 + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz",
3479 + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==",
3480 + "dev": true,
3481 + "license": "MIT",
3482 + "dependencies": {
3483 + "call-bind": "^1.0.9",
3484 + "call-bound": "^1.0.4",
3485 + "es-define-property": "^1.0.1",
3486 + "es-errors": "^1.3.0",
3487 + "functions-have-names": "^1.2.3",
3488 + "has-property-descriptors": "^1.0.2",
3489 + "hasown": "^2.0.4",
3490 + "is-callable": "^1.2.7",
3491 + "is-document.all": "^1.0.0"
3492 + },
3493 + "engines": {
3494 + "node": ">= 0.4"
3495 + },
3496 + "funding": {
3497 + "url": "https://github.com/sponsors/ljharb"
3498 + }
3499 + },
3500 + "node_modules/functions-have-names": {
3501 + "version": "1.2.3",
3502 + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
3503 + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
3504 + "dev": true,
3505 + "license": "MIT",
3506 + "funding": {
3507 + "url": "https://github.com/sponsors/ljharb"
3508 + }
3509 + },
3510 + "node_modules/generator-function": {
3511 + "version": "2.0.1",
3512 + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
3513 + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
3514 + "dev": true,
3515 + "license": "MIT",
3516 + "engines": {
3517 + "node": ">= 0.4"
3518 + }
3519 + },
3520 + "node_modules/gensync": {
3521 + "version": "1.0.0-beta.2",
3522 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
3523 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
3524 + "dev": true,
3525 + "license": "MIT",
3526 + "engines": {
3527 + "node": ">=6.9.0"
3528 + }
3529 + },
3530 + "node_modules/get-intrinsic": {
3531 + "version": "1.3.0",
3532 + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
3533 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
3534 + "dev": true,
3535 + "license": "MIT",
3536 + "dependencies": {
3537 + "call-bind-apply-helpers": "^1.0.2",
3538 + "es-define-property": "^1.0.1",
3539 + "es-errors": "^1.3.0",
3540 + "es-object-atoms": "^1.1.1",
3541 + "function-bind": "^1.1.2",
3542 + "get-proto": "^1.0.1",
3543 + "gopd": "^1.2.0",
3544 + "has-symbols": "^1.1.0",
3545 + "hasown": "^2.0.2",
3546 + "math-intrinsics": "^1.1.0"
3547 + },
3548 + "engines": {
3549 + "node": ">= 0.4"
3550 + },
3551 + "funding": {
3552 + "url": "https://github.com/sponsors/ljharb"
3553 + }
3554 + },
3555 + "node_modules/get-proto": {
3556 + "version": "1.0.1",
3557 + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
3558 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
3559 + "dev": true,
3560 + "license": "MIT",
3561 + "dependencies": {
3562 + "dunder-proto": "^1.0.1",
3563 + "es-object-atoms": "^1.0.0"
3564 + },
3565 + "engines": {
3566 + "node": ">= 0.4"
3567 + }
3568 + },
3569 + "node_modules/get-symbol-description": {
3570 + "version": "1.1.0",
3571 + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
3572 + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
3573 + "dev": true,
3574 + "license": "MIT",
3575 + "dependencies": {
3576 + "call-bound": "^1.0.3",
3577 + "es-errors": "^1.3.0",
3578 + "get-intrinsic": "^1.2.6"
3579 + },
3580 + "engines": {
3581 + "node": ">= 0.4"
3582 + },
3583 + "funding": {
3584 + "url": "https://github.com/sponsors/ljharb"
3585 + }
3586 + },
3587 + "node_modules/get-tsconfig": {
3588 + "version": "4.14.1",
3589 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz",
3590 + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==",
3591 + "dev": true,
3592 + "license": "MIT",
3593 + "dependencies": {
3594 + "resolve-pkg-maps": "^1.0.0"
3595 + },
3596 + "funding": {
3597 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
3598 + }
3599 + },
3600 + "node_modules/glob-parent": {
3601 + "version": "6.0.2",
3602 + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
3603 + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
3604 + "dev": true,
3605 + "license": "ISC",
3606 + "dependencies": {
3607 + "is-glob": "^4.0.3"
3608 + },
3609 + "engines": {
3610 + "node": ">=10.13.0"
3611 + }
3612 + },
3613 + "node_modules/globals": {
3614 + "version": "14.0.0",
3615 + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
3616 + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
3617 + "dev": true,
3618 + "license": "MIT",
3619 + "engines": {
3620 + "node": ">=18"
3621 + },
3622 + "funding": {
3623 + "url": "https://github.com/sponsors/sindresorhus"
3624 + }
3625 + },
3626 + "node_modules/globalthis": {
3627 + "version": "1.0.4",
3628 + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
3629 + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
3630 + "dev": true,
3631 + "license": "MIT",
3632 + "dependencies": {
3633 + "define-properties": "^1.2.1",
3634 + "gopd": "^1.0.1"
3635 + },
3636 + "engines": {
3637 + "node": ">= 0.4"
3638 + },
3639 + "funding": {
3640 + "url": "https://github.com/sponsors/ljharb"
3641 + }
3642 + },
3643 + "node_modules/gopd": {
3644 + "version": "1.2.0",
3645 + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
3646 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
3647 + "dev": true,
3648 + "license": "MIT",
3649 + "engines": {
3650 + "node": ">= 0.4"
3651 + },
3652 + "funding": {
3653 + "url": "https://github.com/sponsors/ljharb"
3654 + }
3655 + },
3656 + "node_modules/has-bigints": {
3657 + "version": "1.1.0",
3658 + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
3659 + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
3660 + "dev": true,
3661 + "license": "MIT",
3662 + "engines": {
3663 + "node": ">= 0.4"
3664 + },
3665 + "funding": {
3666 + "url": "https://github.com/sponsors/ljharb"
3667 + }
3668 + },
3669 + "node_modules/has-flag": {
3670 + "version": "4.0.0",
3671 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
3672 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
3673 + "dev": true,
3674 + "license": "MIT",
3675 + "engines": {
3676 + "node": ">=8"
3677 + }
3678 + },
3679 + "node_modules/has-property-descriptors": {
3680 + "version": "1.0.2",
3681 + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
3682 + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
3683 + "dev": true,
3684 + "license": "MIT",
3685 + "dependencies": {
3686 + "es-define-property": "^1.0.0"
3687 + },
3688 + "funding": {
3689 + "url": "https://github.com/sponsors/ljharb"
3690 + }
3691 + },
3692 + "node_modules/has-proto": {
3693 + "version": "1.2.0",
3694 + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
3695 + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
3696 + "dev": true,
3697 + "license": "MIT",
3698 + "dependencies": {
3699 + "dunder-proto": "^1.0.0"
3700 + },
3701 + "engines": {
3702 + "node": ">= 0.4"
3703 + },
3704 + "funding": {
3705 + "url": "https://github.com/sponsors/ljharb"
3706 + }
3707 + },
3708 + "node_modules/has-symbols": {
3709 + "version": "1.1.0",
3710 + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
3711 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
3712 + "dev": true,
3713 + "license": "MIT",
3714 + "engines": {
3715 + "node": ">= 0.4"
3716 + },
3717 + "funding": {
3718 + "url": "https://github.com/sponsors/ljharb"
3719 + }
3720 + },
3721 + "node_modules/has-tostringtag": {
3722 + "version": "1.0.2",
3723 + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
3724 + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
3725 + "dev": true,
3726 + "license": "MIT",
3727 + "dependencies": {
3728 + "has-symbols": "^1.0.3"
3729 + },
3730 + "engines": {
3731 + "node": ">= 0.4"
3732 + },
3733 + "funding": {
3734 + "url": "https://github.com/sponsors/ljharb"
3735 + }
3736 + },
3737 + "node_modules/hasown": {
3738 + "version": "2.0.4",
3739 + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
3740 + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
3741 + "dev": true,
3742 + "license": "MIT",
3743 + "dependencies": {
3744 + "function-bind": "^1.1.2"
3745 + },
3746 + "engines": {
3747 + "node": ">= 0.4"
3748 + }
3749 + },
3750 + "node_modules/hermes-estree": {
3751 + "version": "0.25.1",
3752 + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
3753 + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
3754 + "dev": true,
3755 + "license": "MIT"
3756 + },
3757 + "node_modules/hermes-parser": {
3758 + "version": "0.25.1",
3759 + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
3760 + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
3761 + "dev": true,
3762 + "license": "MIT",
3763 + "dependencies": {
3764 + "hermes-estree": "0.25.1"
3765 + }
3766 + },
3767 + "node_modules/ignore": {
3768 + "version": "5.3.2",
3769 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
3770 + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
3771 + "dev": true,
3772 + "license": "MIT",
3773 + "engines": {
3774 + "node": ">= 4"
3775 + }
3776 + },
3777 + "node_modules/import-fresh": {
3778 + "version": "3.3.1",
3779 + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
3780 + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
3781 + "dev": true,
3782 + "license": "MIT",
3783 + "dependencies": {
3784 + "parent-module": "^1.0.0",
3785 + "resolve-from": "^4.0.0"
3786 + },
3787 + "engines": {
3788 + "node": ">=6"
3789 + },
3790 + "funding": {
3791 + "url": "https://github.com/sponsors/sindresorhus"
3792 + }
3793 + },
3794 + "node_modules/imurmurhash": {
3795 + "version": "0.1.4",
3796 + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
3797 + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
3798 + "dev": true,
3799 + "license": "MIT",
3800 + "engines": {
3801 + "node": ">=0.8.19"
3802 + }
3803 + },
3804 + "node_modules/internal-slot": {
3805 + "version": "1.1.0",
3806 + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
3807 + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
3808 + "dev": true,
3809 + "license": "MIT",
3810 + "dependencies": {
3811 + "es-errors": "^1.3.0",
3812 + "hasown": "^2.0.2",
3813 + "side-channel": "^1.1.0"
3814 + },
3815 + "engines": {
3816 + "node": ">= 0.4"
3817 + }
3818 + },
3819 + "node_modules/is-array-buffer": {
3820 + "version": "3.0.5",
3821 + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
3822 + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
3823 + "dev": true,
3824 + "license": "MIT",
3825 + "dependencies": {
3826 + "call-bind": "^1.0.8",
3827 + "call-bound": "^1.0.3",
3828 + "get-intrinsic": "^1.2.6"
3829 + },
3830 + "engines": {
3831 + "node": ">= 0.4"
3832 + },
3833 + "funding": {
3834 + "url": "https://github.com/sponsors/ljharb"
3835 + }
3836 + },
3837 + "node_modules/is-async-function": {
3838 + "version": "2.1.1",
3839 + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
3840 + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
3841 + "dev": true,
3842 + "license": "MIT",
3843 + "dependencies": {
3844 + "async-function": "^1.0.0",
3845 + "call-bound": "^1.0.3",
3846 + "get-proto": "^1.0.1",
3847 + "has-tostringtag": "^1.0.2",
3848 + "safe-regex-test": "^1.1.0"
3849 + },
3850 + "engines": {
3851 + "node": ">= 0.4"
3852 + },
3853 + "funding": {
3854 + "url": "https://github.com/sponsors/ljharb"
3855 + }
3856 + },
3857 + "node_modules/is-bigint": {
3858 + "version": "1.1.0",
3859 + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
3860 + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
3861 + "dev": true,
3862 + "license": "MIT",
3863 + "dependencies": {
3864 + "has-bigints": "^1.0.2"
3865 + },
3866 + "engines": {
3867 + "node": ">= 0.4"
3868 + },
3869 + "funding": {
3870 + "url": "https://github.com/sponsors/ljharb"
3871 + }
3872 + },
3873 + "node_modules/is-boolean-object": {
3874 + "version": "1.2.2",
3875 + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
3876 + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
3877 + "dev": true,
3878 + "license": "MIT",
3879 + "dependencies": {
3880 + "call-bound": "^1.0.3",
3881 + "has-tostringtag": "^1.0.2"
3882 + },
3883 + "engines": {
3884 + "node": ">= 0.4"
3885 + },
3886 + "funding": {
3887 + "url": "https://github.com/sponsors/ljharb"
3888 + }
3889 + },
3890 + "node_modules/is-bun-module": {
3891 + "version": "2.0.0",
3892 + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
3893 + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
3894 + "dev": true,
3895 + "license": "MIT",
3896 + "dependencies": {
3897 + "semver": "^7.7.1"
3898 + }
3899 + },
3900 + "node_modules/is-callable": {
3901 + "version": "1.2.7",
3902 + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
3903 + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
3904 + "dev": true,
3905 + "license": "MIT",
3906 + "engines": {
3907 + "node": ">= 0.4"
3908 + },
3909 + "funding": {
3910 + "url": "https://github.com/sponsors/ljharb"
3911 + }
3912 + },
3913 + "node_modules/is-core-module": {
3914 + "version": "2.16.2",
3915 + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
3916 + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
3917 + "dev": true,
3918 + "license": "MIT",
3919 + "dependencies": {
3920 + "hasown": "^2.0.3"
3921 + },
3922 + "engines": {
3923 + "node": ">= 0.4"
3924 + },
3925 + "funding": {
3926 + "url": "https://github.com/sponsors/ljharb"
3927 + }
3928 + },
3929 + "node_modules/is-data-view": {
3930 + "version": "1.0.2",
3931 + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
3932 + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
3933 + "dev": true,
3934 + "license": "MIT",
3935 + "dependencies": {
3936 + "call-bound": "^1.0.2",
3937 + "get-intrinsic": "^1.2.6",
3938 + "is-typed-array": "^1.1.13"
3939 + },
3940 + "engines": {
3941 + "node": ">= 0.4"
3942 + },
3943 + "funding": {
3944 + "url": "https://github.com/sponsors/ljharb"
3945 + }
3946 + },
3947 + "node_modules/is-date-object": {
3948 + "version": "1.1.0",
3949 + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
3950 + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
3951 + "dev": true,
3952 + "license": "MIT",
3953 + "dependencies": {
3954 + "call-bound": "^1.0.2",
3955 + "has-tostringtag": "^1.0.2"
3956 + },
3957 + "engines": {
3958 + "node": ">= 0.4"
3959 + },
3960 + "funding": {
3961 + "url": "https://github.com/sponsors/ljharb"
3962 + }
3963 + },
3964 + "node_modules/is-document.all": {
3965 + "version": "1.0.0",
3966 + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
3967 + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==",
3968 + "dev": true,
3969 + "license": "MIT",
3970 + "dependencies": {
3971 + "call-bound": "^1.0.4"
3972 + },
3973 + "engines": {
3974 + "node": ">= 0.4"
3975 + },
3976 + "funding": {
3977 + "url": "https://github.com/sponsors/ljharb"
3978 + }
3979 + },
3980 + "node_modules/is-extglob": {
3981 + "version": "2.1.1",
3982 + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
3983 + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
3984 + "dev": true,
3985 + "license": "MIT",
3986 + "engines": {
3987 + "node": ">=0.10.0"
3988 + }
3989 + },
3990 + "node_modules/is-finalizationregistry": {
3991 + "version": "1.1.1",
3992 + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
3993 + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
3994 + "dev": true,
3995 + "license": "MIT",
3996 + "dependencies": {
3997 + "call-bound": "^1.0.3"
3998 + },
3999 + "engines": {
4000 + "node": ">= 0.4"
4001 + },
4002 + "funding": {
4003 + "url": "https://github.com/sponsors/ljharb"
4004 + }
4005 + },
4006 + "node_modules/is-generator-function": {
4007 + "version": "1.1.2",
4008 + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
4009 + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
4010 + "dev": true,
4011 + "license": "MIT",
4012 + "dependencies": {
4013 + "call-bound": "^1.0.4",
4014 + "generator-function": "^2.0.0",
4015 + "get-proto": "^1.0.1",
4016 + "has-tostringtag": "^1.0.2",
4017 + "safe-regex-test": "^1.1.0"
4018 + },
4019 + "engines": {
4020 + "node": ">= 0.4"
4021 + },
4022 + "funding": {
4023 + "url": "https://github.com/sponsors/ljharb"
4024 + }
4025 + },
4026 + "node_modules/is-glob": {
4027 + "version": "4.0.3",
4028 + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
4029 + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
4030 + "dev": true,
4031 + "license": "MIT",
4032 + "dependencies": {
4033 + "is-extglob": "^2.1.1"
4034 + },
4035 + "engines": {
4036 + "node": ">=0.10.0"
4037 + }
4038 + },
4039 + "node_modules/is-map": {
4040 + "version": "2.0.3",
4041 + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
4042 + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
4043 + "dev": true,
4044 + "license": "MIT",
4045 + "engines": {
4046 + "node": ">= 0.4"
4047 + },
4048 + "funding": {
4049 + "url": "https://github.com/sponsors/ljharb"
4050 + }
4051 + },
4052 + "node_modules/is-negative-zero": {
4053 + "version": "2.0.3",
4054 + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
4055 + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
4056 + "dev": true,
4057 + "license": "MIT",
4058 + "engines": {
4059 + "node": ">= 0.4"
4060 + },
4061 + "funding": {
4062 + "url": "https://github.com/sponsors/ljharb"
4063 + }
4064 + },
4065 + "node_modules/is-number": {
4066 + "version": "7.0.0",
4067 + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
4068 + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
4069 + "dev": true,
4070 + "license": "MIT",
4071 + "engines": {
4072 + "node": ">=0.12.0"
4073 + }
4074 + },
4075 + "node_modules/is-number-object": {
4076 + "version": "1.1.1",
4077 + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
4078 + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
4079 + "dev": true,
4080 + "license": "MIT",
4081 + "dependencies": {
4082 + "call-bound": "^1.0.3",
4083 + "has-tostringtag": "^1.0.2"
4084 + },
4085 + "engines": {
4086 + "node": ">= 0.4"
4087 + },
4088 + "funding": {
4089 + "url": "https://github.com/sponsors/ljharb"
4090 + }
4091 + },
4092 + "node_modules/is-path-inside": {
4093 + "version": "3.0.3",
4094 + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
4095 + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
4096 + "dev": true,
4097 + "license": "MIT",
4098 + "engines": {
4099 + "node": ">=8"
4100 + }
4101 + },
4102 + "node_modules/is-regex": {
4103 + "version": "1.2.1",
4104 + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
4105 + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
4106 + "dev": true,
4107 + "license": "MIT",
4108 + "dependencies": {
4109 + "call-bound": "^1.0.2",
4110 + "gopd": "^1.2.0",
4111 + "has-tostringtag": "^1.0.2",
4112 + "hasown": "^2.0.2"
4113 + },
4114 + "engines": {
4115 + "node": ">= 0.4"
4116 + },
4117 + "funding": {
4118 + "url": "https://github.com/sponsors/ljharb"
4119 + }
4120 + },
4121 + "node_modules/is-set": {
4122 + "version": "2.0.3",
4123 + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
4124 + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
4125 + "dev": true,
4126 + "license": "MIT",
4127 + "engines": {
4128 + "node": ">= 0.4"
4129 + },
4130 + "funding": {
4131 + "url": "https://github.com/sponsors/ljharb"
4132 + }
4133 + },
4134 + "node_modules/is-shared-array-buffer": {
4135 + "version": "1.0.4",
4136 + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
4137 + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
4138 + "dev": true,
4139 + "license": "MIT",
4140 + "dependencies": {
4141 + "call-bound": "^1.0.3"
4142 + },
4143 + "engines": {
4144 + "node": ">= 0.4"
4145 + },
4146 + "funding": {
4147 + "url": "https://github.com/sponsors/ljharb"
4148 + }
4149 + },
4150 + "node_modules/is-string": {
4151 + "version": "1.1.1",
4152 + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
4153 + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
4154 + "dev": true,
4155 + "license": "MIT",
4156 + "dependencies": {
4157 + "call-bound": "^1.0.3",
4158 + "has-tostringtag": "^1.0.2"
4159 + },
4160 + "engines": {
4161 + "node": ">= 0.4"
4162 + },
4163 + "funding": {
4164 + "url": "https://github.com/sponsors/ljharb"
4165 + }
4166 + },
4167 + "node_modules/is-symbol": {
4168 + "version": "1.1.1",
4169 + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
4170 + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
4171 + "dev": true,
4172 + "license": "MIT",
4173 + "dependencies": {
4174 + "call-bound": "^1.0.2",
4175 + "has-symbols": "^1.1.0",
4176 + "safe-regex-test": "^1.1.0"
4177 + },
4178 + "engines": {
4179 + "node": ">= 0.4"
4180 + },
4181 + "funding": {
4182 + "url": "https://github.com/sponsors/ljharb"
4183 + }
4184 + },
4185 + "node_modules/is-typed-array": {
4186 + "version": "1.1.15",
4187 + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
4188 + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
4189 + "dev": true,
4190 + "license": "MIT",
4191 + "dependencies": {
4192 + "which-typed-array": "^1.1.16"
4193 + },
4194 + "engines": {
4195 + "node": ">= 0.4"
4196 + },
4197 + "funding": {
4198 + "url": "https://github.com/sponsors/ljharb"
4199 + }
4200 + },
4201 + "node_modules/is-weakmap": {
4202 + "version": "2.0.2",
4203 + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
4204 + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
4205 + "dev": true,
4206 + "license": "MIT",
4207 + "engines": {
4208 + "node": ">= 0.4"
4209 + },
4210 + "funding": {
4211 + "url": "https://github.com/sponsors/ljharb"
4212 + }
4213 + },
4214 + "node_modules/is-weakref": {
4215 + "version": "1.1.1",
4216 + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
4217 + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
4218 + "dev": true,
4219 + "license": "MIT",
4220 + "dependencies": {
4221 + "call-bound": "^1.0.3"
4222 + },
4223 + "engines": {
4224 + "node": ">= 0.4"
4225 + },
4226 + "funding": {
4227 + "url": "https://github.com/sponsors/ljharb"
4228 + }
4229 + },
4230 + "node_modules/is-weakset": {
4231 + "version": "2.0.4",
4232 + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
4233 + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
4234 + "dev": true,
4235 + "license": "MIT",
4236 + "dependencies": {
4237 + "call-bound": "^1.0.3",
4238 + "get-intrinsic": "^1.2.6"
4239 + },
4240 + "engines": {
4241 + "node": ">= 0.4"
4242 + },
4243 + "funding": {
4244 + "url": "https://github.com/sponsors/ljharb"
4245 + }
4246 + },
4247 + "node_modules/isarray": {
4248 + "version": "2.0.5",
4249 + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
4250 + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
4251 + "dev": true,
4252 + "license": "MIT"
4253 + },
4254 + "node_modules/isexe": {
4255 + "version": "2.0.0",
4256 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
4257 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
4258 + "dev": true,
4259 + "license": "ISC"
4260 + },
4261 + "node_modules/iterator.prototype": {
4262 + "version": "1.1.5",
4263 + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
4264 + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
4265 + "dev": true,
4266 + "license": "MIT",
4267 + "dependencies": {
4268 + "define-data-property": "^1.1.4",
4269 + "es-object-atoms": "^1.0.0",
4270 + "get-intrinsic": "^1.2.6",
4271 + "get-proto": "^1.0.0",
4272 + "has-symbols": "^1.1.0",
4273 + "set-function-name": "^2.0.2"
4274 + },
4275 + "engines": {
4276 + "node": ">= 0.4"
4277 + }
4278 + },
4279 + "node_modules/js-tokens": {
4280 + "version": "4.0.0",
4281 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
4282 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
4283 + "license": "MIT"
4284 + },
4285 + "node_modules/js-yaml": {
4286 + "version": "4.3.1",
4287 + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
4288 + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
4289 + "dev": true,
4290 + "funding": [
4291 + {
4292 + "type": "github",
4293 + "url": "https://github.com/sponsors/puzrin"
4294 + },
4295 + {
4296 + "type": "github",
4297 + "url": "https://github.com/sponsors/nodeca"
4298 + }
4299 + ],
4300 + "license": "MIT",
4301 + "dependencies": {
4302 + "argparse": "^2.0.1"
4303 + },
4304 + "bin": {
4305 + "js-yaml": "bin/js-yaml.js"
4306 + }
4307 + },
4308 + "node_modules/jsesc": {
4309 + "version": "3.1.0",
4310 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
4311 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
4312 + "dev": true,
4313 + "license": "MIT",
4314 + "bin": {
4315 + "jsesc": "bin/jsesc"
4316 + },
4317 + "engines": {
4318 + "node": ">=6"
4319 + }
4320 + },
4321 + "node_modules/json-buffer": {
4322 + "version": "3.0.1",
4323 + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
4324 + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
4325 + "dev": true,
4326 + "license": "MIT"
4327 + },
4328 + "node_modules/json-schema-traverse": {
4329 + "version": "0.4.1",
4330 + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
4331 + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
4332 + "dev": true,
4333 + "license": "MIT"
4334 + },
4335 + "node_modules/json-stable-stringify-without-jsonify": {
4336 + "version": "1.0.1",
4337 + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
4338 + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
4339 + "dev": true,
4340 + "license": "MIT"
4341 + },
4342 + "node_modules/json5": {
4343 + "version": "1.0.2",
4344 + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
4345 + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
4346 + "dev": true,
4347 + "license": "MIT",
4348 + "dependencies": {
4349 + "minimist": "^1.2.0"
4350 + },
4351 + "bin": {
4352 + "json5": "lib/cli.js"
4353 + }
4354 + },
4355 + "node_modules/jsx-ast-utils": {
4356 + "version": "3.3.5",
4357 + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
4358 + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
4359 + "dev": true,
4360 + "license": "MIT",
4361 + "dependencies": {
4362 + "array-includes": "^3.1.6",
4363 + "array.prototype.flat": "^1.3.1",
4364 + "object.assign": "^4.1.4",
4365 + "object.values": "^1.1.6"
4366 + },
4367 + "engines": {
4368 + "node": ">=4.0"
4369 + }
4370 + },
4371 + "node_modules/keyv": {
4372 + "version": "4.5.4",
4373 + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
4374 + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
4375 + "dev": true,
4376 + "license": "MIT",
4377 + "dependencies": {
4378 + "json-buffer": "3.0.1"
4379 + }
4380 + },
4381 + "node_modules/language-subtag-registry": {
4382 + "version": "0.3.23",
4383 + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
4384 + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==",
4385 + "dev": true,
4386 + "license": "CC0-1.0"
4387 + },
4388 + "node_modules/language-tags": {
4389 + "version": "1.0.9",
4390 + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz",
4391 + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==",
4392 + "dev": true,
4393 + "license": "MIT",
4394 + "dependencies": {
4395 + "language-subtag-registry": "^0.3.20"
4396 + },
4397 + "engines": {
4398 + "node": ">=0.10"
4399 + }
4400 + },
4401 + "node_modules/levn": {
4402 + "version": "0.4.1",
4403 + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
4404 + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
4405 + "dev": true,
4406 + "license": "MIT",
4407 + "dependencies": {
4408 + "prelude-ls": "^1.2.1",
4409 + "type-check": "~0.4.0"
4410 + },
4411 + "engines": {
4412 + "node": ">= 0.8.0"
4413 + }
4414 + },
4415 + "node_modules/locate-path": {
4416 + "version": "6.0.0",
4417 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
4418 + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
4419 + "dev": true,
4420 + "license": "MIT",
4421 + "dependencies": {
4422 + "p-locate": "^5.0.0"
4423 + },
4424 + "engines": {
4425 + "node": ">=10"
4426 + },
4427 + "funding": {
4428 + "url": "https://github.com/sponsors/sindresorhus"
4429 + }
4430 + },
4431 + "node_modules/lodash.merge": {
4432 + "version": "4.6.2",
4433 + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
4434 + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
4435 + "dev": true,
4436 + "license": "MIT"
4437 + },
4438 + "node_modules/loose-envify": {
4439 + "version": "1.4.0",
4440 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
4441 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
4442 + "license": "MIT",
4443 + "dependencies": {
4444 + "js-tokens": "^3.0.0 || ^4.0.0"
4445 + },
4446 + "bin": {
4447 + "loose-envify": "cli.js"
4448 + }
4449 + },
4450 + "node_modules/lru-cache": {
4451 + "version": "5.1.1",
4452 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
4453 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
4454 + "dev": true,
4455 + "license": "ISC",
4456 + "dependencies": {
4457 + "yallist": "^3.0.2"
4458 + }
4459 + },
4460 + "node_modules/lucide-react": {
4461 + "version": "0.468.0",
4462 + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
4463 + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
4464 + "license": "ISC",
4465 + "peerDependencies": {
4466 + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
4467 + }
4468 + },
4469 + "node_modules/math-intrinsics": {
4470 + "version": "1.1.0",
4471 + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
4472 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
4473 + "dev": true,
4474 + "license": "MIT",
4475 + "engines": {
4476 + "node": ">= 0.4"
4477 + }
4478 + },
4479 + "node_modules/merge2": {
4480 + "version": "1.4.1",
4481 + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
4482 + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
4483 + "dev": true,
4484 + "license": "MIT",
4485 + "engines": {
4486 + "node": ">= 8"
4487 + }
4488 + },
4489 + "node_modules/micromatch": {
4490 + "version": "4.0.8",
4491 + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
4492 + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
4493 + "dev": true,
4494 + "license": "MIT",
4495 + "dependencies": {
4496 + "braces": "^3.0.3",
4497 + "picomatch": "^2.3.1"
4498 + },
4499 + "engines": {
4500 + "node": ">=8.6"
4501 + }
4502 + },
4503 + "node_modules/micromatch/node_modules/picomatch": {
4504 + "version": "2.3.2",
4505 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
4506 + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
4507 + "dev": true,
4508 + "license": "MIT",
4509 + "engines": {
4510 + "node": ">=8.6"
4511 + },
4512 + "funding": {
4513 + "url": "https://github.com/sponsors/jonschlinkert"
4514 + }
4515 + },
4516 + "node_modules/minimatch": {
4517 + "version": "3.1.5",
4518 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
4519 + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
4520 + "dev": true,
4521 + "license": "ISC",
4522 + "dependencies": {
4523 + "brace-expansion": "^1.1.7"
4524 + },
4525 + "engines": {
4526 + "node": "*"
4527 + }
4528 + },
4529 + "node_modules/minimist": {
4530 + "version": "1.2.8",
4531 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
4532 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
4533 + "dev": true,
4534 + "license": "MIT",
4535 + "funding": {
4536 + "url": "https://github.com/sponsors/ljharb"
4537 + }
4538 + },
4539 + "node_modules/ms": {
4540 + "version": "2.1.3",
4541 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
4542 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
4543 + "dev": true,
4544 + "license": "MIT"
4545 + },
4546 + "node_modules/nanoid": {
4547 + "version": "3.3.18",
4548 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
4549 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
4550 + "funding": [
4551 + {
4552 + "type": "github",
4553 + "url": "https://github.com/sponsors/ai"
4554 + }
4555 + ],
4556 + "license": "MIT",
4557 + "bin": {
4558 + "nanoid": "bin/nanoid.cjs"
4559 + },
4560 + "engines": {
4561 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
4562 + }
4563 + },
4564 + "node_modules/napi-postinstall": {
4565 + "version": "0.3.4",
4566 + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
4567 + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
4568 + "dev": true,
4569 + "license": "MIT",
4570 + "bin": {
4571 + "napi-postinstall": "lib/cli.js"
4572 + },
4573 + "engines": {
4574 + "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
4575 + },
4576 + "funding": {
4577 + "url": "https://opencollective.com/napi-postinstall"
4578 + }
4579 + },
4580 + "node_modules/natural-compare": {
4581 + "version": "1.4.0",
4582 + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
4583 + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
4584 + "dev": true,
4585 + "license": "MIT"
4586 + },
4587 + "node_modules/next": {
4588 + "version": "16.3.0",
4589 + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
4590 + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
4591 + "license": "MIT",
4592 + "dependencies": {
4593 + "@next/env": "16.3.0",
4594 + "@swc/helpers": "0.5.15",
4595 + "baseline-browser-mapping": "^2.9.19",
4596 + "caniuse-lite": "^1.0.30001579",
4597 + "postcss": "8.5.23",
4598 + "styled-jsx": "5.1.6"
4599 + },
4600 + "bin": {
4601 + "next": "dist/bin/next"
4602 + },
4603 + "engines": {
4604 + "node": ">=20.9.0"
4605 + },
4606 + "optionalDependencies": {
4607 + "@next/swc-darwin-arm64": "16.3.0",
4608 + "@next/swc-darwin-x64": "16.3.0",
4609 + "@next/swc-linux-arm64-gnu": "16.3.0",
4610 + "@next/swc-linux-arm64-musl": "16.3.0",
4611 + "@next/swc-linux-x64-gnu": "16.3.0",
4612 + "@next/swc-linux-x64-musl": "16.3.0",
4613 + "@next/swc-win32-arm64-msvc": "16.3.0",
4614 + "@next/swc-win32-x64-msvc": "16.3.0",
4615 + "sharp": "^0.35.3"
4616 + },
4617 + "peerDependencies": {
4618 + "@opentelemetry/api": "^1.1.0",
4619 + "@playwright/test": "^1.51.1",
4620 + "babel-plugin-react-compiler": "*",
4621 + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
4622 + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
4623 + "sass": "^1.3.0"
4624 + },
4625 + "peerDependenciesMeta": {
4626 + "@opentelemetry/api": {
4627 + "optional": true
4628 + },
4629 + "@playwright/test": {
4630 + "optional": true
4631 + },
4632 + "babel-plugin-react-compiler": {
4633 + "optional": true
4634 + },
4635 + "sass": {
4636 + "optional": true
4637 + }
4638 + }
4639 + },
4640 + "node_modules/node-exports-info": {
4641 + "version": "1.6.2",
4642 + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
4643 + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==",
4644 + "dev": true,
4645 + "license": "MIT",
4646 + "dependencies": {
4647 + "array.prototype.flatmap": "^1.3.3",
4648 + "es-errors": "^1.3.0",
4649 + "object.entries": "^1.1.9",
4650 + "semver": "^6.3.1"
4651 + },
4652 + "engines": {
4653 + "node": ">= 0.4"
4654 + },
4655 + "funding": {
4656 + "url": "https://github.com/sponsors/ljharb"
4657 + }
4658 + },
4659 + "node_modules/node-exports-info/node_modules/semver": {
4660 + "version": "6.3.1",
4661 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
4662 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
4663 + "dev": true,
4664 + "license": "ISC",
4665 + "bin": {
4666 + "semver": "bin/semver.js"
4667 + }
4668 + },
4669 + "node_modules/node-releases": {
4670 + "version": "2.0.53",
4671 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
4672 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
4673 + "dev": true,
4674 + "license": "MIT",
4675 + "engines": {
4676 + "node": ">=18"
4677 + }
4678 + },
4679 + "node_modules/object-assign": {
4680 + "version": "4.1.1",
4681 + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
4682 + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
4683 + "dev": true,
4684 + "license": "MIT",
4685 + "engines": {
4686 + "node": ">=0.10.0"
4687 + }
4688 + },
4689 + "node_modules/object-inspect": {
4690 + "version": "1.13.4",
4691 + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
4692 + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
4693 + "dev": true,
4694 + "license": "MIT",
4695 + "engines": {
4696 + "node": ">= 0.4"
4697 + },
4698 + "funding": {
4699 + "url": "https://github.com/sponsors/ljharb"
4700 + }
4701 + },
4702 + "node_modules/object-keys": {
4703 + "version": "1.1.1",
4704 + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
4705 + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
4706 + "dev": true,
4707 + "license": "MIT",
4708 + "engines": {
4709 + "node": ">= 0.4"
4710 + }
4711 + },
4712 + "node_modules/object.assign": {
4713 + "version": "4.1.7",
4714 + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
4715 + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
4716 + "dev": true,
4717 + "license": "MIT",
4718 + "dependencies": {
4719 + "call-bind": "^1.0.8",
4720 + "call-bound": "^1.0.3",
4721 + "define-properties": "^1.2.1",
4722 + "es-object-atoms": "^1.0.0",
4723 + "has-symbols": "^1.1.0",
4724 + "object-keys": "^1.1.1"
4725 + },
4726 + "engines": {
4727 + "node": ">= 0.4"
4728 + },
4729 + "funding": {
4730 + "url": "https://github.com/sponsors/ljharb"
4731 + }
4732 + },
4733 + "node_modules/object.entries": {
4734 + "version": "1.1.9",
4735 + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
4736 + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
4737 + "dev": true,
4738 + "license": "MIT",
4739 + "dependencies": {
4740 + "call-bind": "^1.0.8",
4741 + "call-bound": "^1.0.4",
4742 + "define-properties": "^1.2.1",
4743 + "es-object-atoms": "^1.1.1"
4744 + },
4745 + "engines": {
4746 + "node": ">= 0.4"
4747 + }
4748 + },
4749 + "node_modules/object.fromentries": {
4750 + "version": "2.0.8",
4751 + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
4752 + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
4753 + "dev": true,
4754 + "license": "MIT",
4755 + "dependencies": {
4756 + "call-bind": "^1.0.7",
4757 + "define-properties": "^1.2.1",
4758 + "es-abstract": "^1.23.2",
4759 + "es-object-atoms": "^1.0.0"
4760 + },
4761 + "engines": {
4762 + "node": ">= 0.4"
4763 + },
4764 + "funding": {
4765 + "url": "https://github.com/sponsors/ljharb"
4766 + }
4767 + },
4768 + "node_modules/object.groupby": {
4769 + "version": "1.0.3",
4770 + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
4771 + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
4772 + "dev": true,
4773 + "license": "MIT",
4774 + "dependencies": {
4775 + "call-bind": "^1.0.7",
4776 + "define-properties": "^1.2.1",
4777 + "es-abstract": "^1.23.2"
4778 + },
4779 + "engines": {
4780 + "node": ">= 0.4"
4781 + }
4782 + },
4783 + "node_modules/object.values": {
4784 + "version": "1.2.1",
4785 + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
4786 + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
4787 + "dev": true,
4788 + "license": "MIT",
4789 + "dependencies": {
4790 + "call-bind": "^1.0.8",
4791 + "call-bound": "^1.0.3",
4792 + "define-properties": "^1.2.1",
4793 + "es-object-atoms": "^1.0.0"
4794 + },
4795 + "engines": {
4796 + "node": ">= 0.4"
4797 + },
4798 + "funding": {
4799 + "url": "https://github.com/sponsors/ljharb"
4800 + }
4801 + },
4802 + "node_modules/optionator": {
4803 + "version": "0.9.4",
4804 + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
4805 + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
4806 + "dev": true,
4807 + "license": "MIT",
4808 + "dependencies": {
4809 + "deep-is": "^0.1.3",
4810 + "fast-levenshtein": "^2.0.6",
4811 + "levn": "^0.4.1",
4812 + "prelude-ls": "^1.2.1",
4813 + "type-check": "^0.4.0",
4814 + "word-wrap": "^1.2.5"
4815 + },
4816 + "engines": {
4817 + "node": ">= 0.8.0"
4818 + }
4819 + },
4820 + "node_modules/own-keys": {
4821 + "version": "1.0.2",
4822 + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
4823 + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==",
4824 + "dev": true,
4825 + "license": "MIT",
4826 + "dependencies": {
4827 + "call-bound": "^1.0.4",
4828 + "get-intrinsic": "^1.3.0",
4829 + "object-keys": "^1.1.1",
4830 + "safe-push-apply": "^1.0.0"
4831 + },
4832 + "engines": {
4833 + "node": ">= 0.4"
4834 + },
4835 + "funding": {
4836 + "url": "https://github.com/sponsors/ljharb"
4837 + }
4838 + },
4839 + "node_modules/p-limit": {
4840 + "version": "3.1.0",
4841 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
4842 + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
4843 + "dev": true,
4844 + "license": "MIT",
4845 + "dependencies": {
4846 + "yocto-queue": "^0.1.0"
4847 + },
4848 + "engines": {
4849 + "node": ">=10"
4850 + },
4851 + "funding": {
4852 + "url": "https://github.com/sponsors/sindresorhus"
4853 + }
4854 + },
4855 + "node_modules/p-locate": {
4856 + "version": "5.0.0",
4857 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
4858 + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
4859 + "dev": true,
4860 + "license": "MIT",
4861 + "dependencies": {
4862 + "p-limit": "^3.0.2"
4863 + },
4864 + "engines": {
4865 + "node": ">=10"
4866 + },
4867 + "funding": {
4868 + "url": "https://github.com/sponsors/sindresorhus"
4869 + }
4870 + },
4871 + "node_modules/parent-module": {
4872 + "version": "1.0.1",
4873 + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
4874 + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
4875 + "dev": true,
4876 + "license": "MIT",
4877 + "dependencies": {
4878 + "callsites": "^3.0.0"
4879 + },
4880 + "engines": {
4881 + "node": ">=6"
4882 + }
4883 + },
4884 + "node_modules/path-exists": {
4885 + "version": "4.0.0",
4886 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
4887 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
4888 + "dev": true,
4889 + "license": "MIT",
4890 + "engines": {
4891 + "node": ">=8"
4892 + }
4893 + },
4894 + "node_modules/path-key": {
4895 + "version": "3.1.1",
4896 + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
4897 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
4898 + "dev": true,
4899 + "license": "MIT",
4900 + "engines": {
4901 + "node": ">=8"
4902 + }
4903 + },
4904 + "node_modules/path-parse": {
4905 + "version": "1.0.7",
4906 + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
4907 + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
4908 + "dev": true,
4909 + "license": "MIT"
4910 + },
4911 + "node_modules/picocolors": {
4912 + "version": "1.1.1",
4913 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
4914 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
4915 + "license": "ISC"
4916 + },
4917 + "node_modules/picomatch": {
4918 + "version": "4.0.5",
4919 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
4920 + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
4921 + "dev": true,
4922 + "license": "MIT",
4923 + "engines": {
4924 + "node": ">=12"
4925 + },
4926 + "funding": {
4927 + "url": "https://github.com/sponsors/jonschlinkert"
4928 + }
4929 + },
4930 + "node_modules/possible-typed-array-names": {
4931 + "version": "1.1.0",
4932 + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
4933 + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
4934 + "dev": true,
4935 + "license": "MIT",
4936 + "engines": {
4937 + "node": ">= 0.4"
4938 + }
4939 + },
4940 + "node_modules/postcss": {
4941 + "version": "8.5.23",
4942 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
4943 + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
4944 + "funding": [
4945 + {
4946 + "type": "opencollective",
4947 + "url": "https://opencollective.com/postcss/"
4948 + },
4949 + {
4950 + "type": "tidelift",
4951 + "url": "https://tidelift.com/funding/github/npm/postcss"
4952 + },
4953 + {
4954 + "type": "github",
4955 + "url": "https://github.com/sponsors/ai"
4956 + }
4957 + ],
4958 + "license": "MIT",
4959 + "dependencies": {
4960 + "nanoid": "^3.3.16",
4961 + "picocolors": "^1.1.1",
4962 + "source-map-js": "^1.2.1"
4963 + },
4964 + "engines": {
4965 + "node": "^10 || ^12 || >=14"
4966 + }
4967 + },
4968 + "node_modules/prelude-ls": {
4969 + "version": "1.2.1",
4970 + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
4971 + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
4972 + "dev": true,
4973 + "license": "MIT",
4974 + "engines": {
4975 + "node": ">= 0.8.0"
4976 + }
4977 + },
4978 + "node_modules/prop-types": {
4979 + "version": "15.8.1",
4980 + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
4981 + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
4982 + "dev": true,
4983 + "license": "MIT",
4984 + "dependencies": {
4985 + "loose-envify": "^1.4.0",
4986 + "object-assign": "^4.1.1",
4987 + "react-is": "^16.13.1"
4988 + }
4989 + },
4990 + "node_modules/punycode": {
4991 + "version": "2.3.1",
4992 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
4993 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
4994 + "dev": true,
4995 + "license": "MIT",
4996 + "engines": {
4997 + "node": ">=6"
4998 + }
4999 + },

This file is too large to show in full.

frontend/package.json new
+25
@@ -0,0 +1,25 @@
1 +{
2 + "name": "ai-investment-platform-frontend",
3 + "version": "0.1.0",
4 + "private": true,
5 + "scripts": {
6 + "dev": "next dev",
7 + "build": "next build",
8 + "start": "next start",
9 + "lint": "eslint ."
10 + },
11 + "dependencies": {
12 + "lucide-react": "^0.468.0",
13 + "next": "^16.3.0",
14 + "react": "18.3.1",
15 + "react-dom": "18.3.1"
16 + },
17 + "devDependencies": {
18 + "@types/node": "^22.7.5",
19 + "@types/react": "^18.3.11",
20 + "@types/react-dom": "^18.3.1",
21 + "eslint": "^9.7.0",
22 + "eslint-config-next": "^16.3.0",
23 + "typescript": "^5.6.3"
24 + }
25 +}
frontend/public/.gitkeep new
+1
@@ -0,0 +1 @@
1 +
frontend/tsconfig.json new
+36
@@ -0,0 +1,36 @@
1 +{
2 + "compilerOptions": {
3 + "target": "es2022",
4 + "lib": [
5 + "dom",
6 + "dom.iterable",
7 + "es2022"
8 + ],
9 + "allowJs": false,
10 + "skipLibCheck": true,
11 + "strict": true,
12 + "noEmit": true,
13 + "esModuleInterop": true,
14 + "module": "esnext",
15 + "moduleResolution": "bundler",
16 + "resolveJsonModule": true,
17 + "isolatedModules": true,
18 + "jsx": "react-jsx",
19 + "incremental": true,
20 + "plugins": [
21 + {
22 + "name": "next"
23 + }
24 + ]
25 + },
26 + "include": [
27 + "next-env.d.ts",
28 + "**/*.ts",
29 + "**/*.tsx",
30 + ".next/types/**/*.ts",
31 + ".next/dev/types/**/*.ts"
32 + ],
33 + "exclude": [
34 + "node_modules"
35 + ]
36 +}
infrastructure/helm/ai-investment-platform/Chart.yaml new
+6
@@ -0,0 +1,6 @@
1 +apiVersion: v2
2 +name: ai-investment-platform
3 +description: AI Investment Intelligence Platform foundation chart
4 +type: application
5 +version: 0.1.0
6 +appVersion: "0.1.0"
infrastructure/helm/ai-investment-platform/templates/_helpers.tpl new
+15
@@ -0,0 +1,15 @@
1 +{{- define "aip.name" -}}
2 +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}}
3 +{{- end -}}
4 +
5 +{{- define "aip.labels" -}}
6 +app.kubernetes.io/name: {{ include "aip.name" . }}
7 +app.kubernetes.io/instance: {{ .Release.Name }}
8 +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
9 +app.kubernetes.io/managed-by: {{ .Release.Service }}
10 +{{- end -}}
11 +
12 +{{- define "aip.image" -}}
13 +{{- $registry := .root.Values.global.imageRegistry -}}
14 +{{- if $registry -}}{{ $registry }}/{{ .image }}:{{ .tag }}{{- else -}}{{ .image }}:{{ .tag }}{{- end -}}
15 +{{- end -}}
infrastructure/helm/ai-investment-platform/templates/ai-services.yaml new
+56
@@ -0,0 +1,56 @@
1 +{{- range .Values.aiServices }}
2 +apiVersion: apps/v1
3 +kind: Deployment
4 +metadata:
5 + name: {{ .name }}
6 + labels:
7 + app.kubernetes.io/component: {{ .name }}
8 +spec:
9 + replicas: 1
10 + selector:
11 + matchLabels:
12 + app.kubernetes.io/component: {{ .name }}
13 + template:
14 + metadata:
15 + labels:
16 + app.kubernetes.io/component: {{ .name }}
17 + spec:
18 + containers:
19 + - name: {{ .name }}
20 + image: {{ include "aip.image" (dict "root" $ "image" .image "tag" .tag) }}
21 + imagePullPolicy: {{ $.Values.global.imagePullPolicy }}
22 + env:
23 + - name: AIP_ENVIRONMENT
24 + value: {{ $.Values.global.environment | quote }}
25 + {{- if eq .name "research-engine" }}
26 + - name: AIP_RESEARCH_LIVE_ENABLED
27 + value: {{ default false $.Values.research.liveEnabled | quote }}
28 + - name: AIP_RESEARCH_DEMO_ENABLED
29 + value: {{ default true $.Values.research.demoEnabled | quote }}
30 + {{- end }}
31 + ports:
32 + - containerPort: {{ .port }}
33 + readinessProbe:
34 + httpGet:
35 + path: /health
36 + port: {{ .port }}
37 + livenessProbe:
38 + httpGet:
39 + path: /health
40 + port: {{ .port }}
41 + resources:
42 + {{- toYaml (default $.Values.aiServiceDefaults.resources .resources) | nindent 12 }}
43 +---
44 +apiVersion: v1
45 +kind: Service
46 +metadata:
47 + name: {{ .name }}
48 +spec:
49 + selector:
50 + app.kubernetes.io/component: {{ .name }}
51 + ports:
52 + - name: http
53 + port: 80
54 + targetPort: {{ .port }}
55 +---
56 +{{- end }}
infrastructure/helm/ai-investment-platform/templates/dev-dependencies.yaml new
+133
@@ -0,0 +1,133 @@
1 +{{- if .Values.devDependencies.enabled }}
2 +apiVersion: v1
3 +kind: Secret
4 +metadata:
5 + name: {{ .Values.devDependencies.postgres.passwordSecretName }}
6 +type: Opaque
7 +stringData:
8 + POSTGRES_PASSWORD: {{ default (randAlphaNum 32) .Values.devDependencies.postgres.password | quote }}
9 +---
10 +apiVersion: apps/v1
11 +kind: Deployment
12 +metadata:
13 + name: postgres
14 +spec:
15 + replicas: 1
16 + selector:
17 + matchLabels:
18 + app.kubernetes.io/component: postgres
19 + template:
20 + metadata:
21 + labels:
22 + app.kubernetes.io/component: postgres
23 + spec:
24 + containers:
25 + - name: postgres
26 + image: {{ .Values.devDependencies.postgres.image }}
27 + env:
28 + - name: POSTGRES_DB
29 + value: {{ .Values.devDependencies.postgres.database | quote }}
30 + - name: POSTGRES_USER
31 + value: {{ .Values.devDependencies.postgres.username | quote }}
32 + - name: POSTGRES_PASSWORD
33 + valueFrom:
34 + secretKeyRef:
35 + name: {{ .Values.devDependencies.postgres.passwordSecretName }}
36 + key: POSTGRES_PASSWORD
37 + ports:
38 + - containerPort: 5432
39 + resources:
40 + {{- toYaml .Values.devDependencies.postgres.resources | nindent 12 }}
41 +---
42 +apiVersion: v1
43 +kind: Service
44 +metadata:
45 + name: postgres
46 +spec:
47 + selector:
48 + app.kubernetes.io/component: postgres
49 + ports:
50 + - port: 5432
51 + targetPort: 5432
52 +---
53 +apiVersion: apps/v1
54 +kind: Deployment
55 +metadata:
56 + name: redis
57 +spec:
58 + replicas: 1
59 + selector:
60 + matchLabels:
61 + app.kubernetes.io/component: redis
62 + template:
63 + metadata:
64 + labels:
65 + app.kubernetes.io/component: redis
66 + spec:
67 + containers:
68 + - name: redis
69 + image: {{ .Values.devDependencies.redis.image }}
70 + ports:
71 + - containerPort: 6379
72 + resources:
73 + {{- toYaml .Values.devDependencies.redis.resources | nindent 12 }}
74 +---
75 +apiVersion: v1
76 +kind: Service
77 +metadata:
78 + name: redis
79 +spec:
80 + selector:
81 + app.kubernetes.io/component: redis
82 + ports:
83 + - port: 6379
84 + targetPort: 6379
85 +---
86 +apiVersion: apps/v1
87 +kind: Deployment
88 +metadata:
89 + name: kafka
90 +spec:
91 + replicas: 1
92 + selector:
93 + matchLabels:
94 + app.kubernetes.io/component: kafka
95 + template:
96 + metadata:
97 + labels:
98 + app.kubernetes.io/component: kafka
99 + spec:
100 + containers:
101 + - name: kafka
102 + image: {{ .Values.devDependencies.kafka.image }}
103 + env:
104 + - name: KAFKA_CFG_NODE_ID
105 + value: "0"
106 + - name: KAFKA_CFG_PROCESS_ROLES
107 + value: controller,broker
108 + - name: KAFKA_CFG_CONTROLLER_QUORUM_VOTERS
109 + value: 0@kafka:9093
110 + - name: KAFKA_CFG_LISTENERS
111 + value: PLAINTEXT://:9092,CONTROLLER://:9093
112 + - name: KAFKA_CFG_ADVERTISED_LISTENERS
113 + value: PLAINTEXT://kafka:9092
114 + - name: KAFKA_CFG_CONTROLLER_LISTENER_NAMES
115 + value: CONTROLLER
116 + - name: ALLOW_PLAINTEXT_LISTENER
117 + value: "yes"
118 + ports:
119 + - containerPort: 9092
120 + resources:
121 + {{- toYaml .Values.devDependencies.kafka.resources | nindent 12 }}
122 +---
123 +apiVersion: v1
124 +kind: Service
125 +metadata:
126 + name: kafka
127 +spec:
128 + selector:
129 + app.kubernetes.io/component: kafka
130 + ports:
131 + - port: 9092
132 + targetPort: 9092
133 +{{- end }}
infrastructure/helm/ai-investment-platform/templates/frontend.yaml new
+47
@@ -0,0 +1,47 @@
1 +{{- if .Values.frontend.enabled }}
2 +apiVersion: apps/v1
3 +kind: Deployment
4 +metadata:
5 + name: frontend
6 + labels:
7 + {{- include "aip.labels" . | nindent 4 }}
8 + app.kubernetes.io/component: frontend
9 +spec:
10 + replicas: 1
11 + selector:
12 + matchLabels:
13 + app.kubernetes.io/component: frontend
14 + template:
15 + metadata:
16 + labels:
17 + app.kubernetes.io/component: frontend
18 + spec:
19 + containers:
20 + - name: frontend
21 + image: {{ include "aip.image" (dict "root" . "image" .Values.frontend.image "tag" .Values.frontend.tag) }}
22 + imagePullPolicy: {{ .Values.global.imagePullPolicy }}
23 + ports:
24 + - containerPort: {{ .Values.frontend.port }}
25 + readinessProbe:
26 + httpGet:
27 + path: /api/health
28 + port: {{ .Values.frontend.port }}
29 + livenessProbe:
30 + httpGet:
31 + path: /api/health
32 + port: {{ .Values.frontend.port }}
33 + resources:
34 + {{- toYaml .Values.frontend.resources | nindent 12 }}
35 +---
36 +apiVersion: v1
37 +kind: Service
38 +metadata:
39 + name: frontend
40 +spec:
41 + selector:
42 + app.kubernetes.io/component: frontend
43 + ports:
44 + - name: http
45 + port: 80
46 + targetPort: {{ .Values.frontend.port }}
47 +{{- end }}
infrastructure/helm/ai-investment-platform/templates/java-services.yaml new
+70
@@ -0,0 +1,70 @@
1 +{{- range .Values.javaServices }}
2 +apiVersion: apps/v1
3 +kind: Deployment
4 +metadata:
5 + name: {{ .name }}
6 + labels:
7 + app.kubernetes.io/component: {{ .name }}
8 +spec:
9 + replicas: 1
10 + selector:
11 + matchLabels:
12 + app.kubernetes.io/component: {{ .name }}
13 + template:
14 + metadata:
15 + labels:
16 + app.kubernetes.io/component: {{ .name }}
17 + spec:
18 + containers:
19 + - name: {{ .name }}
20 + image: {{ include "aip.image" (dict "root" $ "image" .image "tag" .tag) }}
21 + imagePullPolicy: {{ $.Values.global.imagePullPolicy }}
22 + env:
23 + - name: SPRING_PROFILES_ACTIVE
24 + value: {{ $.Values.global.environment | quote }}
25 + - name: SERVER_PORT
26 + value: {{ .port | quote }}
27 + {{- if eq .name "api-gateway" }}
28 + - name: RESEARCH_ENGINE_BASE_URL
29 + value: "http://research-engine"
30 + {{- end }}
31 + {{- if eq .name "broker-service" }}
32 + - name: IBKR_ENABLED
33 + value: "false"
34 + - name: IBKR_OFFICIAL_DOCUMENTATION_VERIFIED
35 + value: "false"
36 + - name: ICICI_DIRECT_ENABLED
37 + value: "false"
38 + - name: ICICI_DIRECT_OFFICIAL_DOCUMENTATION_VERIFIED
39 + value: "false"
40 + {{- end }}
41 + {{- if eq .name "portfolio-service" }}
42 + - name: MARKET_DATA_DEMO_MODE
43 + value: {{ ternary "true" "false" (eq $.Values.global.environment "DEV") | quote }}
44 + {{- end }}
45 + ports:
46 + - containerPort: {{ .port }}
47 + readinessProbe:
48 + httpGet:
49 + path: /actuator/health/readiness
50 + port: {{ .port }}
51 + livenessProbe:
52 + httpGet:
53 + path: /actuator/health/liveness
54 + port: {{ .port }}
55 + resources:
56 + {{- toYaml (default $.Values.javaServiceDefaults.resources .resources) | nindent 12 }}
57 +---
58 +apiVersion: v1
59 +kind: Service
60 +metadata:
61 + name: {{ .name }}
62 +spec:
63 + selector:
64 + app.kubernetes.io/component: {{ .name }}
65 + ports:
66 + - name: http
67 + port: 80
68 + targetPort: {{ .port }}
69 +---
70 +{{- end }}
infrastructure/helm/ai-investment-platform/values-dev.yaml new
+6
@@ -0,0 +1,6 @@
1 +global:
2 + environment: DEV
3 + imageRegistry: localhost:5001
4 +
5 +devDependencies:
6 + enabled: true
infrastructure/helm/ai-investment-platform/values-prd.yaml new
+10
@@ -0,0 +1,10 @@
1 +global:
2 + environment: PRD
3 + imageRegistry: ""
4 +
5 +devDependencies:
6 + enabled: false
7 +
8 +research:
9 + liveEnabled: false
10 + demoEnabled: false
infrastructure/helm/ai-investment-platform/values.yaml new
+129
@@ -0,0 +1,129 @@
1 +global:
2 + imageRegistry: localhost:5001
3 + imagePullPolicy: IfNotPresent
4 + environment: DEV
5 +
6 +frontend:
7 + enabled: true
8 + image: ai-investment/frontend
9 + tag: latest
10 + port: 3000
11 + resources:
12 + requests:
13 + cpu: 100m
14 + memory: 128Mi
15 + limits:
16 + cpu: 500m
17 + memory: 512Mi
18 +
19 +javaServices:
20 + - name: api-gateway
21 + image: ai-investment/api-gateway
22 + tag: latest
23 + port: 8080
24 + - name: auth-service
25 + image: ai-investment/auth-service
26 + tag: latest
27 + port: 8080
28 + - name: portfolio-service
29 + image: ai-investment/portfolio-service
30 + tag: latest
31 + port: 8080
32 + - name: broker-service
33 + image: ai-investment/broker-service
34 + tag: latest
35 + port: 8080
36 + - name: company-service
37 + image: ai-investment/company-service
38 + tag: latest
39 + port: 8080
40 + - name: research-service
41 + image: ai-investment/research-service
42 + tag: latest
43 + port: 8080
44 + - name: recommendation-service
45 + image: ai-investment/recommendation-service
46 + tag: latest
47 + port: 8080
48 + - name: risk-service
49 + image: ai-investment/risk-service
50 + tag: latest
51 + port: 8080
52 + - name: notification-service
53 + image: ai-investment/notification-service
54 + tag: latest
55 + port: 8080
56 +
57 +javaServiceDefaults:
58 + resources:
59 + requests:
60 + cpu: 100m
61 + memory: 256Mi
62 + limits:
63 + cpu: 500m
64 + memory: 768Mi
65 +
66 +aiServices:
67 + - name: research-engine
68 + image: ai-investment/research-engine
69 + tag: latest
70 + port: 8000
71 + - name: valuation-engine
72 + image: ai-investment/valuation-engine
73 + tag: latest
74 + port: 8000
75 + - name: ranking-engine
76 + image: ai-investment/ranking-engine
77 + tag: latest
78 + port: 8000
79 + - name: portfolio-optimizer
80 + image: ai-investment/portfolio-optimizer
81 + tag: latest
82 + port: 8000
83 +
84 +aiServiceDefaults:
85 + resources:
86 + requests:
87 + cpu: 100m
88 + memory: 256Mi
89 + limits:
90 + cpu: 500m
91 + memory: 768Mi
92 +
93 +research:
94 + liveEnabled: false
95 + demoEnabled: true
96 +
97 +devDependencies:
98 + enabled: true
99 + postgres:
100 + image: postgres:16-alpine
101 + database: investment
102 + username: investment
103 + password: ""
104 + passwordSecretName: dev-postgres
105 + resources:
106 + requests:
107 + cpu: 100m
108 + memory: 256Mi
109 + limits:
110 + cpu: 500m
111 + memory: 512Mi
112 + redis:
113 + image: redis:7-alpine
114 + resources:
115 + requests:
116 + cpu: 50m
117 + memory: 64Mi
118 + limits:
119 + cpu: 250m
120 + memory: 256Mi
121 + kafka:
122 + image: bitnami/kafka:3.7
123 + resources:
124 + requests:
125 + cpu: 250m
126 + memory: 512Mi
127 + limits:
128 + cpu: 1000m
129 + memory: 1Gi
infrastructure/k3d/cluster-dev.yaml new
+21
@@ -0,0 +1,21 @@
1 +apiVersion: k3d.io/v1alpha5
2 +kind: Simple
3 +metadata:
4 + name: ai-investment-dev
5 +servers: 1
6 +agents: 2
7 +ports:
8 + - port: 8080:80
9 + nodeFilters:
10 + - loadbalancer
11 +registries:
12 + create:
13 + name: ai-investment-registry
14 + host: 0.0.0.0
15 + hostPort: "5001"
16 +options:
17 + k3s:
18 + extraArgs:
19 + - arg: --disable=traefik
20 + nodeFilters:
21 + - server:*
infrastructure/terraform/environments/prd/.terraform.lock.hcl new
+22
@@ -0,0 +1,22 @@
1 +# This file is maintained automatically by "terraform init".
2 +# Manual edits may be lost in future updates.
3 +
4 +provider "registry.terraform.io/hashicorp/azurerm" {
5 + version = "4.81.0"
6 + constraints = "~> 4.0"
7 + hashes = [
8 + "h1:4BEgtU/crlkbMduT7tm8Sp3+C+1RBfrpUIlTBM/wtMc=",
9 + "zh:0732e7b74264ddfa2b90ba69d01c283d3cbae9f72ed3e506c6ac92529fed7fd3",
10 + "zh:12afb524e232fe4e3d6161927724af5dfa4831d71edd9c174917ca9b7377bfae",
11 + "zh:169d619ae202c4145e02fb706fb7c3679445ab3e3ff722edbf89597517a8c92e",
12 + "zh:6beb95a3ef2f2d9c76abaa48e5450e90686a3fb6a47f1cb0ff7c5e94b6960151",
13 + "zh:705e075fb5ffc4bf66fd7cbabf1a65007a41621e80030a2c158a4c83b6046216",
14 + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
15 + "zh:79a8d17fefe647040fcb9ee8821a4f09f395427c4fd49493489b9a93a9a1038e",
16 + "zh:8cc3f900b3774c0ae37ae42365c4579a199cf9e5edf88e476fdf5ab1048f84ea",
17 + "zh:dec373b9390fa95e257291acd018ed65a7d512b428645d35e22cdbe8b245a08b",
18 + "zh:e60f1e9fb45df6defade2855ed6e68547409ea75d30655c556adb0c08579749b",
19 + "zh:f901d12ec82f3f8b5880a27b5cbcd7bd0d97e60c9367a2d7ed82fdd1157b39ff",
20 + "zh:facf68ea5bf0f2b8ba720e7fba5f86492e1d4c591100460bb91c3f79f391f4b6",
21 + ]
22 +}
infrastructure/terraform/environments/prd/main.tf new
+52
@@ -0,0 +1,52 @@
1 +locals {
2 + short_name = "aiinvestprd"
3 + tags = {
4 + project = var.project_name
5 + environment = "prd"
6 + managed_by = "terraform"
7 + }
8 +}
9 +
10 +module "resource_group" {
11 + source = "../../modules/resource-group"
12 + name = "rg-${var.project_name}-prd"
13 + location = var.location
14 + tags = local.tags
15 +}
16 +
17 +module "networking" {
18 + source = "../../modules/networking"
19 + resource_group_name = module.resource_group.name
20 + location = module.resource_group.location
21 + project_name = var.project_name
22 + address_space = ["10.42.0.0/16"]
23 + aks_subnet_prefixes = ["10.42.1.0/24"]
24 + tags = local.tags
25 +}
26 +
27 +module "acr" {
28 + source = "../../modules/acr"
29 + name = "${local.short_name}acr"
30 + resource_group_name = module.resource_group.name
31 + location = module.resource_group.location
32 + tags = local.tags
33 +}
34 +
35 +module "key_vault" {
36 + source = "../../modules/key-vault"
37 + name = "${local.short_name}kv"
38 + resource_group_name = module.resource_group.name
39 + location = module.resource_group.location
40 + tenant_id = var.tenant_id
41 + tags = local.tags
42 +}
43 +
44 +module "aks" {
45 + source = "../../modules/aks"
46 + name = "aks-${var.project_name}-prd"
47 + resource_group_name = module.resource_group.name
48 + location = module.resource_group.location
49 + dns_prefix = "ai-investment-prd"
50 + subnet_id = module.networking.aks_subnet_id
51 + tags = local.tags
52 +}
infrastructure/terraform/environments/prd/outputs.tf new
+15
@@ -0,0 +1,15 @@
1 +output "resource_group_name" {
2 + value = module.resource_group.name
3 +}
4 +
5 +output "acr_login_server" {
6 + value = module.acr.login_server
7 +}
8 +
9 +output "aks_cluster_name" {
10 + value = module.aks.name
11 +}
12 +
13 +output "key_vault_name" {
14 + value = module.key_vault.name
15 +}
infrastructure/terraform/environments/prd/prd.auto.tfvars.example new
+4
@@ -0,0 +1,4 @@
1 +subscription_id = "2dccba84-7038-4126-b0b2-32f8f29bcbd4"
2 +tenant_id = "ee868f5c-6f21-48fa-b329-3e114d8d229d"
3 +location = "westeurope"
4 +project_name = "ai-investment-platform"
infrastructure/terraform/environments/prd/providers.tf new
+16
@@ -0,0 +1,16 @@
1 +terraform {
2 + required_version = ">= 1.6.0"
3 +
4 + required_providers {
5 + azurerm = {
6 + source = "hashicorp/azurerm"
7 + version = "~> 4.0"
8 + }
9 + }
10 +}
11 +
12 +provider "azurerm" {
13 + features {}
14 + subscription_id = var.subscription_id
15 + tenant_id = var.tenant_id
16 +}
infrastructure/terraform/environments/prd/variables.tf new
+20
@@ -0,0 +1,20 @@
1 +variable "subscription_id" {
2 + type = string
3 + description = "Expected Azure subscription ID."
4 +}
5 +
6 +variable "tenant_id" {
7 + type = string
8 + description = "Expected Azure tenant ID."
9 +}
10 +
11 +variable "location" {
12 + type = string
13 + description = "Azure region."
14 +}
15 +
16 +variable "project_name" {
17 + type = string
18 + description = "Project resource prefix."
19 + default = "ai-investment-platform"
20 +}
infrastructure/terraform/modules/README.md new
+9
@@ -0,0 +1,9 @@
1 +# Terraform Modules
2 +
3 +Initial PRD foundation modules:
4 +
5 +- `resource-group`
6 +- `networking`
7 +- `acr`
8 +- `aks`
9 +- `key-vault`
infrastructure/terraform/modules/acr/main.tf new
+8
@@ -0,0 +1,8 @@
1 +resource "azurerm_container_registry" "main" {
2 + name = var.name
3 + resource_group_name = var.resource_group_name
4 + location = var.location
5 + sku = "Basic"
6 + admin_enabled = false
7 + tags = var.tags
8 +}
infrastructure/terraform/modules/acr/outputs.tf new
+3
@@ -0,0 +1,3 @@
1 +output "login_server" {
2 + value = azurerm_container_registry.main.login_server
3 +}
infrastructure/terraform/modules/acr/variables.tf new
+16
@@ -0,0 +1,16 @@
1 +variable "name" {
2 + type = string
3 +}
4 +
5 +variable "resource_group_name" {
6 + type = string
7 +}
8 +
9 +variable "location" {
10 + type = string
11 +}
12 +
13 +variable "tags" {
14 + type = map(string)
15 + default = {}
16 +}
infrastructure/terraform/modules/aks/main.tf new
+23
@@ -0,0 +1,23 @@
1 +resource "azurerm_kubernetes_cluster" "main" {
2 + name = var.name
3 + resource_group_name = var.resource_group_name
4 + location = var.location
5 + dns_prefix = var.dns_prefix
6 + tags = var.tags
7 +
8 + default_node_pool {
9 + name = "system"
10 + node_count = 2
11 + vm_size = "Standard_B2s"
12 + vnet_subnet_id = var.subnet_id
13 + }
14 +
15 + identity {
16 + type = "SystemAssigned"
17 + }
18 +
19 + network_profile {
20 + network_plugin = "azure"
21 + network_policy = "azure"
22 + }
23 +}
infrastructure/terraform/modules/aks/outputs.tf new
+3
@@ -0,0 +1,3 @@
1 +output "name" {
2 + value = azurerm_kubernetes_cluster.main.name
3 +}
infrastructure/terraform/modules/aks/variables.tf new
+24
@@ -0,0 +1,24 @@
1 +variable "name" {
2 + type = string
3 +}
4 +
5 +variable "resource_group_name" {
6 + type = string
7 +}
8 +
9 +variable "location" {
10 + type = string
11 +}
12 +
13 +variable "dns_prefix" {
14 + type = string
15 +}
16 +
17 +variable "subnet_id" {
18 + type = string
19 +}
20 +
21 +variable "tags" {
22 + type = map(string)
23 + default = {}
24 +}
infrastructure/terraform/modules/key-vault/main.tf new
+10
@@ -0,0 +1,10 @@
1 +resource "azurerm_key_vault" "main" {
2 + name = var.name
3 + resource_group_name = var.resource_group_name
4 + location = var.location
5 + tenant_id = var.tenant_id
6 + sku_name = "standard"
7 + purge_protection_enabled = false
8 + soft_delete_retention_days = 7
9 + tags = var.tags
10 +}
infrastructure/terraform/modules/key-vault/outputs.tf new
+3
@@ -0,0 +1,3 @@
1 +output "name" {
2 + value = azurerm_key_vault.main.name
3 +}
infrastructure/terraform/modules/key-vault/variables.tf new
+20
@@ -0,0 +1,20 @@
1 +variable "name" {
2 + type = string
3 +}
4 +
5 +variable "resource_group_name" {
6 + type = string
7 +}
8 +
9 +variable "location" {
10 + type = string
11 +}
12 +
13 +variable "tenant_id" {
14 + type = string
15 +}
16 +
17 +variable "tags" {
18 + type = map(string)
19 + default = {}
20 +}
infrastructure/terraform/modules/networking/main.tf new
+14
@@ -0,0 +1,14 @@
1 +resource "azurerm_virtual_network" "main" {
2 + name = "vnet-${var.project_name}-prd"
3 + resource_group_name = var.resource_group_name
4 + location = var.location
5 + address_space = var.address_space
6 + tags = var.tags
7 +}
8 +
9 +resource "azurerm_subnet" "aks" {
10 + name = "snet-aks"
11 + resource_group_name = var.resource_group_name
12 + virtual_network_name = azurerm_virtual_network.main.name
13 + address_prefixes = var.aks_subnet_prefixes
14 +}
infrastructure/terraform/modules/networking/outputs.tf new
+7
@@ -0,0 +1,7 @@
1 +output "vnet_name" {
2 + value = azurerm_virtual_network.main.name
3 +}
4 +
5 +output "aks_subnet_id" {
6 + value = azurerm_subnet.aks.id
7 +}
infrastructure/terraform/modules/networking/variables.tf new
+24
@@ -0,0 +1,24 @@
1 +variable "resource_group_name" {
2 + type = string
3 +}
4 +
5 +variable "location" {
6 + type = string
7 +}
8 +
9 +variable "project_name" {
10 + type = string
11 +}
12 +
13 +variable "address_space" {
14 + type = list(string)
15 +}
16 +
17 +variable "aks_subnet_prefixes" {
18 + type = list(string)
19 +}
20 +
21 +variable "tags" {
22 + type = map(string)
23 + default = {}
24 +}
infrastructure/terraform/modules/resource-group/main.tf new
+5
@@ -0,0 +1,5 @@
1 +resource "azurerm_resource_group" "main" {
2 + name = var.name
3 + location = var.location
4 + tags = var.tags
5 +}
infrastructure/terraform/modules/resource-group/outputs.tf new
+7
@@ -0,0 +1,7 @@
1 +output "name" {
2 + value = azurerm_resource_group.main.name
3 +}
4 +
5 +output "location" {
6 + value = azurerm_resource_group.main.location
7 +}
infrastructure/terraform/modules/resource-group/variables.tf new
+12
@@ -0,0 +1,12 @@
1 +variable "name" {
2 + type = string
3 +}
4 +
5 +variable "location" {
6 + type = string
7 +}
8 +
9 +variable "tags" {
10 + type = map(string)
11 + default = {}
12 +}
platform.ps1 new
+120
@@ -0,0 +1,120 @@
1 +param(
2 + [Parameter(Mandatory = $true, Position = 0)]
3 + [ValidateSet("up", "down", "status")]
4 + [string]$Command,
5 +
6 + [Parameter(Mandatory = $true, Position = 1)]
7 + [ValidateSet("DEV", "PRD")]
8 + [string]$Environment
9 +)
10 +
11 +$ErrorActionPreference = "Stop"
12 +
13 +$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
14 +$HelmChart = Join-Path $ProjectRoot "infrastructure\helm\ai-investment-platform"
15 +$K3dConfig = Join-Path $ProjectRoot "infrastructure\k3d\cluster-dev.yaml"
16 +$TerraformPrd = Join-Path $ProjectRoot "infrastructure\terraform\environments\prd"
17 +$AzureConfigPath = Join-Path $ProjectRoot "config\prd\azure.json"
18 +$AzureConfig = Get-Content -Raw $AzureConfigPath | ConvertFrom-Json
19 +
20 +function Require-Command {
21 + param([string]$Name)
22 + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
23 + throw "Required command '$Name' was not found on PATH."
24 + }
25 +}
26 +
27 +function Assert-AzureSubscription {
28 + Require-Command "az"
29 + $account = az account show --query "{subscriptionId:id, name:name, tenantId:tenantId}" -o json | ConvertFrom-Json
30 + if ($account.subscriptionId -ne $AzureConfig.subscriptionId) {
31 + throw "Azure CLI is using subscription '$($account.subscriptionId)' ('$($account.name)'). Expected '$($AzureConfig.subscriptionId)'. Run: az account set --subscription `"$($AzureConfig.subscriptionId)`""
32 + }
33 +}
34 +
35 +function Up-Dev {
36 + Require-Command "k3d"
37 + Require-Command "kubectl"
38 + Require-Command "helm"
39 +
40 + k3d cluster create --config $K3dConfig
41 + helm upgrade --install ai-investment-platform $HelmChart --namespace ai-investment --create-namespace --values (Join-Path $HelmChart "values-dev.yaml")
42 +}
43 +
44 +function Down-Dev {
45 + Require-Command "k3d"
46 + k3d cluster delete ai-investment-dev
47 +}
48 +
49 +function Status-Dev {
50 + Require-Command "kubectl"
51 + kubectl get nodes
52 + kubectl get pods -n ai-investment
53 + kubectl get svc -n ai-investment
54 +}
55 +
56 +function Up-Prd {
57 + Assert-AzureSubscription
58 + Require-Command "terraform"
59 + Require-Command "helm"
60 + Push-Location $TerraformPrd
61 + try {
62 + terraform init
63 + terraform plan -out tfplan `
64 + -var "subscription_id=$($AzureConfig.subscriptionId)" `
65 + -var "tenant_id=$($AzureConfig.tenantId)" `
66 + -var "location=$($AzureConfig.location)"
67 + Write-Host "Review the Terraform plan before running: terraform apply tfplan"
68 + Write-Host "This script intentionally stops before apply in the foundation iteration."
69 + }
70 + finally {
71 + Pop-Location
72 + }
73 +}
74 +
75 +function Down-Prd {
76 + Assert-AzureSubscription
77 + Require-Command "terraform"
78 + Write-Warning "This will run terraform destroy for the PRD Terraform state in '$TerraformPrd'."
79 + Write-Warning "Only resources managed by this project's Terraform state should be destroyed."
80 + $confirmation = Read-Host "Type DESTROY ai-investment-platform PRD to continue"
81 + if ($confirmation -ne "DESTROY ai-investment-platform PRD") {
82 + Write-Host "PRD destroy cancelled."
83 + return
84 + }
85 + Push-Location $TerraformPrd
86 + try {
87 + terraform init
88 + terraform destroy `
89 + -var "subscription_id=$($AzureConfig.subscriptionId)" `
90 + -var "tenant_id=$($AzureConfig.tenantId)" `
91 + -var "location=$($AzureConfig.location)"
92 + Write-Host "Audit remaining resources with:"
93 + Write-Host "az resource list --tag project=ai-investment-platform --output table"
94 + }
95 + finally {
96 + Pop-Location
97 + }
98 +}
99 +
100 +function Status-Prd {
101 + Assert-AzureSubscription
102 + Require-Command "terraform"
103 + Push-Location $TerraformPrd
104 + try {
105 + terraform state list
106 + terraform output
107 + }
108 + finally {
109 + Pop-Location
110 + }
111 +}
112 +
113 +switch ("$Command-$Environment") {
114 + "up-DEV" { Up-Dev }
115 + "down-DEV" { Down-Dev }
116 + "status-DEV" { Status-Dev }
117 + "up-PRD" { Up-Prd }
118 + "down-PRD" { Down-Prd }
119 + "status-PRD" { Status-Prd }
120 +}
pom.xml new
+16
@@ -0,0 +1,16 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0"
3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5 + <modelVersion>4.0.0</modelVersion>
6 +
7 + <groupId>com.aiinvestment</groupId>
8 + <artifactId>ai-investment-platform</artifactId>
9 + <version>0.1.0-SNAPSHOT</version>
10 + <packaging>pom</packaging>
11 + <name>AI Investment Intelligence Platform</name>
12 +
13 + <modules>
14 + <module>services</module>
15 + </modules>
16 +</project>
scripts/README.md new
+3
@@ -0,0 +1,3 @@
1 +# Scripts
2 +
3 +Operational helper scripts belong here. The first iteration keeps platform orchestration in the repository root `platform.ps1` because it is the required command entrypoint.
services/api-gateway/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/api-gateway/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/api-gateway/pom.xml new
+16
@@ -0,0 +1,16 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0"
3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5 + <modelVersion>4.0.0</modelVersion>
6 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
7 + <artifactId>api-gateway</artifactId>
8 + <dependencies>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
10 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
11 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
12 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
13 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
14 + </dependencies>
15 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
16 +</project>
services/api-gateway/src/main/java/com/aiinvestment/apigateway/ApiGatewayApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.apigateway;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class ApiGatewayApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(ApiGatewayApplication.class, args);
12 + }
13 +}
services/api-gateway/src/main/java/com/aiinvestment/apigateway/PortfolioRouteController.java new
+60
@@ -0,0 +1,60 @@
1 +package com.aiinvestment.apigateway;
2 +
3 +import jakarta.servlet.http.HttpServletRequest;
4 +import org.springframework.beans.factory.annotation.Value;
5 +import org.springframework.http.*;
6 +import org.springframework.util.StreamUtils;
7 +import org.springframework.web.bind.annotation.RequestMapping;
8 +import org.springframework.web.bind.annotation.RestController;
9 +import org.springframework.web.client.RestClient;
10 +
11 +import java.io.IOException;
12 +import java.nio.charset.StandardCharsets;
13 +import java.util.Collections;
14 +
15 +@RestController
16 +public class PortfolioRouteController {
17 + private final String portfolioServiceBaseUrl;
18 + private final String brokerServiceBaseUrl;
19 + private final String researchEngineBaseUrl;
20 + private final RestClient restClient;
21 +
22 + public PortfolioRouteController(@Value("${portfolio.service.base-url}") String portfolioServiceBaseUrl,
23 + @Value("${broker.service.base-url}") String brokerServiceBaseUrl,
24 + @Value("${research.engine.base-url}") String researchEngineBaseUrl) {
25 + this.portfolioServiceBaseUrl = portfolioServiceBaseUrl;
26 + this.brokerServiceBaseUrl = brokerServiceBaseUrl;
27 + this.researchEngineBaseUrl = researchEngineBaseUrl;
28 + this.restClient = RestClient.builder().build();
29 + }
30 +
31 + @RequestMapping("/api/v1/portfolios/**")
32 + public ResponseEntity<String> route(HttpServletRequest request) throws IOException {
33 + return forward(request, portfolioServiceBaseUrl);
34 + }
35 +
36 + @RequestMapping({"/api/v1/brokers/**", "/api/v1/brokers", "/api/v1/broker-connections/**", "/api/v1/broker-connections"})
37 + public ResponseEntity<String> routeBroker(HttpServletRequest request) throws IOException {
38 + return forward(request, brokerServiceBaseUrl);
39 + }
40 +
41 + @RequestMapping({"/api/v1/research/**", "/api/v1/research"})
42 + public ResponseEntity<String> routeResearch(HttpServletRequest request) throws IOException {
43 + return forward(request, researchEngineBaseUrl);
44 + }
45 +
46 + private ResponseEntity<String> forward(HttpServletRequest request, String baseUrl) throws IOException {
47 + String path = request.getRequestURI();
48 + String query = request.getQueryString();
49 + String target = baseUrl + path + (query == null ? "" : "?" + query);
50 + String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
51 + HttpHeaders headers = new HttpHeaders();
52 + Collections.list(request.getHeaderNames()).forEach(name -> headers.add(name, request.getHeader(name)));
53 + return restClient.method(HttpMethod.valueOf(request.getMethod()))
54 + .uri(target)
55 + .headers(outbound -> outbound.addAll(headers))
56 + .body(body)
57 + .retrieve()
58 + .toEntity(String.class);
59 + }
60 +}
services/api-gateway/src/main/java/com/aiinvestment/apigateway/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.apigateway;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/api-gateway")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "api-gateway", "status", "starting-foundation");
15 + }
16 +}
services/api-gateway/src/main/resources/application.yml new
+26
@@ -0,0 +1,26 @@
1 +spring:
2 + application:
3 + name: api-gateway
4 +portfolio:
5 + service:
6 + base-url: ${PORTFOLIO_SERVICE_BASE_URL:http://localhost:8082}
7 +broker:
8 + service:
9 + base-url: ${BROKER_SERVICE_BASE_URL:http://localhost:8083}
10 +research:
11 + engine:
12 + base-url: ${RESEARCH_ENGINE_BASE_URL:http://localhost:8000}
13 +server:
14 + port: ${SERVER_PORT:8080}
15 +management:
16 + endpoints:
17 + web:
18 + exposure:
19 + include: health,info,prometheus
20 + endpoint:
21 + health:
22 + probes:
23 + enabled: true
24 +logging:
25 + pattern:
26 + level: "%5p [correlationId:%X{correlationId:-}]"
services/auth-service/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/auth-service/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/auth-service/pom.xml new
+14
@@ -0,0 +1,14 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3 + <modelVersion>4.0.0</modelVersion>
4 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
5 + <artifactId>auth-service</artifactId>
6 + <dependencies>
7 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
8 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
10 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
11 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
12 + </dependencies>
13 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
14 +</project>
services/auth-service/src/main/java/com/aiinvestment/auth/AuthServiceApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.auth;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class AuthServiceApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(AuthServiceApplication.class, args);
12 + }
13 +}
services/auth-service/src/main/java/com/aiinvestment/auth/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.auth;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/auth-service")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "auth-service", "status", "starting-foundation");
15 + }
16 +}
services/auth-service/src/main/resources/application.yml new
+17
@@ -0,0 +1,17 @@
1 +spring:
2 + application:
3 + name: auth-service
4 +server:
5 + port: ${SERVER_PORT:8081}
6 +management:
7 + endpoints:
8 + web:
9 + exposure:
10 + include: health,info,prometheus
11 + endpoint:
12 + health:
13 + probes:
14 + enabled: true
15 +logging:
16 + pattern:
17 + level: "%5p [correlationId:%X{correlationId:-}]"
services/broker-service/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/broker-service/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/broker-service/pom.xml new
+19
@@ -0,0 +1,19 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3 + <modelVersion>4.0.0</modelVersion>
4 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
5 + <artifactId>broker-service</artifactId>
6 + <dependencies>
7 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
8 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
10 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
11 + <dependency><groupId>org.flywaydb</groupId><artifactId>flyway-core</artifactId></dependency>
12 + <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
13 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
14 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
15 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
16 + <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
17 + </dependencies>
18 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
19 +</project>
services/broker-service/src/main/java/com/aiinvestment/broker/BrokerServiceApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.broker;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class BrokerServiceApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(BrokerServiceApplication.class, args);
12 + }
13 +}
services/broker-service/src/main/java/com/aiinvestment/broker/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.broker;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/broker-service")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "broker-service", "status", "starting-foundation");
15 + }
16 +}
services/broker-service/src/main/java/com/aiinvestment/broker/api/ApiErrorResponse.java new
+6
@@ -0,0 +1,6 @@
1 +package com.aiinvestment.broker.api;
2 +
3 +import java.time.Instant;
4 +
5 +public record ApiErrorResponse(Instant timestamp, int status, String code, String message, String correlationId) {
6 +}
services/broker-service/src/main/java/com/aiinvestment/broker/api/BrokerConnectionResponse.java new
+39
@@ -0,0 +1,39 @@
1 +package com.aiinvestment.broker.api;
2 +
3 +import com.aiinvestment.broker.domain.BrokerConnection;
4 +import com.aiinvestment.shared.domain.broker.BrokerProvider;
5 +
6 +import java.time.Instant;
7 +import java.util.Set;
8 +import java.util.UUID;
9 +import java.util.stream.Collectors;
10 +
11 +public record BrokerConnectionResponse(
12 + UUID connectionId,
13 + UUID userId,
14 + String brokerType,
15 + String externalAccountReference,
16 + String displayName,
17 + String status,
18 + Instant connectedAt,
19 + Instant lastSuccessfulSyncAt,
20 + Instant lastSyncAttemptAt,
21 + String lastErrorCode,
22 + Instant createdAt,
23 + Instant updatedAt,
24 + Set<String> capabilities,
25 + String providerStatus,
26 + String dataFreshness,
27 + boolean readOnly
28 +) {
29 + public static BrokerConnectionResponse from(BrokerConnection connection, BrokerProvider provider) {
30 + return new BrokerConnectionResponse(connection.connectionId(), connection.userId(), connection.brokerType().name(),
31 + connection.externalAccountReference(), connection.displayName(), connection.status().name(),
32 + connection.connectedAt(), connection.lastSuccessfulSyncAt(), connection.lastSyncAttemptAt(),
33 + connection.lastErrorCode(), connection.createdAt(), connection.updatedAt(),
34 + provider.connectionCapabilities().capabilities().stream().map(Enum::name).collect(Collectors.toUnmodifiableSet()),
35 + provider.connectionStatus().providerStatus().name(),
36 + connection.brokerType().name().equals("MOCK") ? "DEMO" : "UNAVAILABLE",
37 + !provider.connectionCapabilities().capabilities().contains(com.aiinvestment.shared.domain.broker.BrokerCapability.ORDER_EXECUTION));
38 + }
39 +}
services/broker-service/src/main/java/com/aiinvestment/broker/api/BrokerController.java new
+91
@@ -0,0 +1,91 @@
1 +package com.aiinvestment.broker.api;
2 +
3 +import com.aiinvestment.broker.application.BrokerConnectionService;
4 +import com.aiinvestment.shared.domain.broker.BrokerType;
5 +import com.aiinvestment.shared.domain.broker.BrokerProvider;
6 +import org.springframework.http.HttpStatus;
7 +import org.springframework.web.bind.annotation.*;
8 +
9 +import java.util.Comparator;
10 +import java.util.List;
11 +import java.util.UUID;
12 +
13 +@RestController
14 +public class BrokerController {
15 + private final BrokerConnectionService service;
16 +
17 + public BrokerController(BrokerConnectionService service) {
18 + this.service = service;
19 + }
20 +
21 + @GetMapping("/api/v1/brokers")
22 + public List<BrokerProviderResponse> brokers() {
23 + return service.providers().stream()
24 + .sorted(Comparator.comparing(BrokerProvider::supportedBroker))
25 + .map(BrokerProviderResponse::from)
26 + .toList();
27 + }
28 +
29 + @GetMapping("/api/v1/broker-connections")
30 + public List<BrokerConnectionResponse> connections() {
31 + return service.listConnections().stream()
32 + .map(connection -> BrokerConnectionResponse.from(connection, providerFor(connection.brokerType().name())))
33 + .toList();
34 + }
35 +
36 + @GetMapping("/api/v1/broker-connections/{id}")
37 + public BrokerConnectionResponse connection(@PathVariable("id") UUID id) {
38 + var connection = service.getConnection(id);
39 + return BrokerConnectionResponse.from(connection, providerFor(connection.brokerType().name()));
40 + }
41 +
42 + @PostMapping("/api/v1/broker-connections/mock")
43 + @ResponseStatus(HttpStatus.CREATED)
44 + public BrokerConnectionResponse connectMock() {
45 + var connection = service.connectMock();
46 + return BrokerConnectionResponse.from(connection, providerFor(connection.brokerType().name()));
47 + }
48 +
49 + @PostMapping("/api/v1/broker-connections/{broker}/connect")
50 + @ResponseStatus(HttpStatus.CREATED)
51 + public BrokerConnectionResponse connect(@PathVariable("broker") String broker) {
52 + var connection = service.initiateConnection(BrokerType.valueOf(broker.toUpperCase().replace("-", "_")));
53 + return BrokerConnectionResponse.from(connection, providerFor(connection.brokerType().name()));
54 + }
55 +
56 + @GetMapping("/api/v1/broker-connections/{id}/status")
57 + public BrokerConnectionResponse status(@PathVariable("id") UUID id) {
58 + var connection = service.status(id);
59 + return BrokerConnectionResponse.from(connection, providerFor(connection.brokerType().name()));
60 + }
61 +
62 + @PostMapping("/api/v1/broker-connections/{id}/refresh")
63 + public BrokerConnectionResponse refresh(@PathVariable("id") UUID id) {
64 + var connection = service.refresh(id);
65 + return BrokerConnectionResponse.from(connection, providerFor(connection.brokerType().name()));
66 + }
67 +
68 + @GetMapping("/api/v1/broker-connections/{broker}/callback")
69 + public void callback(@PathVariable("broker") String broker) {
70 + throw new IllegalStateException("Callback flow is unavailable until official " + broker + " authentication documentation is configured.");
71 + }
72 +
73 + @PostMapping("/api/v1/broker-connections/{id}/sync")
74 + public BrokerConnectionResponse sync(@PathVariable("id") UUID id) {
75 + var connection = service.sync(id);
76 + return BrokerConnectionResponse.from(connection, providerFor(connection.brokerType().name()));
77 + }
78 +
79 + @DeleteMapping("/api/v1/broker-connections/{id}")
80 + @ResponseStatus(HttpStatus.NO_CONTENT)
81 + public void disconnect(@PathVariable("id") UUID id) {
82 + service.disconnect(id);
83 + }
84 +
85 + private BrokerProvider providerFor(String brokerType) {
86 + return service.providers().stream()
87 + .filter(provider -> provider.supportedBroker().name().equals(brokerType))
88 + .findFirst()
89 + .orElseThrow(() -> new IllegalStateException("No provider for " + brokerType));
90 + }
91 +}
services/broker-service/src/main/java/com/aiinvestment/broker/api/BrokerProviderResponse.java new
+32
@@ -0,0 +1,32 @@
1 +package com.aiinvestment.broker.api;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerProvider;
4 +import com.aiinvestment.shared.domain.broker.BrokerType;
5 +
6 +import java.util.Set;
7 +import java.util.stream.Collectors;
8 +
9 +public record BrokerProviderResponse(
10 + BrokerType brokerType,
11 + String status,
12 + String providerStatus,
13 + String code,
14 + String message,
15 + Set<String> capabilities,
16 + String connectionMethod,
17 + String dataFreshness,
18 + boolean readOnly,
19 + boolean officialProviderSetupRequired
20 +) {
21 + public static BrokerProviderResponse from(BrokerProvider provider) {
22 + boolean mock = provider.supportedBroker() == BrokerType.MOCK;
23 + boolean setupRequired = !mock && provider.connectionStatus().providerStatus().name().matches("NOT_CONFIGURED|DOCUMENTATION_REQUIRED");
24 + return new BrokerProviderResponse(provider.supportedBroker(), provider.connectionStatus().state().name(),
25 + provider.connectionStatus().providerStatus().name(), provider.connectionStatus().code(), provider.connectionStatus().message(),
26 + provider.connectionCapabilities().capabilities().stream().map(Enum::name).collect(Collectors.toUnmodifiableSet()),
27 + mock ? "Local demo provider" : "Official provider setup required",
28 + mock ? "DEMO" : "UNAVAILABLE",
29 + !provider.connectionCapabilities().capabilities().contains(com.aiinvestment.shared.domain.broker.BrokerCapability.ORDER_EXECUTION),
30 + setupRequired);
31 + }
32 +}
services/broker-service/src/main/java/com/aiinvestment/broker/api/GlobalExceptionHandler.java new
+34
@@ -0,0 +1,34 @@
1 +package com.aiinvestment.broker.api;
2 +
3 +import com.aiinvestment.broker.application.BrokerConnectionNotFoundException;
4 +import org.slf4j.MDC;
5 +import org.springframework.http.HttpStatus;
6 +import org.springframework.http.ResponseEntity;
7 +import org.springframework.web.bind.annotation.ExceptionHandler;
8 +import org.springframework.web.bind.annotation.RestControllerAdvice;
9 +
10 +import java.time.Instant;
11 +
12 +@RestControllerAdvice
13 +public class GlobalExceptionHandler {
14 + @ExceptionHandler(BrokerConnectionNotFoundException.class)
15 + public ResponseEntity<ApiErrorResponse> notFound(BrokerConnectionNotFoundException exception) {
16 + return error(HttpStatus.NOT_FOUND, "BROKER_CONNECTION_NOT_FOUND", exception.getMessage());
17 + }
18 +
19 + @ExceptionHandler(IllegalArgumentException.class)
20 + public ResponseEntity<ApiErrorResponse> badRequest(IllegalArgumentException exception) {
21 + return error(HttpStatus.BAD_REQUEST, "INVALID_BROKER_REQUEST", exception.getMessage());
22 + }
23 +
24 + @ExceptionHandler(IllegalStateException.class)
25 + public ResponseEntity<ApiErrorResponse> unavailable(IllegalStateException exception) {
26 + return error(HttpStatus.BAD_REQUEST, "BROKER_PROVIDER_UNAVAILABLE", exception.getMessage());
27 + }
28 +
29 + private ResponseEntity<ApiErrorResponse> error(HttpStatus status, String code, String message) {
30 + String correlationId = MDC.get("correlationId");
31 + return ResponseEntity.status(status)
32 + .body(new ApiErrorResponse(Instant.now(), status.value(), code, message, correlationId));
33 + }
34 +}
services/broker-service/src/main/java/com/aiinvestment/broker/application/BrokerConnectionNotFoundException.java new
+9
@@ -0,0 +1,9 @@
1 +package com.aiinvestment.broker.application;
2 +
3 +import java.util.UUID;
4 +
5 +public class BrokerConnectionNotFoundException extends RuntimeException {
6 + public BrokerConnectionNotFoundException(UUID connectionId) {
7 + super("Broker connection not found: " + connectionId);
8 + }
9 +}
services/broker-service/src/main/java/com/aiinvestment/broker/application/BrokerConnectionService.java new
+140
@@ -0,0 +1,140 @@
1 +package com.aiinvestment.broker.application;
2 +
3 +import com.aiinvestment.broker.domain.BrokerConnection;
4 +import com.aiinvestment.broker.audit.BrokerOperationAuditor;
5 +import com.aiinvestment.broker.persistence.BrokerConnectionEntity;
6 +import com.aiinvestment.broker.persistence.BrokerConnectionMapper;
7 +import com.aiinvestment.broker.persistence.BrokerConnectionRepository;
8 +import com.aiinvestment.broker.resilience.ProviderCircuitBreaker;
9 +import com.aiinvestment.broker.resilience.ProviderRateLimiter;
10 +import com.aiinvestment.shared.domain.broker.*;
11 +import org.springframework.stereotype.Service;
12 +import org.springframework.transaction.annotation.Transactional;
13 +
14 +import java.time.Instant;
15 +import java.util.List;
16 +import java.util.UUID;
17 +
18 +@Service
19 +public class BrokerConnectionService {
20 + public static final UUID DEFAULT_PHASE_USER = UUID.fromString("00000000-0000-0000-0000-000000000001");
21 +
22 + private final BrokerConnectionRepository repository;
23 + private final List<BrokerProvider> providers;
24 + private final ProviderRateLimiter rateLimiter;
25 + private final ProviderCircuitBreaker circuitBreaker;
26 + private final BrokerOperationAuditor auditor;
27 +
28 + public BrokerConnectionService(BrokerConnectionRepository repository, List<BrokerProvider> providers,
29 + ProviderRateLimiter rateLimiter, ProviderCircuitBreaker circuitBreaker,
30 + BrokerOperationAuditor auditor) {
31 + this.repository = repository;
32 + this.providers = providers;
33 + this.rateLimiter = rateLimiter;
34 + this.circuitBreaker = circuitBreaker;
35 + this.auditor = auditor;
36 + }
37 +
38 + @Transactional(readOnly = true)
39 + public List<BrokerConnection> listConnections() {
40 + return repository.findByUserId(DEFAULT_PHASE_USER).stream().map(BrokerConnectionMapper::toDomain).toList();
41 + }
42 +
43 + @Transactional(readOnly = true)
44 + public BrokerConnection getConnection(UUID connectionId) {
45 + return repository.findById(connectionId).map(BrokerConnectionMapper::toDomain)
46 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
47 + }
48 +
49 + @Transactional
50 + public BrokerConnection connectMock() {
51 + Instant now = Instant.now();
52 + BrokerConnectionEntity entity = new BrokerConnectionEntity(UUID.randomUUID(), DEFAULT_PHASE_USER, BrokerType.MOCK,
53 + "mock-demo", "Demo Broker", BrokerConnectionState.CONNECTED, now, null, null, null, now, now);
54 + return BrokerConnectionMapper.toDomain(repository.save(entity));
55 + }
56 +
57 + @Transactional
58 + public BrokerConnection initiateConnection(BrokerType brokerType) {
59 + Instant startedAt = Instant.now();
60 + if (brokerType == BrokerType.MOCK) {
61 + BrokerConnection connection = connectMock();
62 + auditor.record(brokerType, "connect", startedAt, true, "LOCAL");
63 + return connection;
64 + }
65 + BrokerConnectionStatus status = providerFor(brokerType).connectionStatus();
66 + auditor.record(brokerType, "connect", startedAt, false, status.code());
67 + throw new IllegalStateException(status.code() + ": " + status.message());
68 + }
69 +
70 + @Transactional(readOnly = true)
71 + public BrokerConnection status(UUID connectionId) {
72 + return getConnection(connectionId);
73 + }
74 +
75 + @Transactional
76 + public BrokerConnection refresh(UUID connectionId) {
77 + BrokerConnectionEntity entity = repository.findById(connectionId)
78 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
79 + if (entity.getBrokerType() != BrokerType.MOCK) {
80 + throw new IllegalStateException("Refresh is unavailable until official provider authentication is configured.");
81 + }
82 + entity.setUpdatedAt(Instant.now());
83 + return BrokerConnectionMapper.toDomain(entity);
84 + }
85 +
86 + @Transactional
87 + public BrokerConnection sync(UUID connectionId) {
88 + Instant startedAt = Instant.now();
89 + BrokerConnectionEntity entity = repository.findById(connectionId)
90 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
91 + Instant now = Instant.now();
92 + entity.setStatus(BrokerConnectionState.SYNCING);
93 + entity.setLastSyncAttemptAt(now);
94 + entity.setUpdatedAt(now);
95 + BrokerProvider provider = providerFor(entity.getBrokerType());
96 + if (!circuitBreaker.allowRequest(entity.getBrokerType())) {
97 + entity.setStatus(BrokerConnectionState.DEGRADED);
98 + entity.setLastErrorCode("CIRCUIT_OPEN");
99 + auditor.record(entity.getBrokerType(), "sync", startedAt, false, "CIRCUIT_OPEN");
100 + return BrokerConnectionMapper.toDomain(entity);
101 + }
102 + if (!rateLimiter.tryAcquire(entity.getBrokerType(), "sync")) {
103 + entity.setStatus(BrokerConnectionState.DEGRADED);
104 + entity.setLastErrorCode("RATE_LIMITED");
105 + auditor.record(entity.getBrokerType(), "sync", startedAt, false, "RATE_LIMITED");
106 + return BrokerConnectionMapper.toDomain(entity);
107 + }
108 + if (provider.connectionStatus().state() == BrokerConnectionState.DISCONNECTED && entity.getBrokerType() != BrokerType.MOCK) {
109 + entity.setStatus(BrokerConnectionState.ERROR);
110 + entity.setLastErrorCode(provider.connectionStatus().code());
111 + circuitBreaker.recordFailure(entity.getBrokerType());
112 + auditor.record(entity.getBrokerType(), "sync", startedAt, false, provider.connectionStatus().code());
113 + return BrokerConnectionMapper.toDomain(entity);
114 + }
115 + entity.setStatus(BrokerConnectionState.CONNECTED);
116 + entity.setLastSuccessfulSyncAt(now);
117 + entity.setLastErrorCode(null);
118 + circuitBreaker.recordSuccess(entity.getBrokerType());
119 + auditor.record(entity.getBrokerType(), "sync", startedAt, true, "OK");
120 + return BrokerConnectionMapper.toDomain(entity);
121 + }
122 +
123 + @Transactional
124 + public void disconnect(UUID connectionId) {
125 + BrokerConnectionEntity entity = repository.findById(connectionId)
126 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
127 + providerFor(entity.getBrokerType()).disconnect(connectionId);
128 + entity.setStatus(BrokerConnectionState.DISCONNECTED);
129 + entity.setUpdatedAt(Instant.now());
130 + }
131 +
132 + public List<BrokerProvider> providers() {
133 + return providers;
134 + }
135 +
136 + private BrokerProvider providerFor(BrokerType brokerType) {
137 + return providers.stream().filter(provider -> provider.supportedBroker() == brokerType).findFirst()
138 + .orElseThrow(() -> new IllegalStateException("No provider for " + brokerType));
139 + }
140 +}
services/broker-service/src/main/java/com/aiinvestment/broker/audit/BrokerOperationAuditor.java new
+21
@@ -0,0 +1,21 @@
1 +package com.aiinvestment.broker.audit;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +import org.slf4j.Logger;
5 +import org.slf4j.LoggerFactory;
6 +import org.slf4j.MDC;
7 +import org.springframework.stereotype.Component;
8 +
9 +import java.time.Duration;
10 +import java.time.Instant;
11 +
12 +@Component
13 +public class BrokerOperationAuditor {
14 + private static final Logger LOGGER = LoggerFactory.getLogger(BrokerOperationAuditor.class);
15 +
16 + public void record(BrokerType brokerType, String operation, Instant startedAt, boolean success, String statusCode) {
17 + LOGGER.info("broker_operation provider={} operation={} success={} durationMs={} correlationId={} status={}",
18 + brokerType, operation, success, Duration.between(startedAt, Instant.now()).toMillis(),
19 + MDC.get("correlationId"), statusCode);
20 + }
21 +}
services/broker-service/src/main/java/com/aiinvestment/broker/auth/MockBrokerAuthenticationStrategy.java new
+30
@@ -0,0 +1,30 @@
1 +package com.aiinvestment.broker.auth;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +import com.aiinvestment.shared.domain.broker.auth.BrokerAuthenticationRequest;
5 +import com.aiinvestment.shared.domain.broker.auth.BrokerAuthenticationStrategy;
6 +import com.aiinvestment.shared.domain.broker.auth.BrokerSession;
7 +import com.aiinvestment.shared.domain.broker.auth.BrokerSessionState;
8 +import org.springframework.stereotype.Component;
9 +
10 +import java.time.Instant;
11 +import java.time.temporal.ChronoUnit;
12 +import java.util.UUID;
13 +
14 +@Component
15 +public class MockBrokerAuthenticationStrategy implements BrokerAuthenticationStrategy {
16 + @Override
17 + public BrokerType supportedBroker() {
18 + return BrokerType.MOCK;
19 + }
20 +
21 + @Override
22 + public BrokerSession authenticate(BrokerAuthenticationRequest request) {
23 + return new BrokerSession(UUID.randomUUID(), UUID.randomUUID(), BrokerType.MOCK, BrokerSessionState.CONNECTED, Instant.now().plus(1, ChronoUnit.HOURS), true);
24 + }
25 +
26 + @Override
27 + public BrokerSession refresh(BrokerSession session) {
28 + return new BrokerSession(session.sessionId(), session.connectionId(), session.brokerType(), BrokerSessionState.CONNECTED, Instant.now().plus(1, ChronoUnit.HOURS), true);
29 + }
30 +}
services/broker-service/src/main/java/com/aiinvestment/broker/config/BrokerProviderConfiguration.java new
+9
@@ -0,0 +1,9 @@
1 +package com.aiinvestment.broker.config;
2 +
3 +import org.springframework.boot.context.properties.EnableConfigurationProperties;
4 +import org.springframework.context.annotation.Configuration;
5 +
6 +@Configuration
7 +@EnableConfigurationProperties({IBKRProviderProperties.class, ICICIDirectProviderProperties.class})
8 +public class BrokerProviderConfiguration {
9 +}
services/broker-service/src/main/java/com/aiinvestment/broker/config/IBKRProviderProperties.java new
+14
@@ -0,0 +1,14 @@
1 +package com.aiinvestment.broker.config;
2 +
3 +import org.springframework.boot.context.properties.ConfigurationProperties;
4 +
5 +@ConfigurationProperties(prefix = "broker.ibkr")
6 +public record IBKRProviderProperties(
7 + boolean enabled,
8 + String baseUrl,
9 + String clientId,
10 + String callbackUrl,
11 + String authMethod,
12 + boolean officialDocumentationVerified
13 +) {
14 +}
services/broker-service/src/main/java/com/aiinvestment/broker/config/ICICIDirectProviderProperties.java new
+14
@@ -0,0 +1,14 @@
1 +package com.aiinvestment.broker.config;
2 +
3 +import org.springframework.boot.context.properties.ConfigurationProperties;
4 +
5 +@ConfigurationProperties(prefix = "broker.icici-direct")
6 +public record ICICIDirectProviderProperties(
7 + boolean enabled,
8 + String baseUrl,
9 + String clientId,
10 + String callbackUrl,
11 + String authMethod,
12 + boolean officialDocumentationVerified
13 +) {
14 +}
services/broker-service/src/main/java/com/aiinvestment/broker/domain/BrokerConnection.java new
+23
@@ -0,0 +1,23 @@
1 +package com.aiinvestment.broker.domain;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerConnectionState;
4 +import com.aiinvestment.shared.domain.broker.BrokerType;
5 +
6 +import java.time.Instant;
7 +import java.util.UUID;
8 +
9 +public record BrokerConnection(
10 + UUID connectionId,
11 + UUID userId,
12 + BrokerType brokerType,
13 + String externalAccountReference,
14 + String displayName,
15 + BrokerConnectionState status,
16 + Instant connectedAt,
17 + Instant lastSuccessfulSyncAt,
18 + Instant lastSyncAttemptAt,
19 + String lastErrorCode,
20 + Instant createdAt,
21 + Instant updatedAt
22 +) {
23 +}
services/broker-service/src/main/java/com/aiinvestment/broker/persistence/BrokerConnectionEntity.java new
+79
@@ -0,0 +1,79 @@
1 +package com.aiinvestment.broker.persistence;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerConnectionState;
4 +import com.aiinvestment.shared.domain.broker.BrokerType;
5 +import jakarta.persistence.*;
6 +
7 +import java.time.Instant;
8 +import java.util.UUID;
9 +
10 +@Entity
11 +@Table(name = "broker_connections")
12 +public class BrokerConnectionEntity {
13 + @Id
14 + @Column(name = "connection_id", nullable = false)
15 + private UUID connectionId;
16 + @Column(name = "user_id", nullable = false)
17 + private UUID userId;
18 + @Enumerated(EnumType.STRING)
19 + @Column(name = "broker_type", nullable = false)
20 + private BrokerType brokerType;
21 + @Column(name = "external_account_reference")
22 + private String externalAccountReference;
23 + @Column(name = "display_name", nullable = false)
24 + private String displayName;
25 + @Enumerated(EnumType.STRING)
26 + @Column(nullable = false)
27 + private BrokerConnectionState status;
28 + @Column(name = "connected_at")
29 + private Instant connectedAt;
30 + @Column(name = "last_successful_sync_at")
31 + private Instant lastSuccessfulSyncAt;
32 + @Column(name = "last_sync_attempt_at")
33 + private Instant lastSyncAttemptAt;
34 + @Column(name = "last_error_code")
35 + private String lastErrorCode;
36 + @Column(name = "created_at", nullable = false)
37 + private Instant createdAt;
38 + @Column(name = "updated_at", nullable = false)
39 + private Instant updatedAt;
40 +
41 + protected BrokerConnectionEntity() {
42 + }
43 +
44 + public BrokerConnectionEntity(UUID connectionId, UUID userId, BrokerType brokerType, String externalAccountReference,
45 + String displayName, BrokerConnectionState status, Instant connectedAt,
46 + Instant lastSuccessfulSyncAt, Instant lastSyncAttemptAt, String lastErrorCode,
47 + Instant createdAt, Instant updatedAt) {
48 + this.connectionId = connectionId;
49 + this.userId = userId;
50 + this.brokerType = brokerType;
51 + this.externalAccountReference = externalAccountReference;
52 + this.displayName = displayName;
53 + this.status = status;
54 + this.connectedAt = connectedAt;
55 + this.lastSuccessfulSyncAt = lastSuccessfulSyncAt;
56 + this.lastSyncAttemptAt = lastSyncAttemptAt;
57 + this.lastErrorCode = lastErrorCode;
58 + this.createdAt = createdAt;
59 + this.updatedAt = updatedAt;
60 + }
61 +
62 + public UUID getConnectionId() { return connectionId; }
63 + public UUID getUserId() { return userId; }
64 + public BrokerType getBrokerType() { return brokerType; }
65 + public String getExternalAccountReference() { return externalAccountReference; }
66 + public String getDisplayName() { return displayName; }
67 + public BrokerConnectionState getStatus() { return status; }
68 + public Instant getConnectedAt() { return connectedAt; }
69 + public Instant getLastSuccessfulSyncAt() { return lastSuccessfulSyncAt; }
70 + public Instant getLastSyncAttemptAt() { return lastSyncAttemptAt; }
71 + public String getLastErrorCode() { return lastErrorCode; }
72 + public Instant getCreatedAt() { return createdAt; }
73 + public Instant getUpdatedAt() { return updatedAt; }
74 + public void setStatus(BrokerConnectionState status) { this.status = status; }
75 + public void setLastSuccessfulSyncAt(Instant lastSuccessfulSyncAt) { this.lastSuccessfulSyncAt = lastSuccessfulSyncAt; }
76 + public void setLastSyncAttemptAt(Instant lastSyncAttemptAt) { this.lastSyncAttemptAt = lastSyncAttemptAt; }
77 + public void setLastErrorCode(String lastErrorCode) { this.lastErrorCode = lastErrorCode; }
78 + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
79 +}
services/broker-service/src/main/java/com/aiinvestment/broker/persistence/BrokerConnectionMapper.java new
+15
@@ -0,0 +1,15 @@
1 +package com.aiinvestment.broker.persistence;
2 +
3 +import com.aiinvestment.broker.domain.BrokerConnection;
4 +
5 +public final class BrokerConnectionMapper {
6 + private BrokerConnectionMapper() {
7 + }
8 +
9 + public static BrokerConnection toDomain(BrokerConnectionEntity entity) {
10 + return new BrokerConnection(entity.getConnectionId(), entity.getUserId(), entity.getBrokerType(),
11 + entity.getExternalAccountReference(), entity.getDisplayName(), entity.getStatus(),
12 + entity.getConnectedAt(), entity.getLastSuccessfulSyncAt(), entity.getLastSyncAttemptAt(),
13 + entity.getLastErrorCode(), entity.getCreatedAt(), entity.getUpdatedAt());
14 + }
15 +}
services/broker-service/src/main/java/com/aiinvestment/broker/persistence/BrokerConnectionRepository.java new
+10
@@ -0,0 +1,10 @@
1 +package com.aiinvestment.broker.persistence;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.springframework.data.jpa.repository.JpaRepository;
7 +
8 +public interface BrokerConnectionRepository extends JpaRepository<BrokerConnectionEntity, UUID> {
9 + List<BrokerConnectionEntity> findByUserId(UUID userId);
10 +}
services/broker-service/src/main/java/com/aiinvestment/broker/provider/ibkr/IBKRBrokerProvider.java new
+69
@@ -0,0 +1,69 @@
1 +package com.aiinvestment.broker.provider.ibkr;
2 +
3 +import com.aiinvestment.broker.config.IBKRProviderProperties;
4 +import com.aiinvestment.shared.domain.broker.*;
5 +import org.springframework.stereotype.Component;
6 +
7 +import java.util.List;
8 +import java.util.UUID;
9 +
10 +@Component
11 +public class IBKRBrokerProvider implements BrokerProvider {
12 + private final IBKRProviderProperties properties;
13 +
14 + public IBKRBrokerProvider(IBKRProviderProperties properties) {
15 + this.properties = properties;
16 + }
17 +
18 + public IBKRBrokerProvider() {
19 + this(new IBKRProviderProperties(false, "", "", "", "", false));
20 + }
21 +
22 + @Override
23 + public BrokerType supportedBroker() {
24 + return BrokerType.IBKR;
25 + }
26 +
27 + @Override
28 + public BrokerConnectionCapabilities connectionCapabilities() {
29 + return BrokerConnectionCapabilities.none();
30 + }
31 +
32 + @Override
33 + public BrokerConnectionStatus connectionStatus() {
34 + if (!properties.enabled()) {
35 + return BrokerConnectionStatus.notConfigured(BrokerType.IBKR);
36 + }
37 + if (!properties.officialDocumentationVerified()) {
38 + return BrokerConnectionStatus.documentationRequired(BrokerType.IBKR);
39 + }
40 + if (isBlank(properties.baseUrl()) || isBlank(properties.clientId()) || isBlank(properties.authMethod())) {
41 + return BrokerConnectionStatus.notConfigured(BrokerType.IBKR);
42 + }
43 + return BrokerConnectionStatus.authenticationRequired(BrokerType.IBKR);
44 + }
45 +
46 + @Override
47 + public List<BrokerAccount> fetchAccounts(UUID userId) {
48 + throw new UnsupportedOperationException("IBKR integration is intentionally unsupported until official API documentation is provided and verified.");
49 + }
50 +
51 + @Override
52 + public List<BrokerPosition> fetchPositions(BrokerAccount account) {
53 + throw new UnsupportedOperationException("IBKR positions are intentionally unsupported until official API documentation is provided and verified.");
54 + }
55 +
56 + @Override
57 + public List<BrokerCashBalance> fetchCashBalances(BrokerAccount account) {
58 + throw new UnsupportedOperationException("IBKR cash balances are intentionally unsupported until official API research is complete.");
59 + }
60 +
61 + @Override
62 + public void disconnect(UUID connectionId) {
63 + throw new UnsupportedOperationException("IBKR disconnect is unavailable because the provider is not configured.");
64 + }
65 +
66 + private static boolean isBlank(String value) {
67 + return value == null || value.isBlank();
68 + }
69 +}
services/broker-service/src/main/java/com/aiinvestment/broker/provider/ibkr/IBKRInstrumentNormalizer.java new
+49
@@ -0,0 +1,49 @@
1 +package com.aiinvestment.broker.provider.ibkr;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import com.aiinvestment.shared.domain.Instrument;
5 +import com.aiinvestment.shared.domain.broker.BrokerInstrumentIdentity;
6 +import com.aiinvestment.shared.domain.broker.BrokerInstrumentNormalizer;
7 +import com.aiinvestment.shared.domain.broker.BrokerType;
8 +import org.springframework.stereotype.Component;
9 +
10 +import java.nio.charset.StandardCharsets;
11 +import java.util.UUID;
12 +
13 +@Component
14 +public class IBKRInstrumentNormalizer implements BrokerInstrumentNormalizer {
15 + @Override
16 + public BrokerType supportedBroker() {
17 + return BrokerType.IBKR;
18 + }
19 +
20 + @Override
21 + public Instrument normalize(BrokerInstrumentIdentity identity) {
22 + requireIdentity(identity);
23 + String stableKey = String.join("|", BrokerType.IBKR.name(), safe(identity.brokerContractId()),
24 + safe(identity.isin()), identity.ticker(), identity.exchange(), identity.currency());
25 + return new Instrument(UUID.nameUUIDFromBytes(stableKey.getBytes(StandardCharsets.UTF_8)), identity.isin(),
26 + identity.ticker(), identity.exchange(), identity.mic(), identity.ticker(), assetType(identity),
27 + identity.country(), identity.currency(), null, null);
28 + }
29 +
30 + private static void requireIdentity(BrokerInstrumentIdentity identity) {
31 + if (identity == null || identity.brokerType() != BrokerType.IBKR || isBlank(identity.ticker())
32 + || isBlank(identity.exchange()) || isBlank(identity.currency())
33 + || (isBlank(identity.brokerContractId()) && isBlank(identity.isin()))) {
34 + throw new IllegalArgumentException("IBKR instrument identity requires broker type, ticker, exchange, currency, and contract ID or ISIN");
35 + }
36 + }
37 +
38 + private static boolean isBlank(String value) {
39 + return value == null || value.isBlank();
40 + }
41 +
42 + private static String safe(String value) {
43 + return value == null ? "" : value;
44 + }
45 +
46 + private static AssetType assetType(BrokerInstrumentIdentity identity) {
47 + return identity.assetType() == null ? AssetType.EQUITY : identity.assetType();
48 + }
49 +}
services/broker-service/src/main/java/com/aiinvestment/broker/provider/icici/ICICIDirectBrokerProvider.java new
+69
@@ -0,0 +1,69 @@
1 +package com.aiinvestment.broker.provider.icici;
2 +
3 +import com.aiinvestment.broker.config.ICICIDirectProviderProperties;
4 +import com.aiinvestment.shared.domain.broker.*;
5 +import org.springframework.stereotype.Component;
6 +
7 +import java.util.List;
8 +import java.util.UUID;
9 +
10 +@Component
11 +public class ICICIDirectBrokerProvider implements BrokerProvider {
12 + private final ICICIDirectProviderProperties properties;
13 +
14 + public ICICIDirectBrokerProvider(ICICIDirectProviderProperties properties) {
15 + this.properties = properties;
16 + }
17 +
18 + public ICICIDirectBrokerProvider() {
19 + this(new ICICIDirectProviderProperties(false, "", "", "", "", false));
20 + }
21 +
22 + @Override
23 + public BrokerType supportedBroker() {
24 + return BrokerType.ICICI_DIRECT;
25 + }
26 +
27 + @Override
28 + public BrokerConnectionCapabilities connectionCapabilities() {
29 + return BrokerConnectionCapabilities.none();
30 + }
31 +
32 + @Override
33 + public BrokerConnectionStatus connectionStatus() {
34 + if (!properties.enabled()) {
35 + return BrokerConnectionStatus.notConfigured(BrokerType.ICICI_DIRECT);
36 + }
37 + if (!properties.officialDocumentationVerified()) {
38 + return BrokerConnectionStatus.documentationRequired(BrokerType.ICICI_DIRECT);
39 + }
40 + if (isBlank(properties.baseUrl()) || isBlank(properties.clientId()) || isBlank(properties.authMethod())) {
41 + return BrokerConnectionStatus.notConfigured(BrokerType.ICICI_DIRECT);
42 + }
43 + return BrokerConnectionStatus.authenticationRequired(BrokerType.ICICI_DIRECT);
44 + }
45 +
46 + @Override
47 + public List<BrokerAccount> fetchAccounts(UUID userId) {
48 + throw new UnsupportedOperationException("ICICI Direct integration is intentionally unsupported until official API documentation is provided and verified.");
49 + }
50 +
51 + @Override
52 + public List<BrokerPosition> fetchPositions(BrokerAccount account) {
53 + throw new UnsupportedOperationException("ICICI Direct positions are intentionally unsupported until official API documentation is provided and verified.");
54 + }
55 +
56 + @Override
57 + public List<BrokerCashBalance> fetchCashBalances(BrokerAccount account) {
58 + throw new UnsupportedOperationException("ICICI Direct cash balances are intentionally unsupported until official API research is complete.");
59 + }
60 +
61 + @Override
62 + public void disconnect(UUID connectionId) {
63 + throw new UnsupportedOperationException("ICICI Direct disconnect is unavailable because the provider is not configured.");
64 + }
65 +
66 + private static boolean isBlank(String value) {
67 + return value == null || value.isBlank();
68 + }
69 +}
services/broker-service/src/main/java/com/aiinvestment/broker/provider/icici/ICICIDirectInstrumentNormalizer.java new
+50
@@ -0,0 +1,50 @@
1 +package com.aiinvestment.broker.provider.icici;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import com.aiinvestment.shared.domain.Instrument;
5 +import com.aiinvestment.shared.domain.broker.BrokerInstrumentIdentity;
6 +import com.aiinvestment.shared.domain.broker.BrokerInstrumentNormalizer;
7 +import com.aiinvestment.shared.domain.broker.BrokerType;
8 +import org.springframework.stereotype.Component;
9 +
10 +import java.nio.charset.StandardCharsets;
11 +import java.util.UUID;
12 +
13 +@Component
14 +public class ICICIDirectInstrumentNormalizer implements BrokerInstrumentNormalizer {
15 + @Override
16 + public BrokerType supportedBroker() {
17 + return BrokerType.ICICI_DIRECT;
18 + }
19 +
20 + @Override
21 + public Instrument normalize(BrokerInstrumentIdentity identity) {
22 + requireIdentity(identity);
23 + String stableKey = String.join("|", BrokerType.ICICI_DIRECT.name(), safe(identity.brokerSecurityId()),
24 + safe(identity.isin()), identity.ticker(), identity.exchange(), identity.currency());
25 + return new Instrument(UUID.nameUUIDFromBytes(stableKey.getBytes(StandardCharsets.UTF_8)), identity.isin(),
26 + identity.ticker(), identity.exchange(), identity.mic(), identity.ticker(), assetType(identity),
27 + "IN", identity.currency(), null, null);
28 + }
29 +
30 + private static void requireIdentity(BrokerInstrumentIdentity identity) {
31 + if (identity == null || identity.brokerType() != BrokerType.ICICI_DIRECT || isBlank(identity.ticker())
32 + || isBlank(identity.exchange()) || isBlank(identity.currency())
33 + || (!"INR".equals(identity.currency()))
34 + || (isBlank(identity.brokerSecurityId()) && isBlank(identity.isin()))) {
35 + throw new IllegalArgumentException("ICICI Direct instrument identity requires Indian exchange, INR currency, and broker security ID or ISIN");
36 + }
37 + }
38 +
39 + private static boolean isBlank(String value) {
40 + return value == null || value.isBlank();
41 + }
42 +
43 + private static String safe(String value) {
44 + return value == null ? "" : value;
45 + }
46 +
47 + private static AssetType assetType(BrokerInstrumentIdentity identity) {
48 + return identity.assetType() == null ? AssetType.EQUITY : identity.assetType();
49 + }
50 +}
services/broker-service/src/main/java/com/aiinvestment/broker/provider/mock/MockBrokerProvider.java new
+77
@@ -0,0 +1,77 @@
1 +package com.aiinvestment.broker.provider.mock;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import com.aiinvestment.shared.domain.Instrument;
5 +import com.aiinvestment.shared.domain.Money;
6 +import com.aiinvestment.shared.domain.broker.*;
7 +import org.springframework.stereotype.Component;
8 +
9 +import java.math.BigDecimal;
10 +import java.time.Instant;
11 +import java.util.List;
12 +import java.util.UUID;
13 +
14 +@Component
15 +public class MockBrokerProvider implements BrokerProvider {
16 + @Override
17 + public BrokerType supportedBroker() {
18 + return BrokerType.MOCK;
19 + }
20 +
21 + @Override
22 + public BrokerConnectionCapabilities connectionCapabilities() {
23 + return BrokerConnectionCapabilities.mockReadOnly();
24 + }
25 +
26 + @Override
27 + public BrokerConnectionStatus connectionStatus() {
28 + return new BrokerConnectionStatus(BrokerType.MOCK, BrokerConnectionState.CONNECTED, BrokerProviderStatus.CONNECTED, "MOCK_CONNECTED", "Mock provider returns static fake DEV data.");
29 + }
30 +
31 + @Override
32 + public List<BrokerAccount> fetchAccounts(UUID userId) {
33 + return List.of(
34 + new BrokerAccount("MOCK_EU", userId, BrokerType.MOCK, "mock-eu-account", "Mock EU Account", "EUR", BrokerAccountStatus.ACTIVE),
35 + new BrokerAccount("MOCK_INDIA", userId, BrokerType.MOCK, "mock-india-account", "Mock India Account", "INR", BrokerAccountStatus.ACTIVE)
36 + );
37 + }
38 +
39 + @Override
40 + public List<BrokerPosition> fetchPositions(BrokerAccount account) {
41 + Instant observedAt = Instant.parse("2026-01-01T00:00:00Z");
42 + if ("MOCK_EU".equals(account.brokerAccountId())) {
43 + return List.of(
44 + position(account, "BESI", "NL0012866412", "XAMS", "EUR", "12", "110.00", "132.50", observedAt),
45 + position(account, "AIXA", "DE000A0WMPJ6", "XETR", "EUR", "40", "18.25", "24.10", observedAt),
46 + position(account, "NVDA", "US67066G1040", "XNAS", "USD", "8", "500.00", "920.00", observedAt)
47 + );
48 + }
49 + if ("MOCK_INDIA".equals(account.brokerAccountId())) {
50 + return List.of(
51 + position(account, "RELIANCE", "INE002A01018", "XNSE", "INR", "20", "2400.00", "2850.00", observedAt),
52 + position(account, "ZENTEC", "INE251B01027", "XNSE", "INR", "35", "650.00", "1025.00", observedAt)
53 + );
54 + }
55 + return List.of();
56 + }
57 +
58 + @Override
59 + public List<BrokerCashBalance> fetchCashBalances(BrokerAccount account) {
60 + return List.of(new BrokerCashBalance(account.brokerAccountId(),
61 + new Money(new BigDecimal("MOCK_INDIA".equals(account.brokerAccountId()) ? "150000.00" : "1250.00"), account.baseCurrency())));
62 + }
63 +
64 + @Override
65 + public void disconnect(UUID connectionId) {
66 + // No remote state exists for the mock provider.
67 + }
68 +
69 + private static BrokerPosition position(BrokerAccount account, String ticker, String isin, String exchange, String currency,
70 + String quantity, String averageCost, String currentPrice, Instant observedAt) {
71 + Instrument instrument = new Instrument(UUID.nameUUIDFromBytes((isin + exchange + ticker).getBytes()), isin, ticker, exchange,
72 + exchange, ticker + " Mock Company", AssetType.EQUITY, currency.equals("INR") ? "IN" : currency.equals("USD") ? "US" : "NL",
73 + currency, "Mock Sector", "Mock Industry");
74 + return new BrokerPosition(account.brokerAccountId(), instrument, new BigDecimal(quantity),
75 + new Money(new BigDecimal(averageCost), currency), new Money(new BigDecimal(currentPrice), currency), observedAt);
76 + }
77 +}
services/broker-service/src/main/java/com/aiinvestment/broker/resilience/InMemoryProviderCircuitBreaker.java new
+27
@@ -0,0 +1,27 @@
1 +package com.aiinvestment.broker.resilience;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +import org.springframework.stereotype.Component;
5 +
6 +import java.util.Map;
7 +import java.util.concurrent.ConcurrentHashMap;
8 +
9 +@Component
10 +public class InMemoryProviderCircuitBreaker implements ProviderCircuitBreaker {
11 + private final Map<BrokerType, Integer> failures = new ConcurrentHashMap<>();
12 +
13 + @Override
14 + public boolean allowRequest(BrokerType brokerType) {
15 + return failures.getOrDefault(brokerType, 0) < 3;
16 + }
17 +
18 + @Override
19 + public void recordSuccess(BrokerType brokerType) {
20 + failures.remove(brokerType);
21 + }
22 +
23 + @Override
24 + public void recordFailure(BrokerType brokerType) {
25 + failures.merge(brokerType, 1, Integer::sum);
26 + }
27 +}
services/broker-service/src/main/java/com/aiinvestment/broker/resilience/InMemoryProviderRateLimiter.java new
+25
@@ -0,0 +1,25 @@
1 +package com.aiinvestment.broker.resilience;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +import org.springframework.stereotype.Component;
5 +
6 +import java.time.Instant;
7 +import java.util.Map;
8 +import java.util.concurrent.ConcurrentHashMap;
9 +
10 +@Component
11 +public class InMemoryProviderRateLimiter implements ProviderRateLimiter {
12 + private final Map<String, Instant> nextAllowedAt = new ConcurrentHashMap<>();
13 +
14 + @Override
15 + public boolean tryAcquire(BrokerType brokerType, String operation) {
16 + String key = brokerType + ":" + operation;
17 + Instant now = Instant.now();
18 + Instant allowed = nextAllowedAt.get(key);
19 + if (allowed != null && allowed.isAfter(now)) {
20 + return false;
21 + }
22 + nextAllowedAt.put(key, now.plusMillis(100));
23 + return true;
24 + }
25 +}
services/broker-service/src/main/java/com/aiinvestment/broker/resilience/ProviderCircuitBreaker.java new
+11
@@ -0,0 +1,11 @@
1 +package com.aiinvestment.broker.resilience;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +
5 +public interface ProviderCircuitBreaker {
6 + boolean allowRequest(BrokerType brokerType);
7 +
8 + void recordSuccess(BrokerType brokerType);
9 +
10 + void recordFailure(BrokerType brokerType);
11 +}
services/broker-service/src/main/java/com/aiinvestment/broker/resilience/ProviderRateLimiter.java new
+7
@@ -0,0 +1,7 @@
1 +package com.aiinvestment.broker.resilience;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +
5 +public interface ProviderRateLimiter {
6 + boolean tryAcquire(BrokerType brokerType, String operation);
7 +}
services/broker-service/src/main/java/com/aiinvestment/broker/resilience/ProviderRetryPolicy.java new
+25
@@ -0,0 +1,25 @@
1 +package com.aiinvestment.broker.resilience;
2 +
3 +import org.springframework.stereotype.Component;
4 +
5 +import java.time.Duration;
6 +import java.util.Optional;
7 +
8 +@Component
9 +public class ProviderRetryPolicy {
10 + private static final int MAX_READ_RETRIES = 2;
11 +
12 + public boolean shouldRetry(String operation, int attempt, Integer httpStatus) {
13 + if (operation != null && operation.toLowerCase().contains("auth")) {
14 + return false;
15 + }
16 + if (attempt >= MAX_READ_RETRIES) {
17 + return false;
18 + }
19 + return httpStatus == null || httpStatus == 408 || httpStatus == 429 || httpStatus >= 500;
20 + }
21 +
22 + public Duration backoff(int attempt, Optional<Duration> retryAfter) {
23 + return retryAfter.orElse(Duration.ofMillis(200L * (attempt + 1)));
24 + }
25 +}
services/broker-service/src/main/java/com/aiinvestment/broker/security/AzureKeyVaultSecretProvider.java new
+10
@@ -0,0 +1,10 @@
1 +package com.aiinvestment.broker.security;
2 +
3 +import java.util.Optional;
4 +
5 +public class AzureKeyVaultSecretProvider implements SecretProvider {
6 + @Override
7 + public Optional<String> getSecret(String key) {
8 + throw new UnsupportedOperationException("Azure Key Vault integration is a PRD boundary and is not required for local DEV.");
9 + }
10 +}
services/broker-service/src/main/java/com/aiinvestment/broker/security/BrokerTokenStore.java new
+19
@@ -0,0 +1,19 @@
1 +package com.aiinvestment.broker.security;
2 +
3 +import com.aiinvestment.shared.domain.broker.auth.BrokerSession;
4 +import com.aiinvestment.shared.domain.broker.auth.SensitiveTokenReference;
5 +
6 +import java.util.Optional;
7 +import java.util.UUID;
8 +
9 +public interface BrokerTokenStore {
10 + SensitiveTokenReference storeSessionToken(UUID connectionId, String secretValue);
11 +
12 + Optional<SensitiveTokenReference> getSessionTokenReference(UUID connectionId);
13 +
14 + void attachSession(BrokerSession session);
15 +
16 + Optional<BrokerSession> getSession(UUID connectionId);
17 +
18 + void revoke(UUID connectionId);
19 +}
services/broker-service/src/main/java/com/aiinvestment/broker/security/EnvironmentSecretProvider.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.broker.security;
2 +
3 +import org.springframework.stereotype.Component;
4 +
5 +import java.util.Optional;
6 +
7 +@Component
8 +public class EnvironmentSecretProvider implements SecretProvider {
9 + @Override
10 + public Optional<String> getSecret(String key) {
11 + return Optional.ofNullable(System.getenv(key));
12 + }
13 +}
services/broker-service/src/main/java/com/aiinvestment/broker/security/InMemoryBrokerTokenStore.java new
+47
@@ -0,0 +1,47 @@
1 +package com.aiinvestment.broker.security;
2 +
3 +import com.aiinvestment.shared.domain.broker.auth.BrokerSession;
4 +import com.aiinvestment.shared.domain.broker.auth.SensitiveTokenReference;
5 +import org.springframework.stereotype.Component;
6 +
7 +import java.util.Map;
8 +import java.util.Optional;
9 +import java.util.UUID;
10 +import java.util.concurrent.ConcurrentHashMap;
11 +
12 +@Component
13 +public class InMemoryBrokerTokenStore implements BrokerTokenStore {
14 + private final Map<UUID, SensitiveTokenReference> tokenReferences = new ConcurrentHashMap<>();
15 + private final Map<UUID, BrokerSession> sessions = new ConcurrentHashMap<>();
16 +
17 + @Override
18 + public SensitiveTokenReference storeSessionToken(UUID connectionId, String secretValue) {
19 + if (secretValue == null || secretValue.isBlank()) {
20 + throw new IllegalArgumentException("secretValue is required");
21 + }
22 + SensitiveTokenReference reference = new SensitiveTokenReference("env-or-vault:" + connectionId);
23 + tokenReferences.put(connectionId, reference);
24 + return reference;
25 + }
26 +
27 + @Override
28 + public Optional<SensitiveTokenReference> getSessionTokenReference(UUID connectionId) {
29 + return Optional.ofNullable(tokenReferences.get(connectionId));
30 + }
31 +
32 + @Override
33 + public void attachSession(BrokerSession session) {
34 + sessions.put(session.connectionId(), session);
35 + }
36 +
37 + @Override
38 + public Optional<BrokerSession> getSession(UUID connectionId) {
39 + return Optional.ofNullable(sessions.get(connectionId));
40 + }
41 +
42 + @Override
43 + public void revoke(UUID connectionId) {
44 + tokenReferences.remove(connectionId);
45 + sessions.remove(connectionId);
46 + }
47 +}
services/broker-service/src/main/java/com/aiinvestment/broker/security/SecretProvider.java new
+7
@@ -0,0 +1,7 @@
1 +package com.aiinvestment.broker.security;
2 +
3 +import java.util.Optional;
4 +
5 +public interface SecretProvider {
6 + Optional<String> getSecret(String key);
7 +}
services/broker-service/src/main/resources/application-real-broker-dev.yml new
+15
@@ -0,0 +1,15 @@
1 +broker:
2 + ibkr:
3 + enabled: ${IBKR_ENABLED:false}
4 + base-url: ${IBKR_BASE_URL:}
5 + client-id: ${IBKR_CLIENT_ID:}
6 + callback-url: ${IBKR_CALLBACK_URL:}
7 + auth-method: ${IBKR_AUTH_METHOD:}
8 + official-documentation-verified: ${IBKR_OFFICIAL_DOCUMENTATION_VERIFIED:false}
9 + icici-direct:
10 + enabled: ${ICICI_DIRECT_ENABLED:false}
11 + base-url: ${ICICI_DIRECT_BASE_URL:}
12 + client-id: ${ICICI_DIRECT_CLIENT_ID:}
13 + callback-url: ${ICICI_DIRECT_CALLBACK_URL:}
14 + auth-method: ${ICICI_DIRECT_AUTH_METHOD:}
15 + official-documentation-verified: ${ICICI_DIRECT_OFFICIAL_DOCUMENTATION_VERIFIED:false}
services/broker-service/src/main/resources/application.yml new
+42
@@ -0,0 +1,42 @@
1 +spring:
2 + application:
3 + name: broker-service
4 + datasource:
5 + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:investment}
6 + username: ${DB_USER:investment}
7 + password: ${DB_PASSWORD:}
8 + jpa:
9 + hibernate:
10 + ddl-auto: validate
11 + open-in-view: false
12 + flyway:
13 + enabled: true
14 +broker:
15 + ibkr:
16 + enabled: ${IBKR_ENABLED:false}
17 + base-url: ${IBKR_BASE_URL:}
18 + client-id: ${IBKR_CLIENT_ID:}
19 + callback-url: ${IBKR_CALLBACK_URL:}
20 + auth-method: ${IBKR_AUTH_METHOD:}
21 + official-documentation-verified: ${IBKR_OFFICIAL_DOCUMENTATION_VERIFIED:false}
22 + icici-direct:
23 + enabled: ${ICICI_DIRECT_ENABLED:false}
24 + base-url: ${ICICI_DIRECT_BASE_URL:}
25 + client-id: ${ICICI_DIRECT_CLIENT_ID:}
26 + callback-url: ${ICICI_DIRECT_CALLBACK_URL:}
27 + auth-method: ${ICICI_DIRECT_AUTH_METHOD:}
28 + official-documentation-verified: ${ICICI_DIRECT_OFFICIAL_DOCUMENTATION_VERIFIED:false}
29 +server:
30 + port: ${SERVER_PORT:8083}
31 +management:
32 + endpoints:
33 + web:
34 + exposure:
35 + include: health,info,prometheus
36 + endpoint:
37 + health:
38 + probes:
39 + enabled: true
40 +logging:
41 + pattern:
42 + level: "%5p [correlationId:%X{correlationId:-}]"
services/broker-service/src/main/resources/db/migration/V1__broker_connections.sql new
+18
@@ -0,0 +1,18 @@
1 +CREATE TABLE broker_connections (
2 + connection_id UUID PRIMARY KEY,
3 + user_id UUID NOT NULL,
4 + broker_type VARCHAR(40) NOT NULL,
5 + external_account_reference VARCHAR(160),
6 + display_name VARCHAR(160) NOT NULL,
7 + status VARCHAR(40) NOT NULL,
8 + connected_at TIMESTAMP,
9 + last_successful_sync_at TIMESTAMP,
10 + last_sync_attempt_at TIMESTAMP,
11 + last_error_code VARCHAR(80),
12 + created_at TIMESTAMP NOT NULL,
13 + updated_at TIMESTAMP NOT NULL
14 +);
15 +
16 +CREATE INDEX idx_broker_connections_user_id ON broker_connections (user_id);
17 +CREATE INDEX idx_broker_connections_broker_type ON broker_connections (broker_type);
18 +CREATE INDEX idx_broker_connections_status ON broker_connections (status);
services/broker-service/src/test/java/com/aiinvestment/broker/api/BrokerControllerTest.java new
+64
@@ -0,0 +1,64 @@
1 +package com.aiinvestment.broker.api;
2 +
3 +import org.junit.jupiter.api.Test;
4 +import org.springframework.beans.factory.annotation.Autowired;
5 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
6 +import org.springframework.boot.test.context.SpringBootTest;
7 +import org.springframework.test.context.ActiveProfiles;
8 +import org.springframework.test.web.servlet.MockMvc;
9 +
10 +import static org.hamcrest.Matchers.*;
11 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
12 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
13 +
14 +@SpringBootTest
15 +@AutoConfigureMockMvc
16 +@ActiveProfiles("test")
17 +class BrokerControllerTest {
18 + @Autowired
19 + private MockMvc mockMvc;
20 +
21 + @Test
22 + void listsProvidersWithCapabilitiesAndNotConfiguredStatus() throws Exception {
23 + mockMvc.perform(get("/api/v1/brokers"))
24 + .andExpect(status().isOk())
25 + .andExpect(jsonPath("$[*].brokerType", containsInAnyOrder("MOCK", "IBKR", "ICICI_DIRECT")))
26 + .andExpect(jsonPath("$[?(@.brokerType == 'MOCK')].capabilities[0]", notNullValue()))
27 + .andExpect(jsonPath("$[?(@.brokerType == 'IBKR')].code", contains("NOT_CONFIGURED")));
28 + }
29 +
30 + @Test
31 + void mockConnectionLifecycleDoesNotRequireSecrets() throws Exception {
32 + String body = mockMvc.perform(post("/api/v1/broker-connections/mock"))
33 + .andExpect(status().isCreated())
34 + .andExpect(jsonPath("$.brokerType").value("MOCK"))
35 + .andExpect(jsonPath("$.status").value("CONNECTED"))
36 + .andExpect(jsonPath("$.capabilities", hasItem("POSITIONS_READ")))
37 + .andReturn().getResponse().getContentAsString();
38 +
39 + String connectionId = body.replaceAll(".*\"connectionId\":\"([^\"]+)\".*", "$1");
40 +
41 + mockMvc.perform(get("/api/v1/broker-connections"))
42 + .andExpect(status().isOk())
43 + .andExpect(jsonPath("$[*].connectionId", hasItem(connectionId)));
44 +
45 + mockMvc.perform(post("/api/v1/broker-connections/{id}/sync", connectionId))
46 + .andExpect(status().isOk())
47 + .andExpect(jsonPath("$.lastSuccessfulSyncAt", notNullValue()));
48 +
49 + mockMvc.perform(delete("/api/v1/broker-connections/{id}", connectionId))
50 + .andExpect(status().isNoContent());
51 + }
52 +
53 + @Test
54 + void unconfiguredRealProviderConnectReturnsSafeError() throws Exception {
55 + mockMvc.perform(post("/api/v1/broker-connections/ibkr/connect")
56 + .header("X-Correlation-Id", "phase2b-test-correlation"))
57 + .andExpect(status().isBadRequest())
58 + .andExpect(jsonPath("$.code").value("BROKER_PROVIDER_UNAVAILABLE"))
59 + .andExpect(jsonPath("$.message", containsString("NOT_CONFIGURED")))
60 + .andExpect(jsonPath("$.correlationId").value("phase2b-test-correlation"))
61 + .andExpect(jsonPath("$.message", not(containsString("password"))))
62 + .andExpect(jsonPath("$.message", not(containsString("token"))));
63 + }
64 +}
services/broker-service/src/test/java/com/aiinvestment/broker/provider/BrokerProviderTest.java new
+53
@@ -0,0 +1,53 @@
1 +package com.aiinvestment.broker.provider;
2 +
3 +import com.aiinvestment.broker.provider.ibkr.IBKRBrokerProvider;
4 +import com.aiinvestment.broker.provider.icici.ICICIDirectBrokerProvider;
5 +import com.aiinvestment.broker.provider.mock.MockBrokerProvider;
6 +import com.aiinvestment.broker.config.IBKRProviderProperties;
7 +import com.aiinvestment.shared.domain.broker.BrokerType;
8 +import com.aiinvestment.shared.domain.broker.BrokerConnectionState;
9 +import com.aiinvestment.shared.domain.broker.BrokerProviderStatus;
10 +import org.junit.jupiter.api.Test;
11 +
12 +import java.util.UUID;
13 +
14 +import static org.assertj.core.api.Assertions.assertThat;
15 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
16 +
17 +class BrokerProviderTest {
18 + @Test
19 + void mockProviderReturnsTwoAccountsAndPositions() {
20 + MockBrokerProvider provider = new MockBrokerProvider();
21 +
22 + var accounts = provider.fetchAccounts(UUID.randomUUID());
23 +
24 + assertThat(accounts).hasSize(2);
25 + assertThat(accounts).extracting("brokerAccountId").containsExactly("MOCK_EU", "MOCK_INDIA");
26 + assertThat(provider.fetchPositions(accounts.get(0))).isNotEmpty();
27 + assertThat(provider.fetchCashBalances(accounts.get(1))).isNotEmpty();
28 + }
29 +
30 + @Test
31 + void realProviderPlaceholdersAreDisabledByDefault() {
32 + IBKRBrokerProvider ibkr = new IBKRBrokerProvider();
33 + ICICIDirectBrokerProvider icici = new ICICIDirectBrokerProvider();
34 +
35 + assertThat(ibkr.supportedBroker()).isEqualTo(BrokerType.IBKR);
36 + assertThat(icici.supportedBroker()).isEqualTo(BrokerType.ICICI_DIRECT);
37 + assertThat(ibkr.connectionStatus().state()).isEqualTo(BrokerConnectionState.DISCONNECTED);
38 + assertThat(icici.connectionStatus().code()).isEqualTo("NOT_CONFIGURED");
39 + assertThat(ibkr.connectionCapabilities().capabilities()).isEmpty();
40 + assertThat(icici.connectionCapabilities().capabilities()).isEmpty();
41 + assertThatThrownBy(() -> ibkr.fetchAccounts(UUID.randomUUID())).isInstanceOf(UnsupportedOperationException.class);
42 + assertThatThrownBy(() -> icici.fetchAccounts(UUID.randomUUID())).isInstanceOf(UnsupportedOperationException.class);
43 + }
44 +
45 + @Test
46 + void enabledRealProviderStillRequiresVerifiedLocalOfficialDocumentation() {
47 + IBKRBrokerProvider ibkr = new IBKRBrokerProvider(
48 + new IBKRProviderProperties(true, "https://localhost:5000", "client", "https://localhost/callback", "gateway", false));
49 +
50 + assertThat(ibkr.connectionStatus().providerStatus()).isEqualTo(BrokerProviderStatus.DOCUMENTATION_REQUIRED);
51 + assertThat(ibkr.connectionCapabilities().capabilities()).isEmpty();
52 + }
53 +}
services/broker-service/src/test/java/com/aiinvestment/broker/provider/InstrumentNormalizerTest.java new
+59
@@ -0,0 +1,59 @@
1 +package com.aiinvestment.broker.provider;
2 +
3 +import com.aiinvestment.broker.provider.ibkr.IBKRInstrumentNormalizer;
4 +import com.aiinvestment.broker.provider.icici.ICICIDirectInstrumentNormalizer;
5 +import com.aiinvestment.shared.domain.AssetType;
6 +import com.aiinvestment.shared.domain.broker.BrokerInstrumentIdentity;
7 +import com.aiinvestment.shared.domain.broker.BrokerType;
8 +import org.junit.jupiter.api.Test;
9 +
10 +import static org.assertj.core.api.Assertions.assertThat;
11 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
12 +
13 +class InstrumentNormalizerTest {
14 + @Test
15 + void ibkrNormalizerUsesContractExchangeAndCurrencyInStableIdentity() {
16 + IBKRInstrumentNormalizer normalizer = new IBKRInstrumentNormalizer();
17 +
18 + var instrument = normalizer.normalize(new BrokerInstrumentIdentity(BrokerType.IBKR, null, "265598",
19 + "US67066G1040", "NVDA", "XNAS", "XNAS", "USD", "US", AssetType.EQUITY));
20 +
21 + assertThat(instrument.ticker()).isEqualTo("NVDA");
22 + assertThat(instrument.exchange()).isEqualTo("XNAS");
23 + assertThat(instrument.tradingCurrency()).isEqualTo("USD");
24 + assertThat(instrument.assetType()).isEqualTo(AssetType.EQUITY);
25 + }
26 +
27 + @Test
28 + void normalizerDoesNotTreatTickerAsGloballyUnique() {
29 + IBKRInstrumentNormalizer normalizer = new IBKRInstrumentNormalizer();
30 +
31 + var besi = normalizer.normalize(new BrokerInstrumentIdentity(BrokerType.IBKR, null, "481771",
32 + "NL0012866412", "BESI", "XAMS", "XAMS", "EUR", "NL", AssetType.EQUITY));
33 + var aixtron = normalizer.normalize(new BrokerInstrumentIdentity(BrokerType.IBKR, null, "123456",
34 + "DE000A0WMPJ6", "AIXTRON", "XETR", "XETR", "EUR", "DE", AssetType.EQUITY));
35 + var sameTickerDifferentVenue = normalizer.normalize(new BrokerInstrumentIdentity(BrokerType.IBKR, null, "999999",
36 + "US67066G1040", "NVDA", "XETR", "XETR", "EUR", "DE", AssetType.EQUITY));
37 + var nvda = normalizer.normalize(new BrokerInstrumentIdentity(BrokerType.IBKR, null, "265598",
38 + "US67066G1040", "NVDA", "XNAS", "XNAS", "USD", "US", AssetType.EQUITY));
39 +
40 + assertThat(besi.exchange()).isEqualTo("XAMS");
41 + assertThat(aixtron.exchange()).isEqualTo("XETR");
42 + assertThat(nvda.exchange()).isEqualTo("XNAS");
43 + assertThat(sameTickerDifferentVenue.instrumentId()).isNotEqualTo(nvda.instrumentId());
44 + }
45 +
46 + @Test
47 + void iciciNormalizerRequiresExplicitIndianMarketIdentity() {
48 + ICICIDirectInstrumentNormalizer normalizer = new ICICIDirectInstrumentNormalizer();
49 +
50 + var instrument = normalizer.normalize(new BrokerInstrumentIdentity(BrokerType.ICICI_DIRECT, "INE002A01018",
51 + null, "INE002A01018", "RELIANCE", "XNSE", "XNSE", "INR", "IN", AssetType.EQUITY));
52 +
53 + assertThat(instrument.country()).isEqualTo("IN");
54 + assertThat(instrument.tradingCurrency()).isEqualTo("INR");
55 + assertThat(instrument.isin()).isEqualTo("INE002A01018");
56 + assertThatThrownBy(() -> normalizer.normalize(new BrokerInstrumentIdentity(BrokerType.ICICI_DIRECT, "x",
57 + null, null, "RELIANCE", "XNSE", "XNSE", "USD", "US", AssetType.EQUITY))).isInstanceOf(IllegalArgumentException.class);
58 + }
59 +}
services/broker-service/src/test/java/com/aiinvestment/broker/resilience/ProviderResilienceTest.java new
+33
@@ -0,0 +1,33 @@
1 +package com.aiinvestment.broker.resilience;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +import org.junit.jupiter.api.Test;
5 +
6 +import static org.assertj.core.api.Assertions.assertThat;
7 +
8 +class ProviderResilienceTest {
9 + @Test
10 + void rateLimiterIsProviderAndOperationSpecific() {
11 + InMemoryProviderRateLimiter limiter = new InMemoryProviderRateLimiter();
12 +
13 + assertThat(limiter.tryAcquire(BrokerType.MOCK, "sync")).isTrue();
14 + assertThat(limiter.tryAcquire(BrokerType.MOCK, "sync")).isFalse();
15 + assertThat(limiter.tryAcquire(BrokerType.MOCK, "status")).isTrue();
16 + assertThat(limiter.tryAcquire(BrokerType.IBKR, "sync")).isTrue();
17 + }
18 +
19 + @Test
20 + void circuitBreakerOpensAfterBoundedFailuresAndClosesOnSuccess() {
21 + InMemoryProviderCircuitBreaker breaker = new InMemoryProviderCircuitBreaker();
22 +
23 + breaker.recordFailure(BrokerType.IBKR);
24 + breaker.recordFailure(BrokerType.IBKR);
25 + assertThat(breaker.allowRequest(BrokerType.IBKR)).isTrue();
26 +
27 + breaker.recordFailure(BrokerType.IBKR);
28 + assertThat(breaker.allowRequest(BrokerType.IBKR)).isFalse();
29 +
30 + breaker.recordSuccess(BrokerType.IBKR);
31 + assertThat(breaker.allowRequest(BrokerType.IBKR)).isTrue();
32 + }
33 +}
services/broker-service/src/test/java/com/aiinvestment/broker/resilience/ProviderRetryPolicyTest.java new
+20
@@ -0,0 +1,20 @@
1 +package com.aiinvestment.broker.resilience;
2 +
3 +import org.junit.jupiter.api.Test;
4 +
5 +import java.time.Duration;
6 +import java.util.Optional;
7 +
8 +import static org.assertj.core.api.Assertions.assertThat;
9 +
10 +class ProviderRetryPolicyTest {
11 + @Test
12 + void retriesOnlyBoundedTransientReadFailuresAndHonorsRetryAfter() {
13 + ProviderRetryPolicy policy = new ProviderRetryPolicy();
14 +
15 + assertThat(policy.shouldRetry("fetchPositions", 0, 429)).isTrue();
16 + assertThat(policy.shouldRetry("fetchPositions", 2, 429)).isFalse();
17 + assertThat(policy.shouldRetry("authenticate", 0, 500)).isFalse();
18 + assertThat(policy.backoff(0, Optional.of(Duration.ofSeconds(3)))).isEqualTo(Duration.ofSeconds(3));
19 + }
20 +}
services/broker-service/src/test/java/com/aiinvestment/broker/security/BrokerTokenStoreTest.java new
+34
@@ -0,0 +1,34 @@
1 +package com.aiinvestment.broker.security;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +import com.aiinvestment.shared.domain.broker.auth.BrokerSession;
5 +import com.aiinvestment.shared.domain.broker.auth.BrokerSessionState;
6 +import org.junit.jupiter.api.Test;
7 +
8 +import java.time.Instant;
9 +import java.util.UUID;
10 +
11 +import static org.assertj.core.api.Assertions.assertThat;
12 +
13 +class BrokerTokenStoreTest {
14 + @Test
15 + void storesOnlyTokenReferencesAndSessionLifecycleState() {
16 + InMemoryBrokerTokenStore store = new InMemoryBrokerTokenStore();
17 + UUID connectionId = UUID.randomUUID();
18 +
19 + var reference = store.storeSessionToken(connectionId, "raw-secret-token");
20 + store.attachSession(new BrokerSession(UUID.randomUUID(), connectionId, BrokerType.MOCK,
21 + BrokerSessionState.CONNECTED, Instant.now().plusSeconds(60), true));
22 +
23 + assertThat(reference.keyRef()).contains(connectionId.toString());
24 + assertThat(reference.keyRef()).doesNotContain("raw-secret-token");
25 + assertThat(reference.toString()).doesNotContain("raw-secret-token");
26 + assertThat(store.getSession(connectionId)).hasValueSatisfying(session ->
27 + assertThat(session.state()).isEqualTo(BrokerSessionState.CONNECTED));
28 +
29 + store.revoke(connectionId);
30 +
31 + assertThat(store.getSessionTokenReference(connectionId)).isEmpty();
32 + assertThat(store.getSession(connectionId)).isEmpty();
33 + }
34 +}
services/broker-service/src/test/resources/application-test.yml new
+12
@@ -0,0 +1,12 @@
1 +spring:
2 + datasource:
3 + url: jdbc:h2:mem:broker;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
4 + driver-class-name: org.h2.Driver
5 + username: sa
6 + password:
7 + jpa:
8 + hibernate:
9 + ddl-auto: validate
10 + open-in-view: false
11 + flyway:
12 + enabled: true
services/company-service/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/company-service/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/company-service/pom.xml new
+14
@@ -0,0 +1,14 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3 + <modelVersion>4.0.0</modelVersion>
4 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
5 + <artifactId>company-service</artifactId>
6 + <dependencies>
7 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
8 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
10 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
11 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
12 + </dependencies>
13 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
14 +</project>
services/company-service/src/main/java/com/aiinvestment/company/CompanyServiceApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.company;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class CompanyServiceApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(CompanyServiceApplication.class, args);
12 + }
13 +}
services/company-service/src/main/java/com/aiinvestment/company/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.company;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/company-service")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "company-service", "status", "starting-foundation");
15 + }
16 +}
services/company-service/src/main/resources/application.yml new
+17
@@ -0,0 +1,17 @@
1 +spring:
2 + application:
3 + name: company-service
4 +server:
5 + port: ${SERVER_PORT:8084}
6 +management:
7 + endpoints:
8 + web:
9 + exposure:
10 + include: health,info,prometheus
11 + endpoint:
12 + health:
13 + probes:
14 + enabled: true
15 +logging:
16 + pattern:
17 + level: "%5p [correlationId:%X{correlationId:-}]"
services/notification-service/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/notification-service/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/notification-service/pom.xml new
+14
@@ -0,0 +1,14 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3 + <modelVersion>4.0.0</modelVersion>
4 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
5 + <artifactId>notification-service</artifactId>
6 + <dependencies>
7 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
8 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
10 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
11 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
12 + </dependencies>
13 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
14 +</project>
services/notification-service/src/main/java/com/aiinvestment/notification/NotificationServiceApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.notification;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class NotificationServiceApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(NotificationServiceApplication.class, args);
12 + }
13 +}
services/notification-service/src/main/java/com/aiinvestment/notification/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.notification;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/notification-service")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "notification-service", "status", "starting-foundation");
15 + }
16 +}
services/notification-service/src/main/resources/application.yml new
+17
@@ -0,0 +1,17 @@
1 +spring:
2 + application:
3 + name: notification-service
4 +server:
5 + port: ${SERVER_PORT:8088}
6 +management:
7 + endpoints:
8 + web:
9 + exposure:
10 + include: health,info,prometheus
11 + endpoint:
12 + health:
13 + probes:
14 + enabled: true
15 +logging:
16 + pattern:
17 + level: "%5p [correlationId:%X{correlationId:-}]"
services/pom.xml new
+75
@@ -0,0 +1,75 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0"
3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5 + <modelVersion>4.0.0</modelVersion>
6 +
7 + <groupId>com.aiinvestment</groupId>
8 + <artifactId>ai-investment-platform-services</artifactId>
9 + <version>0.1.0-SNAPSHOT</version>
10 + <packaging>pom</packaging>
11 + <name>AI Investment Platform Services</name>
12 +
13 + <modules>
14 + <module>../shared/java/domain</module>
15 + <module>../shared/java/web</module>
16 + <module>api-gateway</module>
17 + <module>auth-service</module>
18 + <module>portfolio-service</module>
19 + <module>broker-service</module>
20 + <module>company-service</module>
21 + <module>research-service</module>
22 + <module>recommendation-service</module>
23 + <module>risk-service</module>
24 + <module>notification-service</module>
25 + </modules>
26 +
27 + <properties>
28 + <java.version>17</java.version>
29 + <maven.compiler.release>${java.version}</maven.compiler.release>
30 + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
31 + <spring.boot.version>3.3.4</spring.boot.version>
32 + </properties>
33 +
34 + <dependencyManagement>
35 + <dependencies>
36 + <dependency>
37 + <groupId>org.springframework.boot</groupId>
38 + <artifactId>spring-boot-dependencies</artifactId>
39 + <version>${spring.boot.version}</version>
40 + <type>pom</type>
41 + <scope>import</scope>
42 + </dependency>
43 + </dependencies>
44 + </dependencyManagement>
45 +
46 + <build>
47 + <pluginManagement>
48 + <plugins>
49 + <plugin>
50 + <groupId>org.apache.maven.plugins</groupId>
51 + <artifactId>maven-compiler-plugin</artifactId>
52 + <version>3.13.0</version>
53 + <configuration>
54 + <release>${java.version}</release>
55 + </configuration>
56 + </plugin>
57 + <plugin>
58 + <groupId>org.springframework.boot</groupId>
59 + <artifactId>spring-boot-maven-plugin</artifactId>
60 + <version>${spring.boot.version}</version>
61 + </plugin>
62 + </plugins>
63 + </pluginManagement>
64 + </build>
65 +
66 + <profiles>
67 + <profile>
68 + <id>integration-real</id>
69 + <properties>
70 + <groups>integration-real</groups>
71 + <spring.profiles.active>real-broker-dev</spring.profiles.active>
72 + </properties>
73 + </profile>
74 + </profiles>
75 +</project>
services/portfolio-service/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/portfolio-service/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/portfolio-service/pom.xml new
+21
@@ -0,0 +1,21 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3 + <modelVersion>4.0.0</modelVersion>
4 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
5 + <artifactId>portfolio-service</artifactId>
6 + <dependencies>
7 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
8 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
10 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
11 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
12 + <dependency><groupId>org.flywaydb</groupId><artifactId>flyway-core</artifactId></dependency>
13 + <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
14 + <dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>2.6.0</version></dependency>
15 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
16 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
17 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
18 + <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
19 + </dependencies>
20 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
21 +</project>
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/PortfolioServiceApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.portfolio;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class PortfolioServiceApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(PortfolioServiceApplication.class, args);
12 + }
13 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.portfolio;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/portfolio-service")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "portfolio-service", "status", "starting-foundation");
15 + }
16 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/AllocationResponse.java new
+19
@@ -0,0 +1,19 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.portfolio.domain.AllocationBreakdown;
4 +
5 +import java.math.BigDecimal;
6 +import java.util.Map;
7 +
8 +public record AllocationResponse(
9 + Map<String, BigDecimal> country,
10 + Map<String, BigDecimal> currency,
11 + Map<String, BigDecimal> sector,
12 + Map<String, BigDecimal> assetType,
13 + Map<String, BigDecimal> broker
14 +) {
15 + public static AllocationResponse from(AllocationBreakdown allocation) {
16 + return new AllocationResponse(allocation.country(), allocation.currency(), allocation.sector(),
17 + allocation.assetType(), allocation.broker());
18 + }
19 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/ApiErrorResponse.java new
+12
@@ -0,0 +1,12 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import java.time.Instant;
4 +
5 +public record ApiErrorResponse(
6 + Instant timestamp,
7 + int status,
8 + String code,
9 + String message,
10 + String correlationId
11 +) {
12 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/CreatePortfolioRequest.java new
+11
@@ -0,0 +1,11 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import jakarta.validation.constraints.NotBlank;
4 +import jakarta.validation.constraints.Pattern;
5 +import jakarta.validation.constraints.Size;
6 +
7 +public record CreatePortfolioRequest(
8 + @NotBlank @Size(max = 160) String name,
9 + @NotBlank @Pattern(regexp = "[A-Z]{3}") String baseCurrency
10 +) {
11 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/GlobalExceptionHandler.java new
+39
@@ -0,0 +1,39 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.portfolio.application.PortfolioNotFoundException;
4 +import com.aiinvestment.shared.web.CorrelationIdFilter;
5 +import jakarta.servlet.http.HttpServletRequest;
6 +import org.slf4j.MDC;
7 +import org.springframework.http.HttpStatus;
8 +import org.springframework.http.ResponseEntity;
9 +import org.springframework.web.bind.MethodArgumentNotValidException;
10 +import org.springframework.web.bind.annotation.ExceptionHandler;
11 +import org.springframework.web.bind.annotation.RestControllerAdvice;
12 +
13 +import java.time.Instant;
14 +
15 +@RestControllerAdvice
16 +public class GlobalExceptionHandler {
17 + @ExceptionHandler(MethodArgumentNotValidException.class)
18 + public ResponseEntity<ApiErrorResponse> validation(MethodArgumentNotValidException ex, HttpServletRequest request) {
19 + return error(HttpStatus.BAD_REQUEST, "INVALID_PORTFOLIO_REQUEST", "Invalid portfolio request", request);
20 + }
21 +
22 + @ExceptionHandler({IllegalArgumentException.class})
23 + public ResponseEntity<ApiErrorResponse> illegalArgument(IllegalArgumentException ex, HttpServletRequest request) {
24 + return error(HttpStatus.BAD_REQUEST, "INVALID_PORTFOLIO_REQUEST", ex.getMessage(), request);
25 + }
26 +
27 + @ExceptionHandler(PortfolioNotFoundException.class)
28 + public ResponseEntity<ApiErrorResponse> notFound(PortfolioNotFoundException ex, HttpServletRequest request) {
29 + return error(HttpStatus.NOT_FOUND, "PORTFOLIO_NOT_FOUND", ex.getMessage(), request);
30 + }
31 +
32 + private ResponseEntity<ApiErrorResponse> error(HttpStatus status, String code, String message, HttpServletRequest request) {
33 + String correlationId = MDC.get("correlationId");
34 + if (correlationId == null || correlationId.isBlank()) {
35 + correlationId = request.getHeader(CorrelationIdFilter.HEADER_NAME);
36 + }
37 + return ResponseEntity.status(status).body(new ApiErrorResponse(Instant.now(), status.value(), code, message, correlationId));
38 + }
39 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/InstrumentResponse.java new
+26
@@ -0,0 +1,26 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import com.aiinvestment.shared.domain.Instrument;
5 +
6 +import java.util.UUID;
7 +
8 +public record InstrumentResponse(
9 + UUID instrumentId,
10 + String isin,
11 + String ticker,
12 + String exchange,
13 + String mic,
14 + String companyName,
15 + AssetType assetType,
16 + String country,
17 + String tradingCurrency,
18 + String sector,
19 + String industry
20 +) {
21 + public static InstrumentResponse from(Instrument instrument) {
22 + return new InstrumentResponse(instrument.instrumentId(), instrument.isin(), instrument.ticker(), instrument.exchange(),
23 + instrument.mic(), instrument.companyName(), instrument.assetType(), instrument.country(),
24 + instrument.tradingCurrency(), instrument.sector(), instrument.industry());
25 + }
26 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/MoneyResponse.java new
+14
@@ -0,0 +1,14 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.shared.domain.Money;
4 +
5 +import java.math.BigDecimal;
6 +
7 +public record MoneyResponse(BigDecimal amount, String currency) {
8 + public static MoneyResponse from(Money money) {
9 + if (money == null) {
10 + return null;
11 + }
12 + return new MoneyResponse(money.amount(), money.currency());
13 + }
14 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/PortfolioController.java new
+64
@@ -0,0 +1,64 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.portfolio.application.PortfolioService;
4 +import com.aiinvestment.shared.domain.market.MarketDataProvider;
5 +import io.swagger.v3.oas.annotations.Operation;
6 +import jakarta.validation.Valid;
7 +import org.springframework.http.HttpStatus;
8 +import org.springframework.web.bind.annotation.*;
9 +
10 +import java.util.List;
11 +import java.util.UUID;
12 +
13 +@RestController
14 +@RequestMapping("/api/v1/portfolios")
15 +public class PortfolioController {
16 + private final PortfolioService portfolioService;
17 + private final MarketDataProvider marketDataProvider;
18 +
19 + public PortfolioController(PortfolioService portfolioService, MarketDataProvider marketDataProvider) {
20 + this.portfolioService = portfolioService;
21 + this.marketDataProvider = marketDataProvider;
22 + }
23 +
24 + @PostMapping
25 + @ResponseStatus(HttpStatus.CREATED)
26 + @Operation(summary = "Create a portfolio")
27 + public PortfolioResponse create(@Valid @RequestBody CreatePortfolioRequest request) {
28 + return PortfolioResponse.from(portfolioService.createPortfolio(request.name(), request.baseCurrency()));
29 + }
30 +
31 + @GetMapping
32 + @Operation(summary = "List portfolios for the current Phase 2B validation user")
33 + public List<PortfolioListItemResponse> list() {
34 + return portfolioService.listPortfolioSummaries().stream()
35 + .map(summary -> PortfolioListItemResponse.from(portfolioService.getPortfolio(summary.portfolioId()), summary))
36 + .toList();
37 + }
38 +
39 + @GetMapping("/{portfolioId}")
40 + @Operation(summary = "Get a portfolio")
41 + public PortfolioResponse get(@PathVariable("portfolioId") UUID portfolioId) {
42 + return PortfolioResponse.from(portfolioService.getPortfolio(portfolioId));
43 + }
44 +
45 + @GetMapping("/{portfolioId}/positions")
46 + @Operation(summary = "Get portfolio positions")
47 + public List<PortfolioPositionResponse> positions(@PathVariable("portfolioId") UUID portfolioId) {
48 + return portfolioService.getPositions(portfolioId).stream()
49 + .map(position -> PortfolioPositionResponse.from(position, QuoteResponse.from(marketDataProvider.getQuote(position.instrument()))))
50 + .toList();
51 + }
52 +
53 + @GetMapping("/{portfolioId}/summary")
54 + @Operation(summary = "Get portfolio summary")
55 + public PortfolioSummaryResponse summary(@PathVariable("portfolioId") UUID portfolioId) {
56 + return PortfolioSummaryResponse.from(portfolioService.getSummary(portfolioId));
57 + }
58 +
59 + @PostMapping("/{portfolioId}/sync")
60 + @Operation(summary = "Sync portfolio from the Phase 2B mock broker provider")
61 + public PortfolioSummaryResponse sync(@PathVariable("portfolioId") UUID portfolioId) {
62 + return PortfolioSummaryResponse.from(portfolioService.sync(portfolioId));
63 + }
64 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/PortfolioListItemResponse.java new
+24
@@ -0,0 +1,24 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.portfolio.domain.Portfolio;
4 +import com.aiinvestment.portfolio.domain.PortfolioSummary;
5 +
6 +import java.time.Instant;
7 +import java.util.UUID;
8 +
9 +public record PortfolioListItemResponse(
10 + UUID portfolioId,
11 + String name,
12 + String baseCurrency,
13 + MoneyResponse totalMarketValue,
14 + MoneyResponse unrealizedProfitLoss,
15 + java.math.BigDecimal unrealizedProfitLossPercent,
16 + int positions,
17 + Instant updatedAt
18 +) {
19 + public static PortfolioListItemResponse from(Portfolio portfolio, PortfolioSummary summary) {
20 + return new PortfolioListItemResponse(portfolio.portfolioId(), portfolio.name(), portfolio.baseCurrency(),
21 + MoneyResponse.from(summary.totalMarketValue()), MoneyResponse.from(summary.unrealizedProfitLoss()),
22 + summary.unrealizedProfitLossPercent(), summary.numberOfPositions(), portfolio.updatedAt());
23 + }
24 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/PortfolioPositionResponse.java new
+39
@@ -0,0 +1,39 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.portfolio.domain.PortfolioPosition;
4 +
5 +import java.math.BigDecimal;
6 +import java.time.Instant;
7 +import java.util.UUID;
8 +
9 +public record PortfolioPositionResponse(
10 + UUID positionId,
11 + UUID portfolioId,
12 + InstrumentResponse instrument,
13 + BigDecimal quantity,
14 + MoneyResponse averageCost,
15 + MoneyResponse currentPrice,
16 + MoneyResponse marketValue,
17 + MoneyResponse costBasis,
18 + MoneyResponse unrealizedProfitLoss,
19 + BigDecimal unrealizedProfitLossPercent,
20 + String brokerAccountId,
21 + Instant lastUpdated,
22 + QuoteResponse quote
23 +) {
24 + public static PortfolioPositionResponse from(PortfolioPosition position) {
25 + return new PortfolioPositionResponse(position.positionId(), position.portfolioId(), InstrumentResponse.from(position.instrument()),
26 + position.quantity(), MoneyResponse.from(position.averageCost()), MoneyResponse.from(position.currentPrice()),
27 + MoneyResponse.from(position.marketValue()), MoneyResponse.from(position.costBasis()),
28 + MoneyResponse.from(position.unrealizedProfitLoss()), position.unrealizedProfitLossPercent(),
29 + position.brokerAccountId(), position.lastUpdated(), null);
30 + }
31 +
32 + public static PortfolioPositionResponse from(PortfolioPosition position, QuoteResponse quote) {
33 + return new PortfolioPositionResponse(position.positionId(), position.portfolioId(), InstrumentResponse.from(position.instrument()),
34 + position.quantity(), MoneyResponse.from(position.averageCost()), MoneyResponse.from(position.currentPrice()),
35 + MoneyResponse.from(position.marketValue()), MoneyResponse.from(position.costBasis()),
36 + MoneyResponse.from(position.unrealizedProfitLoss()), position.unrealizedProfitLossPercent(),
37 + position.brokerAccountId(), position.lastUpdated(), quote);
38 + }
39 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/PortfolioResponse.java new
+20
@@ -0,0 +1,20 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.portfolio.domain.Portfolio;
4 +
5 +import java.time.Instant;
6 +import java.util.UUID;
7 +
8 +public record PortfolioResponse(
9 + UUID portfolioId,
10 + UUID userId,
11 + String name,
12 + String baseCurrency,
13 + Instant createdAt,
14 + Instant updatedAt
15 +) {
16 + public static PortfolioResponse from(Portfolio portfolio) {
17 + return new PortfolioResponse(portfolio.portfolioId(), portfolio.userId(), portfolio.name(),
18 + portfolio.baseCurrency(), portfolio.createdAt(), portfolio.updatedAt());
19 + }
20 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/PortfolioSummaryResponse.java new
+25
@@ -0,0 +1,25 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.portfolio.domain.PortfolioSummary;
4 +
5 +import java.math.BigDecimal;
6 +import java.util.UUID;
7 +
8 +public record PortfolioSummaryResponse(
9 + UUID portfolioId,
10 + String baseCurrency,
11 + MoneyResponse totalMarketValue,
12 + MoneyResponse totalCostBasis,
13 + MoneyResponse unrealizedProfitLoss,
14 + BigDecimal unrealizedProfitLossPercent,
15 + MoneyResponse cash,
16 + int positions,
17 + AllocationResponse allocation
18 +) {
19 + public static PortfolioSummaryResponse from(PortfolioSummary summary) {
20 + return new PortfolioSummaryResponse(summary.portfolioId(), summary.baseCurrency(),
21 + MoneyResponse.from(summary.totalMarketValue()), MoneyResponse.from(summary.totalCostBasis()),
22 + MoneyResponse.from(summary.unrealizedProfitLoss()), summary.unrealizedProfitLossPercent(),
23 + MoneyResponse.from(summary.cash()), summary.numberOfPositions(), AllocationResponse.from(summary.allocation()));
24 + }
25 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/api/QuoteResponse.java new
+25
@@ -0,0 +1,25 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import com.aiinvestment.shared.domain.market.Quote;
4 +
5 +import java.time.Instant;
6 +
7 +public record QuoteResponse(
8 + MoneyResponse bid,
9 + MoneyResponse ask,
10 + MoneyResponse last,
11 + MoneyResponse previousClose,
12 + String currency,
13 + Instant timestamp,
14 + String source,
15 + String freshness,
16 + String marketStatus,
17 + Instant sourceTimestamp,
18 + Instant receivedAt
19 +) {
20 + public static QuoteResponse from(Quote quote) {
21 + return new QuoteResponse(MoneyResponse.from(quote.bid()), MoneyResponse.from(quote.ask()), MoneyResponse.from(quote.last()),
22 + MoneyResponse.from(quote.previousClose()), quote.currency(), quote.timestamp(), quote.source(),
23 + quote.freshness().name(), quote.marketStatus().name(), quote.sourceTimestamp(), quote.receivedAt());
24 + }
25 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/PlatformEventPublisher.java new
+7
@@ -0,0 +1,7 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import com.aiinvestment.shared.domain.event.PlatformEvent;
4 +
5 +public interface PlatformEventPublisher {
6 + void publish(PlatformEvent event);
7 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/PortfolioNotFoundException.java new
+9
@@ -0,0 +1,9 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import java.util.UUID;
4 +
5 +public class PortfolioNotFoundException extends RuntimeException {
6 + public PortfolioNotFoundException(UUID portfolioId) {
7 + super("Portfolio not found: " + portfolioId);
8 + }
9 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/PortfolioService.java new
+155
@@ -0,0 +1,155 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import com.aiinvestment.portfolio.domain.Portfolio;
4 +import com.aiinvestment.portfolio.domain.PortfolioCalculator;
5 +import com.aiinvestment.portfolio.domain.PortfolioPosition;
6 +import com.aiinvestment.portfolio.domain.PortfolioSummary;
7 +import com.aiinvestment.portfolio.infrastructure.persistence.*;
8 +import com.aiinvestment.shared.domain.broker.*;
9 +import com.aiinvestment.shared.domain.event.BrokerSyncEvent;
10 +import com.aiinvestment.shared.domain.event.PortfolioUpdatedEvent;
11 +import org.slf4j.MDC;
12 +import org.springframework.stereotype.Service;
13 +import org.springframework.transaction.annotation.Transactional;
14 +
15 +import java.time.Instant;
16 +import java.util.ArrayList;
17 +import java.util.List;
18 +import java.util.Set;
19 +import java.util.UUID;
20 +import java.util.concurrent.ConcurrentHashMap;
21 +
22 +@Service
23 +public class PortfolioService {
24 + private static final UUID DEFAULT_PHASE1_USER = UUID.fromString("00000000-0000-0000-0000-000000000001");
25 + private static final UUID MOCK_CONNECTION_ID = UUID.nameUUIDFromBytes("mock-broker-connection".getBytes());
26 + private static final Set<UUID> ACTIVE_SYNCS = ConcurrentHashMap.newKeySet();
27 +
28 + private final PortfolioRepository portfolioRepository;
29 + private final BrokerAccountRepository brokerAccountRepository;
30 + private final InstrumentRepository instrumentRepository;
31 + private final PortfolioPositionRepository positionRepository;
32 + private final PortfolioCalculator calculator;
33 + private final BrokerProvider brokerProvider;
34 + private final PlatformEventPublisher eventPublisher;
35 +
36 + public PortfolioService(PortfolioRepository portfolioRepository,
37 + BrokerAccountRepository brokerAccountRepository,
38 + InstrumentRepository instrumentRepository,
39 + PortfolioPositionRepository positionRepository,
40 + PortfolioCalculator calculator,
41 + PlatformEventPublisher eventPublisher,
42 + List<BrokerProvider> brokerProviders) {
43 + this.portfolioRepository = portfolioRepository;
44 + this.brokerAccountRepository = brokerAccountRepository;
45 + this.instrumentRepository = instrumentRepository;
46 + this.positionRepository = positionRepository;
47 + this.calculator = calculator;
48 + this.eventPublisher = eventPublisher;
49 + this.brokerProvider = brokerProviders.stream()
50 + .filter(provider -> provider.supportedBroker() == BrokerType.MOCK)
51 + .findFirst()
52 + .orElseThrow(() -> new IllegalStateException("MockBrokerProvider is required for Phase 2B sync validation"));
53 + }
54 +
55 + @Transactional
56 + public Portfolio createPortfolio(String name, String baseCurrency) {
57 + Instant now = Instant.now();
58 + PortfolioEntity entity = new PortfolioEntity(UUID.randomUUID(), DEFAULT_PHASE1_USER, name, baseCurrency, now, now);
59 + return PortfolioMapper.toDomain(portfolioRepository.save(entity));
60 + }
61 +
62 + @Transactional(readOnly = true)
63 + public Portfolio getPortfolio(UUID portfolioId) {
64 + return portfolioRepository.findById(portfolioId)
65 + .map(PortfolioMapper::toDomain)
66 + .orElseThrow(() -> new PortfolioNotFoundException(portfolioId));
67 + }
68 +
69 + @Transactional(readOnly = true)
70 + public List<PortfolioSummary> listPortfolioSummaries() {
71 + return portfolioRepository.findByUserId(DEFAULT_PHASE1_USER).stream()
72 + .map(PortfolioMapper::toDomain)
73 + .map(portfolio -> calculator.summarize(portfolio, getPositions(portfolio.portfolioId()), cashBalancesFor(portfolio)))
74 + .toList();
75 + }
76 +
77 + @Transactional(readOnly = true)
78 + public List<PortfolioPosition> getPositions(UUID portfolioId) {
79 + ensurePortfolioExists(portfolioId);
80 + return positionRepository.findByPortfolioPortfolioId(portfolioId).stream()
81 + .map(PortfolioMapper::toDomain)
82 + .toList();
83 + }
84 +
85 + @Transactional(readOnly = true)
86 + public PortfolioSummary getSummary(UUID portfolioId) {
87 + Portfolio portfolio = getPortfolio(portfolioId);
88 + List<PortfolioPosition> positions = getPositions(portfolioId);
89 + return calculator.summarize(portfolio, positions, cashBalancesFor(portfolio));
90 + }
91 +
92 + @Transactional
93 + public PortfolioSummary sync(UUID portfolioId) {
94 + if (!ACTIVE_SYNCS.add(portfolioId)) {
95 + throw new IllegalStateException("Portfolio sync already in progress: " + portfolioId);
96 + }
97 + String correlationId = MDC.get("correlationId");
98 + eventPublisher.publish(BrokerSyncEvent.started(MOCK_CONNECTION_ID, portfolioId, correlationId));
99 + try {
100 + PortfolioSummary summary = doSync(portfolioId, correlationId);
101 + eventPublisher.publish(BrokerSyncEvent.completed(MOCK_CONNECTION_ID, portfolioId, correlationId));
102 + return summary;
103 + } catch (RuntimeException exception) {
104 + eventPublisher.publish(BrokerSyncEvent.failed(MOCK_CONNECTION_ID, portfolioId, correlationId, exception.getClass().getSimpleName()));
105 + throw exception;
106 + } finally {
107 + ACTIVE_SYNCS.remove(portfolioId);
108 + }
109 + }
110 +
111 + private PortfolioSummary doSync(UUID portfolioId, String correlationId) {
112 + PortfolioEntity portfolio = portfolioRepository.findById(portfolioId)
113 + .orElseThrow(() -> new PortfolioNotFoundException(portfolioId));
114 + positionRepository.deleteByPortfolioPortfolioId(portfolioId);
115 + List<BrokerCashBalance> cashBalances = new ArrayList<>();
116 + for (BrokerAccount account : brokerProvider.fetchAccounts(portfolio.getUserId())) {
117 + BrokerAccountEntity accountEntity = brokerAccountRepository.save(PortfolioMapper.toEntity(account));
118 + cashBalances.addAll(brokerProvider.fetchCashBalances(account));
119 + for (BrokerPosition brokerPosition : brokerProvider.fetchPositions(account)) {
120 + InstrumentEntity instrument = instrumentRepository.save(PortfolioMapper.toEntity(brokerPosition.instrument()));
121 + PortfolioPositionEntity position = new PortfolioPositionEntity(
122 + UUID.nameUUIDFromBytes((portfolioId + "|" + account.brokerAccountId() + "|" + instrument.getInstrumentId()).getBytes()),
123 + portfolio,
124 + instrument,
125 + brokerPosition.quantity(),
126 + brokerPosition.averageCost().amount(),
127 + brokerPosition.averageCost().currency(),
128 + brokerPosition.currentPrice().amount(),
129 + brokerPosition.currentPrice().currency(),
130 + accountEntity,
131 + brokerPosition.observedAt()
132 + );
133 + positionRepository.save(position);
134 + }
135 + }
136 + List<PortfolioPosition> positions = getPositions(portfolioId);
137 + PortfolioSummary summary = calculator.summarize(PortfolioMapper.toDomain(portfolio), positions, cashBalances);
138 + eventPublisher.publish(new PortfolioUpdatedEvent(UUID.randomUUID(), correlationId, Instant.now(), portfolioId, portfolio.getUserId()));
139 + return summary;
140 + }
141 +
142 + private List<BrokerCashBalance> cashBalancesFor(Portfolio portfolio) {
143 + return brokerAccountRepository.findAll().stream()
144 + .map(PortfolioMapper::toDomain)
145 + .filter(account -> account.userId().equals(portfolio.userId()))
146 + .flatMap(account -> brokerProvider.fetchCashBalances(account).stream())
147 + .toList();
148 + }
149 +
150 + private void ensurePortfolioExists(UUID portfolioId) {
151 + if (!portfolioRepository.existsById(portfolioId)) {
152 + throw new PortfolioNotFoundException(portfolioId);
153 + }
154 + }
155 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/domain/AllocationBreakdown.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.portfolio.domain;
2 +
3 +import java.math.BigDecimal;
4 +import java.util.Map;
5 +
6 +public record AllocationBreakdown(
7 + Map<String, BigDecimal> country,
8 + Map<String, BigDecimal> currency,
9 + Map<String, BigDecimal> sector,
10 + Map<String, BigDecimal> assetType,
11 + Map<String, BigDecimal> broker
12 +) {
13 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/domain/MockFxRateProvider.java new
+39
@@ -0,0 +1,39 @@
1 +package com.aiinvestment.portfolio.domain;
2 +
3 +import com.aiinvestment.shared.domain.fx.FxRateProvider;
4 +import org.springframework.stereotype.Component;
5 +
6 +import java.math.BigDecimal;
7 +import java.util.Map;
8 +
9 +@Component
10 +public class MockFxRateProvider implements FxRateProvider {
11 + private final Map<String, BigDecimal> rates = Map.of(
12 + "USD_EUR", new BigDecimal("0.9200"),
13 + "INR_EUR", new BigDecimal("0.0110"),
14 + "EUR_USD", new BigDecimal("1.0870"),
15 + "INR_USD", new BigDecimal("0.0120"),
16 + "EUR_INR", new BigDecimal("90.9000"),
17 + "USD_INR", new BigDecimal("83.3000")
18 + );
19 +
20 + @Override
21 + public BigDecimal getRate(String fromCurrency, String toCurrency) {
22 + requireCurrency(fromCurrency);
23 + requireCurrency(toCurrency);
24 + if (fromCurrency.equals(toCurrency)) {
25 + return BigDecimal.ONE;
26 + }
27 + BigDecimal rate = rates.get(fromCurrency + "_" + toCurrency);
28 + if (rate == null) {
29 + throw new IllegalArgumentException("No mock FX rate configured for " + fromCurrency + " to " + toCurrency);
30 + }
31 + return rate;
32 + }
33 +
34 + private static void requireCurrency(String currency) {
35 + if (currency == null || !currency.matches("[A-Z]{3}")) {
36 + throw new IllegalArgumentException("currency must be a 3-letter ISO currency code");
37 + }
38 + }
39 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/domain/Portfolio.java new
+27
@@ -0,0 +1,27 @@
1 +package com.aiinvestment.portfolio.domain;
2 +
3 +import java.time.Instant;
4 +import java.util.Objects;
5 +import java.util.UUID;
6 +
7 +public record Portfolio(
8 + UUID portfolioId,
9 + UUID userId,
10 + String name,
11 + String baseCurrency,
12 + Instant createdAt,
13 + Instant updatedAt
14 +) {
15 + public Portfolio {
16 + Objects.requireNonNull(portfolioId, "portfolioId is required");
17 + Objects.requireNonNull(userId, "userId is required");
18 + if (name == null || name.isBlank()) {
19 + throw new IllegalArgumentException("name is required");
20 + }
21 + if (baseCurrency == null || !baseCurrency.matches("[A-Z]{3}")) {
22 + throw new IllegalArgumentException("baseCurrency must be a 3-letter ISO currency code");
23 + }
24 + Objects.requireNonNull(createdAt, "createdAt is required");
25 + Objects.requireNonNull(updatedAt, "updatedAt is required");
26 + }
27 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/domain/PortfolioCalculator.java new
+85
@@ -0,0 +1,85 @@
1 +package com.aiinvestment.portfolio.domain;
2 +
3 +import com.aiinvestment.shared.domain.Money;
4 +import com.aiinvestment.shared.domain.broker.BrokerCashBalance;
5 +import com.aiinvestment.shared.domain.fx.FxRateProvider;
6 +import org.springframework.stereotype.Component;
7 +
8 +import java.math.BigDecimal;
9 +import java.math.RoundingMode;
10 +import java.util.LinkedHashMap;
11 +import java.util.List;
12 +import java.util.Map;
13 +import java.util.function.Function;
14 +
15 +@Component
16 +public class PortfolioCalculator {
17 + private final FxRateProvider fxRateProvider;
18 +
19 + public PortfolioCalculator(FxRateProvider fxRateProvider) {
20 + this.fxRateProvider = fxRateProvider;
21 + }
22 +
23 + public Money convert(Money money, String targetCurrency) {
24 + BigDecimal rate = fxRateProvider.getRate(money.currency(), targetCurrency);
25 + return new Money(money.amount().multiply(rate), targetCurrency);
26 + }
27 +
28 + public PortfolioSummary summarize(Portfolio portfolio, List<PortfolioPosition> positions, List<BrokerCashBalance> cashBalances) {
29 + Money marketValue = Money.zero(portfolio.baseCurrency());
30 + Money costBasis = Money.zero(portfolio.baseCurrency());
31 + for (PortfolioPosition position : positions) {
32 + marketValue = marketValue.add(convert(position.marketValue(), portfolio.baseCurrency()));
33 + costBasis = costBasis.add(convert(position.costBasis(), portfolio.baseCurrency()));
34 + }
35 + Money cash = Money.zero(portfolio.baseCurrency());
36 + for (BrokerCashBalance balance : cashBalances) {
37 + cash = cash.add(convert(balance.cash(), portfolio.baseCurrency()));
38 + }
39 + Money profitLoss = marketValue.subtract(costBasis);
40 + BigDecimal profitLossPercent = BigDecimal.ZERO;
41 + if (costBasis.amount().compareTo(BigDecimal.ZERO) != 0) {
42 + profitLossPercent = profitLoss.amount()
43 + .divide(costBasis.amount(), 8, RoundingMode.HALF_UP)
44 + .multiply(BigDecimal.valueOf(100))
45 + .setScale(4, RoundingMode.HALF_UP);
46 + }
47 + return new PortfolioSummary(portfolio.portfolioId(), portfolio.baseCurrency(), marketValue, costBasis,
48 + profitLoss, profitLossPercent, cash, positions.size(), allocations(positions, portfolio.baseCurrency()));
49 + }
50 +
51 + private AllocationBreakdown allocations(List<PortfolioPosition> positions, String baseCurrency) {
52 + return new AllocationBreakdown(
53 + allocation(positions, p -> nullToUnknown(p.instrument().country()), baseCurrency),
54 + allocation(positions, p -> p.instrument().tradingCurrency(), baseCurrency),
55 + allocation(positions, p -> nullToUnknown(p.instrument().sector()), baseCurrency),
56 + allocation(positions, p -> p.instrument().assetType().name(), baseCurrency),
57 + allocation(positions, PortfolioPosition::brokerAccountId, baseCurrency)
58 + );
59 + }
60 +
61 + private Map<String, BigDecimal> allocation(List<PortfolioPosition> positions, Function<PortfolioPosition, String> classifier, String baseCurrency) {
62 + Money total = Money.zero(baseCurrency);
63 + Map<String, Money> values = new LinkedHashMap<>();
64 + for (PortfolioPosition position : positions) {
65 + Money converted = convert(position.marketValue(), baseCurrency);
66 + total = total.add(converted);
67 + String key = classifier.apply(position);
68 + values.merge(key, converted, Money::add);
69 + }
70 + Map<String, BigDecimal> percentages = new LinkedHashMap<>();
71 + for (Map.Entry<String, Money> entry : values.entrySet()) {
72 + BigDecimal pct = BigDecimal.ZERO;
73 + if (total.amount().compareTo(BigDecimal.ZERO) != 0) {
74 + pct = entry.getValue().amount().divide(total.amount(), 8, RoundingMode.HALF_UP)
75 + .multiply(BigDecimal.valueOf(100)).setScale(4, RoundingMode.HALF_UP);
76 + }
77 + percentages.put(entry.getKey(), pct);
78 + }
79 + return percentages;
80 + }
81 +
82 + private static String nullToUnknown(String value) {
83 + return value == null || value.isBlank() ? "UNKNOWN" : value;
84 + }
85 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/domain/PortfolioPosition.java new
+69
@@ -0,0 +1,69 @@
1 +package com.aiinvestment.portfolio.domain;
2 +
3 +import com.aiinvestment.shared.domain.Instrument;
4 +import com.aiinvestment.shared.domain.Money;
5 +
6 +import java.math.BigDecimal;
7 +import java.math.RoundingMode;
8 +import java.time.Instant;
9 +import java.util.Objects;
10 +import java.util.UUID;
11 +
12 +public record PortfolioPosition(
13 + UUID positionId,
14 + UUID portfolioId,
15 + Instrument instrument,
16 + BigDecimal quantity,
17 + Money averageCost,
18 + Money currentPrice,
19 + Money marketValue,
20 + Money costBasis,
21 + Money unrealizedProfitLoss,
22 + BigDecimal unrealizedProfitLossPercent,
23 + String brokerAccountId,
24 + Instant lastUpdated
25 +) {
26 + public PortfolioPosition {
27 + Objects.requireNonNull(positionId, "positionId is required");
28 + Objects.requireNonNull(portfolioId, "portfolioId is required");
29 + Objects.requireNonNull(instrument, "instrument is required");
30 + Objects.requireNonNull(quantity, "quantity is required");
31 + if (quantity.signum() < 0) {
32 + throw new IllegalArgumentException("quantity cannot be negative");
33 + }
34 + Objects.requireNonNull(averageCost, "averageCost is required");
35 + Objects.requireNonNull(currentPrice, "currentPrice is required");
36 + Objects.requireNonNull(marketValue, "marketValue is required");
37 + Objects.requireNonNull(costBasis, "costBasis is required");
38 + Objects.requireNonNull(unrealizedProfitLoss, "unrealizedProfitLoss is required");
39 + Objects.requireNonNull(unrealizedProfitLossPercent, "unrealizedProfitLossPercent is required");
40 + if (brokerAccountId == null || brokerAccountId.isBlank()) {
41 + throw new IllegalArgumentException("brokerAccountId is required");
42 + }
43 + Objects.requireNonNull(lastUpdated, "lastUpdated is required");
44 + }
45 +
46 + public static PortfolioPosition priced(
47 + UUID positionId,
48 + UUID portfolioId,
49 + Instrument instrument,
50 + BigDecimal quantity,
51 + Money averageCost,
52 + Money currentPrice,
53 + String brokerAccountId,
54 + Instant lastUpdated
55 + ) {
56 + Money marketValue = currentPrice.multiply(quantity);
57 + Money costBasis = averageCost.multiply(quantity);
58 + Money profitLoss = marketValue.subtract(costBasis);
59 + BigDecimal profitLossPercent = BigDecimal.ZERO;
60 + if (costBasis.amount().compareTo(BigDecimal.ZERO) != 0) {
61 + profitLossPercent = profitLoss.amount()
62 + .divide(costBasis.amount(), 8, RoundingMode.HALF_UP)
63 + .multiply(BigDecimal.valueOf(100))
64 + .setScale(4, RoundingMode.HALF_UP);
65 + }
66 + return new PortfolioPosition(positionId, portfolioId, instrument, quantity, averageCost, currentPrice,
67 + marketValue, costBasis, profitLoss, profitLossPercent, brokerAccountId, lastUpdated);
68 + }
69 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/domain/PortfolioSummary.java new
+19
@@ -0,0 +1,19 @@
1 +package com.aiinvestment.portfolio.domain;
2 +
3 +import com.aiinvestment.shared.domain.Money;
4 +
5 +import java.math.BigDecimal;
6 +import java.util.UUID;
7 +
8 +public record PortfolioSummary(
9 + UUID portfolioId,
10 + String baseCurrency,
11 + Money totalMarketValue,
12 + Money totalCostBasis,
13 + Money unrealizedProfitLoss,
14 + BigDecimal unrealizedProfitLossPercent,
15 + Money cash,
16 + int numberOfPositions,
17 + AllocationBreakdown allocation
18 +) {
19 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/broker/MockBrokerProvider.java new
+86
@@ -0,0 +1,86 @@
1 +package com.aiinvestment.portfolio.infrastructure.broker;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import com.aiinvestment.shared.domain.Instrument;
5 +import com.aiinvestment.shared.domain.Money;
6 +import com.aiinvestment.shared.domain.broker.*;
7 +import org.springframework.stereotype.Component;
8 +
9 +import java.math.BigDecimal;
10 +import java.time.Instant;
11 +import java.util.List;
12 +import java.util.UUID;
13 +
14 +@Component
15 +public class MockBrokerProvider implements BrokerProvider {
16 + @Override
17 + public BrokerType supportedBroker() {
18 + return BrokerType.MOCK;
19 + }
20 +
21 + @Override
22 + public BrokerConnectionCapabilities connectionCapabilities() {
23 + return BrokerConnectionCapabilities.mockReadOnly();
24 + }
25 +
26 + @Override
27 + public BrokerConnectionStatus connectionStatus() {
28 + return new BrokerConnectionStatus(BrokerType.MOCK, BrokerConnectionState.CONNECTED, BrokerProviderStatus.CONNECTED, "MOCK_CONNECTED", "Mock broker provider uses static fake DEV data.");
29 + }
30 +
31 + @Override
32 + public List<BrokerAccount> fetchAccounts(UUID userId) {
33 + return List.of(
34 + new BrokerAccount("MOCK_EU", userId, BrokerType.MOCK, "mock-eu-account", "Mock EU Account", "EUR", BrokerAccountStatus.ACTIVE),
35 + new BrokerAccount("MOCK_INDIA", userId, BrokerType.MOCK, "mock-india-account", "Mock India Account", "INR", BrokerAccountStatus.ACTIVE)
36 + );
37 + }
38 +
39 + @Override
40 + public List<BrokerPosition> fetchPositions(BrokerAccount account) {
41 + Instant observedAt = Instant.parse("2026-01-01T00:00:00Z");
42 + if ("MOCK_EU".equals(account.brokerAccountId())) {
43 + return List.of(
44 + position(account, instrument("BESI", "NL0012866412", "XAMS", "XAMS", "BE Semiconductor Industries", "NL", "EUR", "Technology", "Semiconductor Equipment"), "12", "110.00", "132.50", observedAt),
45 + position(account, instrument("AIXA", "DE000A0WMPJ6", "XETR", "XETR", "AIXTRON SE", "DE", "EUR", "Technology", "Semiconductor Equipment"), "40", "18.25", "24.10", observedAt),
46 + position(account, instrument("NVDA", "US67066G1040", "XNAS", "XNAS", "NVIDIA Corporation", "US", "USD", "Technology", "Semiconductors"), "8", "500.00", "920.00", observedAt)
47 + );
48 + }
49 + if ("MOCK_INDIA".equals(account.brokerAccountId())) {
50 + return List.of(
51 + position(account, instrument("RELIANCE", "INE002A01018", "XNSE", "XNSE", "Reliance Industries Limited", "IN", "INR", "Energy", "Oil Gas and Consumable Fuels"), "20", "2400.00", "2850.00", observedAt),
52 + position(account, instrument("ZENTEC", "INE251B01027", "XNSE", "XNSE", "Zen Technologies Limited", "IN", "INR", "Industrials", "Aerospace and Defense"), "35", "650.00", "1025.00", observedAt)
53 + );
54 + }
55 + return List.of();
56 + }
57 +
58 + @Override
59 + public void disconnect(UUID connectionId) {
60 + // No remote state exists for the mock provider.
61 + }
62 +
63 + @Override
64 + public List<BrokerCashBalance> fetchCashBalances(BrokerAccount account) {
65 + if ("MOCK_EU".equals(account.brokerAccountId())) {
66 + return List.of(new BrokerCashBalance(account.brokerAccountId(), new Money(new BigDecimal("1250.00"), "EUR")));
67 + }
68 + if ("MOCK_INDIA".equals(account.brokerAccountId())) {
69 + return List.of(new BrokerCashBalance(account.brokerAccountId(), new Money(new BigDecimal("150000.00"), "INR")));
70 + }
71 + return List.of();
72 + }
73 +
74 + private static BrokerPosition position(BrokerAccount account, Instrument instrument, String quantity, String averageCost,
75 + String currentPrice, Instant observedAt) {
76 + return new BrokerPosition(account.brokerAccountId(), instrument, new BigDecimal(quantity),
77 + new Money(new BigDecimal(averageCost), instrument.tradingCurrency()),
78 + new Money(new BigDecimal(currentPrice), instrument.tradingCurrency()), observedAt);
79 + }
80 +
81 + private static Instrument instrument(String ticker, String isin, String exchange, String mic, String companyName,
82 + String country, String currency, String sector, String industry) {
83 + UUID instrumentId = UUID.nameUUIDFromBytes((isin + "|" + exchange + "|" + ticker).getBytes());
84 + return new Instrument(instrumentId, isin, ticker, exchange, mic, companyName, AssetType.EQUITY, country, currency, sector, industry);
85 + }
86 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/events/LoggingPlatformEventPublisher.java new
+18
@@ -0,0 +1,18 @@
1 +package com.aiinvestment.portfolio.infrastructure.events;
2 +
3 +import com.aiinvestment.portfolio.application.PlatformEventPublisher;
4 +import com.aiinvestment.shared.domain.event.PlatformEvent;
5 +import org.slf4j.Logger;
6 +import org.slf4j.LoggerFactory;
7 +import org.springframework.stereotype.Component;
8 +
9 +@Component
10 +public class LoggingPlatformEventPublisher implements PlatformEventPublisher {
11 + private static final Logger log = LoggerFactory.getLogger(LoggingPlatformEventPublisher.class);
12 +
13 + @Override
14 + public void publish(PlatformEvent event) {
15 + log.info("eventType={} version={} eventId={} correlationId={}",
16 + event.eventType(), event.version(), event.eventId(), event.correlationId());
17 + }
18 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/market/FallbackMarketDataProvider.java new
+67
@@ -0,0 +1,67 @@
1 +package com.aiinvestment.portfolio.infrastructure.market;
2 +
3 +import com.aiinvestment.shared.domain.Instrument;
4 +import com.aiinvestment.shared.domain.market.*;
5 +import org.springframework.beans.factory.annotation.Value;
6 +import org.springframework.context.annotation.Primary;
7 +import org.springframework.stereotype.Component;
8 +
9 +import java.time.Instant;
10 +import java.util.List;
11 +
12 +@Primary
13 +@Component
14 +public class FallbackMarketDataProvider implements MarketDataProvider {
15 + private final QuoteCache quoteCache;
16 + private final MockMarketDataProvider mockMarketDataProvider;
17 + private final boolean demoMode;
18 +
19 + public FallbackMarketDataProvider(QuoteCache quoteCache,
20 + MockMarketDataProvider mockMarketDataProvider,
21 + @Value("${market.demo-mode:false}") boolean demoMode) {
22 + this.quoteCache = quoteCache;
23 + this.mockMarketDataProvider = mockMarketDataProvider;
24 + this.demoMode = demoMode;
25 + }
26 +
27 + @Override
28 + public Quote getQuote(Instrument instrument) {
29 + return safeGet(instrument, false)
30 + .or(() -> safeGet(instrument, true).map(FallbackMarketDataProvider::markStale))
31 + .orElseGet(() -> demoMode ? mockMarketDataProvider.getQuote(instrument) : unavailableQuote(instrument));
32 + }
33 +
34 + @Override
35 + public List<Quote> getQuotes(List<Instrument> instruments) {
36 + return instruments.stream().map(this::getQuote).toList();
37 + }
38 +
39 + @Override
40 + public MarketDataStatus getMarketDataStatus(Instrument instrument) {
41 + Quote quote = getQuote(instrument);
42 + return new MarketDataStatus(instrument.instrumentId(), quote.source(), quote.freshness(), quote.marketStatus(), quote.timestamp());
43 + }
44 +
45 + private static Quote markStale(Quote quote) {
46 + return new Quote(quote.instrumentId(), quote.bid(), quote.ask(), quote.last(), quote.previousClose(), quote.currency(),
47 + quote.timestamp(), quote.source(), MarketDataFreshness.STALE, quote.marketStatus(), quote.sourceTimestamp(), quote.receivedAt());
48 + }
49 +
50 + private java.util.Optional<Quote> safeGet(Instrument instrument, boolean stale) {
51 + try {
52 + java.util.Optional<Quote> quote = stale ? quoteCache.getStale(instrument.instrumentId()) : quoteCache.get(instrument.instrumentId());
53 + return quote.filter(this::isAllowedInCurrentMode);
54 + } catch (RuntimeException exception) {
55 + return java.util.Optional.empty();
56 + }
57 + }
58 +
59 + private boolean isAllowedInCurrentMode(Quote quote) {
60 + return demoMode || quote.freshness() != MarketDataFreshness.MOCK;
61 + }
62 +
63 + private static Quote unavailableQuote(Instrument instrument) {
64 + return new Quote(instrument.instrumentId(), null, null, null, null, instrument.tradingCurrency(), Instant.now(),
65 + "NoVerifiedMarketDataProvider", MarketDataFreshness.UNAVAILABLE, MarketStatus.UNKNOWN);
66 + }
67 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/market/InMemoryQuoteCache.java new
+42
@@ -0,0 +1,42 @@
1 +package com.aiinvestment.portfolio.infrastructure.market;
2 +
3 +import com.aiinvestment.shared.domain.market.Quote;
4 +import com.aiinvestment.shared.domain.market.QuoteCache;
5 +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
6 +import org.springframework.stereotype.Component;
7 +
8 +import java.time.Duration;
9 +import java.time.Instant;
10 +import java.util.Map;
11 +import java.util.Optional;
12 +import java.util.UUID;
13 +import java.util.concurrent.ConcurrentHashMap;
14 +
15 +@Component
16 +@ConditionalOnProperty(name = "market.quote-cache.mode", havingValue = "memory", matchIfMissing = true)
17 +public class InMemoryQuoteCache implements QuoteCache {
18 + private final Map<UUID, CachedQuote> cache = new ConcurrentHashMap<>();
19 +
20 + @Override
21 + public Optional<Quote> get(UUID instrumentId) {
22 + CachedQuote cached = cache.get(instrumentId);
23 + if (cached == null || cached.expiresAt().isBefore(Instant.now())) {
24 + return Optional.empty();
25 + }
26 + return Optional.of(cached.quote());
27 + }
28 +
29 + @Override
30 + public Optional<Quote> getStale(UUID instrumentId) {
31 + CachedQuote cached = cache.get(instrumentId);
32 + return cached == null ? Optional.empty() : Optional.of(cached.quote());
33 + }
34 +
35 + @Override
36 + public void put(Quote quote, Duration ttl) {
37 + cache.put(quote.instrumentId(), new CachedQuote(quote, Instant.now().plus(ttl)));
38 + }
39 +
40 + private record CachedQuote(Quote quote, Instant expiresAt) {
41 + }
42 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/market/MockMarketDataProvider.java new
+63
@@ -0,0 +1,63 @@
1 +package com.aiinvestment.portfolio.infrastructure.market;
2 +
3 +import com.aiinvestment.shared.domain.Instrument;
4 +import com.aiinvestment.shared.domain.Money;
5 +import com.aiinvestment.shared.domain.market.*;
6 +import org.springframework.stereotype.Component;
7 +
8 +import java.math.BigDecimal;
9 +import java.time.Duration;
10 +import java.time.Instant;
11 +import java.util.List;
12 +
13 +@Component
14 +public class MockMarketDataProvider implements MarketDataProvider {
15 + private static final Duration MOCK_TTL = Duration.ofMinutes(5);
16 +
17 + private final QuoteCache quoteCache;
18 +
19 + public MockMarketDataProvider(QuoteCache quoteCache) {
20 + this.quoteCache = quoteCache;
21 + }
22 +
23 + @Override
24 + public Quote getQuote(Instrument instrument) {
25 + return quoteCache.get(instrument.instrumentId()).orElseGet(() -> {
26 + Quote quote = buildMockQuote(instrument);
27 + try {
28 + quoteCache.put(quote, MOCK_TTL);
29 + } catch (RuntimeException exception) {
30 + // Quote caching is an infrastructure optimization; demo quote generation must remain usable without it.
31 + }
32 + return quote;
33 + });
34 + }
35 +
36 + @Override
37 + public List<Quote> getQuotes(List<Instrument> instruments) {
38 + return instruments.stream().map(this::getQuote).toList();
39 + }
40 +
41 + @Override
42 + public MarketDataStatus getMarketDataStatus(Instrument instrument) {
43 + Quote quote = getQuote(instrument);
44 + return new MarketDataStatus(instrument.instrumentId(), quote.source(), quote.freshness(), quote.marketStatus(), quote.timestamp());
45 + }
46 +
47 + private static Quote buildMockQuote(Instrument instrument) {
48 + Instant timestamp = Instant.parse("2026-01-01T00:00:00Z");
49 + BigDecimal last = switch (instrument.ticker()) {
50 + case "BESI" -> new BigDecimal("132.50");
51 + case "AIXA" -> new BigDecimal("24.10");
52 + case "NVDA" -> new BigDecimal("920.00");
53 + case "RELIANCE" -> new BigDecimal("2850.00");
54 + case "ZENTEC" -> new BigDecimal("1025.00");
55 + default -> BigDecimal.ZERO;
56 + };
57 + String currency = instrument.tradingCurrency();
58 + return new Quote(instrument.instrumentId(), new Money(last.subtract(new BigDecimal("0.10")), currency),
59 + new Money(last.add(new BigDecimal("0.10")), currency), new Money(last, currency),
60 + new Money(last.multiply(new BigDecimal("0.98")), currency), currency, timestamp,
61 + "MockMarketDataProvider", MarketDataFreshness.MOCK, MarketStatus.UNKNOWN);
62 + }
63 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/market/RedisQuoteCache.java new
+86
@@ -0,0 +1,86 @@
1 +package com.aiinvestment.portfolio.infrastructure.market;
2 +
3 +import com.aiinvestment.shared.domain.market.Quote;
4 +import com.aiinvestment.shared.domain.market.QuoteCache;
5 +import com.fasterxml.jackson.core.JsonProcessingException;
6 +import com.fasterxml.jackson.databind.ObjectMapper;
7 +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
8 +import org.springframework.data.redis.core.StringRedisTemplate;
9 +import org.springframework.stereotype.Component;
10 +
11 +import java.time.Duration;
12 +import java.util.Optional;
13 +import java.util.UUID;
14 +
15 +@Component
16 +@ConditionalOnProperty(name = "market.quote-cache.mode", havingValue = "redis")
17 +public class RedisQuoteCache implements QuoteCache {
18 + private final StringRedisTemplate redisTemplate;
19 + private final ObjectMapper objectMapper;
20 +
21 + public RedisQuoteCache(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
22 + this.redisTemplate = redisTemplate;
23 + this.objectMapper = objectMapper;
24 + }
25 +
26 + @Override
27 + public Optional<Quote> get(UUID instrumentId) {
28 + String json = redisTemplate.opsForValue().get(freshKey(instrumentId));
29 + if (json == null) {
30 + return Optional.empty();
31 + }
32 + return deserialize(instrumentId, json, false);
33 + }
34 +
35 + @Override
36 + public Optional<Quote> getStale(UUID instrumentId) {
37 + String json = redisTemplate.opsForValue().get(staleKey(instrumentId));
38 + if (json == null) {
39 + return Optional.empty();
40 + }
41 + return deserialize(instrumentId, json, true);
42 + }
43 +
44 + @Override
45 + public void put(Quote quote, Duration ttl) {
46 + try {
47 + String json = objectMapper.writeValueAsString(quote);
48 + redisTemplate.opsForValue().set(freshKey(quote.instrumentId()), json, ttl);
49 + redisTemplate.opsForValue().set(staleKey(quote.instrumentId()), json, staleTtl(ttl));
50 + } catch (JsonProcessingException exception) {
51 + throw new IllegalStateException("Quote cache serialization failed", exception);
52 + }
53 + }
54 +
55 + private Optional<Quote> deserialize(UUID instrumentId, String json, boolean stale) {
56 + try {
57 + Quote quote = objectMapper.readValue(json, Quote.class);
58 + if (!stale) {
59 + return Optional.of(quote);
60 + }
61 + return Optional.of(new Quote(quote.instrumentId(), quote.bid(), quote.ask(), quote.last(), quote.previousClose(),
62 + quote.currency(), quote.timestamp(), quote.source(), com.aiinvestment.shared.domain.market.MarketDataFreshness.STALE,
63 + quote.marketStatus(), quote.sourceTimestamp(), quote.receivedAt()));
64 + } catch (JsonProcessingException exception) {
65 + redisTemplate.delete(stale ? staleKey(instrumentId) : freshKey(instrumentId));
66 + return Optional.empty();
67 + }
68 + }
69 +
70 + private static Duration staleTtl(Duration ttl) {
71 + Duration minimum = Duration.ofDays(1);
72 + if (ttl == null || ttl.isNegative() || ttl.isZero()) {
73 + return minimum;
74 + }
75 + Duration expanded = ttl.multipliedBy(12);
76 + return expanded.compareTo(minimum) > 0 ? expanded : minimum;
77 + }
78 +
79 + private static String freshKey(UUID instrumentId) {
80 + return "market:quote:" + instrumentId;
81 + }
82 +
83 + private static String staleKey(UUID instrumentId) {
84 + return "market:quote:stale:" + instrumentId;
85 + }
86 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/BrokerAccountEntity.java new
+51
@@ -0,0 +1,51 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerAccountStatus;
4 +import com.aiinvestment.shared.domain.broker.BrokerType;
5 +import jakarta.persistence.*;
6 +
7 +import java.util.UUID;
8 +
9 +@Entity
10 +@Table(name = "broker_accounts")
11 +public class BrokerAccountEntity {
12 + @Id
13 + @Column(name = "broker_account_id", nullable = false)
14 + private String brokerAccountId;
15 + @Column(name = "user_id", nullable = false)
16 + private UUID userId;
17 + @Enumerated(EnumType.STRING)
18 + @Column(name = "broker_type", nullable = false)
19 + private BrokerType brokerType;
20 + @Column(name = "external_account_reference")
21 + private String externalAccountReference;
22 + @Column(name = "display_name", nullable = false)
23 + private String displayName;
24 + @Column(name = "base_currency", nullable = false)
25 + private String baseCurrency;
26 + @Enumerated(EnumType.STRING)
27 + @Column(nullable = false)
28 + private BrokerAccountStatus status;
29 +
30 + protected BrokerAccountEntity() {
31 + }
32 +
33 + public BrokerAccountEntity(String brokerAccountId, UUID userId, BrokerType brokerType, String externalAccountReference,
34 + String displayName, String baseCurrency, BrokerAccountStatus status) {
35 + this.brokerAccountId = brokerAccountId;
36 + this.userId = userId;
37 + this.brokerType = brokerType;
38 + this.externalAccountReference = externalAccountReference;
39 + this.displayName = displayName;
40 + this.baseCurrency = baseCurrency;
41 + this.status = status;
42 + }
43 +
44 + public String getBrokerAccountId() { return brokerAccountId; }
45 + public UUID getUserId() { return userId; }
46 + public BrokerType getBrokerType() { return brokerType; }
47 + public String getExternalAccountReference() { return externalAccountReference; }
48 + public String getDisplayName() { return displayName; }
49 + public String getBaseCurrency() { return baseCurrency; }
50 + public BrokerAccountStatus getStatus() { return status; }
51 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/BrokerAccountRepository.java new
+6
@@ -0,0 +1,6 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import org.springframework.data.jpa.repository.JpaRepository;
4 +
5 +public interface BrokerAccountRepository extends JpaRepository<BrokerAccountEntity, String> {
6 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/InstrumentEntity.java new
+60
@@ -0,0 +1,60 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import jakarta.persistence.*;
5 +
6 +import java.util.UUID;
7 +
8 +@Entity
9 +@Table(name = "instruments")
10 +public class InstrumentEntity {
11 + @Id
12 + @Column(name = "instrument_id", nullable = false)
13 + private UUID instrumentId;
14 + private String isin;
15 + @Column(nullable = false)
16 + private String ticker;
17 + @Column(nullable = false)
18 + private String exchange;
19 + private String mic;
20 + @Column(name = "company_name", nullable = false)
21 + private String companyName;
22 + @Enumerated(EnumType.STRING)
23 + @Column(name = "asset_type", nullable = false)
24 + private AssetType assetType;
25 + private String country;
26 + @Column(name = "trading_currency", nullable = false)
27 + private String tradingCurrency;
28 + private String sector;
29 + private String industry;
30 +
31 + protected InstrumentEntity() {
32 + }
33 +
34 + public InstrumentEntity(UUID instrumentId, String isin, String ticker, String exchange, String mic, String companyName,
35 + AssetType assetType, String country, String tradingCurrency, String sector, String industry) {
36 + this.instrumentId = instrumentId;
37 + this.isin = isin;
38 + this.ticker = ticker;
39 + this.exchange = exchange;
40 + this.mic = mic;
41 + this.companyName = companyName;
42 + this.assetType = assetType;
43 + this.country = country;
44 + this.tradingCurrency = tradingCurrency;
45 + this.sector = sector;
46 + this.industry = industry;
47 + }
48 +
49 + public UUID getInstrumentId() { return instrumentId; }
50 + public String getIsin() { return isin; }
51 + public String getTicker() { return ticker; }
52 + public String getExchange() { return exchange; }
53 + public String getMic() { return mic; }
54 + public String getCompanyName() { return companyName; }
55 + public AssetType getAssetType() { return assetType; }
56 + public String getCountry() { return country; }
57 + public String getTradingCurrency() { return tradingCurrency; }
58 + public String getSector() { return sector; }
59 + public String getIndustry() { return industry; }
60 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/InstrumentRepository.java new
+8
@@ -0,0 +1,8 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import org.springframework.data.jpa.repository.JpaRepository;
4 +
5 +import java.util.UUID;
6 +
7 +public interface InstrumentRepository extends JpaRepository<InstrumentEntity, UUID> {
8 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/PortfolioEntity.java new
+46
@@ -0,0 +1,46 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import jakarta.persistence.Column;
4 +import jakarta.persistence.Entity;
5 +import jakarta.persistence.Id;
6 +import jakarta.persistence.Table;
7 +
8 +import java.time.Instant;
9 +import java.util.UUID;
10 +
11 +@Entity
12 +@Table(name = "portfolios")
13 +public class PortfolioEntity {
14 + @Id
15 + @Column(name = "portfolio_id", nullable = false)
16 + private UUID portfolioId;
17 + @Column(name = "user_id", nullable = false)
18 + private UUID userId;
19 + @Column(nullable = false)
20 + private String name;
21 + @Column(name = "base_currency", nullable = false)
22 + private String baseCurrency;
23 + @Column(name = "created_at", nullable = false)
24 + private Instant createdAt;
25 + @Column(name = "updated_at", nullable = false)
26 + private Instant updatedAt;
27 +
28 + protected PortfolioEntity() {
29 + }
30 +
31 + public PortfolioEntity(UUID portfolioId, UUID userId, String name, String baseCurrency, Instant createdAt, Instant updatedAt) {
32 + this.portfolioId = portfolioId;
33 + this.userId = userId;
34 + this.name = name;
35 + this.baseCurrency = baseCurrency;
36 + this.createdAt = createdAt;
37 + this.updatedAt = updatedAt;
38 + }
39 +
40 + public UUID getPortfolioId() { return portfolioId; }
41 + public UUID getUserId() { return userId; }
42 + public String getName() { return name; }
43 + public String getBaseCurrency() { return baseCurrency; }
44 + public Instant getCreatedAt() { return createdAt; }
45 + public Instant getUpdatedAt() { return updatedAt; }
46 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/PortfolioMapper.java new
+46
@@ -0,0 +1,46 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import com.aiinvestment.portfolio.domain.Portfolio;
4 +import com.aiinvestment.portfolio.domain.PortfolioPosition;
5 +import com.aiinvestment.shared.domain.Instrument;
6 +import com.aiinvestment.shared.domain.Money;
7 +import com.aiinvestment.shared.domain.broker.BrokerAccount;
8 +
9 +public final class PortfolioMapper {
10 + private PortfolioMapper() {
11 + }
12 +
13 + public static Portfolio toDomain(PortfolioEntity entity) {
14 + return new Portfolio(entity.getPortfolioId(), entity.getUserId(), entity.getName(), entity.getBaseCurrency(),
15 + entity.getCreatedAt(), entity.getUpdatedAt());
16 + }
17 +
18 + public static Instrument toDomain(InstrumentEntity entity) {
19 + return new Instrument(entity.getInstrumentId(), entity.getIsin(), entity.getTicker(), entity.getExchange(),
20 + entity.getMic(), entity.getCompanyName(), entity.getAssetType(), entity.getCountry(),
21 + entity.getTradingCurrency(), entity.getSector(), entity.getIndustry());
22 + }
23 +
24 + public static BrokerAccount toDomain(BrokerAccountEntity entity) {
25 + return new BrokerAccount(entity.getBrokerAccountId(), entity.getUserId(), entity.getBrokerType(),
26 + entity.getExternalAccountReference(), entity.getDisplayName(), entity.getBaseCurrency(), entity.getStatus());
27 + }
28 +
29 + public static PortfolioPosition toDomain(PortfolioPositionEntity entity) {
30 + return PortfolioPosition.priced(entity.getPositionId(), entity.getPortfolio().getPortfolioId(), toDomain(entity.getInstrument()),
31 + entity.getQuantity(), new Money(entity.getAverageCostAmount(), entity.getAverageCostCurrency()),
32 + new Money(entity.getCurrentPriceAmount(), entity.getCurrentPriceCurrency()),
33 + entity.getBrokerAccount().getBrokerAccountId(), entity.getLastUpdated());
34 + }
35 +
36 + public static InstrumentEntity toEntity(Instrument instrument) {
37 + return new InstrumentEntity(instrument.instrumentId(), instrument.isin(), instrument.ticker(), instrument.exchange(),
38 + instrument.mic(), instrument.companyName(), instrument.assetType(), instrument.country(),
39 + instrument.tradingCurrency(), instrument.sector(), instrument.industry());
40 + }
41 +
42 + public static BrokerAccountEntity toEntity(BrokerAccount account) {
43 + return new BrokerAccountEntity(account.brokerAccountId(), account.userId(), account.brokerType(),
44 + account.externalAccountReference(), account.displayName(), account.baseCurrency(), account.status());
45 + }
46 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/PortfolioPositionEntity.java new
+65
@@ -0,0 +1,65 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import jakarta.persistence.*;
4 +
5 +import java.math.BigDecimal;
6 +import java.time.Instant;
7 +import java.util.UUID;
8 +
9 +@Entity
10 +@Table(name = "portfolio_positions")
11 +public class PortfolioPositionEntity {
12 + @Id
13 + @Column(name = "position_id", nullable = false)
14 + private UUID positionId;
15 + @ManyToOne(optional = false)
16 + @JoinColumn(name = "portfolio_id")
17 + private PortfolioEntity portfolio;
18 + @ManyToOne(optional = false, cascade = CascadeType.MERGE)
19 + @JoinColumn(name = "instrument_id")
20 + private InstrumentEntity instrument;
21 + @Column(nullable = false)
22 + private BigDecimal quantity;
23 + @Column(name = "average_cost_amount", nullable = false)
24 + private BigDecimal averageCostAmount;
25 + @Column(name = "average_cost_currency", nullable = false)
26 + private String averageCostCurrency;
27 + @Column(name = "current_price_amount", nullable = false)
28 + private BigDecimal currentPriceAmount;
29 + @Column(name = "current_price_currency", nullable = false)
30 + private String currentPriceCurrency;
31 + @ManyToOne(optional = false)
32 + @JoinColumn(name = "broker_account_id")
33 + private BrokerAccountEntity brokerAccount;
34 + @Column(name = "last_updated", nullable = false)
35 + private Instant lastUpdated;
36 +
37 + protected PortfolioPositionEntity() {
38 + }
39 +
40 + public PortfolioPositionEntity(UUID positionId, PortfolioEntity portfolio, InstrumentEntity instrument, BigDecimal quantity,
41 + BigDecimal averageCostAmount, String averageCostCurrency, BigDecimal currentPriceAmount,
42 + String currentPriceCurrency, BrokerAccountEntity brokerAccount, Instant lastUpdated) {
43 + this.positionId = positionId;
44 + this.portfolio = portfolio;
45 + this.instrument = instrument;
46 + this.quantity = quantity;
47 + this.averageCostAmount = averageCostAmount;
48 + this.averageCostCurrency = averageCostCurrency;
49 + this.currentPriceAmount = currentPriceAmount;
50 + this.currentPriceCurrency = currentPriceCurrency;
51 + this.brokerAccount = brokerAccount;
52 + this.lastUpdated = lastUpdated;
53 + }
54 +
55 + public UUID getPositionId() { return positionId; }
56 + public PortfolioEntity getPortfolio() { return portfolio; }
57 + public InstrumentEntity getInstrument() { return instrument; }
58 + public BigDecimal getQuantity() { return quantity; }
59 + public BigDecimal getAverageCostAmount() { return averageCostAmount; }
60 + public String getAverageCostCurrency() { return averageCostCurrency; }
61 + public BigDecimal getCurrentPriceAmount() { return currentPriceAmount; }
62 + public String getCurrentPriceCurrency() { return currentPriceCurrency; }
63 + public BrokerAccountEntity getBrokerAccount() { return brokerAccount; }
64 + public Instant getLastUpdated() { return lastUpdated; }
65 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/PortfolioPositionRepository.java new
+12
@@ -0,0 +1,12 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import org.springframework.data.jpa.repository.JpaRepository;
4 +
5 +import java.util.List;
6 +import java.util.UUID;
7 +
8 +public interface PortfolioPositionRepository extends JpaRepository<PortfolioPositionEntity, UUID> {
9 + List<PortfolioPositionEntity> findByPortfolioPortfolioId(UUID portfolioId);
10 +
11 + void deleteByPortfolioPortfolioId(UUID portfolioId);
12 +}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/infrastructure/persistence/PortfolioRepository.java new
+10
@@ -0,0 +1,10 @@
1 +package com.aiinvestment.portfolio.infrastructure.persistence;
2 +
3 +import org.springframework.data.jpa.repository.JpaRepository;
4 +
5 +import java.util.List;
6 +import java.util.UUID;
7 +
8 +public interface PortfolioRepository extends JpaRepository<PortfolioEntity, UUID> {
9 + List<PortfolioEntity> findByUserId(UUID userId);
10 +}
services/portfolio-service/src/main/resources/application-real-broker-dev.yml new
+2
@@ -0,0 +1,2 @@
1 +market:
2 + demo-mode: ${MARKET_DATA_DEMO_MODE:false}
services/portfolio-service/src/main/resources/application.yml new
+53
@@ -0,0 +1,53 @@
1 +spring:
2 + application:
3 + name: portfolio-service
4 + datasource:
5 + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:investment}
6 + username: ${DB_USER:investment}
7 + password: ${DB_PASSWORD:}
8 + jpa:
9 + hibernate:
10 + ddl-auto: validate
11 + open-in-view: false
12 + flyway:
13 + enabled: true
14 + data:
15 + redis:
16 + host: ${REDIS_HOST:localhost}
17 + port: ${REDIS_PORT:6379}
18 +market:
19 + demo-mode: ${MARKET_DATA_DEMO_MODE:false}
20 + quote-cache:
21 + mode: ${MARKET_QUOTE_CACHE_MODE:memory}
22 + ttl: ${MARKET_QUOTE_CACHE_TTL:PT5M}
23 +server:
24 + port: ${SERVER_PORT:8082}
25 +springdoc:
26 + api-docs:
27 + enabled: ${OPENAPI_ENABLED:false}
28 + swagger-ui:
29 + enabled: ${OPENAPI_ENABLED:false}
30 +management:
31 + endpoints:
32 + web:
33 + exposure:
34 + include: health,info,prometheus
35 + endpoint:
36 + health:
37 + probes:
38 + enabled: true
39 +logging:
40 + pattern:
41 + level: "%5p [correlationId:%X{correlationId:-}]"
42 +---
43 +spring:
44 + config:
45 + activate:
46 + on-profile: DEV
47 +market:
48 + demo-mode: ${MARKET_DATA_DEMO_MODE:true}
49 +springdoc:
50 + api-docs:
51 + enabled: true
52 + swagger-ui:
53 + enabled: true
services/portfolio-service/src/main/resources/db/migration/V1__portfolio_foundation.sql new
+57
@@ -0,0 +1,57 @@
1 +CREATE TABLE portfolios (
2 + portfolio_id UUID PRIMARY KEY,
3 + user_id UUID NOT NULL,
4 + name VARCHAR(160) NOT NULL,
5 + base_currency VARCHAR(3) NOT NULL,
6 + created_at TIMESTAMP NOT NULL,
7 + updated_at TIMESTAMP NOT NULL
8 +);
9 +
10 +CREATE TABLE broker_accounts (
11 + broker_account_id VARCHAR(80) PRIMARY KEY,
12 + user_id UUID NOT NULL,
13 + broker_type VARCHAR(40) NOT NULL,
14 + external_account_reference VARCHAR(160),
15 + display_name VARCHAR(160) NOT NULL,
16 + base_currency VARCHAR(3) NOT NULL,
17 + status VARCHAR(40) NOT NULL
18 +);
19 +
20 +CREATE TABLE instruments (
21 + instrument_id UUID PRIMARY KEY,
22 + isin VARCHAR(20),
23 + ticker VARCHAR(40) NOT NULL,
24 + exchange VARCHAR(40) NOT NULL,
25 + mic VARCHAR(12),
26 + company_name VARCHAR(240) NOT NULL,
27 + asset_type VARCHAR(40) NOT NULL,
28 + country VARCHAR(80),
29 + trading_currency VARCHAR(3) NOT NULL,
30 + sector VARCHAR(120),
31 + industry VARCHAR(160)
32 +);
33 +
34 +CREATE TABLE portfolio_positions (
35 + position_id UUID PRIMARY KEY,
36 + portfolio_id UUID NOT NULL,
37 + instrument_id UUID NOT NULL,
38 + quantity DECIMAL(28, 8) NOT NULL,
39 + average_cost_amount DECIMAL(28, 8) NOT NULL,
40 + average_cost_currency VARCHAR(3) NOT NULL,
41 + current_price_amount DECIMAL(28, 8) NOT NULL,
42 + current_price_currency VARCHAR(3) NOT NULL,
43 + broker_account_id VARCHAR(80) NOT NULL,
44 + last_updated TIMESTAMP NOT NULL,
45 + CONSTRAINT fk_positions_portfolio FOREIGN KEY (portfolio_id) REFERENCES portfolios (portfolio_id),
46 + CONSTRAINT fk_positions_instrument FOREIGN KEY (instrument_id) REFERENCES instruments (instrument_id),
47 + CONSTRAINT fk_positions_broker_account FOREIGN KEY (broker_account_id) REFERENCES broker_accounts (broker_account_id)
48 +);
49 +
50 +CREATE INDEX idx_portfolios_user_id ON portfolios (user_id);
51 +CREATE INDEX idx_broker_accounts_user_id ON broker_accounts (user_id);
52 +CREATE INDEX idx_positions_portfolio_id ON portfolio_positions (portfolio_id);
53 +CREATE INDEX idx_positions_instrument_id ON portfolio_positions (instrument_id);
54 +CREATE INDEX idx_positions_broker_account_id ON portfolio_positions (broker_account_id);
55 +CREATE INDEX idx_instruments_ticker ON instruments (ticker);
56 +CREATE INDEX idx_instruments_isin ON instruments (isin);
57 +CREATE INDEX idx_instruments_exchange ON instruments (exchange);
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/api/PortfolioControllerTest.java new
+79
@@ -0,0 +1,79 @@
1 +package com.aiinvestment.portfolio.api;
2 +
3 +import org.junit.jupiter.api.Test;
4 +import org.springframework.beans.factory.annotation.Autowired;
5 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
6 +import org.springframework.boot.test.context.SpringBootTest;
7 +import org.springframework.http.MediaType;
8 +import org.springframework.test.context.ActiveProfiles;
9 +import org.springframework.test.web.servlet.MockMvc;
10 +
11 +import static org.hamcrest.Matchers.*;
12 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
13 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
14 +
15 +@SpringBootTest
16 +@AutoConfigureMockMvc
17 +@ActiveProfiles("test")
18 +class PortfolioControllerTest {
19 + @Autowired
20 + private MockMvc mockMvc;
21 +
22 + @Test
23 + void createGetSyncPositionsAndSummaryFlow() throws Exception {
24 + String body = mockMvc.perform(post("/api/v1/portfolios")
25 + .contentType(MediaType.APPLICATION_JSON)
26 + .content("{\"name\":\"My Global Portfolio\",\"baseCurrency\":\"EUR\"}")
27 + .header("X-Correlation-Id", "phase-1-test"))
28 + .andExpect(status().isCreated())
29 + .andExpect(header().string("X-Correlation-Id", "phase-1-test"))
30 + .andExpect(jsonPath("$.portfolioId", notNullValue()))
31 + .andReturn().getResponse().getContentAsString();
32 + String portfolioId = body.replaceAll(".*\"portfolioId\":\"([^\"]+)\".*", "$1");
33 +
34 + mockMvc.perform(get("/api/v1/portfolios/{portfolioId}", portfolioId))
35 + .andExpect(status().isOk())
36 + .andExpect(jsonPath("$.name").value("My Global Portfolio"));
37 +
38 + mockMvc.perform(post("/api/v1/portfolios/{portfolioId}/sync", portfolioId))
39 + .andExpect(status().isOk())
40 + .andExpect(jsonPath("$.positions").value(5))
41 + .andExpect(jsonPath("$.allocation.currency.EUR", notNullValue()))
42 + .andExpect(jsonPath("$.allocation.currency.USD", notNullValue()))
43 + .andExpect(jsonPath("$.allocation.currency.INR", notNullValue()));
44 +
45 + mockMvc.perform(get("/api/v1/portfolios"))
46 + .andExpect(status().isOk())
47 + .andExpect(jsonPath("$[*].portfolioId", hasItem(portfolioId)))
48 + .andExpect(jsonPath("$[0].totalMarketValue.currency", notNullValue()));
49 +
50 + mockMvc.perform(get("/api/v1/portfolios/{portfolioId}/positions", portfolioId))
51 + .andExpect(status().isOk())
52 + .andExpect(jsonPath("$", hasSize(5)))
53 + .andExpect(jsonPath("$[0].quote.freshness").value("MOCK"))
54 + .andExpect(jsonPath("$[0].quote.source").value("MockMarketDataProvider"));
55 +
56 + mockMvc.perform(get("/api/v1/portfolios/{portfolioId}/summary", portfolioId))
57 + .andExpect(status().isOk())
58 + .andExpect(jsonPath("$.baseCurrency").value("EUR"))
59 + .andExpect(jsonPath("$.totalMarketValue.currency").value("EUR"));
60 + }
61 +
62 + @Test
63 + void invalidRequestReturnsConsistentError() throws Exception {
64 + mockMvc.perform(post("/api/v1/portfolios")
65 + .contentType(MediaType.APPLICATION_JSON)
66 + .content("{\"name\":\"\",\"baseCurrency\":\"EURO\"}")
67 + .header("X-Correlation-Id", "bad-request"))
68 + .andExpect(status().isBadRequest())
69 + .andExpect(jsonPath("$.code").value("INVALID_PORTFOLIO_REQUEST"))
70 + .andExpect(jsonPath("$.correlationId").value("bad-request"));
71 + }
72 +
73 + @Test
74 + void unknownPortfolioReturnsNotFound() throws Exception {
75 + mockMvc.perform(get("/api/v1/portfolios/00000000-0000-0000-0000-000000000999"))
76 + .andExpect(status().isNotFound())
77 + .andExpect(jsonPath("$.code").value("PORTFOLIO_NOT_FOUND"));
78 + }
79 +}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/PlatformEventSerializationTest.java new
+36
@@ -0,0 +1,36 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerConnectionState;
4 +import com.aiinvestment.shared.domain.broker.BrokerType;
5 +import com.aiinvestment.shared.domain.event.*;
6 +import com.aiinvestment.shared.domain.market.MarketDataFreshness;
7 +import com.fasterxml.jackson.databind.ObjectMapper;
8 +import org.junit.jupiter.api.Test;
9 +
10 +import java.time.Instant;
11 +import java.util.UUID;
12 +
13 +import static org.assertj.core.api.Assertions.assertThat;
14 +
15 +class PlatformEventSerializationTest {
16 + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
17 +
18 + @Test
19 + void brokerAndPortfolioEventsSerializeWithoutAuthPayloads() throws Exception {
20 + UUID id = UUID.randomUUID();
21 + String brokerConnection = objectMapper.writeValueAsString(new BrokerConnectionChangedEvent(
22 + UUID.randomUUID(), "corr", Instant.now(), id, UUID.randomUUID(), BrokerType.MOCK, BrokerConnectionState.CONNECTED));
23 + String sync = objectMapper.writeValueAsString(BrokerSyncEvent.completed(id, UUID.randomUUID(), "corr"));
24 + String portfolio = objectMapper.writeValueAsString(new PortfolioUpdatedEvent(UUID.randomUUID(), "corr", Instant.now(), UUID.randomUUID(), UUID.randomUUID()));
25 + String quote = objectMapper.writeValueAsString(new MarketQuoteUpdatedEvent(UUID.randomUUID(), "corr", Instant.now(), UUID.randomUUID(), MarketDataFreshness.DELAYED));
26 + String researchDocument = objectMapper.writeValueAsString(ResearchDocumentEvent.processed(UUID.randomUUID(), UUID.randomUUID(), "INVESTOR_RELATIONS", "corr"));
27 + String researchEvent = objectMapper.writeValueAsString(ResearchExtractedEvent.extracted(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "NEW_ORDER", "POSITIVE", 0.91, "corr"));
28 + String researchCompany = objectMapper.writeValueAsString(new ResearchCompanyUpdatedEvent(UUID.randomUUID(), "corr", Instant.now(), UUID.randomUUID(), UUID.randomUUID()));
29 +
30 + String combined = brokerConnection + sync + portfolio + quote + researchDocument + researchEvent + researchCompany;
31 + assertThat(combined).contains("broker.connection.changed", "broker.sync.completed", "portfolio.updated", "market.quote.updated",
32 + "research.document.processed", "research.event.extracted", "research.company.updated");
33 + assertThat(combined).contains("\"version\":1");
34 + assertThat(combined.toLowerCase()).doesNotContain("password").doesNotContain("token").doesNotContain("otp").doesNotContain("mfa");
35 + }
36 +}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/PortfolioServiceIntegrationTest.java new
+67
@@ -0,0 +1,67 @@
1 +package com.aiinvestment.portfolio.application;
2 +
3 +import com.aiinvestment.portfolio.domain.Portfolio;
4 +import com.aiinvestment.portfolio.domain.PortfolioSummary;
5 +import com.aiinvestment.portfolio.infrastructure.persistence.PortfolioRepository;
6 +import com.aiinvestment.shared.domain.event.BrokerSyncEvent;
7 +import com.fasterxml.jackson.databind.ObjectMapper;
8 +import org.junit.jupiter.api.Test;
9 +import org.springframework.beans.factory.annotation.Autowired;
10 +import org.springframework.boot.test.context.SpringBootTest;
11 +import org.springframework.test.context.ActiveProfiles;
12 +
13 +import static org.assertj.core.api.Assertions.assertThat;
14 +
15 +@SpringBootTest
16 +@ActiveProfiles("test")
17 +class PortfolioServiceIntegrationTest {
18 + @Autowired
19 + private PortfolioService portfolioService;
20 + @Autowired
21 + private PortfolioRepository portfolioRepository;
22 + @Autowired
23 + private ObjectMapper objectMapper;
24 +
25 + @Test
26 + void migrationsCreateRepositoryBackedSchema() {
27 + Portfolio portfolio = portfolioService.createPortfolio("Migration Check", "EUR");
28 +
29 + assertThat(portfolioRepository.findById(portfolio.portfolioId())).isPresent();
30 + }
31 +
32 + @Test
33 + void mockBrokerSyncCreatesPositionsAndSummary() {
34 + Portfolio portfolio = portfolioService.createPortfolio("My Global Portfolio", "EUR");
35 +
36 + PortfolioSummary summary = portfolioService.sync(portfolio.portfolioId());
37 +
38 + assertThat(summary.numberOfPositions()).isEqualTo(5);
39 + assertThat(summary.baseCurrency()).isEqualTo("EUR");
40 + assertThat(summary.totalMarketValue().amount()).isPositive();
41 + assertThat(summary.cash().amount()).isPositive();
42 + assertThat(summary.allocation().currency()).containsKeys("EUR", "USD", "INR");
43 + assertThat(summary.allocation().broker()).containsKeys("MOCK_EU", "MOCK_INDIA");
44 + }
45 +
46 + @Test
47 + void repeatedMockBrokerSyncIsIdempotent() {
48 + Portfolio portfolio = portfolioService.createPortfolio("Idempotent Portfolio", "EUR");
49 +
50 + portfolioService.sync(portfolio.portfolioId());
51 + portfolioService.sync(portfolio.portfolioId());
52 +
53 + assertThat(portfolioService.getPositions(portfolio.portfolioId())).hasSize(5);
54 + }
55 +
56 + @Test
57 + void portfolioListReturnsSummariesAndEventsAreSerializable() throws Exception {
58 + Portfolio portfolio = portfolioService.createPortfolio("Serializable Events", "EUR");
59 + portfolioService.sync(portfolio.portfolioId());
60 +
61 + assertThat(portfolioService.listPortfolioSummaries()).anySatisfy(summary ->
62 + assertThat(summary.portfolioId()).isEqualTo(portfolio.portfolioId()));
63 +
64 + String json = objectMapper.writeValueAsString(BrokerSyncEvent.started(null, portfolio.portfolioId(), "test-correlation"));
65 + assertThat(json).contains("broker.sync.started").contains("test-correlation");
66 + }
67 +}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/domain/PortfolioCalculatorTest.java new
+89
@@ -0,0 +1,89 @@
1 +package com.aiinvestment.portfolio.domain;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import com.aiinvestment.shared.domain.Instrument;
5 +import com.aiinvestment.shared.domain.Money;
6 +import com.aiinvestment.shared.domain.broker.BrokerCashBalance;
7 +import org.junit.jupiter.api.Test;
8 +
9 +import java.math.BigDecimal;
10 +import java.time.Instant;
11 +import java.util.List;
12 +import java.util.UUID;
13 +
14 +import static org.assertj.core.api.Assertions.assertThat;
15 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
16 +
17 +class PortfolioCalculatorTest {
18 + private final MockFxRateProvider fx = new MockFxRateProvider();
19 + private final PortfolioCalculator calculator = new PortfolioCalculator(fx);
20 +
21 + @Test
22 + void sameCurrencyFxRateIsOne() {
23 + assertThat(fx.getRate("EUR", "EUR")).isEqualByComparingTo(BigDecimal.ONE);
24 + }
25 +
26 + @Test
27 + void moneyRequiresIsoStyleCurrency() {
28 + assertThatThrownBy(() -> new Money(BigDecimal.ONE, "EURO"))
29 + .isInstanceOf(IllegalArgumentException.class);
30 + }
31 +
32 + @Test
33 + void positionCalculatesMarketValueCostBasisAndProfitLoss() {
34 + PortfolioPosition position = position("NVDA", "USD", "8", "500.00", "920.00", "US", "Technology", "MOCK_EU");
35 +
36 + assertThat(position.marketValue().amount()).isEqualByComparingTo("7360.0000");
37 + assertThat(position.costBasis().amount()).isEqualByComparingTo("4000.0000");
38 + assertThat(position.unrealizedProfitLoss().amount()).isEqualByComparingTo("3360.0000");
39 + assertThat(position.unrealizedProfitLossPercent()).isEqualByComparingTo("84.0000");
40 + }
41 +
42 + @Test
43 + void zeroCostBasisProfitLossPercentIsZero() {
44 + PortfolioPosition position = position("BESI", "EUR", "10", "0.00", "12.00", "NL", "Technology", "MOCK_EU");
45 +
46 + assertThat(position.unrealizedProfitLossPercent()).isEqualByComparingTo(BigDecimal.ZERO);
47 + }
48 +
49 + @Test
50 + void negativeQuantityIsRejected() {
51 + assertThatThrownBy(() -> position("BESI", "EUR", "-1", "10.00", "11.00", "NL", "Technology", "MOCK_EU"))
52 + .isInstanceOf(IllegalArgumentException.class);
53 + }
54 +
55 + @Test
56 + void summaryNormalizesMultiCurrencyValuesAndAllocations() {
57 + UUID portfolioId = UUID.randomUUID();
58 + Portfolio portfolio = new Portfolio(portfolioId, UUID.randomUUID(), "Global", "EUR", Instant.now(), Instant.now());
59 + List<PortfolioPosition> positions = List.of(
60 + position(portfolioId, "BESI", "EUR", "10", "100.00", "110.00", "NL", "Technology", "MOCK_EU"),
61 + position(portfolioId, "NVDA", "USD", "2", "500.00", "1000.00", "US", "Technology", "MOCK_EU"),
62 + position(portfolioId, "RELIANCE", "INR", "5", "1000.00", "1200.00", "IN", "Energy", "MOCK_INDIA")
63 + );
64 +
65 + PortfolioSummary summary = calculator.summarize(portfolio, positions,
66 + List.of(new BrokerCashBalance("MOCK_EU", new Money(new BigDecimal("10.00"), "EUR"))));
67 +
68 + assertThat(summary.totalMarketValue().currency()).isEqualTo("EUR");
69 + assertThat(summary.totalMarketValue().amount()).isEqualByComparingTo("3006.0000");
70 + assertThat(summary.totalCostBasis().amount()).isEqualByComparingTo("1975.0000");
71 + assertThat(summary.numberOfPositions()).isEqualTo(3);
72 + assertThat(summary.allocation().country()).containsKeys("NL", "US", "IN");
73 + assertThat(summary.allocation().broker()).containsKeys("MOCK_EU", "MOCK_INDIA");
74 + }
75 +
76 + private static PortfolioPosition position(String ticker, String currency, String quantity, String averageCost,
77 + String currentPrice, String country, String sector, String broker) {
78 + return position(UUID.randomUUID(), ticker, currency, quantity, averageCost, currentPrice, country, sector, broker);
79 + }
80 +
81 + private static PortfolioPosition position(UUID portfolioId, String ticker, String currency, String quantity,
82 + String averageCost, String currentPrice, String country, String sector, String broker) {
83 + Instrument instrument = new Instrument(UUID.randomUUID(), ticker + "ISIN", ticker, "XNAS", "XNAS",
84 + ticker + " Corp", AssetType.EQUITY, country, currency, sector, "Industry");
85 + return PortfolioPosition.priced(UUID.randomUUID(), portfolioId, instrument, new BigDecimal(quantity),
86 + new Money(new BigDecimal(averageCost), currency), new Money(new BigDecimal(currentPrice), currency),
87 + broker, Instant.now());
88 + }
89 +}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/infrastructure/market/FallbackMarketDataProviderTest.java new
+98
@@ -0,0 +1,98 @@
1 +package com.aiinvestment.portfolio.infrastructure.market;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import com.aiinvestment.shared.domain.Instrument;
5 +import com.aiinvestment.shared.domain.Money;
6 +import com.aiinvestment.shared.domain.market.MarketDataFreshness;
7 +import com.aiinvestment.shared.domain.market.MarketStatus;
8 +import com.aiinvestment.shared.domain.market.Quote;
9 +import org.junit.jupiter.api.Test;
10 +
11 +import java.math.BigDecimal;
12 +import java.time.Duration;
13 +import java.time.Instant;
14 +import java.util.UUID;
15 +
16 +import static org.assertj.core.api.Assertions.assertThat;
17 +
18 +class FallbackMarketDataProviderTest {
19 + @Test
20 + void fallsBackToDemoOnlyWhenDemoModeIsEnabled() {
21 + InMemoryQuoteCache cache = new InMemoryQuoteCache();
22 + FallbackMarketDataProvider provider = new FallbackMarketDataProvider(cache, new MockMarketDataProvider(cache), true);
23 +
24 + Quote quote = provider.getQuote(instrument());
25 +
26 + assertThat(quote.freshness()).isEqualTo(MarketDataFreshness.MOCK);
27 + assertThat(quote.source()).isEqualTo("MockMarketDataProvider");
28 + }
29 +
30 + @Test
31 + void returnsUnavailableInsteadOfInventingPriceWhenDemoModeIsDisabled() {
32 + InMemoryQuoteCache cache = new InMemoryQuoteCache();
33 + FallbackMarketDataProvider provider = new FallbackMarketDataProvider(cache, new MockMarketDataProvider(cache), false);
34 +
35 + Quote quote = provider.getQuote(instrument());
36 +
37 + assertThat(quote.freshness()).isEqualTo(MarketDataFreshness.UNAVAILABLE);
38 + assertThat(quote.last()).isNull();
39 + }
40 +
41 + @Test
42 + void ignoresCachedMockQuoteWhenDemoModeIsDisabled() {
43 + InMemoryQuoteCache cache = new InMemoryQuoteCache();
44 + Instrument instrument = instrument();
45 + cache.put(new MockMarketDataProvider(cache).getQuote(instrument), Duration.ofMinutes(5));
46 + FallbackMarketDataProvider provider = new FallbackMarketDataProvider(cache, new MockMarketDataProvider(cache), false);
47 +
48 + Quote quote = provider.getQuote(instrument);
49 +
50 + assertThat(quote.freshness()).isEqualTo(MarketDataFreshness.UNAVAILABLE);
51 + assertThat(quote.last()).isNull();
52 + }
53 +
54 + @Test
55 + void cacheFailureFallsBackWithoutFailingQuoteRequest() {
56 + com.aiinvestment.shared.domain.market.QuoteCache failingCache = new com.aiinvestment.shared.domain.market.QuoteCache() {
57 + @Override
58 + public java.util.Optional<Quote> get(UUID instrumentId) {
59 + throw new IllegalStateException("redis unavailable");
60 + }
61 +
62 + @Override
63 + public java.util.Optional<Quote> getStale(UUID instrumentId) {
64 + throw new IllegalStateException("redis unavailable");
65 + }
66 +
67 + @Override
68 + public void put(Quote quote, Duration ttl) {
69 + throw new IllegalStateException("redis unavailable");
70 + }
71 + };
72 + FallbackMarketDataProvider provider = new FallbackMarketDataProvider(failingCache, new MockMarketDataProvider(failingCache), false);
73 +
74 + Quote quote = provider.getQuote(instrument());
75 +
76 + assertThat(quote.freshness()).isEqualTo(MarketDataFreshness.UNAVAILABLE);
77 + }
78 +
79 + @Test
80 + void exposesExpiredCacheAsStaleBeforeUsingDemoFallback() {
81 + InMemoryQuoteCache cache = new InMemoryQuoteCache();
82 + Instrument instrument = instrument();
83 + cache.put(new Quote(instrument.instrumentId(), null, null, new Money(new BigDecimal("10.00"), "USD"),
84 + null, "USD", Instant.now().minusSeconds(60), "TestCache", MarketDataFreshness.DELAYED, MarketStatus.UNKNOWN),
85 + Duration.ofMillis(-1));
86 + FallbackMarketDataProvider provider = new FallbackMarketDataProvider(cache, new MockMarketDataProvider(cache), true);
87 +
88 + Quote quote = provider.getQuote(instrument);
89 +
90 + assertThat(quote.freshness()).isEqualTo(MarketDataFreshness.STALE);
91 + assertThat(quote.source()).isEqualTo("TestCache");
92 + }
93 +
94 + private static Instrument instrument() {
95 + return new Instrument(UUID.randomUUID(), "US67066G1040", "NVDA", "XNAS", "XNAS",
96 + "NVIDIA Corporation", AssetType.EQUITY, "US", "USD", "Technology", "Semiconductors");
97 + }
98 +}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/infrastructure/market/MockMarketDataProviderTest.java new
+27
@@ -0,0 +1,27 @@
1 +package com.aiinvestment.portfolio.infrastructure.market;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +import com.aiinvestment.shared.domain.Instrument;
5 +import com.aiinvestment.shared.domain.market.MarketDataFreshness;
6 +import org.junit.jupiter.api.Test;
7 +
8 +import java.util.UUID;
9 +
10 +import static org.assertj.core.api.Assertions.assertThat;
11 +
12 +class MockMarketDataProviderTest {
13 + @Test
14 + void returnsMockFreshnessAndCachesQuote() {
15 + InMemoryQuoteCache cache = new InMemoryQuoteCache();
16 + MockMarketDataProvider provider = new MockMarketDataProvider(cache);
17 + Instrument instrument = new Instrument(UUID.randomUUID(), "US67066G1040", "NVDA", "XNAS", "XNAS",
18 + "NVIDIA Corporation", AssetType.EQUITY, "US", "USD", "Technology", "Semiconductors");
19 +
20 + var quote = provider.getQuote(instrument);
21 +
22 + assertThat(quote.freshness()).isEqualTo(MarketDataFreshness.MOCK);
23 + assertThat(quote.last().amount()).isPositive();
24 + assertThat(cache.get(instrument.instrumentId())).contains(quote);
25 + assertThat(provider.getMarketDataStatus(instrument).source()).isEqualTo("MockMarketDataProvider");
26 + }
27 +}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/infrastructure/market/RedisQuoteCacheTest.java new
+56
@@ -0,0 +1,56 @@
1 +package com.aiinvestment.portfolio.infrastructure.market;
2 +
3 +import com.aiinvestment.shared.domain.Money;
4 +import com.aiinvestment.shared.domain.market.MarketDataFreshness;
5 +import com.aiinvestment.shared.domain.market.MarketStatus;
6 +import com.aiinvestment.shared.domain.market.Quote;
7 +import com.fasterxml.jackson.databind.ObjectMapper;
8 +import org.junit.jupiter.api.Test;
9 +import org.springframework.data.redis.core.StringRedisTemplate;
10 +import org.springframework.data.redis.core.ValueOperations;
11 +
12 +import java.math.BigDecimal;
13 +import java.time.Duration;
14 +import java.time.Instant;
15 +import java.util.Optional;
16 +import java.util.UUID;
17 +
18 +import static org.assertj.core.api.Assertions.assertThat;
19 +import static org.mockito.ArgumentMatchers.*;
20 +import static org.mockito.Mockito.*;
21 +
22 +class RedisQuoteCacheTest {
23 + @Test
24 + void writesFreshAndStaleKeysAndReadsStaleAsStale() throws Exception {
25 + StringRedisTemplate redis = mock(StringRedisTemplate.class);
26 + @SuppressWarnings("unchecked")
27 + ValueOperations<String, String> values = mock(ValueOperations.class);
28 + ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
29 + RedisQuoteCache cache = new RedisQuoteCache(redis, objectMapper);
30 + Quote quote = quote();
31 + String json = objectMapper.writeValueAsString(quote);
32 +
33 + when(redis.opsForValue()).thenReturn(values);
34 + when(values.get("market:quote:" + quote.instrumentId())).thenReturn(null);
35 + when(values.get("market:quote:stale:" + quote.instrumentId())).thenReturn(json);
36 +
37 + cache.put(quote, Duration.ofMinutes(5));
38 + Optional<Quote> stale = cache.getStale(quote.instrumentId());
39 +
40 + verify(values).set(eq("market:quote:" + quote.instrumentId()), anyString(), eq(Duration.ofMinutes(5)));
41 + verify(values).set(eq("market:quote:stale:" + quote.instrumentId()), anyString(), eq(Duration.ofDays(1)));
42 + assertThat(stale).hasValueSatisfying(value -> {
43 + assertThat(value.freshness()).isEqualTo(MarketDataFreshness.STALE);
44 + assertThat(value.last().amount()).isEqualByComparingTo("42.00");
45 + });
46 + }
47 +
48 + private static Quote quote() {
49 + UUID instrumentId = UUID.randomUUID();
50 + Instant sourceTimestamp = Instant.parse("2026-01-01T10:00:00Z");
51 + Instant receivedAt = Instant.parse("2026-01-01T10:00:01Z");
52 + return new Quote(instrumentId, null, null, new Money(new BigDecimal("42.00"), "USD"),
53 + null, "USD", sourceTimestamp, "TestSource", MarketDataFreshness.DELAYED,
54 + MarketStatus.UNKNOWN, sourceTimestamp, receivedAt);
55 + }
56 +}
services/portfolio-service/src/test/resources/application-test.yml new
+19
@@ -0,0 +1,19 @@
1 +spring:
2 + datasource:
3 + url: jdbc:h2:mem:portfolio;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
4 + username: sa
5 + password:
6 + driver-class-name: org.h2.Driver
7 + jpa:
8 + hibernate:
9 + ddl-auto: validate
10 + open-in-view: false
11 + flyway:
12 + enabled: true
13 +springdoc:
14 + api-docs:
15 + enabled: false
16 + swagger-ui:
17 + enabled: false
18 +market:
19 + demo-mode: true
services/recommendation-service/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/recommendation-service/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/recommendation-service/pom.xml new
+14
@@ -0,0 +1,14 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3 + <modelVersion>4.0.0</modelVersion>
4 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
5 + <artifactId>recommendation-service</artifactId>
6 + <dependencies>
7 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
8 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
10 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
11 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
12 + </dependencies>
13 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
14 +</project>
services/recommendation-service/src/main/java/com/aiinvestment/recommendation/RecommendationServiceApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.recommendation;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class RecommendationServiceApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(RecommendationServiceApplication.class, args);
12 + }
13 +}
services/recommendation-service/src/main/java/com/aiinvestment/recommendation/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.recommendation;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/recommendation-service")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "recommendation-service", "status", "starting-foundation");
15 + }
16 +}
services/recommendation-service/src/main/resources/application.yml new
+17
@@ -0,0 +1,17 @@
1 +spring:
2 + application:
3 + name: recommendation-service
4 +server:
5 + port: ${SERVER_PORT:8086}
6 +management:
7 + endpoints:
8 + web:
9 + exposure:
10 + include: health,info,prometheus
11 + endpoint:
12 + health:
13 + probes:
14 + enabled: true
15 +logging:
16 + pattern:
17 + level: "%5p [correlationId:%X{correlationId:-}]"
services/research-service/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/research-service/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/research-service/pom.xml new
+14
@@ -0,0 +1,14 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3 + <modelVersion>4.0.0</modelVersion>
4 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
5 + <artifactId>research-service</artifactId>
6 + <dependencies>
7 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
8 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
10 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
11 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
12 + </dependencies>
13 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
14 +</project>
services/research-service/src/main/java/com/aiinvestment/research/ResearchServiceApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.research;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class ResearchServiceApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(ResearchServiceApplication.class, args);
12 + }
13 +}
services/research-service/src/main/java/com/aiinvestment/research/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.research;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/research-service")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "research-service", "status", "starting-foundation");
15 + }
16 +}
services/research-service/src/main/resources/application.yml new
+17
@@ -0,0 +1,17 @@
1 +spring:
2 + application:
3 + name: research-service
4 +server:
5 + port: ${SERVER_PORT:8085}
6 +management:
7 + endpoints:
8 + web:
9 + exposure:
10 + include: health,info,prometheus
11 + endpoint:
12 + health:
13 + probes:
14 + enabled: true
15 +logging:
16 + pattern:
17 + level: "%5p [correlationId:%X{correlationId:-}]"
services/risk-service/.dockerignore new
+7
@@ -0,0 +1,7 @@
1 +target/classes
2 +target/generated-sources
3 +*.log
4 +.env
5 +.env.*
6 +.idea
7 +*.iml
services/risk-service/Dockerfile new
+6
@@ -0,0 +1,6 @@
1 +FROM eclipse-temurin:17-jre
2 +WORKDIR /app
3 +COPY target/*.jar app.jar
4 +EXPOSE 8080
5 +USER 10001
6 +ENTRYPOINT ["java", "-jar", "/app/app.jar"]
services/risk-service/pom.xml new
+14
@@ -0,0 +1,14 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3 + <modelVersion>4.0.0</modelVersion>
4 + <parent><groupId>com.aiinvestment</groupId><artifactId>ai-investment-platform-services</artifactId><version>0.1.0-SNAPSHOT</version></parent>
5 + <artifactId>risk-service</artifactId>
6 + <dependencies>
7 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
8 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
9 + <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
10 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-domain</artifactId><version>${project.version}</version></dependency>
11 + <dependency><groupId>com.aiinvestment</groupId><artifactId>shared-web</artifactId><version>${project.version}</version></dependency>
12 + </dependencies>
13 + <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
14 +</project>
services/risk-service/src/main/java/com/aiinvestment/risk/RiskServiceApplication.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.risk;
2 +
3 +import org.springframework.boot.SpringApplication;
4 +import org.springframework.boot.autoconfigure.SpringBootApplication;
5 +import org.springframework.context.annotation.ComponentScan;
6 +
7 +@SpringBootApplication
8 +@ComponentScan("com.aiinvestment")
9 +public class RiskServiceApplication {
10 + public static void main(String[] args) {
11 + SpringApplication.run(RiskServiceApplication.class, args);
12 + }
13 +}
services/risk-service/src/main/java/com/aiinvestment/risk/ServiceInfoController.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.risk;
2 +
3 +import org.springframework.web.bind.annotation.GetMapping;
4 +import org.springframework.web.bind.annotation.RequestMapping;
5 +import org.springframework.web.bind.annotation.RestController;
6 +
7 +import java.util.Map;
8 +
9 +@RestController
10 +@RequestMapping("/api/risk-service")
11 +public class ServiceInfoController {
12 + @GetMapping("/info")
13 + public Map<String, String> info() {
14 + return Map.of("service", "risk-service", "status", "starting-foundation");
15 + }
16 +}
services/risk-service/src/main/resources/application.yml new
+17
@@ -0,0 +1,17 @@
1 +spring:
2 + application:
3 + name: risk-service
4 +server:
5 + port: ${SERVER_PORT:8087}
6 +management:
7 + endpoints:
8 + web:
9 + exposure:
10 + include: health,info,prometheus
11 + endpoint:
12 + health:
13 + probes:
14 + enabled: true
15 +logging:
16 + pattern:
17 + level: "%5p [correlationId:%X{correlationId:-}]"
shared/java/domain/pom.xml new
+16
@@ -0,0 +1,16 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0"
3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5 + <modelVersion>4.0.0</modelVersion>
6 +
7 + <parent>
8 + <groupId>com.aiinvestment</groupId>
9 + <artifactId>ai-investment-platform-services</artifactId>
10 + <version>0.1.0-SNAPSHOT</version>
11 + <relativePath>../../../services/pom.xml</relativePath>
12 + </parent>
13 +
14 + <artifactId>shared-domain</artifactId>
15 + <name>Shared Domain</name>
16 +</project>
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/AssetType.java new
+11
@@ -0,0 +1,11 @@
1 +package com.aiinvestment.shared.domain;
2 +
3 +public enum AssetType {
4 + EQUITY,
5 + ETF,
6 + FUND,
7 + BOND,
8 + CASH,
9 + CRYPTO,
10 + OTHER
11 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/Instrument.java new
+40
@@ -0,0 +1,40 @@
1 +package com.aiinvestment.shared.domain;
2 +
3 +import java.util.Objects;
4 +import java.util.UUID;
5 +
6 +public record Instrument(
7 + UUID instrumentId,
8 + String isin,
9 + String ticker,
10 + String exchange,
11 + String mic,
12 + String companyName,
13 + AssetType assetType,
14 + String country,
15 + String tradingCurrency,
16 + String sector,
17 + String industry
18 +) {
19 + public Instrument {
20 + Objects.requireNonNull(instrumentId, "instrumentId is required");
21 + requireText(ticker, "ticker");
22 + requireText(exchange, "exchange");
23 + requireText(companyName, "companyName");
24 + Objects.requireNonNull(assetType, "assetType is required");
25 + requireCurrency(tradingCurrency, "tradingCurrency");
26 + }
27 +
28 + private static void requireText(String value, String name) {
29 + if (value == null || value.isBlank()) {
30 + throw new IllegalArgumentException(name + " is required");
31 + }
32 + }
33 +
34 + private static void requireCurrency(String value, String name) {
35 + requireText(value, name);
36 + if (!value.matches("[A-Z]{3}")) {
37 + throw new IllegalArgumentException(name + " must be a 3-letter ISO currency code");
38 + }
39 + }
40 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/Money.java new
+40
@@ -0,0 +1,40 @@
1 +package com.aiinvestment.shared.domain;
2 +
3 +import java.math.BigDecimal;
4 +import java.math.RoundingMode;
5 +import java.util.Objects;
6 +
7 +public record Money(BigDecimal amount, String currency) {
8 + public Money {
9 + Objects.requireNonNull(amount, "amount is required");
10 + if (currency == null || !currency.matches("[A-Z]{3}")) {
11 + throw new IllegalArgumentException("currency must be a 3-letter ISO currency code");
12 + }
13 + amount = amount.setScale(4, RoundingMode.HALF_UP);
14 + }
15 +
16 + public static Money zero(String currency) {
17 + return new Money(BigDecimal.ZERO, currency);
18 + }
19 +
20 + public Money add(Money other) {
21 + requireSameCurrency(other);
22 + return new Money(amount.add(other.amount), currency);
23 + }
24 +
25 + public Money subtract(Money other) {
26 + requireSameCurrency(other);
27 + return new Money(amount.subtract(other.amount), currency);
28 + }
29 +
30 + public Money multiply(BigDecimal multiplier) {
31 + return new Money(amount.multiply(Objects.requireNonNull(multiplier, "multiplier is required")), currency);
32 + }
33 +
34 + private void requireSameCurrency(Money other) {
35 + Objects.requireNonNull(other, "other money is required");
36 + if (!currency.equals(other.currency)) {
37 + throw new IllegalArgumentException("currency mismatch: " + currency + " != " + other.currency);
38 + }
39 + }
40 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerAccount.java new
+36
@@ -0,0 +1,36 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +import java.util.Objects;
4 +import java.util.UUID;
5 +
6 +public record BrokerAccount(
7 + String brokerAccountId,
8 + UUID userId,
9 + BrokerType brokerType,
10 + String externalAccountReference,
11 + String displayName,
12 + String baseCurrency,
13 + BrokerAccountStatus status
14 +) {
15 + public BrokerAccount {
16 + requireText(brokerAccountId, "brokerAccountId");
17 + Objects.requireNonNull(userId, "userId is required");
18 + Objects.requireNonNull(brokerType, "brokerType is required");
19 + requireText(displayName, "displayName");
20 + requireCurrency(baseCurrency, "baseCurrency");
21 + Objects.requireNonNull(status, "status is required");
22 + }
23 +
24 + private static void requireText(String value, String name) {
25 + if (value == null || value.isBlank()) {
26 + throw new IllegalArgumentException(name + " is required");
27 + }
28 + }
29 +
30 + private static void requireCurrency(String value, String name) {
31 + requireText(value, name);
32 + if (!value.matches("[A-Z]{3}")) {
33 + throw new IllegalArgumentException(name + " must be a 3-letter ISO currency code");
34 + }
35 + }
36 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerAccountStatus.java new
+7
@@ -0,0 +1,7 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +public enum BrokerAccountStatus {
4 + ACTIVE,
5 + DISCONNECTED,
6 + ERROR
7 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerCapability.java new
+14
@@ -0,0 +1,14 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +public enum BrokerCapability {
4 + ACCOUNTS_READ,
5 + ACCOUNT_METADATA_READ,
6 + PORTFOLIO_READ,
7 + POSITIONS_READ,
8 + CASH_READ,
9 + MARKET_DATA_READ,
10 + LIVE_QUOTES,
11 + DELAYED_QUOTES,
12 + ORDER_READ,
13 + ORDER_EXECUTION
14 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerCashBalance.java new
+6
@@ -0,0 +1,6 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +import com.aiinvestment.shared.domain.Money;
4 +
5 +public record BrokerCashBalance(String brokerAccountId, Money cash) {
6 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerConnectionCapabilities.java new
+29
@@ -0,0 +1,29 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +import java.util.EnumSet;
4 +import java.util.Set;
5 +
6 +public record BrokerConnectionCapabilities(Set<BrokerCapability> capabilities) {
7 + public BrokerConnectionCapabilities {
8 + capabilities = Set.copyOf(capabilities);
9 + if (capabilities.contains(BrokerCapability.ORDER_EXECUTION)) {
10 + throw new IllegalArgumentException("ORDER_EXECUTION is not enabled in Phase 2B");
11 + }
12 + }
13 +
14 + public static BrokerConnectionCapabilities none() {
15 + return new BrokerConnectionCapabilities(Set.of());
16 + }
17 +
18 + public static BrokerConnectionCapabilities mockReadOnly() {
19 + return new BrokerConnectionCapabilities(EnumSet.of(
20 + BrokerCapability.ACCOUNTS_READ,
21 + BrokerCapability.ACCOUNT_METADATA_READ,
22 + BrokerCapability.PORTFOLIO_READ,
23 + BrokerCapability.POSITIONS_READ,
24 + BrokerCapability.CASH_READ,
25 + BrokerCapability.MARKET_DATA_READ,
26 + BrokerCapability.DELAYED_QUOTES
27 + ));
28 + }
29 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerConnectionState.java new
+15
@@ -0,0 +1,15 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +public enum BrokerConnectionState {
4 + CREATED,
5 + AUTHENTICATING,
6 + CONNECTING,
7 + CONNECTED,
8 + SYNCING,
9 + REFRESH_REQUIRED,
10 + EXPIRED,
11 + REVOKED,
12 + DEGRADED,
13 + DISCONNECTED,
14 + ERROR
15 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerConnectionStatus.java new
+22
@@ -0,0 +1,22 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +public record BrokerConnectionStatus(BrokerType brokerType, BrokerConnectionState state, BrokerProviderStatus providerStatus, String code, String message) {
4 + public static BrokerConnectionStatus notConfigured(BrokerType brokerType) {
5 + return new BrokerConnectionStatus(brokerType, BrokerConnectionState.DISCONNECTED, BrokerProviderStatus.NOT_CONFIGURED, "NOT_CONFIGURED", "Provider is not configured");
6 + }
7 +
8 + public static BrokerConnectionStatus documentationRequired(BrokerType brokerType) {
9 + return new BrokerConnectionStatus(brokerType, BrokerConnectionState.DISCONNECTED, BrokerProviderStatus.DOCUMENTATION_REQUIRED,
10 + "DOCUMENTATION_REQUIRED", "Official provider documentation is required before this adapter can be configured");
11 + }
12 +
13 + public static BrokerConnectionStatus authenticationRequired(BrokerType brokerType) {
14 + return new BrokerConnectionStatus(brokerType, BrokerConnectionState.AUTHENTICATING, BrokerProviderStatus.AUTHENTICATION_REQUIRED,
15 + "AUTHENTICATION_REQUIRED", "Provider configuration is present, but a supported authentication flow must be completed");
16 + }
17 +
18 + public static BrokerConnectionStatus unavailable(BrokerType brokerType, String message) {
19 + return new BrokerConnectionStatus(brokerType, BrokerConnectionState.DISCONNECTED, BrokerProviderStatus.UNAVAILABLE,
20 + "UNAVAILABLE", message);
21 + }
22 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerInstrumentIdentity.java new
+17
@@ -0,0 +1,17 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +import com.aiinvestment.shared.domain.AssetType;
4 +
5 +public record BrokerInstrumentIdentity(
6 + BrokerType brokerType,
7 + String brokerSecurityId,
8 + String brokerContractId,
9 + String isin,
10 + String ticker,
11 + String exchange,
12 + String mic,
13 + String currency,
14 + String country,
15 + AssetType assetType
16 +) {
17 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerInstrumentNormalizer.java new
+9
@@ -0,0 +1,9 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +import com.aiinvestment.shared.domain.Instrument;
4 +
5 +public interface BrokerInstrumentNormalizer {
6 + BrokerType supportedBroker();
7 +
8 + Instrument normalize(BrokerInstrumentIdentity identity);
9 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerPosition.java new
+31
@@ -0,0 +1,31 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +import com.aiinvestment.shared.domain.Instrument;
4 +import com.aiinvestment.shared.domain.Money;
5 +
6 +import java.math.BigDecimal;
7 +import java.time.Instant;
8 +import java.util.Objects;
9 +
10 +public record BrokerPosition(
11 + String brokerAccountId,
12 + Instrument instrument,
13 + BigDecimal quantity,
14 + Money averageCost,
15 + Money currentPrice,
16 + Instant observedAt
17 +) {
18 + public BrokerPosition {
19 + if (brokerAccountId == null || brokerAccountId.isBlank()) {
20 + throw new IllegalArgumentException("brokerAccountId is required");
21 + }
22 + Objects.requireNonNull(instrument, "instrument is required");
23 + Objects.requireNonNull(quantity, "quantity is required");
24 + if (quantity.signum() < 0) {
25 + throw new IllegalArgumentException("quantity cannot be negative");
26 + }
27 + Objects.requireNonNull(averageCost, "averageCost is required");
28 + Objects.requireNonNull(currentPrice, "currentPrice is required");
29 + Objects.requireNonNull(observedAt, "observedAt is required");
30 + }
31 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerProvider.java new
+20
@@ -0,0 +1,20 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +public interface BrokerProvider {
7 + BrokerType supportedBroker();
8 +
9 + BrokerConnectionCapabilities connectionCapabilities();
10 +
11 + BrokerConnectionStatus connectionStatus();
12 +
13 + List<BrokerAccount> fetchAccounts(UUID userId);
14 +
15 + List<BrokerPosition> fetchPositions(BrokerAccount account);
16 +
17 + List<BrokerCashBalance> fetchCashBalances(BrokerAccount account);
18 +
19 + void disconnect(UUID connectionId);
20 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerProviderStatus.java new
+11
@@ -0,0 +1,11 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +public enum BrokerProviderStatus {
4 + NOT_CONFIGURED,
5 + DOCUMENTATION_REQUIRED,
6 + AUTHENTICATION_REQUIRED,
7 + CONNECTED,
8 + DEGRADED,
9 + UNAVAILABLE,
10 + ERROR
11 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/BrokerType.java new
+7
@@ -0,0 +1,7 @@
1 +package com.aiinvestment.shared.domain.broker;
2 +
3 +public enum BrokerType {
4 + MOCK,
5 + IBKR,
6 + ICICI_DIRECT
7 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/auth/BrokerAuthenticationRequest.java new
+12
@@ -0,0 +1,12 @@
1 +package com.aiinvestment.shared.domain.broker.auth;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +
5 +import java.util.Map;
6 +import java.util.UUID;
7 +
8 +public record BrokerAuthenticationRequest(UUID userId, BrokerType brokerType, Map<String, String> nonSecretMetadata) {
9 + public BrokerAuthenticationRequest {
10 + nonSecretMetadata = Map.copyOf(nonSecretMetadata);
11 + }
12 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/auth/BrokerAuthenticationStrategy.java new
+11
@@ -0,0 +1,11 @@
1 +package com.aiinvestment.shared.domain.broker.auth;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +
5 +public interface BrokerAuthenticationStrategy {
6 + BrokerType supportedBroker();
7 +
8 + BrokerSession authenticate(BrokerAuthenticationRequest request);
9 +
10 + BrokerSession refresh(BrokerSession session);
11 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/auth/BrokerSession.java new
+9
@@ -0,0 +1,9 @@
1 +package com.aiinvestment.shared.domain.broker.auth;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerType;
4 +
5 +import java.time.Instant;
6 +import java.util.UUID;
7 +
8 +public record BrokerSession(UUID sessionId, UUID connectionId, BrokerType brokerType, BrokerSessionState state, Instant expiresAt, boolean mock) {
9 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/auth/BrokerSessionState.java new
+11
@@ -0,0 +1,11 @@
1 +package com.aiinvestment.shared.domain.broker.auth;
2 +
3 +public enum BrokerSessionState {
4 + CREATED,
5 + AUTHENTICATING,
6 + CONNECTED,
7 + REFRESH_REQUIRED,
8 + EXPIRED,
9 + REVOKED,
10 + ERROR
11 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/broker/auth/SensitiveTokenReference.java new
+9
@@ -0,0 +1,9 @@
1 +package com.aiinvestment.shared.domain.broker.auth;
2 +
3 +public record SensitiveTokenReference(String keyRef) {
4 + public SensitiveTokenReference {
5 + if (keyRef == null || keyRef.isBlank()) {
6 + throw new IllegalArgumentException("Token reference is required");
7 + }
8 + }
9 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/event/BrokerConnectionChangedEvent.java new
+24
@@ -0,0 +1,24 @@
1 +package com.aiinvestment.shared.domain.event;
2 +
3 +import com.aiinvestment.shared.domain.broker.BrokerConnectionState;
4 +import com.aiinvestment.shared.domain.broker.BrokerType;
5 +
6 +import java.time.Instant;
7 +import java.util.UUID;
8 +
9 +public record BrokerConnectionChangedEvent(
10 + String eventType,
11 + int version,
12 + UUID eventId,
13 + String correlationId,
14 + Instant occurredAt,
15 + UUID connectionId,
16 + UUID userId,
17 + BrokerType brokerType,
18 + BrokerConnectionState status
19 +) implements PlatformEvent {
20 + public BrokerConnectionChangedEvent(UUID eventId, String correlationId, Instant occurredAt, UUID connectionId,
21 + UUID userId, BrokerType brokerType, BrokerConnectionState status) {
22 + this("broker.connection.changed", 1, eventId, correlationId, occurredAt, connectionId, userId, brokerType, status);
23 + }
24 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/event/BrokerSyncEvent.java new
+31
@@ -0,0 +1,31 @@
1 +package com.aiinvestment.shared.domain.event;
2 +
3 +import java.time.Instant;
4 +import java.util.UUID;
5 +
6 +public record BrokerSyncEvent(
7 + String eventType,
8 + int version,
9 + UUID eventId,
10 + String correlationId,
11 + Instant occurredAt,
12 + UUID connectionId,
13 + UUID portfolioId,
14 + String errorCode
15 +) implements PlatformEvent {
16 + public static BrokerSyncEvent started(UUID connectionId, UUID portfolioId, String correlationId) {
17 + return event("broker.sync.started", connectionId, portfolioId, correlationId, null);
18 + }
19 +
20 + public static BrokerSyncEvent completed(UUID connectionId, UUID portfolioId, String correlationId) {
21 + return event("broker.sync.completed", connectionId, portfolioId, correlationId, null);
22 + }
23 +
24 + public static BrokerSyncEvent failed(UUID connectionId, UUID portfolioId, String correlationId, String errorCode) {
25 + return event("broker.sync.failed", connectionId, portfolioId, correlationId, errorCode);
26 + }
27 +
28 + private static BrokerSyncEvent event(String type, UUID connectionId, UUID portfolioId, String correlationId, String errorCode) {
29 + return new BrokerSyncEvent(type, 1, UUID.randomUUID(), correlationId, Instant.now(), connectionId, portfolioId, errorCode);
30 + }
31 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/event/MarketQuoteUpdatedEvent.java new
+21
@@ -0,0 +1,21 @@
1 +package com.aiinvestment.shared.domain.event;
2 +
3 +import com.aiinvestment.shared.domain.market.MarketDataFreshness;
4 +
5 +import java.time.Instant;
6 +import java.util.UUID;
7 +
8 +public record MarketQuoteUpdatedEvent(
9 + String eventType,
10 + int version,
11 + UUID eventId,
12 + String correlationId,
13 + Instant occurredAt,
14 + UUID instrumentId,
15 + MarketDataFreshness freshness
16 +) implements PlatformEvent {
17 + public MarketQuoteUpdatedEvent(UUID eventId, String correlationId, Instant occurredAt, UUID instrumentId,
18 + MarketDataFreshness freshness) {
19 + this("market.quote.updated", 1, eventId, correlationId, occurredAt, instrumentId, freshness);
20 + }
21 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/event/PlatformEvent.java new
+16
@@ -0,0 +1,16 @@
1 +package com.aiinvestment.shared.domain.event;
2 +
3 +import java.time.Instant;
4 +import java.util.UUID;
5 +
6 +public interface PlatformEvent {
7 + String eventType();
8 +
9 + int version();
10 +
11 + UUID eventId();
12 +
13 + String correlationId();
14 +
15 + Instant occurredAt();
16 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/event/PortfolioUpdatedEvent.java new
+11
@@ -0,0 +1,11 @@
1 +package com.aiinvestment.shared.domain.event;
2 +
3 +import java.time.Instant;
4 +import java.util.UUID;
5 +
6 +public record PortfolioUpdatedEvent(String eventType, int version, UUID eventId, String correlationId, Instant occurredAt,
7 + UUID portfolioId, UUID userId) implements PlatformEvent {
8 + public PortfolioUpdatedEvent(UUID eventId, String correlationId, Instant occurredAt, UUID portfolioId, UUID userId) {
9 + this("portfolio.updated", 1, eventId, correlationId, occurredAt, portfolioId, userId);
10 + }
11 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/event/ResearchCompanyUpdatedEvent.java new
+18
@@ -0,0 +1,18 @@
1 +package com.aiinvestment.shared.domain.event;
2 +
3 +import java.time.Instant;
4 +import java.util.UUID;
5 +
6 +public record ResearchCompanyUpdatedEvent(
7 + String eventType,
8 + int version,
9 + UUID eventId,
10 + String correlationId,
11 + Instant occurredAt,
12 + UUID instrumentId,
13 + UUID companyId
14 +) implements PlatformEvent {
15 + public ResearchCompanyUpdatedEvent(UUID eventId, String correlationId, Instant occurredAt, UUID instrumentId, UUID companyId) {
16 + this("research.company.updated", 1, eventId, correlationId, occurredAt, instrumentId, companyId);
17 + }
18 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/event/ResearchDocumentEvent.java new
+32
@@ -0,0 +1,32 @@
1 +package com.aiinvestment.shared.domain.event;
2 +
3 +import java.time.Instant;
4 +import java.util.UUID;
5 +
6 +public record ResearchDocumentEvent(
7 + String eventType,
8 + int version,
9 + UUID eventId,
10 + String correlationId,
11 + Instant occurredAt,
12 + UUID documentId,
13 + UUID instrumentId,
14 + String sourceType,
15 + String status
16 +) implements PlatformEvent {
17 + public static ResearchDocumentEvent discovered(UUID documentId, UUID instrumentId, String sourceType, String correlationId) {
18 + return event("research.document.discovered", documentId, instrumentId, sourceType, "DISCOVERED", correlationId);
19 + }
20 +
21 + public static ResearchDocumentEvent fetched(UUID documentId, UUID instrumentId, String sourceType, String correlationId) {
22 + return event("research.document.fetched", documentId, instrumentId, sourceType, "FETCHED", correlationId);
23 + }
24 +
25 + public static ResearchDocumentEvent processed(UUID documentId, UUID instrumentId, String sourceType, String correlationId) {
26 + return event("research.document.processed", documentId, instrumentId, sourceType, "PROCESSED", correlationId);
27 + }
28 +
29 + private static ResearchDocumentEvent event(String type, UUID documentId, UUID instrumentId, String sourceType, String status, String correlationId) {
30 + return new ResearchDocumentEvent(type, 1, UUID.randomUUID(), correlationId, Instant.now(), documentId, instrumentId, sourceType, status);
31 + }
32 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/event/ResearchExtractedEvent.java new
+31
@@ -0,0 +1,31 @@
1 +package com.aiinvestment.shared.domain.event;
2 +
3 +import java.time.Instant;
4 +import java.util.UUID;
5 +
6 +public record ResearchExtractedEvent(
7 + String eventType,
8 + int version,
9 + UUID eventId,
10 + String correlationId,
11 + Instant occurredAt,
12 + UUID researchEventId,
13 + UUID instrumentId,
14 + UUID sourceDocumentId,
15 + String researchEventType,
16 + String impact,
17 + double confidence,
18 + String rejectionCode
19 +) implements PlatformEvent {
20 + public static ResearchExtractedEvent extracted(UUID researchEventId, UUID instrumentId, UUID sourceDocumentId,
21 + String researchEventType, String impact, double confidence,
22 + String correlationId) {
23 + return new ResearchExtractedEvent("research.event.extracted", 1, UUID.randomUUID(), correlationId, Instant.now(),
24 + researchEventId, instrumentId, sourceDocumentId, researchEventType, impact, confidence, null);
25 + }
26 +
27 + public static ResearchExtractedEvent rejected(UUID instrumentId, UUID sourceDocumentId, String rejectionCode, String correlationId) {
28 + return new ResearchExtractedEvent("research.event.rejected", 1, UUID.randomUUID(), correlationId, Instant.now(),
29 + null, instrumentId, sourceDocumentId, null, null, 0.0, rejectionCode);
30 + }
31 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/fx/FxRateProvider.java new
+7
@@ -0,0 +1,7 @@
1 +package com.aiinvestment.shared.domain.fx;
2 +
3 +import java.math.BigDecimal;
4 +
5 +public interface FxRateProvider {
6 + BigDecimal getRate(String fromCurrency, String toCurrency);
7 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/market/DataProvenance.java new
+14
@@ -0,0 +1,14 @@
1 +package com.aiinvestment.shared.domain.market;
2 +
3 +import java.time.Instant;
4 +
5 +public record DataProvenance(String source, Instant sourceTimestamp, Instant receivedAt, MarketDataFreshness freshness) {
6 + public DataProvenance {
7 + if (source == null || source.isBlank()) {
8 + throw new IllegalArgumentException("Source is required");
9 + }
10 + if (sourceTimestamp == null || receivedAt == null || freshness == null) {
11 + throw new IllegalArgumentException("Provenance timestamps and freshness are required");
12 + }
13 + }
14 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/market/MarketDataFreshness.java new
+10
@@ -0,0 +1,10 @@
1 +package com.aiinvestment.shared.domain.market;
2 +
3 +public enum MarketDataFreshness {
4 + REAL_TIME,
5 + DELAYED,
6 + END_OF_DAY,
7 + STALE,
8 + MOCK,
9 + UNAVAILABLE
10 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/market/MarketDataProvider.java new
+13
@@ -0,0 +1,13 @@
1 +package com.aiinvestment.shared.domain.market;
2 +
3 +import com.aiinvestment.shared.domain.Instrument;
4 +
5 +import java.util.List;
6 +
7 +public interface MarketDataProvider {
8 + Quote getQuote(Instrument instrument);
9 +
10 + List<Quote> getQuotes(List<Instrument> instruments);
11 +
12 + MarketDataStatus getMarketDataStatus(Instrument instrument);
13 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/market/MarketDataStatus.java new
+7
@@ -0,0 +1,7 @@
1 +package com.aiinvestment.shared.domain.market;
2 +
3 +import java.time.Instant;
4 +import java.util.UUID;
5 +
6 +public record MarketDataStatus(UUID instrumentId, String source, MarketDataFreshness freshness, MarketStatus marketStatus, Instant updatedAt) {
7 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/market/MarketStatus.java new
+7
@@ -0,0 +1,7 @@
1 +package com.aiinvestment.shared.domain.market;
2 +
3 +public enum MarketStatus {
4 + OPEN,
5 + CLOSED,
6 + UNKNOWN
7 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/market/Quote.java new
+39
@@ -0,0 +1,39 @@
1 +package com.aiinvestment.shared.domain.market;
2 +
3 +import com.aiinvestment.shared.domain.Money;
4 +
5 +import java.time.Instant;
6 +import java.util.UUID;
7 +
8 +public record Quote(
9 + UUID instrumentId,
10 + Money bid,
11 + Money ask,
12 + Money last,
13 + Money previousClose,
14 + String currency,
15 + Instant timestamp,
16 + String source,
17 + MarketDataFreshness freshness,
18 + MarketStatus marketStatus,
19 + Instant sourceTimestamp,
20 + Instant receivedAt
21 +) {
22 + public Quote(UUID instrumentId, Money bid, Money ask, Money last, Money previousClose, String currency,
23 + Instant timestamp, String source, MarketDataFreshness freshness, MarketStatus marketStatus) {
24 + this(instrumentId, bid, ask, last, previousClose, currency, timestamp, source, freshness, marketStatus, timestamp, Instant.now());
25 + }
26 +
27 + public Quote {
28 + if (sourceTimestamp == null) {
29 + sourceTimestamp = timestamp;
30 + }
31 + if (receivedAt == null) {
32 + receivedAt = Instant.now();
33 + }
34 + }
35 +
36 + public DataProvenance provenance() {
37 + return new DataProvenance(source, sourceTimestamp, receivedAt, freshness);
38 + }
39 +}
shared/java/domain/src/main/java/com/aiinvestment/shared/domain/market/QuoteCache.java new
+15
@@ -0,0 +1,15 @@
1 +package com.aiinvestment.shared.domain.market;
2 +
3 +import java.time.Duration;
4 +import java.util.Optional;
5 +import java.util.UUID;
6 +
7 +public interface QuoteCache {
8 + Optional<Quote> get(UUID instrumentId);
9 +
10 + default Optional<Quote> getStale(UUID instrumentId) {
11 + return Optional.empty();
12 + }
13 +
14 + void put(Quote quote, Duration ttl);
15 +}
shared/java/web/pom.xml new
+36
@@ -0,0 +1,36 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<project xmlns="http://maven.apache.org/POM/4.0.0"
3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5 + <modelVersion>4.0.0</modelVersion>
6 +
7 + <parent>
8 + <groupId>com.aiinvestment</groupId>
9 + <artifactId>ai-investment-platform-services</artifactId>
10 + <version>0.1.0-SNAPSHOT</version>
11 + <relativePath>../../../services/pom.xml</relativePath>
12 + </parent>
13 +
14 + <artifactId>shared-web</artifactId>
15 + <name>Shared Web</name>
16 +
17 + <dependencies>
18 + <dependency>
19 + <groupId>org.springframework</groupId>
20 + <artifactId>spring-web</artifactId>
21 + </dependency>
22 + <dependency>
23 + <groupId>org.springframework</groupId>
24 + <artifactId>spring-context</artifactId>
25 + </dependency>
26 + <dependency>
27 + <groupId>org.slf4j</groupId>
28 + <artifactId>slf4j-api</artifactId>
29 + </dependency>
30 + <dependency>
31 + <groupId>jakarta.servlet</groupId>
32 + <artifactId>jakarta.servlet-api</artifactId>
33 + <scope>provided</scope>
34 + </dependency>
35 + </dependencies>
36 +</project>
shared/java/web/src/main/java/com/aiinvestment/shared/web/CorrelationIdFilter.java new
+34
@@ -0,0 +1,34 @@
1 +package com.aiinvestment.shared.web;
2 +
3 +import jakarta.servlet.FilterChain;
4 +import jakarta.servlet.ServletException;
5 +import jakarta.servlet.http.HttpServletRequest;
6 +import jakarta.servlet.http.HttpServletResponse;
7 +import org.slf4j.MDC;
8 +import org.springframework.stereotype.Component;
9 +import org.springframework.web.filter.OncePerRequestFilter;
10 +
11 +import java.io.IOException;
12 +import java.util.UUID;
13 +
14 +@Component
15 +public class CorrelationIdFilter extends OncePerRequestFilter {
16 + public static final String HEADER_NAME = "X-Correlation-Id";
17 + private static final String MDC_KEY = "correlationId";
18 +
19 + @Override
20 + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
21 + throws ServletException, IOException {
22 + String correlationId = request.getHeader(HEADER_NAME);
23 + if (correlationId == null || correlationId.isBlank()) {
24 + correlationId = UUID.randomUUID().toString();
25 + }
26 + MDC.put(MDC_KEY, correlationId);
27 + response.setHeader(HEADER_NAME, correlationId);
28 + try {
29 + filterChain.doFilter(request, response);
30 + } finally {
31 + MDC.remove(MDC_KEY);
32 + }
33 + }
34 +}