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/
node_modules/

View File

@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.routers import runs, settings
@ -11,5 +14,5 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(runs.router, prefix="/runs", tags=["runs"])
app.include_router(settings.router, prefix="/settings", tags=["settings"])
app.include_router(runs.router, prefix="/api/runs", tags=["runs"])
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()
_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 api.models.settings import Settings
from api.services.settings_service import load_settings, save_settings
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]
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 sqlite3
import hashlib
import logging
from pathlib import Path
import json
from datetime import date
from typing import Dict, Any, Tuple, List, Optional
logger = logging.getLogger(__name__)
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.sqlite import SqliteSaver
@ -43,6 +46,24 @@ from .reflection import Reflector
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:
"""Main class that orchestrates the trading agents framework."""
@ -286,6 +307,95 @@ class TradingAgentsGraph:
# Return decision and processed signal
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):
"""Log the final state to a JSON file."""
self.log_states_dict[str(trade_date)] = {

View File

@ -6,6 +6,28 @@ from .base_client import BaseLLMClient
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):
"""Client for Anthropic Claude models."""
@ -20,7 +42,7 @@ class AnthropicClient(BaseLLMClient):
if key in self.kwargs:
llm_kwargs[key] = self.kwargs[key]
return ChatAnthropic(**llm_kwargs)
return NormalizedChatAnthropic(**llm_kwargs)
def validate_model(self) -> bool:
"""Validate model for Anthropic."""

View File

@ -6,6 +6,28 @@ from langchain_openai import ChatOpenAI
from .base_client import BaseLLMClient
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
_PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "reasoning_effort",
@ -66,7 +88,7 @@ class OpenAIClient(BaseLLMClient):
if self.provider == "openai":
llm_kwargs["use_responses_api"] = True
return ChatOpenAI(**llm_kwargs)
return NormalizedChatOpenAI(**llm_kwargs)
def validate_model(self) -> bool:
"""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"]
}