feat: initialize trading agents project with FastAPI and essential configurations

- Added FastAPI-based API structure with routers for runs and settings management.
- Implemented endpoints for creating, listing, and retrieving run configurations.
- Introduced settings management with load and update functionality.
- Created models for run configurations and settings using Pydantic.
- Established a store for managing run states and results.
- Enhanced .gitignore to exclude node_modules and results directories.
- Added package.json and package-lock.json for frontend dependencies.
- Included initial tests for API endpoints and model validations.
This commit is contained in:
Ali AL OGAILI 2026-03-23 05:16:57 +01:00
parent 49283f47d5
commit ae6776afc3
75 changed files with 14656 additions and 5 deletions

2
.gitignore vendored
View File

@ -223,3 +223,5 @@ __marimo__/
# Results # Results
**/results/ **/results/
node_modules/

View File

@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from api.routers import runs, settings from api.routers import runs, settings
@ -11,5 +14,5 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(runs.router, prefix="/runs", tags=["runs"]) app.include_router(runs.router, prefix="/api/runs", tags=["runs"])
app.include_router(settings.router, prefix="/settings", tags=["settings"]) app.include_router(settings.router, prefix="/api/settings", tags=["settings"])

38
api/models/run.py Normal file
View File

@ -0,0 +1,38 @@
from enum import Enum
from typing import Literal, Optional
from pydantic import BaseModel, Field
class RunStatus(str, Enum):
QUEUED = "queued"
RUNNING = "running"
COMPLETE = "complete"
ERROR = "error"
class RunConfig(BaseModel):
ticker: str
date: str # "YYYY-MM-DD"
llm_provider: str = "openai"
deep_think_llm: str = "gpt-5.2"
quick_think_llm: str = "gpt-5-mini"
max_debate_rounds: int = Field(default=1, ge=1, le=5)
max_risk_discuss_rounds: int = Field(default=1, ge=1, le=5)
enabled_analysts: list[str] = Field(
default=["market", "news", "fundamentals", "social"]
)
class RunSummary(BaseModel):
id: str
ticker: str
date: str
status: RunStatus
decision: Optional[Literal["BUY", "SELL", "HOLD"]] = None
created_at: str
class RunResult(RunSummary):
config: Optional[RunConfig] = None
reports: dict[str, str] = {}
error: Optional[str] = None

9
api/models/settings.py Normal file
View File

@ -0,0 +1,9 @@
from pydantic import BaseModel, Field
class Settings(BaseModel):
deep_think_llm: str = "gpt-5.2"
quick_think_llm: str = "gpt-5-mini"
llm_provider: str = "openai"
max_debate_rounds: int = Field(default=1, ge=1, le=5)
max_risk_discuss_rounds: int = Field(default=1, ge=1, le=5)

View File

@ -1,3 +1,50 @@
from fastapi import APIRouter import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from api.models.run import RunConfig, RunResult, RunSummary
from api.services.run_service import RunService
from api.store.runs_store import RunsStore
router = APIRouter() router = APIRouter()
_store = RunsStore()
_service = RunService(_store)
@router.post("", response_model=RunSummary)
def create_run(config: RunConfig):
run = _store.create(config)
return run
@router.get("", response_model=list[RunSummary])
def list_runs():
return _store.list_all()
@router.get("/{run_id}", response_model=RunResult)
def get_run(run_id: str):
run = _store.get(run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
return run
@router.get("/{run_id}/stream")
def stream_run(run_id: str):
run = _store.get(run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
def event_generator():
for event in _service.stream_events(run_id):
data = json.dumps(event["data"])
yield f"event: {event['event']}\ndata: {data}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View File

@ -1,3 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from api.models.settings import Settings
from api.services.settings_service import load_settings, save_settings
router = APIRouter() router = APIRouter()
@router.get("", response_model=Settings)
def get_settings():
return load_settings()
@router.put("", response_model=Settings)
def update_settings(settings: Settings):
save_settings(settings)
return settings

View File

@ -0,0 +1,61 @@
import logging
from collections import defaultdict
from typing import Generator
from api.store.runs_store import RunsStore
from api.models.run import RunConfig, RunStatus
logger = logging.getLogger(__name__)
try:
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
except ImportError:
TradingAgentsGraph = None # type: ignore
DEFAULT_CONFIG = {}
class RunService:
def __init__(self, store: RunsStore):
self._store = store
def stream_events(self, run_id: str) -> Generator[dict, None, None]:
run = self._store.get(run_id)
if not run or not run.config:
yield {"event": "run:error", "data": {"message": "Run not found"}}
return
self._store.update_status(run_id, RunStatus.RUNNING)
config = run.config
ta_config = DEFAULT_CONFIG.copy()
ta_config["llm_provider"] = config.llm_provider
ta_config["deep_think_llm"] = config.deep_think_llm
ta_config["quick_think_llm"] = config.quick_think_llm
ta_config["max_debate_rounds"] = config.max_debate_rounds
ta_config["max_risk_discuss_rounds"] = config.max_risk_discuss_rounds
try:
ta = TradingAgentsGraph(
debug=False,
config=ta_config,
selected_analysts=config.enabled_analysts or
["market", "news", "fundamentals", "social"],
)
turn_counts: defaultdict[str, int] = defaultdict(int)
for step_key, report in ta.stream_propagate(config.ticker, config.date):
turn = turn_counts[step_key]
yield {"event": "agent:start", "data": {"step": step_key, "turn": turn}}
yield {"event": "agent:complete", "data": {"step": step_key, "turn": turn, "report": report}}
self._store.add_report(run_id, f"{step_key}:{turn}", report)
turn_counts[step_key] += 1
decision = ta._last_decision or "HOLD"
self._store.update_decision(run_id, decision)
self._store.update_status(run_id, RunStatus.COMPLETE)
yield {"event": "run:complete", "data": {"decision": decision, "run_id": run_id}}
except Exception as e:
self._store.set_error(run_id, str(e))
yield {"event": "run:error", "data": {"message": str(e)}}

View File

@ -0,0 +1,18 @@
import json
import os
from pathlib import Path
from api.models.settings import Settings
SETTINGS_PATH = Path(os.getenv("SETTINGS_PATH", "api/settings.json"))
def load_settings() -> Settings:
if SETTINGS_PATH.exists():
data = json.loads(SETTINGS_PATH.read_text())
return Settings(**data)
return Settings()
def save_settings(settings: Settings) -> None:
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_PATH.write_text(settings.model_dump_json(indent=2))

7
api/settings.json Normal file
View File

@ -0,0 +1,7 @@
{
"deep_think_llm": "claude-opus-4-6",
"quick_think_llm": "claude-haiku-4-5-20251001",
"llm_provider": "anthropic",
"max_debate_rounds": 2,
"max_risk_discuss_rounds": 2
}

63
api/store/runs_store.py Normal file
View File

@ -0,0 +1,63 @@
import uuid
from datetime import datetime, timezone
from threading import Lock
from api.models.run import RunConfig, RunResult, RunStatus
from typing import Optional, Literal
class RunsStore:
def __init__(self):
self._runs: dict[str, RunResult] = {}
self._lock = Lock()
def create(self, config: RunConfig) -> RunResult:
run_id = str(uuid.uuid4())[:8]
run = RunResult(
id=run_id,
ticker=config.ticker,
date=config.date,
status=RunStatus.QUEUED,
created_at=datetime.now(timezone.utc).isoformat(),
config=config,
)
with self._lock:
self._runs[run_id] = run
return run
def get(self, run_id: str) -> Optional[RunResult]:
return self._runs.get(run_id)
def list_all(self) -> list[RunResult]:
return list(self._runs.values())
def update_status(self, run_id: str, status: RunStatus) -> None:
with self._lock:
if run_id in self._runs:
self._runs[run_id] = self._runs[run_id].model_copy(
update={"status": status}
)
def update_decision(
self, run_id: str, decision: Literal["BUY", "SELL", "HOLD"]
) -> None:
with self._lock:
if run_id in self._runs:
self._runs[run_id] = self._runs[run_id].model_copy(
update={"decision": decision}
)
def add_report(self, run_id: str, step: str, report: str) -> None:
with self._lock:
if run_id in self._runs:
reports = dict(self._runs[run_id].reports)
reports[step] = report
self._runs[run_id] = self._runs[run_id].model_copy(
update={"reports": reports}
)
def set_error(self, run_id: str, error: str) -> None:
with self._lock:
if run_id in self._runs:
self._runs[run_id] = self._runs[run_id].model_copy(
update={"status": RunStatus.ERROR, "error": error}
)

File diff suppressed because one or more lines are too long

330
package-lock.json generated Normal file
View File

@ -0,0 +1,330 @@
{
"name": "tradingagents",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tradingagents",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "tradingagents",
"version": "1.0.0",
"description": "<p align=\"center\">\r <img src=\"assets/TauricResearch.png\" style=\"width: 60%; height: auto;\">\r </p>",
"main": "index.js",
"directories": {
"doc": "docs",
"test": "tests"
},
"scripts": {
"dev": "concurrently \"venv\\\\Scripts\\\\python.exe -m uvicorn api.main:app --reload --port 8000\" \"cd ui && npm run dev\"",
"dev:api": "venv\\Scripts\\python.exe -m uvicorn api.main:app --reload --port 8000",
"dev:ui": "cd ui && npm run dev",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/TauricResearch/TradingAgents.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/TauricResearch/TradingAgents/issues"
},
"homepage": "https://github.com/TauricResearch/TradingAgents#readme",
"devDependencies": {
"concurrently": "^9.2.1"
}
}

View File

@ -38,3 +38,6 @@ tradingagents = "cli.main:app"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["tradingagents*", "cli*"] include = ["tradingagents*", "cli*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

View File

23
tests/api/test_models.py Normal file
View File

@ -0,0 +1,23 @@
import pytest
from api.models.run import RunConfig, RunSummary, RunStatus
from api.models.settings import Settings
def test_run_config_defaults():
config = RunConfig(ticker="NVDA", date="2024-05-10")
assert config.llm_provider == "openai"
assert config.max_debate_rounds == 1
def test_run_summary_has_decision():
summary = RunSummary(
id="abc123", ticker="NVDA", date="2024-05-10",
status=RunStatus.COMPLETE, decision="BUY", created_at="2026-03-23T09:00:00"
)
assert summary.decision == "BUY"
def test_settings_defaults():
s = Settings()
assert s.deep_think_llm == "gpt-5.2"
assert s.max_debate_rounds == 1

View File

@ -0,0 +1,118 @@
import pytest
from collections import defaultdict
from unittest.mock import patch, MagicMock
from api.services.run_service import RunService
from api.store.runs_store import RunsStore
from api.models.run import RunConfig, RunStatus
@pytest.fixture
def store():
return RunsStore()
@pytest.fixture
def service(store):
return RunService(store)
def _mock_graph(stream_yields, decision="BUY"):
"""Return a mock TradingAgentsGraph instance for stream_propagate tests."""
mock = MagicMock()
mock.stream_propagate.return_value = iter(stream_yields)
mock._last_decision = decision
return mock
def test_emits_agent_start_and_complete_per_step(service, store):
config = RunConfig(ticker="NVDA", date="2026-03-23")
run = store.create(config)
yields = [
("market_analyst", "bullish"),
("news_analyst", "stable"),
]
with patch("api.services.run_service.TradingAgentsGraph") as MockGraph:
MockGraph.return_value = _mock_graph(yields)
events = list(service.stream_events(run.id))
starts = [e for e in events if e["event"] == "agent:start"]
completes = [e for e in events if e["event"] == "agent:complete"]
assert len(starts) == 2
assert len(completes) == 2
assert starts[0]["data"]["step"] == "market_analyst"
assert starts[0]["data"]["turn"] == 0
assert completes[0]["data"]["report"] == "bullish"
def test_turn_increments_for_repeat_steps(service, store):
config = RunConfig(ticker="NVDA", date="2026-03-23")
run = store.create(config)
yields = [
("bull_researcher", "bull round 1"),
("bear_researcher", "bear round 1"),
("bull_researcher", "bull round 2"),
]
with patch("api.services.run_service.TradingAgentsGraph") as MockGraph:
MockGraph.return_value = _mock_graph(yields)
events = list(service.stream_events(run.id))
bull_completes = [e for e in events
if e["event"] == "agent:complete" and e["data"]["step"] == "bull_researcher"]
assert len(bull_completes) == 2
assert bull_completes[0]["data"]["turn"] == 0
assert bull_completes[1]["data"]["turn"] == 1
def test_run_complete_emitted_with_decision(service, store):
config = RunConfig(ticker="NVDA", date="2026-03-23")
run = store.create(config)
with patch("api.services.run_service.TradingAgentsGraph") as MockGraph:
MockGraph.return_value = _mock_graph([("trader", "buy signal")], decision="SELL")
events = list(service.stream_events(run.id))
complete = next(e for e in events if e["event"] == "run:complete")
assert complete["data"]["decision"] == "SELL"
def test_store_status_set_to_complete(service, store):
config = RunConfig(ticker="NVDA", date="2026-03-23")
run = store.create(config)
with patch("api.services.run_service.TradingAgentsGraph") as MockGraph:
MockGraph.return_value = _mock_graph([])
list(service.stream_events(run.id))
assert store.get(run.id).status == RunStatus.COMPLETE
def test_error_during_stream_emits_run_error(service, store):
config = RunConfig(ticker="NVDA", date="2026-03-23")
run = store.create(config)
def bad_stream(*args, **kwargs):
yield ("market_analyst", "ok")
raise RuntimeError("LLM network error")
with patch("api.services.run_service.TradingAgentsGraph") as MockGraph:
mock = MagicMock()
mock.stream_propagate.side_effect = bad_stream
MockGraph.return_value = mock
events = list(service.stream_events(run.id))
error_events = [e for e in events if e["event"] == "run:error"]
assert len(error_events) == 1
assert "LLM network error" in error_events[0]["data"]["message"]
# Also verify the store recorded the error
assert store.get(run.id).error is not None
assert "LLM network error" in store.get(run.id).error
def test_selected_analysts_passed_to_graph(service, store):
config = RunConfig(ticker="NVDA", date="2026-03-23",
enabled_analysts=["market", "news"])
run = store.create(config)
with patch("api.services.run_service.TradingAgentsGraph") as MockGraph:
MockGraph.return_value = _mock_graph([])
list(service.stream_events(run.id))
call_kwargs = MockGraph.call_args.kwargs
assert call_kwargs.get("selected_analysts") == ["market", "news"]

23
tests/api/test_runs.py Normal file
View File

@ -0,0 +1,23 @@
import pytest
from httpx import AsyncClient, ASGITransport
from api.main import app
@pytest.mark.asyncio
async def test_create_run_returns_run_id():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/runs", json={
"ticker": "NVDA", "date": "2024-05-10"
})
assert response.status_code == 200
assert "id" in response.json()
@pytest.mark.asyncio
async def test_list_runs_empty_initially():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/runs")
assert response.status_code == 200
assert isinstance(response.json(), list)

View File

@ -0,0 +1,29 @@
import pytest
from httpx import AsyncClient, ASGITransport
from api.main import app
@pytest.mark.asyncio
async def test_get_settings_returns_defaults():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/settings")
assert response.status_code == 200
data = response.json()
assert "deep_think_llm" in data
assert "max_debate_rounds" in data
@pytest.mark.asyncio
async def test_put_settings_updates_values():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.put("/settings", json={
"deep_think_llm": "claude-opus-4-6",
"quick_think_llm": "claude-haiku-4-5-20251001",
"llm_provider": "anthropic",
"max_debate_rounds": 2,
"max_risk_discuss_rounds": 2,
})
assert response.status_code == 200
assert response.json()["deep_think_llm"] == "claude-opus-4-6"

28
tests/api/test_store.py Normal file
View File

@ -0,0 +1,28 @@
import pytest
from api.store.runs_store import RunsStore
from api.models.run import RunConfig, RunStatus
def test_create_and_get_run():
store = RunsStore()
config = RunConfig(ticker="NVDA", date="2024-05-10")
run = store.create(config)
assert run.id is not None
assert run.status == RunStatus.QUEUED
fetched = store.get(run.id)
assert fetched.ticker == "NVDA"
def test_list_runs():
store = RunsStore()
store.create(RunConfig(ticker="NVDA", date="2024-05-10"))
store.create(RunConfig(ticker="AAPL", date="2024-05-09"))
runs = store.list_all()
assert len(runs) == 2
def test_update_run_status():
store = RunsStore()
run = store.create(RunConfig(ticker="NVDA", date="2024-05-10"))
store.update_status(run.id, RunStatus.RUNNING)
assert store.get(run.id).status == RunStatus.RUNNING

View File

@ -0,0 +1,127 @@
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
from tradingagents.graph.trading_graph import TradingAgentsGraph
def _make_graph(chunks):
"""Helper: build a mocked TradingAgentsGraph whose graph.stream yields chunks."""
with patch.object(TradingAgentsGraph, '__init__', lambda self, *a, **kw: None):
ta = TradingAgentsGraph.__new__(TradingAgentsGraph)
ta.graph = MagicMock()
ta.graph.stream.return_value = iter(chunks)
ta.graph.get_state.return_value = MagicMock(next=None) # no checkpoint to resume
ta.quick_thinking_llm = MagicMock()
ta.signal_processor = MagicMock()
ta.signal_processor.process_signal.return_value = "BUY"
ta.config = {"llm_provider": "openai", "deep_think_llm": "gpt-4",
"quick_think_llm": "gpt-4-mini", "max_debate_rounds": 1,
"max_risk_discuss_rounds": 1, "results_dir": "./results"}
ta._last_decision = None
ta.selected_analysts = ["market", "news", "fundamentals", "social"]
# propagator needed by stream_propagate for initial state and graph args
ta.propagator = MagicMock()
ta.propagator.create_initial_state.return_value = {
"messages": [], "company_of_interest": "NVDA", "trade_date": "2026-03-23",
"investment_debate_state": {
"bull_history": "", "bear_history": "", "history": "",
"current_response": "", "judge_decision": "", "count": 0
},
"risk_debate_state": {
"aggressive_history": "", "conservative_history": "", "neutral_history": "",
"history": "", "latest_speaker": "", "current_aggressive_response": "",
"current_conservative_response": "", "current_neutral_response": "",
"judge_decision": "", "count": 0
},
"market_report": "", "fundamentals_report": "",
"sentiment_report": "", "news_report": "",
}
ta.propagator.get_graph_args.return_value = {
"stream_mode": "updates",
"config": {"configurable": {"thread_id": "test-thread"}, "recursion_limit": 100},
}
# _log_state writes to disk — mock it out in all tests
ta._log_state = MagicMock()
return ta
def test_yields_known_node():
ta = _make_graph([
{"Market Analyst": {"market_report": "bullish outlook"}},
])
results = list(ta.stream_propagate("NVDA", "2026-03-23"))
assert results == [("market_analyst", "bullish outlook")]
def test_skips_tool_nodes():
ta = _make_graph([
{"tools_market": {"messages": []}},
{"Market Analyst": {"market_report": "ok"}},
])
results = list(ta.stream_propagate("NVDA", "2026-03-23"))
assert len(results) == 1
assert results[0][0] == "market_analyst"
def test_skips_msg_clear_nodes():
ta = _make_graph([
{"Msg Clear Market": {}},
{"News Analyst": {"news_report": "stable"}},
])
results = list(ta.stream_propagate("NVDA", "2026-03-23"))
assert len(results) == 1
assert results[0][0] == "news_analyst"
def test_skips_unknown_nodes_with_warning(caplog):
import logging
ta = _make_graph([
{"Unknown Future Node": {"some_field": "value"}},
{"Trader": {"trader_investment_plan": "buy 100 shares"}},
])
with caplog.at_level(logging.WARNING):
results = list(ta.stream_propagate("NVDA", "2026-03-23"))
assert len(results) == 1
assert results[0][0] == "trader"
assert any("Unknown Future Node" in r.message for r in caplog.records)
def test_last_decision_set_after_exhaustion():
ta = _make_graph([
{"Risk Judge": {"risk_debate_state": {"judge_decision": "SELL signal strong"}}},
])
# graph.get_state() is called post-loop to fetch the full final snapshot
ta.graph.get_state.return_value = MagicMock(
next=None,
values={"final_trade_decision": "strong SELL signal from risk team"}
)
list(ta.stream_propagate("NVDA", "2026-03-23"))
# signal_processor returns "BUY" from mock setup; _last_decision should be set
assert ta._last_decision == "BUY"
def test_bull_researcher_extracts_bull_history():
ta = _make_graph([
{"Bull Researcher": {"investment_debate_state": {
"bull_history": "bullish case round 1", "bear_history": "",
"history": "", "current_response": "", "judge_decision": "", "count": 1
}}},
])
results = list(ta.stream_propagate("NVDA", "2026-03-23"))
assert results[0] == ("bull_researcher", "bullish case round 1")
def test_research_manager_extracts_investment_plan():
ta = _make_graph([
{"Research Manager": {"investment_plan": "Invest 20% in NVDA"}},
])
results = list(ta.stream_propagate("NVDA", "2026-03-23"))
assert results[0] == ("research_manager", "Invest 20% in NVDA")
def test_missing_field_yields_empty_string():
ta = _make_graph([
{"Market Analyst": {}}, # no market_report key
])
results = list(ta.stream_propagate("NVDA", "2026-03-23"))
assert results[0] == ("market_analyst", "")

2
tests/conftest.py Normal file
View File

@ -0,0 +1,2 @@
# This file prevents asyncio-mode issues with pytest-asyncio
# when running non-async tests

View File

@ -3,11 +3,14 @@
import os import os
import sqlite3 import sqlite3
import hashlib import hashlib
import logging
from pathlib import Path from pathlib import Path
import json import json
from datetime import date from datetime import date
from typing import Dict, Any, Tuple, List, Optional from typing import Dict, Any, Tuple, List, Optional
logger = logging.getLogger(__name__)
from langgraph.prebuilt import ToolNode from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.sqlite import SqliteSaver from langgraph.checkpoint.sqlite import SqliteSaver
@ -43,6 +46,24 @@ from .reflection import Reflector
from .signal_processing import SignalProcessor from .signal_processing import SignalProcessor
_NODE_TO_STEP = {
"Market Analyst": "market_analyst",
"News Analyst": "news_analyst",
"Fundamentals Analyst": "fundamentals_analyst",
"Social Analyst": "social_analyst",
"Bull Researcher": "bull_researcher",
"Bear Researcher": "bear_researcher",
"Research Manager": "research_manager",
"Trader": "trader",
"Aggressive Analyst": "aggressive_analyst",
"Conservative Analyst": "conservative_analyst",
"Neutral Analyst": "neutral_analyst",
"Risk Judge": "risk_judge",
}
_SKIP_NODES = {"tools_market", "tools_news", "tools_fundamentals", "tools_social"}
class TradingAgentsGraph: class TradingAgentsGraph:
"""Main class that orchestrates the trading agents framework.""" """Main class that orchestrates the trading agents framework."""
@ -286,6 +307,95 @@ class TradingAgentsGraph:
# Return decision and processed signal # Return decision and processed signal
return final_state, self.process_signal(final_state["final_trade_decision"]) return final_state, self.process_signal(final_state["final_trade_decision"])
@staticmethod
def _extract_report(step_key: str, update: dict) -> str:
"""Extract the relevant report string from a node's state update."""
extractors = {
"market_analyst": lambda u: u.get("market_report", ""),
"news_analyst": lambda u: u.get("news_report", ""),
"fundamentals_analyst": lambda u: u.get("fundamentals_report", ""),
"social_analyst": lambda u: u.get("sentiment_report", ""),
"bull_researcher": lambda u: (u.get("investment_debate_state") or {}).get("bull_history", ""),
"bear_researcher": lambda u: (u.get("investment_debate_state") or {}).get("bear_history", ""),
"research_manager": lambda u: u.get("investment_plan", ""),
"trader": lambda u: u.get("trader_investment_plan", ""),
"aggressive_analyst": lambda u: (u.get("risk_debate_state") or {}).get("current_aggressive_response", ""),
"conservative_analyst": lambda u: (u.get("risk_debate_state") or {}).get("current_conservative_response", ""),
"neutral_analyst": lambda u: (u.get("risk_debate_state") or {}).get("current_neutral_response", ""),
"risk_judge": lambda u: (u.get("risk_debate_state") or {}).get("judge_decision", ""),
}
return extractors[step_key](update) or ""
def stream_propagate(self, company_name: str, trade_date: str, thread_id=None):
"""Stream trading analysis events as each agent node completes.
Yields:
(step_key, report) tuples for each meaningful node completion.
After the generator is exhausted, self._last_decision is set to the
normalized decision string ("BUY", "SELL", or "HOLD").
"""
self.ticker = company_name
self._last_decision = None
if thread_id is None:
payload = json.dumps(
{
"ticker": company_name.strip().upper(),
"trade_date": str(trade_date),
"analysts": sorted(self.selected_analysts),
"llm_provider": self.config.get("llm_provider"),
"deep_think_llm": self.config.get("deep_think_llm"),
"quick_think_llm": self.config.get("quick_think_llm"),
"max_debate_rounds": self.config.get("max_debate_rounds"),
"max_risk_discuss_rounds": self.config.get("max_risk_discuss_rounds"),
},
sort_keys=True,
).encode()
thread_id = "ta_prog_" + hashlib.sha256(payload).hexdigest()[:24]
init_agent_state = self.propagator.create_initial_state(company_name, trade_date)
args = self.propagator.get_graph_args(thread_id=thread_id)
args["stream_mode"] = "updates" # stream per-node deltas, not full state snapshots
thread_config = {"configurable": {"thread_id": thread_id}}
snap = self.graph.get_state(thread_config)
stream_input = None if snap.next else init_agent_state
for chunk in self.graph.stream(stream_input, **args):
node_name, update = next(iter(chunk.items()))
# Filter: skip list first, then known nodes, else warn and skip
if node_name in _SKIP_NODES or node_name.startswith("Msg Clear"):
continue
if node_name not in _NODE_TO_STEP:
logger.warning("stream_propagate: unknown node '%s' — skipping", node_name)
continue
step_key = _NODE_TO_STEP[node_name]
report = TradingAgentsGraph._extract_report(step_key, update)
yield step_key, report
# Post-loop: fetch the complete final state snapshot (all fields populated).
# stream_mode="updates" gives only deltas — use get_state() for the full picture
# needed by _log_state and process_signal.
final_snap = self.graph.get_state(thread_config)
final_state = final_snap.values if hasattr(final_snap, "values") else {}
raw_signal = final_state.get("final_trade_decision", "")
try:
raw_decision = self.process_signal(raw_signal)
decision = raw_decision.strip().upper()
if decision not in {"BUY", "SELL", "HOLD"}:
logger.warning("stream_propagate: unexpected decision '%s' — defaulting to HOLD", decision)
decision = "HOLD"
except Exception:
raise # propagate to run_service for run:error handling
self._last_decision = decision
self._log_state(trade_date, final_state)
def _log_state(self, trade_date, final_state): def _log_state(self, trade_date, final_state):
"""Log the final state to a JSON file.""" """Log the final state to a JSON file."""
self.log_states_dict[str(trade_date)] = { self.log_states_dict[str(trade_date)] = {

View File

@ -6,6 +6,28 @@ from .base_client import BaseLLMClient
from .validators import validate_model from .validators import validate_model
class NormalizedChatAnthropic(ChatAnthropic):
"""ChatAnthropic with normalized content output.
Newer Claude models can return content as a list of content blocks.
This normalizes to a plain string for consistent downstream handling.
"""
def _normalize_content(self, response):
content = response.content
if isinstance(content, list):
texts = [
item.get("text", "") if isinstance(item, dict) and item.get("type") == "text"
else item if isinstance(item, str) else ""
for item in content
]
response.content = "\n".join(t for t in texts if t)
return response
def invoke(self, input, config=None, **kwargs):
return self._normalize_content(super().invoke(input, config, **kwargs))
class AnthropicClient(BaseLLMClient): class AnthropicClient(BaseLLMClient):
"""Client for Anthropic Claude models.""" """Client for Anthropic Claude models."""
@ -20,7 +42,7 @@ class AnthropicClient(BaseLLMClient):
if key in self.kwargs: if key in self.kwargs:
llm_kwargs[key] = self.kwargs[key] llm_kwargs[key] = self.kwargs[key]
return ChatAnthropic(**llm_kwargs) return NormalizedChatAnthropic(**llm_kwargs)
def validate_model(self) -> bool: def validate_model(self) -> bool:
"""Validate model for Anthropic.""" """Validate model for Anthropic."""

View File

@ -6,6 +6,28 @@ from langchain_openai import ChatOpenAI
from .base_client import BaseLLMClient from .base_client import BaseLLMClient
from .validators import validate_model from .validators import validate_model
class NormalizedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with normalized content output.
Some OpenAI-compatible providers (e.g. Responses API) can return content
as a list of content blocks. This normalizes to a plain string.
"""
def _normalize_content(self, response):
content = response.content
if isinstance(content, list):
texts = [
item.get("text", "") if isinstance(item, dict) and item.get("type") == "text"
else item if isinstance(item, str) else ""
for item in content
]
response.content = "\n".join(t for t in texts if t)
return response
def invoke(self, input, config=None, **kwargs):
return self._normalize_content(super().invoke(input, config, **kwargs))
# Kwargs forwarded from user config to ChatOpenAI # Kwargs forwarded from user config to ChatOpenAI
_PASSTHROUGH_KWARGS = ( _PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "reasoning_effort", "timeout", "max_retries", "reasoning_effort",
@ -66,7 +88,7 @@ class OpenAIClient(BaseLLMClient):
if self.provider == "openai": if self.provider == "openai":
llm_kwargs["use_responses_api"] = True llm_kwargs["use_responses_api"] = True
return ChatOpenAI(**llm_kwargs) return NormalizedChatOpenAI(**llm_kwargs)
def validate_model(self) -> bool: def validate_model(self) -> bool:
"""Validate model for the provider.""" """Validate model for the provider."""

41
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
ui/AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
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/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
ui/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

36
ui/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import Sidebar from '@/components/Sidebar'
jest.mock('next/navigation', () => ({
usePathname: () => '/new-run',
}))
test('renders all nav links', () => {
render(<Sidebar />)
expect(screen.getByText('New Run')).toBeInTheDocument()
expect(screen.getByText('History')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
})
test('marks active link for current path', () => {
render(<Sidebar />)
const newRunLink = screen.getByText('New Run').closest('a')
// Active link has vault primary colour class
expect(newRunLink?.className).toContain('text-[#adc6ff]')
})

View File

@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react'
import RunHistoryTable from '@/features/history/components/RunHistoryTable'
const runs = [
{ id: 'abc', ticker: 'NVDA', date: '2024-05-10', status: 'complete' as const,
decision: 'BUY' as const, created_at: '2026-03-23T09:00:00' },
{ id: 'def', ticker: 'AAPL', date: '2024-05-09', status: 'complete' as const,
decision: 'HOLD' as const, created_at: '2026-03-22T16:00:00' },
]
test('renders ticker and decision for each run', () => {
render(<RunHistoryTable runs={runs} />)
expect(screen.getByText('NVDA')).toBeInTheDocument()
expect(screen.getByText('BUY')).toBeInTheDocument()
expect(screen.getByText('AAPL')).toBeInTheDocument()
expect(screen.getByText('HOLD')).toBeInTheDocument()
})
test('renders View Report links for each run', () => {
render(<RunHistoryTable runs={runs} />)
const links = screen.getAllByText('View Report →')
expect(links).toHaveLength(2)
expect(links[0].closest('a')).toHaveAttribute('href', '/runs/abc')
})

View File

@ -0,0 +1,26 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import RunConfigForm from '@/features/new-run/components/RunConfigForm'
const mockPush = jest.fn()
jest.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
jest.mock('@/lib/api-client', () => ({
createRun: jest.fn().mockResolvedValue({ id: 'test123' }),
}))
test('renders ticker and date inputs', () => {
render(<RunConfigForm />)
expect(screen.getByPlaceholderText('e.g. NVDA')).toBeInTheDocument()
expect(screen.getByLabelText(/trade date/i)).toBeInTheDocument()
})
test('submitting navigates to run detail page', async () => {
render(<RunConfigForm />)
fireEvent.change(screen.getByPlaceholderText('e.g. NVDA'), {
target: { value: 'NVDA' },
})
fireEvent.change(screen.getByLabelText(/trade date/i), {
target: { value: '2024-05-10' },
})
fireEvent.click(screen.getByText(/run analysis/i))
await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/runs/test123'))
})

View File

@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import PipelineStepper from '@/features/run-detail/components/PipelineStepper'
import { AGENT_STEPS } from '@/lib/types/run'
import type { StepStatus } from '@/lib/types/agents'
function makeSteps(overrides: Partial<Record<string, StepStatus>> = {}) {
return Object.fromEntries(
AGENT_STEPS.map((s) => [s, overrides[s] ?? 'pending' as StepStatus])
) as Record<string, StepStatus>
}
test('renders 4 phase labels', () => {
render(<PipelineStepper steps={makeSteps()} />)
expect(screen.getByText('Analysts')).toBeInTheDocument()
expect(screen.getByText('Researchers')).toBeInTheDocument()
expect(screen.getByText('Trader')).toBeInTheDocument()
expect(screen.getByText('Risk')).toBeInTheDocument()
})
test('phase is done when all its steps are done', () => {
const steps = makeSteps({
market_analyst: 'done', news_analyst: 'done',
fundamentals_analyst: 'done', social_analyst: 'done',
})
const { container } = render(<PipelineStepper steps={steps} />)
// The Analysts phase dot should have done styling (text-[#adc6ff])
// We verify by checking the component renders without error and has 4 phases
expect(screen.getAllByText(/Analysts|Researchers|Trader|Risk/).length).toBeGreaterThanOrEqual(4)
})

View File

@ -0,0 +1,48 @@
import { renderHook, act } from '@testing-library/react'
import { useRunStream } from '@/features/run-detail/hooks/useRunStream'
jest.mock('@/lib/sse', () => ({
createSSEConnection: jest.fn((url: string, handlers: Record<string, (d: unknown) => void>) => {
setTimeout(() => {
// First turn of bull_researcher
handlers.onAgentStart?.({ step: 'bull_researcher', turn: 0 })
handlers.onAgentComplete?.({ step: 'bull_researcher', turn: 0, report: 'Bull case round 1' })
// Second turn of bull_researcher
handlers.onAgentStart?.({ step: 'bull_researcher', turn: 1 })
handlers.onAgentComplete?.({ step: 'bull_researcher', turn: 1, report: 'Bull case round 2' })
handlers.onRunComplete?.({ decision: 'BUY', run_id: 'abc' })
}, 0)
return jest.fn()
}),
}))
jest.mock('@/lib/api-client', () => ({
getRunStreamUrl: (id: string) => `/api/runs/${id}/stream`,
}))
test('appends multiple turns for same step', async () => {
const { result } = renderHook(() => useRunStream('abc'))
await act(async () => { await new Promise((r) => setTimeout(r, 10)) })
expect(result.current.reports['bull_researcher']).toEqual([
'Bull case round 1',
'Bull case round 2',
])
})
test('step status stays done after multiple turns', async () => {
const { result } = renderHook(() => useRunStream('abc'))
await act(async () => { await new Promise((r) => setTimeout(r, 10)) })
expect(result.current.steps['bull_researcher']).toBe('done')
})
test('verdict and status set on run:complete', async () => {
const { result } = renderHook(() => useRunStream('abc'))
await act(async () => { await new Promise((r) => setTimeout(r, 10)) })
expect(result.current.verdict).toBe('BUY')
expect(result.current.status).toBe('complete')
})
test('initial reports are empty arrays', () => {
const { result } = renderHook(() => useRunStream('abc'))
expect(result.current.reports['market_analyst']).toEqual([])
})

View File

@ -0,0 +1,40 @@
'use client'
import Link from 'next/link'
import { useRunHistory } from '@/features/history/hooks/useRunHistory'
import RunHistoryTable from '@/features/history/components/RunHistoryTable'
export default function HistoryPage() {
const { runs, loading, error } = useRunHistory()
return (
<div className="max-w-4xl animate-fade-up">
<div className="flex items-center justify-between mb-8">
<div>
<h1
className="text-2xl font-bold mb-1"
style={{ color: 'var(--text-high)', fontFamily: 'var(--font-manrope)' }}
>
Run History
</h1>
<p className="text-sm" style={{ color: 'var(--text-mid)' }}>
Comprehensive log of all agent execution cycles
</p>
</div>
<Link href="/new-run" className="btn-primary px-5 py-2.5 text-sm">
+ New Analysis
</Link>
</div>
{loading && (
<p className="text-sm" style={{ color: 'var(--text-mid)' }}>
Loading...
</p>
)}
{error && (
<p className="text-sm" style={{ color: 'var(--error)' }}>
{error}
</p>
)}
{!loading && <RunHistoryTable runs={runs} />}
</div>
)
}

View File

@ -0,0 +1,10 @@
import Sidebar from '@/components/Sidebar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen" style={{ background: 'var(--bg-base)', color: 'var(--text-high)' }}>
<Sidebar />
<main className="flex-1 p-8 overflow-auto">{children}</main>
</div>
)
}

View File

@ -0,0 +1,35 @@
import RunConfigForm from '@/features/new-run/components/RunConfigForm'
export default function NewRunPage() {
return (
<div className="max-w-[640px] animate-fade-up">
{/* Page header */}
<div className="mb-8">
<div
className="apex-label mb-3"
>
Intelligence Engine
</div>
<h1
className="text-[28px] font-bold tracking-tight mb-2"
style={{
color: 'var(--text-high)',
fontFamily: 'var(--font-manrope)',
letterSpacing: '-0.03em',
}}
>
New Analysis
</h1>
<p
className="text-sm leading-relaxed"
style={{ color: 'var(--text-mid)' }}
>
Configure a multi-agent analysis run. Your AI team will research market data,
debate investment thesis, and deliver a structured decision.
</p>
</div>
<RunConfigForm />
</div>
)
}

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function RootPage() {
redirect('/new-run')
}

View File

@ -0,0 +1,107 @@
'use client'
import { use, useEffect, useState } from 'react'
import { useRunStream } from '@/features/run-detail/hooks/useRunStream'
import PipelineStepper from '@/features/run-detail/components/PipelineStepper'
import VerdictBanner from '@/features/run-detail/components/VerdictBanner'
import PhaseTabs from '@/features/run-detail/components/PhaseTabs'
import { getRun } from '@/lib/api-client'
import type { RunSummary } from '@/lib/types/run'
const STATUS_CONFIG: Record<string, { bg: string; color: string; dot: string; label: string }> = {
connecting: { bg: 'var(--bg-elevated)', color: 'var(--text-mid)', dot: 'var(--text-low)', label: 'Connecting' },
running: { bg: 'var(--hold-bg)', color: 'var(--hold)', dot: 'var(--hold)', label: 'Running' },
complete: { bg: 'var(--buy-bg)', color: 'var(--buy)', dot: 'var(--buy)', label: 'Complete' },
error: { bg: 'var(--error-bg)', color: 'var(--error)', dot: 'var(--error)', label: 'Error' },
}
export default function RunDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const { steps, reports, verdict, status, error } = useRunStream(id)
const [run, setRun] = useState<RunSummary | null>(null)
useEffect(() => {
getRun(id).then(setRun).catch(() => {})
}, [id])
const sc = STATUS_CONFIG[status] ?? STATUS_CONFIG.connecting
return (
<div className="max-w-4xl space-y-4 animate-fade-up">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<div
className="apex-label mb-2"
>
Analysis Run
</div>
<h1
className="text-[26px] font-bold tracking-tight"
style={{
color: 'var(--text-high)',
fontFamily: 'var(--font-manrope)',
letterSpacing: '-0.03em',
}}
>
{run ? (
<>
<span style={{ color: 'var(--accent-light)' }}>{run.ticker}</span>
<span style={{ color: 'var(--text-low)', fontWeight: 400, margin: '0 8px' }}>·</span>
<span>{run.date}</span>
</>
) : (
<span style={{ color: 'var(--text-mid)' }}>Loading</span>
)}
</h1>
</div>
{/* Status badge */}
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium mt-1"
style={{
background: sc.bg,
color: sc.color,
border: `1px solid ${sc.dot}40`,
fontFamily: 'var(--font-manrope)',
}}
>
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{
background: sc.dot,
animation: status === 'running' ? 'shimmer 1.2s ease-in-out infinite' : 'none',
}}
/>
{sc.label}
</div>
</div>
{/* Pipeline */}
<PipelineStepper steps={steps} />
{/* Error */}
{error && (
<div
className="px-4 py-3 rounded-lg text-sm"
style={{
background: 'var(--error-bg)',
color: 'var(--error)',
border: '1px solid rgba(255,68,68,0.25)',
}}
>
<span className="font-medium">Error:</span> {error}
</div>
)}
{/* Verdict */}
{verdict && run && (
<VerdictBanner verdict={verdict} ticker={run.ticker} date={run.date} />
)}
{/* Phase tabs + reports */}
<PhaseTabs steps={steps} reports={reports} />
</div>
)
}

View File

@ -0,0 +1,18 @@
import SettingsForm from '@/features/settings/components/SettingsForm'
export default function SettingsPage() {
return (
<div>
<h1
className="text-2xl font-bold mb-1"
style={{ color: 'var(--text-high)', fontFamily: 'var(--font-manrope)' }}
>
Settings
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-mid)' }}>
Configure default values for new analysis runs. API keys are managed via server environment variables.
</p>
<SettingsForm />
</div>
)
}

BIN
ui/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

249
ui/app/globals.css Normal file
View File

@ -0,0 +1,249 @@
@import "tailwindcss";
/* ─── APEX Design Tokens ────────────────────────────────────────── */
:root {
/* Backgrounds */
--bg-base: #040C1A;
--bg-surface: #070F1C;
--bg-card: #0C1628;
--bg-elevated: #121E30;
--bg-hover: #182338;
--bg-active: #1E2E42;
--bg-sidebar: #030810;
/* Borders */
--border: rgba(82, 122, 196, 0.10);
--border-raised: rgba(82, 122, 196, 0.18);
--border-active: rgba(68, 128, 255, 0.40);
--border-accent: rgba(68, 128, 255, 0.60);
/* Text */
--text-high: #E0E8FF;
--text-mid: #7A8FAD;
--text-low: #354869;
--text-faint: #1C2A40;
/* Accent Blue */
--accent: #4480FF;
--accent-light: #8AAFFF;
--accent-dim: #1A3A88;
--accent-glow: rgba(68, 128, 255, 0.14);
--accent-glow2: rgba(68, 128, 255, 0.06);
/* Semantic */
--buy: #00CE68;
--buy-bg: rgba(0, 206, 104, 0.08);
--buy-ring: rgba(0, 206, 104, 0.25);
--sell: #FF3355;
--sell-bg: rgba(255, 51, 85, 0.08);
--sell-ring:rgba(255, 51, 85, 0.25);
--hold: #F59E0B;
--hold-bg: rgba(245, 158, 11, 0.08);
--hold-ring:rgba(245, 158, 11, 0.25);
--error: #FF4444;
--error-bg: rgba(255, 68, 68, 0.08);
/* Status */
--status-running: #F59E0B;
--status-done: #4480FF;
--status-pending: #1C2A40;
}
/* ─── Animations ─────────────────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes shimmer {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.9; }
}
@keyframes spin-slow {
to { transform: rotate(360deg); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); }
50% { box-shadow: 0 0 0 6px var(--accent-glow); }
}
@keyframes scan-line {
from { transform: translateX(-100%); }
to { transform: translateX(200%); }
}
/* ─── Base ───────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body {
background-color: var(--bg-base);
color: var(--text-high);
font-family: var(--font-manrope, 'Manrope'), sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
/* ─── Typography ─────────────────────────────────────────────────── */
.apex-display {
font-family: var(--font-manrope, 'Manrope'), sans-serif;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text-high);
}
.apex-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mid);
font-family: var(--font-manrope, 'Manrope'), sans-serif;
}
/* ─── Cards ──────────────────────────────────────────────────────── */
.apex-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
}
.apex-card-elevated {
background: var(--bg-elevated);
border: 1px solid var(--border-raised);
border-radius: 10px;
}
/* ─── Buttons ────────────────────────────────────────────────────── */
.btn-primary {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
font-weight: 600;
font-size: 13px;
letter-spacing: 0.01em;
border-radius: 8px;
padding: 9px 20px;
transition: background 0.15s, opacity 0.15s, box-shadow 0.15s;
font-family: var(--font-manrope, 'Manrope'), sans-serif;
cursor: pointer;
}
.btn-primary::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.12) 0%, transparent 60%);
pointer-events: none;
}
.btn-primary:hover {
background: #5590FF;
box-shadow: 0 4px 16px rgba(68,128,255,0.35);
}
.btn-primary:active { opacity: 0.85; }
.btn-primary:disabled {
opacity: 0.35;
cursor: not-allowed;
box-shadow: none;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
color: var(--text-mid);
font-weight: 500;
font-size: 13px;
border: 1px solid var(--border-raised);
border-radius: 8px;
padding: 8px 16px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
font-family: var(--font-manrope, 'Manrope'), sans-serif;
cursor: pointer;
}
.btn-secondary:hover {
background: var(--bg-hover);
color: var(--text-high);
border-color: var(--border-active);
}
/* backwards compat aliases */
.btn-ghost { @apply btn-secondary; }
/* ─── Inputs ─────────────────────────────────────────────────────── */
.vault-input {
width: 100%;
background: var(--bg-elevated);
color: var(--text-high);
font-size: 13px;
border: 1px solid var(--border-raised);
border-radius: 6px;
padding: 9px 12px;
outline: none;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
font-family: var(--font-manrope, 'Manrope'), sans-serif;
}
.vault-input::placeholder { color: var(--text-low); }
.vault-input:focus {
border-color: var(--border-active);
background: var(--bg-hover);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.vault-input option { background: var(--bg-elevated); }
/* ─── Badges ─────────────────────────────────────────────────────── */
.badge-buy {
display: inline-flex; align-items: center;
background: var(--buy-bg);
color: var(--buy);
border: 1px solid var(--buy-ring);
font-size: 11px; font-weight: 700;
padding: 2px 9px; border-radius: 99px;
letter-spacing: 0.04em;
font-family: var(--font-manrope, 'Manrope'), sans-serif;
}
.badge-sell {
display: inline-flex; align-items: center;
background: var(--sell-bg);
color: var(--sell);
border: 1px solid var(--sell-ring);
font-size: 11px; font-weight: 700;
padding: 2px 9px; border-radius: 99px;
letter-spacing: 0.04em;
font-family: var(--font-manrope, 'Manrope'), sans-serif;
}
.badge-hold {
display: inline-flex; align-items: center;
background: var(--hold-bg);
color: var(--hold);
border: 1px solid var(--hold-ring);
font-size: 11px; font-weight: 700;
padding: 2px 9px; border-radius: 99px;
letter-spacing: 0.04em;
font-family: var(--font-manrope, 'Manrope'), sans-serif;
}
/* ─── Scrollbar ──────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-raised); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-active); }
/* ─── Selection ──────────────────────────────────────────────────── */
::selection { background: var(--accent-glow); color: var(--text-high); }
/* ─── Fade animations for components ────────────────────────────── */
.animate-fade-up { animation: fadeUp 0.3s ease-out both; }
.animate-fade-in { animation: fadeIn 0.25s ease-out both; }
.animate-shimmer { animation: shimmer 1.6s ease-in-out infinite; }

35
ui/app/layout.tsx Normal file
View File

@ -0,0 +1,35 @@
import type { Metadata } from 'next'
import { Manrope, Inter } from 'next/font/google'
import './globals.css'
const manrope = Manrope({
variable: '--font-manrope',
subsets: ['latin'],
weight: ['400', '500', '600', '700', '800'],
})
const inter = Inter({
variable: '--font-inter',
subsets: ['latin'],
weight: ['400', '500', '600'],
})
export const metadata: Metadata = {
title: 'TradingAgents',
description: 'Multi-agent trading analysis',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
lang="en"
className={`${manrope.variable} ${inter.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
)
}

5
ui/app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/new-run')
}

154
ui/components/Sidebar.tsx Normal file
View File

@ -0,0 +1,154 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const NAV = [
{
href: '/new-run',
label: 'New Analysis',
icon: (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.2"/>
<path d="M7 4v3l2 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
),
},
{
href: '/history',
label: 'Run History',
icon: (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="1" y="2" width="12" height="2" rx="1" fill="currentColor" opacity=".9"/>
<rect x="1" y="6" width="8" height="2" rx="1" fill="currentColor" opacity=".6"/>
<rect x="1" y="10" width="10" height="2" rx="1" fill="currentColor" opacity=".75"/>
</svg>
),
},
{
href: '/settings',
label: 'Settings',
icon: (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="2.2" stroke="currentColor" strokeWidth="1.2"/>
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.5 2.5l1 1M10.5 10.5l1 1M11.5 2.5l-1 1M3.5 10.5l-1 1" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
),
},
]
export default function Sidebar() {
const path = usePathname()
return (
<aside
className="w-[220px] min-h-screen flex flex-col shrink-0"
style={{
background: 'var(--bg-sidebar)',
borderRight: '1px solid var(--border)',
}}
>
{/* Logo */}
<div
className="px-5 py-5"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="flex items-center gap-2.5">
{/* Geometric logo mark */}
<div
className="relative w-7 h-7 rounded-lg shrink-0 flex items-center justify-center"
style={{
background: 'var(--accent-dim)',
border: '1px solid var(--accent)',
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<polyline points="1,10 4,6 7,8 10,4 13,2" stroke="var(--accent-light)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<circle cx="13" cy="2" r="1.2" fill="var(--accent-light)"/>
</svg>
</div>
<div>
<div
className="text-sm font-bold tracking-tight leading-none"
style={{ color: 'var(--text-high)', fontFamily: 'var(--font-manrope)' }}
>
TradingAgents
</div>
<div
className="text-[9px] mt-0.5 font-medium tracking-widest uppercase"
style={{ color: 'var(--text-low)' }}
>
Multi-Agent AI
</div>
</div>
</div>
</div>
{/* Nav section */}
<div className="px-2.5 pt-4 flex-1">
<div
className="apex-label px-2.5 mb-2"
>
Navigation
</div>
<nav className="flex flex-col gap-0.5">
{NAV.map(({ href, label, icon }) => {
const active = path === href || path.startsWith(href + '/')
return (
<Link
key={href}
href={href}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] font-medium transition-all duration-150"
style={
active
? {
background: 'var(--accent-glow)',
color: 'var(--accent-light)',
borderLeft: '2px solid var(--accent)',
paddingLeft: '9px',
}
: {
color: 'var(--text-mid)',
}
}
onMouseEnter={(e) => {
if (!active) {
(e.currentTarget as HTMLElement).style.background = 'var(--bg-hover)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-high)'
}
}}
onMouseLeave={(e) => {
if (!active) {
(e.currentTarget as HTMLElement).style.background = ''
;(e.currentTarget as HTMLElement).style.color = 'var(--text-mid)'
}
}}
>
<span className="shrink-0 opacity-70">{icon}</span>
<span style={{ fontFamily: 'var(--font-manrope)' }}>{label}</span>
</Link>
)
})}
</nav>
</div>
{/* Footer */}
<div
className="px-5 py-4"
style={{ borderTop: '1px solid var(--border)' }}
>
<div className="flex items-center gap-2">
<div
className="w-1.5 h-1.5 rounded-full"
style={{ background: 'var(--buy)', boxShadow: '0 0 4px var(--buy)' }}
/>
<span
className="text-[10px] font-medium"
style={{ color: 'var(--text-low)', fontFamily: 'var(--font-manrope)' }}
>
Local · Development
</span>
</div>
</div>
</aside>
)
}

18
ui/eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@ -0,0 +1,122 @@
import Link from 'next/link'
import type { RunSummary } from '@/lib/types/run'
type Props = { runs: RunSummary[] }
function DecisionBadge({ decision }: { decision: string }) {
const lower = decision.toLowerCase()
if (lower === 'buy') return <span className="badge-buy">{decision}</span>
if (lower === 'sell') return <span className="badge-sell">{decision}</span>
if (lower === 'hold') return <span className="badge-hold">{decision}</span>
return (
<span
className="px-2.5 py-1 rounded-full text-xs font-semibold"
style={{ backgroundColor: 'var(--bg-elevated)', color: 'var(--text-mid)' }}
>
{decision}
</span>
)
}
export default function RunHistoryTable({ runs }: Props) {
if (runs.length === 0) {
return (
<div
className="rounded-lg px-6 py-12 text-center"
style={{ backgroundColor: 'var(--bg-card)' }}
>
<p className="text-sm" style={{ color: 'var(--text-low)' }}>
No runs yet. Start a new analysis.
</p>
</div>
)
}
return (
<div
className="rounded-lg overflow-hidden"
style={{ backgroundColor: 'var(--bg-card)', border: '1px solid var(--border)' }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: 'var(--bg-elevated)' }}>
<th
className="apex-label px-5 py-3 text-left"
style={{ fontFamily: 'var(--font-manrope)' }}
>
Ticker
</th>
<th
className="apex-label px-5 py-3 text-left"
style={{ fontFamily: 'var(--font-manrope)' }}
>
Date
</th>
<th
className="apex-label px-5 py-3 text-left"
style={{ fontFamily: 'var(--font-manrope)' }}
>
Decision
</th>
<th
className="apex-label px-5 py-3 text-left"
style={{ fontFamily: 'var(--font-manrope)' }}
>
Created
</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr
key={run.id}
className="transition-colors duration-100"
style={{ borderTop: '1px solid var(--border)' }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = 'var(--bg-hover)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = '')
}
>
<td
className="px-5 py-4 font-mono font-semibold tracking-wider"
style={{ color: 'var(--text-high)' }}
>
{run.ticker}
</td>
<td className="px-5 py-4" style={{ color: 'var(--text-mid)' }}>
{run.date}
</td>
<td className="px-5 py-4">
{run.decision ? (
<DecisionBadge decision={run.decision} />
) : (
<span style={{ color: 'var(--text-low)' }}></span>
)}
</td>
<td className="px-5 py-4 text-xs" style={{ color: 'var(--text-mid)' }}>
{new Date(run.created_at).toLocaleString()}
</td>
<td className="px-5 py-4">
<Link
href={`/runs/${run.id}`}
className="text-xs font-medium transition-colors"
style={{ color: 'var(--accent)' }}
onMouseEnter={(e) =>
(e.currentTarget.style.color = 'var(--accent-light)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = 'var(--accent)')
}
>
View Report
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -0,0 +1,19 @@
'use client'
import { useEffect, useState } from 'react'
import { listRuns } from '@/lib/api-client'
import type { RunSummary } from '@/lib/types/run'
export function useRunHistory() {
const [runs, setRuns] = useState<RunSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
listRuns()
.then(setRuns)
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [])
return { runs, loading, error }
}

View File

@ -0,0 +1,103 @@
'use client'
const ANALYSTS = [
{
id: 'market',
label: 'Market',
full: 'Market Analyst',
desc: 'Price action & technicals',
dot: '#4480FF',
},
{
id: 'news',
label: 'News',
full: 'News Analyst',
desc: 'Sentiment & headlines',
dot: '#A78BFA',
},
{
id: 'fundamentals',
label: 'Fundamentals',
full: 'Fundamentals Analyst',
desc: 'Earnings & financials',
dot: '#00CE68',
},
{
id: 'social',
label: 'Social',
full: 'Social Analyst',
desc: 'Social media signals',
dot: '#F59E0B',
},
]
type Props = {
selected: string[]
onChange: (selected: string[]) => void
}
export default function AnalystSelector({ selected, onChange }: Props) {
const toggle = (id: string) => {
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id])
}
return (
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
{ANALYSTS.map(({ id, label, desc, dot, full }) => {
const active = selected.includes(id)
return (
<button
key={id}
type="button"
title={full}
onClick={() => toggle(id)}
className="relative p-4 text-left transition-all duration-200"
style={{
background: active ? 'var(--bg-active)' : 'var(--bg-elevated)',
border: active ? `1px solid ${dot}40` : '1px solid var(--border)',
borderTop: active ? `2px solid ${dot}` : '1px solid var(--border)',
borderRadius: '10px',
}}
>
{/* Color dot */}
<div
className="w-2 h-2 rounded-full mb-3"
style={{
background: dot,
boxShadow: active ? `0 0 6px ${dot}80` : 'none',
}}
/>
{/* Check */}
{active && (
<div
className="absolute top-3 right-3 w-4 h-4 rounded-full flex items-center justify-center"
style={{ background: dot, opacity: 0.9 }}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
<polyline points="1.5,4 3,5.5 6.5,2" stroke="white" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
)}
<div
className="text-sm font-semibold mb-0.5"
style={{
color: active ? 'var(--text-high)' : 'var(--text-mid)',
fontFamily: 'var(--font-manrope)',
}}
>
{label}
</div>
<div
className="text-[11px] leading-snug"
style={{ color: 'var(--text-low)' }}
>
{desc}
</div>
</button>
)
})}
</div>
)
}

View File

@ -0,0 +1,279 @@
'use client'
import { useState } from 'react'
import AnalystSelector from './AnalystSelector'
import { useRunSubmit } from '../hooks/useRunSubmit'
import { DEFAULT_FORM } from '../types'
import type { NewRunFormState } from '../types'
function SectionHeader({ step, title, subtitle }: { step: number; title: string; subtitle?: string }) {
return (
<div className="flex items-start gap-3.5 mb-5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0 mt-0.5"
style={{
background: 'var(--accent-dim, #1A3A88)',
color: 'var(--accent-light)',
border: '1px solid var(--accent)',
fontFamily: 'var(--font-manrope)',
}}
>
{step}
</div>
<div>
<div
className="text-sm font-semibold"
style={{ color: 'var(--text-high)', fontFamily: 'var(--font-manrope)' }}
>
{title}
</div>
{subtitle && (
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-low)' }}>
{subtitle}
</div>
)}
</div>
</div>
)
}
export default function RunConfigForm() {
const [form, setForm] = useState<NewRunFormState>(DEFAULT_FORM)
const { submit, loading, error } = useRunSubmit()
const set = (k: keyof NewRunFormState, v: unknown) =>
setForm((f) => ({ ...f, [k]: v }))
return (
<form
onSubmit={(e) => { e.preventDefault(); submit(form) }}
className="space-y-3"
>
{/* Error banner */}
{error && (
<div
className="px-4 py-3 rounded-lg text-sm"
style={{
background: 'var(--error-bg)',
color: 'var(--error)',
border: '1px solid rgba(255,68,68,0.25)',
}}
>
{error}
</div>
)}
{/* ── Section 1: Target ──────────────────────────────────────── */}
<section
className="p-5"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
}}
>
<SectionHeader
step={1}
title="Analysis Target"
subtitle="Choose the security and date for the analysis"
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label
className="block text-[11px] font-medium mb-1.5"
style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-manrope)' }}
>
Ticker Symbol
</label>
<input
className="vault-input font-mono text-sm font-semibold tracking-wider"
placeholder="e.g. NVDA"
value={form.ticker}
onChange={(e) => set('ticker', e.target.value.toUpperCase())}
required
/>
</div>
<div>
<label
htmlFor="trade-date"
className="block text-[11px] font-medium mb-1.5"
style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-manrope)' }}
>
Trade Date
</label>
<input
id="trade-date"
type="date"
className="vault-input"
value={form.date}
onChange={(e) => set('date', e.target.value)}
required
/>
</div>
</div>
</section>
{/* ── Section 2: Model ───────────────────────────────────────── */}
<section
className="p-5"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
}}
>
<SectionHeader
step={2}
title="Model Configuration"
subtitle="Select your LLM provider and reasoning models"
/>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 sm:col-span-1">
<label
className="block text-[11px] font-medium mb-1.5"
style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-manrope)' }}
>
LLM Provider
</label>
<select
className="vault-input"
value={form.llm_provider}
onChange={(e) => set('llm_provider', e.target.value)}
>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="google">Google</option>
</select>
</div>
<div />
<div>
<label
className="block text-[11px] font-medium mb-1.5"
style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-manrope)' }}
>
Deep Think LLM
</label>
<input
className="vault-input text-[13px]"
value={form.deep_think_llm}
onChange={(e) => set('deep_think_llm', e.target.value)}
/>
</div>
<div>
<label
className="block text-[11px] font-medium mb-1.5"
style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-manrope)' }}
>
Quick Think LLM
</label>
<input
className="vault-input text-[13px]"
value={form.quick_think_llm}
onChange={(e) => set('quick_think_llm', e.target.value)}
/>
</div>
<div>
<label
className="block text-[11px] font-medium mb-1.5"
style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-manrope)' }}
>
Debate Rounds
</label>
<input
type="number"
min={1}
max={5}
className="vault-input"
value={form.max_debate_rounds}
onChange={(e) => set('max_debate_rounds', Number(e.target.value))}
/>
</div>
<div>
<label
className="block text-[11px] font-medium mb-1.5"
style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-manrope)' }}
>
Risk Discussion Rounds
</label>
<input
type="number"
min={1}
max={5}
className="vault-input"
value={form.max_risk_discuss_rounds}
onChange={(e) => set('max_risk_discuss_rounds', Number(e.target.value))}
/>
</div>
</div>
</section>
{/* ── Section 3: Analysts ────────────────────────────────────── */}
<section
className="p-5"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
}}
>
<SectionHeader
step={3}
title="Active Analysts"
subtitle="Select which AI analysts participate in this run"
/>
<AnalystSelector
selected={form.enabled_analysts}
onChange={(v) => set('enabled_analysts', v)}
/>
</section>
{/* ── Submit ──────────────────────────────────────────────────── */}
<div className="flex items-center justify-between pt-1">
<p
className="text-[11px]"
style={{ color: 'var(--text-low)' }}
>
Analysis takes 25 minutes depending on model and configuration.
</p>
<button
type="submit"
disabled={loading}
className="btn-primary"
style={{ minWidth: '150px', justifyContent: 'center' }}
>
{loading ? (
<>
<svg
width="13"
height="13"
viewBox="0 0 13 13"
fill="none"
style={{ animation: 'spin-slow 0.8s linear infinite' }}
>
<circle
cx="6.5"
cy="6.5"
r="5"
stroke="rgba(255,255,255,0.3)"
strokeWidth="1.5"
/>
<path
d="M6.5 1.5a5 5 0 0 1 5 5"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
Starting
</>
) : (
<>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<polygon points="3,2 10,6 3,10" fill="white"/>
</svg>
Run Analysis
</>
)}
</button>
</div>
</form>
)
}

View File

@ -0,0 +1,26 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createRun } from '@/lib/api-client'
import type { NewRunFormState } from '../types'
export function useRunSubmit() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (form: NewRunFormState) => {
setLoading(true)
setError(null)
try {
const run = await createRun(form)
router.push(`/runs/${run.id}`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start run')
} finally {
setLoading(false)
}
}
return { submit, loading, error }
}

View File

@ -0,0 +1,21 @@
export type NewRunFormState = {
ticker: string
date: string
llm_provider: string
deep_think_llm: string
quick_think_llm: string
max_debate_rounds: number
max_risk_discuss_rounds: number
enabled_analysts: string[]
}
export const DEFAULT_FORM: NewRunFormState = {
ticker: '',
date: '',
llm_provider: 'openai',
deep_think_llm: 'gpt-5.2',
quick_think_llm: 'gpt-5-mini',
max_debate_rounds: 1,
max_risk_discuss_rounds: 1,
enabled_analysts: ['market', 'news', 'fundamentals', 'social'],
}

View File

@ -0,0 +1,227 @@
import { AGENT_STEPS, AGENT_STEP_LABELS, STEP_PHASE } from '@/lib/types/run'
import type { AgentStep } from '@/lib/types/run'
import type { StepStatus } from '@/lib/types/agents'
type Phase = 'analysts' | 'researchers' | 'trader' | 'risk'
const MULTI_TURN_STEPS = new Set<AgentStep>([
'bull_researcher', 'bear_researcher',
'aggressive_analyst', 'conservative_analyst', 'neutral_analyst',
])
// Color accent per step for visual differentiation
const STEP_ACCENT: Record<AgentStep, string> = {
market_analyst: '#4480FF',
news_analyst: '#A78BFA',
fundamentals_analyst: '#00CE68',
social_analyst: '#F59E0B',
bull_researcher: '#00CE68',
bear_researcher: '#FF3355',
research_manager: '#4480FF',
trader: '#F59E0B',
aggressive_analyst: '#FF3355',
conservative_analyst: '#4480FF',
neutral_analyst: '#A78BFA',
risk_judge: '#F59E0B',
}
type Props = {
phase: Phase
steps: Record<AgentStep, StepStatus>
reports: Record<AgentStep, string[]>
}
export default function AnalystReports({ phase, steps, reports }: Props) {
const phaseSteps = AGENT_STEPS.filter((s) => STEP_PHASE[s] === phase)
return (
<div className="space-y-2.5">
{phaseSteps.map((step) => {
const stepStatus = steps[step] ?? 'pending'
const turns = reports[step] ?? []
const isRunning = stepStatus === 'running'
const isDone = stepStatus === 'done'
const isMulti = MULTI_TURN_STEPS.has(step)
const accent = STEP_ACCENT[step]
return (
<div key={step}>
{/* ── Completed turns ─────────────────────────────── */}
{turns.map((report, i) => (
<div
key={`${step}-${i}`}
className="p-5 mb-2"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-raised)',
borderLeft: `3px solid ${accent}`,
borderRadius: '10px',
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ background: accent, flexShrink: 0 }}
/>
<span
className="text-[13px] font-semibold"
style={{ color: 'var(--text-high)', fontFamily: 'var(--font-manrope)' }}
>
{AGENT_STEP_LABELS[step]}
</span>
{isMulti && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
background: `${accent}18`,
color: accent,
fontFamily: 'var(--font-manrope)',
}}
>
Turn {i + 1}
</span>
)}
</div>
<div
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium"
style={{
background: 'rgba(0,206,104,0.08)',
color: '#00CE68',
border: '1px solid rgba(0,206,104,0.20)',
}}
>
<div className="w-1.5 h-1.5 rounded-full" style={{ background: '#00CE68' }} />
Done
</div>
</div>
{/* Report text */}
<p
className="text-sm leading-relaxed line-clamp-5"
style={{ color: 'var(--text-mid)', lineHeight: '1.7' }}
>
{report}
</p>
</div>
))}
{/* ── Running spinner ──────────────────────────────── */}
{isRunning && (
<div
className="p-5 mb-2"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderLeft: `3px solid var(--status-running)`,
borderRadius: '10px',
opacity: 0.9,
}}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{
background: accent,
animation: 'shimmer 1s ease-in-out infinite',
flexShrink: 0,
}}
/>
<span
className="text-[13px] font-semibold"
style={{ color: 'var(--text-high)', fontFamily: 'var(--font-manrope)' }}
>
{AGENT_STEP_LABELS[step]}
</span>
{isMulti && turns.length > 0 && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
background: `${accent}18`,
color: accent,
fontFamily: 'var(--font-manrope)',
}}
>
Turn {turns.length + 1}
</span>
)}
</div>
<div
className="flex items-center gap-1.5 text-[10px] font-medium"
style={{ color: 'var(--status-running)', fontFamily: 'var(--font-manrope)' }}
>
<div
className="w-1.5 h-1.5 rounded-full"
style={{
background: 'var(--status-running)',
animation: 'shimmer 1s ease-in-out infinite',
}}
/>
Analyzing
</div>
</div>
{/* Shimmer lines */}
<div className="space-y-2">
<div
className="h-2.5 rounded animate-shimmer"
style={{ background: 'var(--border-raised)', width: '85%' }}
/>
<div
className="h-2.5 rounded animate-shimmer"
style={{ background: 'var(--border-raised)', width: '65%', animationDelay: '0.2s' }}
/>
<div
className="h-2.5 rounded animate-shimmer"
style={{ background: 'var(--border-raised)', width: '45%', animationDelay: '0.4s' }}
/>
</div>
</div>
)}
{/* ── Pending placeholder ──────────────────────────── */}
{turns.length === 0 && !isRunning && (
<div
className="p-5 mb-2"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderLeft: `3px solid var(--border)`,
borderRadius: '10px',
opacity: 0.5,
}}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ background: 'var(--text-low)', flexShrink: 0 }}
/>
<span
className="text-[13px] font-medium"
style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-manrope)' }}
>
{AGENT_STEP_LABELS[step]}
</span>
</div>
<span
className="text-[10px]"
style={{ color: 'var(--text-low)', fontFamily: 'var(--font-manrope)' }}
>
Queued
</span>
</div>
<div
className="h-2 rounded"
style={{ background: 'var(--border)', width: '40%' }}
/>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@ -0,0 +1,32 @@
type Props = { content: string; speakerA: string; speakerB: string }
export default function DebateView({ content, speakerA, speakerB }: Props) {
if (!content) {
return <p className="text-[#8c909f] text-sm">Waiting for debate to complete</p>
}
const turns = content.split(/\n{2,}/).filter(Boolean)
return (
<div className="space-y-3">
{turns.map((turn, i) => {
const isA = i % 2 === 0
return (
<div
key={i}
className={`rounded-lg p-4 ${isA ? 'bg-[#171f33]' : 'bg-[#131b2e]'}`}
>
<div
className="text-[11px] font-semibold mb-2 uppercase tracking-wider"
style={{
color: isA ? '#adc6ff' : '#c2c6d6',
fontFamily: 'var(--font-manrope)',
}}
>
{isA ? speakerA : speakerB}
</div>
<p className="text-sm text-[#c2c6d6] leading-relaxed">{turn}</p>
</div>
)
})}
</div>
)
}

View File

@ -0,0 +1,91 @@
'use client'
import { useState } from 'react'
import { AGENT_STEPS, STEP_PHASE } from '@/lib/types/run'
import type { AgentStep } from '@/lib/types/run'
import type { StepStatus } from '@/lib/types/agents'
import AnalystReports from './AnalystReports'
type Phase = 'analysts' | 'researchers' | 'trader' | 'risk'
const TABS: { label: string; phase: Phase; desc: string }[] = [
{ label: 'Analysts', phase: 'analysts', desc: '4 agents' },
{ label: 'Researchers', phase: 'researchers', desc: '3 agents' },
{ label: 'Trader', phase: 'trader', desc: '1 agent' },
{ label: 'Risk', phase: 'risk', desc: '4 agents' },
]
type Props = {
steps: Record<AgentStep, StepStatus>
reports: Record<AgentStep, string[]>
}
function getPhaseCompletion(phase: Phase, steps: Record<AgentStep, StepStatus>): number {
const phaseSteps = AGENT_STEPS.filter((s) => STEP_PHASE[s] === phase)
const done = phaseSteps.filter((s) => steps[s as AgentStep] === 'done').length
return phaseSteps.length > 0 ? Math.round((done / phaseSteps.length) * 100) : 0
}
export default function PhaseTabs({ steps, reports }: Props) {
const [active, setActive] = useState<Phase>('analysts')
return (
<div>
{/* Section label */}
<div
className="apex-label mb-3"
>
Agent Reports
</div>
{/* Tab bar */}
<div
className="flex gap-1 p-1 mb-5"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
width: 'fit-content',
}}
>
{TABS.map(({ label, phase }) => {
const isActive = active === phase
const completion = getPhaseCompletion(phase, steps)
const allDone = completion === 100
return (
<button
key={phase}
onClick={() => setActive(phase)}
className="relative flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-150"
style={
isActive
? {
background: 'var(--accent-glow)',
color: 'var(--accent-light)',
border: '1px solid var(--border-active)',
fontFamily: 'var(--font-manrope)',
}
: {
color: 'var(--text-mid)',
fontFamily: 'var(--font-manrope)',
}
}
>
{/* Done indicator dot */}
{allDone && (
<div
className="w-1.5 h-1.5 rounded-full"
style={{ background: 'var(--buy)', flexShrink: 0 }}
/>
)}
{label}
</button>
)
})}
</div>
{/* Reports */}
<AnalystReports phase={active} steps={steps} reports={reports} />
</div>
)
}

View File

@ -0,0 +1,136 @@
import { AGENT_STEPS, STEP_PHASE } from '@/lib/types/run'
import type { AgentStep } from '@/lib/types/run'
import type { StepStatus } from '@/lib/types/agents'
type Phase = 'analysts' | 'researchers' | 'trader' | 'risk'
type Props = { steps: Record<string, StepStatus> }
const PHASES: Phase[] = ['analysts', 'researchers', 'trader', 'risk']
const PHASE_LABELS: Record<Phase, string> = {
analysts: 'Analysts', researchers: 'Researchers', trader: 'Trader', risk: 'Risk',
}
const PHASE_NUMS: Record<Phase, string> = {
analysts: '01', researchers: '02', trader: '03', risk: '04',
}
function phaseStatus(phase: Phase, steps: Record<string, StepStatus>): StepStatus {
const phaseSteps = AGENT_STEPS.filter((s) => STEP_PHASE[s as AgentStep] === phase)
if (phaseSteps.every((s) => steps[s] === 'done')) return 'done'
if (phaseSteps.some((s) => steps[s] === 'running')) return 'running'
return 'pending'
}
export default function PipelineStepper({ steps }: Props) {
return (
<div
className="px-6 py-5"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
}}
>
<div
className="apex-label mb-4"
>
Pipeline
</div>
<div className="flex items-center gap-0">
{PHASES.map((phase, i) => {
const status = phaseStatus(phase, steps)
const isDone = status === 'done'
const isRunning = status === 'running'
const isPending = status === 'pending'
return (
<div key={phase} className="flex items-center flex-1 last:flex-none">
{/* Step node */}
<div className="flex flex-col items-center gap-2 relative">
{/* Circle */}
<div
className="relative w-9 h-9 rounded-full flex items-center justify-center transition-all duration-500"
style={{
background: isDone
? 'var(--accent)'
: isRunning
? 'var(--bg-elevated)'
: 'var(--bg-elevated)',
border: isDone
? '2px solid var(--accent)'
: isRunning
? '2px solid var(--status-running)'
: '1px solid var(--status-pending)',
boxShadow: isDone
? '0 0 16px var(--accent-glow)'
: isRunning
? '0 0 12px rgba(245,158,11,0.20)'
: 'none',
animation: isRunning ? 'pulse-glow 2s ease-in-out infinite' : 'none',
}}
>
{isDone ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<polyline
points="3,7 5.5,9.5 11,4"
stroke="white"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : isRunning ? (
<div
className="w-2 h-2 rounded-full"
style={{
background: 'var(--status-running)',
animation: 'shimmer 1s ease-in-out infinite',
}}
/>
) : (
<span
className="text-[10px] font-bold"
style={{ color: 'var(--text-low)', fontFamily: 'var(--font-manrope)' }}
>
{PHASE_NUMS[phase]}
</span>
)}
</div>
{/* Label */}
<span
className="text-[10px] font-medium text-center whitespace-nowrap transition-all duration-300"
style={{
color: isDone ? 'var(--accent-light)' : isRunning ? 'var(--status-running)' : 'var(--text-low)',
fontFamily: 'var(--font-manrope)',
letterSpacing: '0.04em',
}}
>
{PHASE_LABELS[phase]}
</span>
</div>
{/* Connector */}
{i < PHASES.length - 1 && (
<div
className="flex-1 h-px mx-3 transition-all duration-700 relative overflow-hidden"
style={{
background: isDone ? 'var(--accent)' : 'var(--border)',
boxShadow: isDone ? '0 0 6px var(--accent-glow)' : 'none',
marginBottom: '20px',
}}
>
{isRunning && (
<div
className="absolute inset-y-0 w-1/2 bg-gradient-to-r from-transparent via-yellow-400 to-transparent opacity-60"
style={{ animation: 'scan-line 1.5s ease-in-out infinite' }}
/>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
type Props = { content: string }
export default function TraderPanel({ content }: Props) {
if (!content) {
return <p className="text-[#8c909f] text-sm">Waiting for trader analysis</p>
}
return (
<div className="rounded-lg bg-[#171f33] p-6">
<h3
className="text-sm font-semibold text-[#adc6ff] mb-4"
style={{ fontFamily: 'var(--font-manrope)' }}
>
Investment Plan
</h3>
<p className="text-sm text-[#c2c6d6] whitespace-pre-wrap leading-relaxed">{content}</p>
</div>
)
}

View File

@ -0,0 +1,142 @@
import type { Decision } from '@/lib/types/agents'
type Props = { verdict: Decision | null; ticker: string; date: string }
const VERDICT_CONFIG: Record<Decision, {
color: string
colorBg: string
colorRing: string
label: string
sublabel: string
arrow: string
}> = {
BUY: {
color: 'var(--buy)',
colorBg: 'var(--buy-bg)',
colorRing: 'var(--buy-ring)',
label: 'BUY',
sublabel: 'Long position recommended',
arrow: '↑',
},
SELL: {
color: 'var(--sell)',
colorBg: 'var(--sell-bg)',
colorRing: 'var(--sell-ring)',
label: 'SELL',
sublabel: 'Exit position recommended',
arrow: '↓',
},
HOLD: {
color: 'var(--hold)',
colorBg: 'var(--hold-bg)',
colorRing: 'var(--hold-ring)',
label: 'HOLD',
sublabel: 'Maintain current position',
arrow: '→',
},
}
export default function VerdictBanner({ verdict, ticker, date }: Props) {
if (!verdict || !VERDICT_CONFIG[verdict]) return null
const cfg = VERDICT_CONFIG[verdict]
return (
<div
className="relative overflow-hidden animate-fade-up"
style={{
background: cfg.colorBg,
border: `1px solid ${cfg.colorRing}`,
borderRadius: '12px',
padding: '24px 28px',
}}
>
{/* Background glow */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: `radial-gradient(ellipse at 80% 50%, ${cfg.color}08 0%, transparent 70%)`,
}}
/>
<div className="relative flex items-center justify-between gap-4">
{/* Left: metadata */}
<div>
<div
className="apex-label mb-2"
>
Analysis Complete
</div>
<div
className="text-[22px] font-bold tracking-tight mb-1"
style={{
color: 'var(--text-high)',
fontFamily: 'var(--font-manrope)',
letterSpacing: '-0.03em',
}}
>
{ticker}
</div>
<div
className="text-xs font-mono"
style={{ color: 'var(--text-mid)' }}
>
{date}
</div>
</div>
{/* Divider */}
<div
className="hidden sm:block w-px self-stretch"
style={{ background: cfg.colorRing, opacity: 0.4 }}
/>
{/* Right: verdict */}
<div className="flex items-center gap-5">
<div>
<div
className="text-xs font-medium mb-1 text-right"
style={{ color: cfg.color, opacity: 0.8, fontFamily: 'var(--font-manrope)' }}
>
{cfg.sublabel}
</div>
<div
className="text-right"
style={{
color: 'var(--text-low)',
fontSize: '11px',
}}
>
AI consensus decision
</div>
</div>
{/* Big verdict */}
<div
className="flex items-center gap-2 px-5 py-3 rounded-xl"
style={{
background: `${cfg.colorRing}`,
border: `1px solid ${cfg.colorRing}`,
}}
>
<span
className="text-2xl font-bold leading-none"
style={{ color: cfg.color }}
>
{cfg.arrow}
</span>
<span
className="text-2xl font-bold tracking-tight leading-none"
style={{
color: cfg.color,
fontFamily: 'var(--font-manrope)',
letterSpacing: '0.04em',
}}
>
{cfg.label}
</span>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
'use client'
import { useEffect, useReducer } from 'react'
import { createSSEConnection } from '@/lib/sse'
import { getRunStreamUrl } from '@/lib/api-client'
import { AGENT_STEPS } from '@/lib/types/run'
import type { AgentStep } from '@/lib/types/run'
import type { RunStreamState } from '../types'
const initialState: RunStreamState = {
status: 'connecting',
steps: Object.fromEntries(AGENT_STEPS.map((s) => [s, 'pending'])) as RunStreamState['steps'],
reports: Object.fromEntries(AGENT_STEPS.map((s) => [s, []])) as RunStreamState['reports'],
verdict: null,
error: null,
}
type Action =
| { type: 'AGENT_START'; step: AgentStep; turn: number }
| { type: 'AGENT_COMPLETE'; step: AgentStep; turn: number; report: string }
| { type: 'RUN_COMPLETE'; decision: string }
| { type: 'RUN_ERROR'; message: string }
| { type: 'CONNECTED' }
function reducer(state: RunStreamState, action: Action): RunStreamState {
switch (action.type) {
case 'CONNECTED':
return { ...state, status: 'running' }
case 'AGENT_START':
// Only transition to 'running' on first turn (don't regress from 'done')
if (state.steps[action.step] !== 'pending') return state
return { ...state, steps: { ...state.steps, [action.step]: 'running' } }
case 'AGENT_COMPLETE':
return {
...state,
steps: { ...state.steps, [action.step]: 'done' },
reports: {
...state.reports,
[action.step]: [...(state.reports[action.step] ?? []), action.report],
},
}
case 'RUN_COMPLETE':
return { ...state, status: 'complete', verdict: action.decision as RunStreamState['verdict'] }
case 'RUN_ERROR':
return { ...state, status: 'error', error: action.message }
default:
return state
}
}
export function useRunStream(runId: string): RunStreamState {
const [state, dispatch] = useReducer(reducer, initialState)
useEffect(() => {
const url = getRunStreamUrl(runId)
const close = createSSEConnection(url, {
onOpen: () => dispatch({ type: 'CONNECTED' }),
onAgentStart: ({ step, turn }) =>
dispatch({ type: 'AGENT_START', step: step as AgentStep, turn }),
onAgentComplete: ({ step, turn, report }) =>
dispatch({ type: 'AGENT_COMPLETE', step: step as AgentStep, turn, report }),
onRunComplete: ({ decision }) => dispatch({ type: 'RUN_COMPLETE', decision }),
onRunError: ({ message }) => dispatch({ type: 'RUN_ERROR', message }),
})
return close
}, [runId])
return state
}

View File

@ -0,0 +1,10 @@
import type { AgentStep, RunStatus } from '@/lib/types/run'
import type { Decision, StepStatus } from '@/lib/types/agents'
export type RunStreamState = {
status: RunStatus | 'connecting'
steps: Record<AgentStep, StepStatus>
reports: Record<AgentStep, string[]>
verdict: Decision | null
error: string | null
}

View File

@ -0,0 +1,99 @@
'use client'
import { useEffect, useState } from 'react'
import { getSettings, updateSettings } from '@/lib/api-client'
import type { SettingsFormState } from '../types'
const DEFAULTS: SettingsFormState = {
deep_think_llm: 'gpt-5.2',
quick_think_llm: 'gpt-5-mini',
max_debate_rounds: 1,
max_risk_discuss_rounds: 1,
}
export default function SettingsForm() {
const [form, setForm] = useState<SettingsFormState>(DEFAULTS)
const [saved, setSaved] = useState(false)
const set = (k: keyof SettingsFormState, v: unknown) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => { getSettings().then(setForm).catch(() => {}) }, [])
const save = async (e: React.FormEvent) => {
e.preventDefault()
await updateSettings(form)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
return (
<form onSubmit={save} className="max-w-lg space-y-4">
{/* ── Model Configuration ─────────────────────────────────── */}
<section className="rounded-lg p-6 space-y-4" style={{ background: 'var(--bg-card)', border: '1px solid var(--border)' }}>
<h2
className="apex-label"
style={{ fontFamily: 'var(--font-manrope)' }}
>
Model Configuration
</h2>
{(['deep_think_llm', 'quick_think_llm'] as const).map((key) => (
<div key={key}>
<label className="block text-xs mb-2 capitalize" style={{ color: 'var(--text-mid)' }}>
{key.replace(/_/g, ' ')}
</label>
<input
className="vault-input"
value={form[key]}
onChange={(e) => set(key, e.target.value)}
/>
</div>
))}
</section>
{/* ── Analysis Parameters ─────────────────────────────────── */}
<section className="rounded-lg p-6 space-y-4" style={{ background: 'var(--bg-card)', border: '1px solid var(--border)' }}>
<h2
className="apex-label"
style={{ fontFamily: 'var(--font-manrope)' }}
>
Analysis Parameters
</h2>
{(['max_debate_rounds', 'max_risk_discuss_rounds'] as const).map((key) => (
<div key={key}>
<label className="block text-xs mb-2 capitalize" style={{ color: 'var(--text-mid)' }}>
{key.replace(/_/g, ' ')}
</label>
<input
type="number" min={1} max={5}
className="vault-input"
value={form[key]}
onChange={(e) => set(key, Number(e.target.value))}
/>
</div>
))}
</section>
{/* ── Security notice ─────────────────────────────────────── */}
<div className="rounded-lg px-5 py-3.5 text-xs leading-relaxed" style={{ background: 'var(--bg-elevated)', color: 'var(--text-mid)', border: '1px solid var(--border)' }}>
API keys and secrets are configured via <code style={{ color: 'var(--accent-light)' }} className="font-mono">.env</code> on the server and are not editable here.
</div>
{/* ── Actions ─────────────────────────────────────────────── */}
<div className="flex gap-3 justify-end pt-1">
<button
type="button"
onClick={() => setForm(DEFAULTS)}
className="btn-secondary px-4 py-2.5 text-sm"
>
Reset to Defaults
</button>
<button
type="submit"
className="btn-primary px-5 py-2.5 text-sm"
>
{saved ? '✓ Saved' : 'Save Changes'}
</button>
</div>
</form>
)
}

View File

@ -0,0 +1,6 @@
export type SettingsFormState = {
deep_think_llm: string
quick_think_llm: string
max_debate_rounds: number
max_risk_discuss_rounds: number
}

14
ui/jest.config.ts Normal file
View File

@ -0,0 +1,14 @@
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
const createJestConfig = nextJest({ dir: './' })
const config: Config = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
}
export default createJestConfig(config)

1
ui/jest.setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

7
ui/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11085
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
ui/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.3.0",
"tailwindcss": "^4",
"ts-jest": "^29.4.6",
"typescript": "^5"
}
}

7
ui/postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
ui/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
ui/public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
ui/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
ui/public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
ui/public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
ui/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}