Self-hosted trading copilot. Chat with an AI that has tools for moomoo market data/trading, Ghostfolio portfolio reconciliation, TradingAgents research, and paper-only algo workflows.
Current end-to-end flow:
- Log in (single password), see your moomoo watchlist in the sidebar — or in the navigation drawer on a phone, where the same rail (watchlist + past conversations) moves behind the ☰ button.
- Ask
show NVDA daily→ real candlestick chart inline. - Ask
what's on my watchlist?→ list pulled from your real moomoo account. - Ask
add US.AAPL to my watchlist/remove US.NVDA→ mutates moomoo's watchlist. - Ask
any news on NVDA?→ Tavily-powered news cards. - Ask
show me my portfolio→ real positions + cash from your paper or live moomoo account; Ghostfolio MCP can add tracker/reconciliation context when configured. - Ask for a full ticker analysis → TradingAgents runs analysts, bull/bear debate, risk review, and a portfolio-manager verdict.
- Ask
notify me when NVDA hits 150orremind me to check NVDA earnings on Friday 9am→ an email and a push notification when it fires (see Notifications & reminders). - Manage paper algo strategies from
/algo; scheduler order placement is paper-only. go live (paper) stays disabled until the strategy is saved and its latest backtest clears the blocking checks, and the reason is printed under the button. A failed go-live, stop, or kill-switch request shows its error next to the control. Passing checks fold into one "all N checks passed" line. Below 1024px the strategy assistant opens from the chat button as a side panel (Esc closes it); order ids sit under technical details in the signals table.
- Docker Desktop (or compose v2)
- moomoo OpenD installed on the host machine and logged in, listening on
127.0.0.1:11111 - An API key for at least one online model provider: Anthropic, OpenAI, Google, DeepSeek, or OpenRouter. You add it in the app under Settings, not in
.env. - (Optional but recommended) Tavily API key for news/web search — without it, search tools surface a clean error message and the rest still works
cp .env.example .env
# Edit .env:
# APP_PASSWORD — what you type to log in (anything)
# SESSION_SECRET — at least 32 random bytes; changing it signs everyone out
# INTERNAL_BEARER — used between Nuxt and FastAPI; generate with `openssl rand -base64 32`
# ENCRYPTION_KEY — encrypts stored provider keys; generate with `openssl rand -base64 32`
# The web service refuses to start while INTERNAL_BEARER or ENCRYPTION_KEY is
# the `change-me…` example value or shorter than 32 characters.
# TAVILY_API_KEY — tvly-… key for news/web search (optional)
# POSTGRES_PORT — host port for postgres (default 5432; override if 5432 is taken)
# RESEND_*, VAPID_* — email and push for alerts/reminders (optional; see Notifications & reminders)
docker compose up -d --build
open http://localhost:3000Sign in with APP_PASSWORD, open Settings, add a model provider, and pick a chat model and a quick model (see Model providers). Then type Show me NVDA daily in the chat box. The empty chat also offers four opening prompts drawn from your watchlist, holdings, and recently triggered alerts; they redraw on every new chat and fall back to a static set when nothing is configured yet.
If the chat says "No model provider is configured", nothing has been saved in Settings yet. The link under the chat box goes there.
A sign-in lasts 30 days, then the app asks for the password again. After 10 wrong passwords from one address in 15 minutes, login answers 429 until the window passes. The count lives in memory and resets when the web container restarts. Behind a reverse proxy every visitor shares the proxy's address, so the limit applies to everyone at once. The session cookie is marked Secure when the request arrived over https (directly or with X-Forwarded-Proto: https); set SESSION_COOKIE_SECURE=true or false in .env to force it.
Settings (/settings, also reachable from the model · … badge in the header on wide screens, or the model line at the foot of the menu drawer on phones) holds the LLM configuration. Nothing about models lives in .env except ENCRYPTION_KEY.
- Providers. Add as many as you like from the online providers: Anthropic, OpenAI, Google, DeepSeek, and OpenRouter. Local model servers such as Ollama are not supported. Every provider needs an API key. Each uses its public endpoint unless you switch on Override base URL, for example to route through a company proxy. An override must be an
https://URL on a public host:localhost, private, loopback and link-local addresses, bare container names such asapi, and.localor.internalhosts are rejected. For models none of these host directly, use OpenRouter. - Keys. A key is encrypted with AES-256-GCM under
ENCRYPTION_KEYbefore it is stored. The page shows only its last four characters. Editing a provider with the key field blank keeps the stored key, unless you change the base URL or switch the override off: then the key field is required, so a stored key is never sent to a host it was not entered for. Changing or losingENCRYPTION_KEYmakes stored keys unreadable, so re-enter them if you rotate it. Until you do, chat, test connection and research runs report that the stored key can't be decrypted and point you to Settings. - Test connection. Lists the provider's models. It also sends a short request to each model the Models section currently points at this provider, saved or not, so a wrong model id for either the chat or the quick role shows up here rather than in chat. Switching a role to another provider clears its model unless that provider lists the same id. Editing a provider clears its earlier test result and model list.
- Models. Two roles. The chat model runs chat, risk reports, strategy authoring, and the research debate's deep-thinking agents. The quick model runs news angles and the debate's fast analyst passes. Each role picks a provider and a model id. The model field suggests the provider's model list and accepts any id you type.
- Removing a provider. Blocked while either role uses it. Point the role at another provider and save first.
- Upgrading from
LLM_MODELenv vars. On first start with an empty provider table, the web service importsLLM_MODEL,LLM_MODEL_QUICK, and the matching*_API_KEYfrom its environment into Settings and logs[llm] imported …. IfLLM_MODELnames a provider with no key in the environment, only the keys are imported and the log says to pick the chat and quick models in Settings. It runs once. After that the env vars are ignored and can be deleted. - Cost.
/usageprices calls for models in the built-in price table. Other models show as unpriced. Costs show to the cent; a cost under a cent shows two significant digits (e.g.$0.0042) so it doesn't read as free.
Behind a reverse proxy, long chat and research streams can sit quiet between tokens. nginx drops a proxied response after 60 s of silence by default. Raising proxy_read_timeout (for example to 600s) on the location that serves the web app is optional but avoids cut-off replies.
Settings has three tabs: models (the providers and models above), notifications, and prompts. The open tab is kept in the URL (/settings?tab=prompts).
Settings → prompts lists the instructions each model gets, grouped as Chat, Research, News, Algo and Agents debate. Click a row to open its text. A modified badge marks a prompt you have edited.
- What you can edit. Chat: the chat system prompt and the moomoo rules. Research: the risk report. News: the search angles. Algo: the strategy assistant. Agents debate: the 13 TradingAgents node prompts (the four analysts, bull and bear researchers, research manager, trader system and request, the three risk analysts, risk manager) and the confidence judge.
- When edits apply. The next chat message, report or debate run uses the saved text. A run already in progress keeps the text it started with.
- Defaults. A prompt you have not edited follows the built-in default, so it picks up future changes to it. Saving text identical to the default clears the override. reset to default (shown only on a modified prompt) asks for confirmation, then deletes your text.
- What the chat prompt does not include. The server appends a runtime block to the chat prompt on every turn, after your text: the current date and time in the timezone set under Settings → Notifications, the Ghostfolio status, recent research runs for the tickers you mention, and the slash-command directive. That block is not editable.
- Placeholders (agents debate only). Debate prompts are Python format templates. The editor shows the
{placeholders}the default uses as chips; your text may use any of them and no others. Any other literal brace must be doubled ({{or}}). Unknown placeholders and stray braces are rejected when you save, with the reason shown under the text. - Contract lines. The risk manager's output must contain
FINAL TRANSACTION PROPOSALand the confidence judge's must ask for aCONFIDENCE:line, because the debate parses them. Removing that wording shows a warning but does not block the save; the run may then fail to read a decision or a confidence score. - Limits. A prompt cannot be empty or longer than 50,000 characters.
- If the api is down. The debate prompts' defaults come from the api service. While it is unreachable, the Agents debate group shows an error with a retry and its rows cannot be opened; the other groups still work. If a debate run cannot read the overrides, it logs a warning and uses the defaults.
Price alerts and reminders are delivered by email (Resend) and Web Push to every subscribed device. Finished research runs and chat replies are push only: they never send email. While an app tab is open, a triggered price alert also shows a browser notification (the tab polls for alerts fired in the last 10 minutes).
- Price alerts. Arm one under the holdings tab of
/portfolioor in chat (notify me when NVDA hits 150). The server checks armed alerts every minute and fires each one once.- The alert email shows the name and symbol, the last price with the day change, and which condition fired and when you set the alert.
- It has a price chart of today's session in 5-minute bars (or the last 3 months of daily closes when there is no session data). The chart marks the alert threshold (amber dashed line) and the previous close (grey dotted line). A line is left out when its level is too far from the price to fit without flattening the chart.
- Below the chart: open, previous close, day range, 52-week range, volume and the distance from the threshold.
- When you hold the stock in your moomoo live account, a Your position block shows quantity, average cost, market value and unrealized P&L.
- Your note is included, and the Open research and Manage alerts buttons appear when
APP_PUBLIC_URLis set. - This data is fetched when the alert fires, with an 8-second limit per piece. A piece that fails or times out is left out of the email, and the alert still fires.
- The push notification reads like
343.46 (+1.20% today) · crossed above 340.00.
- Reminders. Schedule one under the holdings tab of
/portfolio(when, message, optional symbol) or in chat (remind me to check NVDA earnings on Friday 9am). Times are entered and shown in the timezone set in Settings → notifications, not the device's. A due time can be at most a year ahead. cancel asks for confirmation; a reminder that already fired cannot be cancelled. - Research runs (push only). When an agents debate finishes, fails or is cancelled you get its rating and confidence, with a link to the run. This covers runs started from the research page and background runs the chat starts with
research_start. A debate the chat runs inline (agents_debate) does not push on its own; the chat reply that reports it does. - Chat replies (push only). When a copilot reply finishes and you are not looking at that chat, you get a push with the chat title and the start of the reply. Tapping it opens that chat. "Not looking" means the tab is in the background, the window is not focused, the app is closed, or another chat is open. A reply you stopped does not push. A reply that failed pushes "Reply failed" with the reason. Each open tab tells the server whether it shows the chat, and repeats it every 30 seconds while it does; a tab not heard from for 60 seconds counts as gone.
Settings → notifications
- Recipient email. Blank (the default) means nothing is emailed. send test email sends one email to the saved address straight away and shows the result.
- Timezone for reminders, and on/off switches for each category (price alerts, reminders, research runs, chat replies). Each switch is labelled email + push or push only. A switched-off category is still recorded but not sent.
- Push devices. enable push on this device asks the browser for permission (only when you click it) and subscribes that browser. Every subscribed device is listed with when it last received something; the bin icon removes it after a confirmation. send test push pushes to all of them. A device the push service reports as gone is removed automatically.
- iPhone / iPad. Push works only from the Home Screen app: in Safari tap Share → Add to Home Screen, open ai·trader from the Home Screen, then enable push there. The page shows this hint when it is opened in the browser on iOS.
- Scheduler. When each check (price alerts, reminders, email/push sender) last ran. A check that has not run for 3 minutes shows not running in red, here and on the alerts and reminders panels.
How sending works. Three Nitro scheduled tasks run every minute inside the web container: alerts:evaluate, reminders:dispatch and notify:send. A triggered alert, a due reminder or a finished run writes one row to the notifications outbox in the same transaction that changes its status, keyed so the same event can never be queued twice. The producing task then starts the sender at once, so a trigger goes out within seconds; notify:send also sweeps the outbox every minute. It delivers each row by email and push and records each channel as sent, failed or skipped. A channel is skipped, not failed, when the category is push only (email shows push-only notification), its category is off, there is no recipient, its keys are unset, or no device is subscribed. A temporary failure is retried with backoff (1, 4, 16, 64 minutes) up to 5 attempts; a rejected address or key fails at once. Emails carry a Resend idempotency key, so a retry after a crash does not send twice. The alert chart is rendered to a PNG when the alert fires (by @resvg/resvg-js) and stored with the outbox row. It is attached as an inline image, so every retry sends the same image. The web, api and postgres containers restart on their own (restart: unless-stopped); after a restart the tasks pick up where they left off.
Setup (.env, then docker compose up -d --build web):
| Variable | What it does |
|---|---|
RESEND_API_KEY |
Resend API key. Unset: email is skipped. |
RESEND_DOMAIN |
A domain verified in Resend. Mail comes from ai-trader <alerts@RESEND_DOMAIN>. |
APP_PUBLIC_URL |
Public base URL for links in emails (https://trader.example.com). Unset: emails have no links. |
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY |
Web Push keys, from npx web-push generate-vapid-keys. Changing them unsubscribes every device. Unset: push is skipped. |
VAPID_SUBJECT |
Contact for the push services, e.g. mailto:you@example.com. |
PRICE_ALERTS_DISABLED |
1 stops the price-alert check; reminders and sending keep running. (PRICE_ALERTS_INTERVAL_MS is gone: every check runs once a minute.) |
Push needs the app served over HTTPS (or localhost). /sw.js, /manifest.webmanifest and the app icons are served without a login so the browser and the Home Screen can fetch them.
docker compose down # keep DB volume
docker compose down -v # also wipe DB volume (resets users / chat history)apps/
web/ # Nuxt 4 + Nuxt UI v4 + AI SDK chat orchestration
app/ # Nuxt 4 layout: components, pages, layouts
server/ # Nitro routes, middleware, chat prompt + tools
db/ # Drizzle schema + migrations
tests/ # vitest unit + playwright e2e
api/ # FastAPI wrapping moomoo OpenD
app/ # routers, services, schemas
tests/ # pytest
docker-compose.yml
┌──────────────────── docker compose ─────────────────────┐
│ web (Nuxt 4 + ai-sdk) :3000 chat • research • algo │
│ api (FastAPI) :8000 TradingAgents │
│ (LangGraph multi-agent)│
│ drizzle-migrate one-shot schema sync │
│ postgres (16-alpine) chat • runs • algo • agents │
└──┬──────────────┬──────────────────┬────────────────────┘
│ │ │
▼ ▼ ▼
moomoo OpenD LLM APIs external HTTP
(host :11111) • Anthropic • Brave / Tavily (search)
• market data • OpenAI • Yahoo Finance (fundamentals)
• watchlist • Google • Ghostfolio MCP (BYO endpoint)
• paper trade • DeepSeek └→ Ghostfolio (cross-broker)
- The Nuxt server runs
ai-sdk(plus@modelcontextprotocol/sdkfor the Ghostfolio MCP client) and proxies market data + paper-trading calls to FastAPI. - The FastAPI side embeds TradingAgents — a LangGraph multi-agent debate (analysts → bull/bear researchers → trader → risk panel → portfolio manager). Run checkpoints persist via
langgraph-checkpoint-postgres. Algo strategies/runs/signals are also written from this side via asyncpg, so both services share the Drizzle-managed schema. - Ghostfolio MCP is a remote MCP endpoint you bring yourself (set
GHOSTFOLIO_MCP_URL+ bearer); it talks to your Ghostfolio instance and gives the agent cross-broker holdings/performance/dividends tools. Leave it unset and the agent simply doesn't see theghostfolio_*tools. Only the read-only subset listed inGHOSTFOLIO_TOOL_ALLOWLIST(apps/web/server/llm/mcp.ts) is exposed to the model; the server's write tools (create/delete accounts and activities, imports, balance transfers) are never reachable from chat.
Ghostfolio mirrors the moomoo account, so the two sources overlap on purpose:
| Layer | Source | Chat tools | Page |
|---|---|---|---|
| Investments — what you own on moomoo live, with day change and P&L | moomoo OpenD | investment_portfolio, investment_performance, holdings_context |
/portfolio → holdings tab |
| Net worth — every account incl. cash and non-investment assets | Ghostfolio | portfolio_performance, ghostfolio_* reads |
/portfolio key figures and plan tab |
holdings_context reports both quantities for a symbol and flags a mismatch as a reconciliation issue rather than extra shares. Opening /portfolio records the day's snapshot of both layers (at most one automatic snapshot per UTC day; the performance card's save snapshot button adds a manual one at any time), and the equity curve refreshes when a new point lands. There is no background scheduler, so days the page is not opened have no snapshot. The net-worth snapshot is recorded only when Ghostfolio reports a total; a moomoo account total (live or paper) is never written as net worth.
/portfolio opens on four key figures (net worth, 1-day change, P&L on cost, cash) and splits the rest into tabs. The tab is kept in the URL (?tab=holdings, ?tab=plan, ?tab=risk), so a tab can be bookmarked or linked.
| Tab | What it shows |
|---|---|
| overview | A "needs attention" list (rebalance actions, a position over the concentration limit, goals that are behind) and the performance card with total return, max drawdown and 1/7/30-day change. |
| holdings | One sortable positions table. When more than one source is connected, a switch picks all accounts, moomoo live or moomoo paper. Closed positions and empty accounts are folded behind a "show N" toggle. Below 640px the table becomes a card list with a sort picker. Price alerts and reminders sit underneath, each with when the server last ran its check. |
| plan | Adjusted net worth, liabilities, monthly surplus, savings rate; allocation against target with the buy/sell amount per bucket; goals; the last five monthly snapshots and a "save today" button. |
| risk | Largest position and its weight, positions over 20% / 10%, and the correlation matrix. |
edit plan (page header, or the plan tab) opens a side panel for targets, cash reserve, debts and monthly cashflow. Targets must add up to 100% before save plan is enabled; scale to 100% rescales them proportionally. Cancel or Esc discards the edits.
Tools are defined in apps/web/server/llm/tools.ts and the routing rules in apps/web/server/llm/chat-context.ts. A unit test (tests/unit/chat-prompt-tool-names.test.ts) fails when the prompt or a slash command names a tool that no longer exists.
| Group | Tools |
|---|---|
| market | market_kline, market_snapshot, market_order_book |
| watchlist | watchlist_list, watchlist_add, watchlist_remove |
| news / web | news_pulse (stock news + the macro/sector context behind a move), search_news (topics that are not one ticker), search_web |
| broker (moomoo) | trade_accounts, trade_account_overview, trade_portfolio, trade_orders, trade_fills (today by default, pass start/end for history), trade_place_order, trade_modify_order, trade_cancel_order |
| portfolio | investment_portfolio, investment_performance, holdings_context, portfolio_performance, portfolio_mpt_analysis, convert_fx |
| research | agents_debate, research_start, research_status, research_get, investment_research, thesis_tracker, dyp_ask, value_stock, value_screen, technical_analysis |
| algo (paper only) | algo_list, algo_backtest, algo_recent_signals, algo_state, algo_kill, algo_unkill |
| alerts / reminders / usage | alert_create, alert_list, alert_cancel, reminder_create, reminder_list, reminder_cancel, usage_summary |
- Live (REAL) orders need
ALLOW_LIVE_TRADING=trueon the api and a typed confirmation phrase in the chat turn. Placement and modification both count againstMAX_DAILY_LIVE_NOTIONAL_USD; orders moomoo reports at price 0 (market orders) are valued at the last trade. - The TradingAgents pipeline stops at
AGENTS_DAILY_COST_USD_CAPper day across runs, resumes and every backtest pair. - Algo strategies run in an AST-validated sandbox: no imports beyond math/numpy/pandas/statistics, no dunder access, and no pandas/numpy file IO methods (
read_*,to_*,np.load, ...). Every backtest and every liveon_barcall runs in a child process with a wall-clock limit (ALGO_BACKTEST_TIMEOUT_SEC,ALGO_STRATEGY_TIMEOUT_SEC), so a runaway loop is killed and reported instead of stalling the api. The scheduler only ever places paper orders. A tick that fails before the strategy decides (klines, compile, position query,on_bar) is recorded as anERRORsignal. After an api restart the pyramiding count is rebuilt from the signal history, so a held position does not get a freshpyramiding_maxbudget. - When OpenD is down, each api call to it makes one connection attempt (at most 20 s) and then fails with 502, or reports
reachable: falsefor/quote/state. OpenD calls run off the event loop, so the other api routes keep answering. - Valuations are computed in the currency the company reports in. When the quote trades in another currency (an HKD-listed company reporting in CNY) the price series is converted at the current FX rate and the result says so; the card labels prices with the ISO code.
- The agent streams NDJSON chunks (
run-start,node-start,node-end,tool-call,tool-result,debate-round,risk-debate-turn,report,decision,synthesis,final-state) which the chat + research UIs parse inline.
Chat replies and research runs are generated on the server, apart from the page that asked for them. Closing the tab, reloading, or losing the network does not cut them short.
- Chat. Reopening a chat while its reply is still generating replays the reply so far and follows it to the end. After a dropped connection the page waits until it is back online, then reattaches once. If that fails too, a card under the thread shows the error with retry. An error the server answers with (no model provider configured, a server error) is not a dropped connection: the card shows it with its HTTP status. A chat takes one reply at a time. Sending is blocked while a reply is generating, and a send from a second tab puts the text back in the box and follows the running reply. Stop ends the generation on the server and keeps the partial reply, marked stopped. It also works while the chat is still waiting for the first token; on a new chat the page then opens the thread the question was saved to. Stopping from one tab updates every other tab following the reply, and opening another chat stops following the old one. A reply that ended with an error or was stopped, a reply that finished without an answer (an empty completion, the model's output limit, a content filter, or the step limit reached on a tool call), or a question left without a reply (the web container restarted mid-reply) shows the same card, saying which, with retry. An error from the provider before the first token is saved with the thread, so a reload shows it too. Retrying does not save the question twice. Deleting a chat stops its reply.
- Ghostfolio. The chat waits at most 3 s for the Ghostfolio MCP server. If the server is down, the reply goes ahead without the
ghostfolio_*tools, and the connection is retried after 60 s. - Research. Leaving or reloading
/research/<symbol>does not stop a run. Reopening the page replays its events and follows the rest, and a run started from chat or another tab shows up the same way. A symbol has at most one running run. Starting another, from the page or from chat, follows the one already running instead. A dropped stream reconnects on its own, and so does a stream that goes silent for 45 s (the server pings every 15 s). After repeated failures the page shows connection lost with reconnect, separate from the run's own status. The api sends heartbeats during long silent steps, and a run whose upstream goes quiet for 90 s ends as failed rather than hanging. A run whose start cannot reach the api ends as failed. If the web or api restarts mid-run, the run ends as failed. Cancel stops a running run; a run that finished first keeps its result. If the cancel request fails, the error shows next to the button. Resume continues a failed or cancelled run from its last checkpoint with the same options, and its events continue the same timeline. The button is disabled while the resume request is pending, and a completed run cannot be resumed.
One /research/<symbol> run flows through the TradingAgents LangGraph: four analysts pull from their own data sources, two researchers debate, the trader proposes a transaction, three risk personas debate the proposal, the portfolio manager calls it, and the decision lands as either a paper or (gated) live order on moomoo.
flowchart LR
%% ───── Data sources ─────
subgraph SRC["📡 Data sources"]
direction TB
MOOMOO["moomoo OpenD<br/>klines · watchlist"]
STOCK["stockstats<br/>50+ indicators"]
YAHOO["Yahoo Finance<br/>balance sheet · cashflow<br/>income statement<br/>fundamentals · insider txn"]
SEARCH["Brave / Tavily<br/>news · web · social"]
end
%% ───── Analyst Team ─────
subgraph ANL["🔬 Analyst Team (LangGraph nodes)"]
direction TB
MA["Market Analyst"]
FA["Fundamentals Analyst"]
NA["News Analyst"]
SA["Social Analyst"]
end
%% ───── Researcher debate ─────
subgraph RES["⚖️ Researcher Team"]
direction TB
BULL["Bullish Researcher"]
BEAR["Bearish Researcher"]
BULL <-. "debate<br/>(max_debate_rounds)" .-> BEAR
end
TRADER(["💼 Trader<br/>transaction proposal"])
%% ───── Risk debate ─────
subgraph RISK["🛡️ Risk Management Team"]
direction TB
AGG["Aggressive"]
NEU["Neutral"]
CON["Conservative"]
AGG <-. "risk debate<br/>(max_risk_discuss_rounds)" .-> NEU
NEU <-. " " .-> CON
end
MGR{{"👔 Portfolio Manager<br/>final decision"}}
%% ───── Execution + side systems ─────
subgraph EXEC["⚡ Execution"]
direction TB
PAPER["moomoo paper<br/>(SIMULATE)"]
LIVE["moomoo live<br/>(REAL — gated by<br/>daily $ cap)"]
end
DB[("Postgres<br/>langgraph checkpoints<br/>agent runs · decisions<br/>algo state · chat")]
UI{{"Nuxt 4 + ai-sdk UI<br/>NDJSON stream<br/>chat · research · algo"}}
GFMCP["Ghostfolio MCP<br/>(BYO endpoint)"]
GF["Ghostfolio<br/>cross-broker holdings"]
%% ───── Flow ─────
MOOMOO --> MA
STOCK --> MA
YAHOO --> FA
SEARCH --> NA
SEARCH --> SA
MA --> RES
FA --> RES
NA --> RES
SA --> RES
RES == "buy / sell evidence" ==> TRADER
TRADER == "transaction proposal" ==> RISK
RISK == "risk-adjusted plan" ==> MGR
MGR == "BUY / SELL / HOLD" ==> EXEC
ANL -. "reports + state" .-> DB
RES -. " " .-> DB
TRADER -. " " .-> DB
MGR -. " " .-> DB
UI <== "streams research run" ==> ANL
UI <-. "holdings tools" .-> GFMCP
GFMCP <--> GF
%% ───── Styling ─────
classDef src fill:#fff7d6,stroke:#d4b400,color:#5a4500
classDef anl fill:#fbf3df,stroke:#caa84a,color:#3a2f00
classDef bull fill:#dcfbe6,stroke:#3aa860,color:#0e3a1c
classDef bear fill:#fde0e0,stroke:#c44d4d,color:#4b1010
classDef trader fill:#ece1ff,stroke:#7c4ed1,color:#241144
classDef risk fill:#e8f0ff,stroke:#5a7bd6,color:#15224a
classDef mgr fill:#dde7ff,stroke:#3a5fcc,color:#0f1f4a
classDef exec fill:#d6ecff,stroke:#2f6fdc,color:#103366
classDef our fill:#f3f4f6,stroke:#888,color:#222
class MOOMOO,STOCK,YAHOO,SEARCH src
class MA,FA,NA,SA anl
class BULL bull
class BEAR bear
class TRADER trader
class AGG,NEU,CON risk
class MGR mgr
class PAPER,LIVE exec
class DB,UI,GFMCP,GF our
Role overview
| Role | What it does | Inputs |
|---|---|---|
| Market Analyst | Reads price + computes indicators (MACD, RSI, SMA family, …). | moomoo k-lines → stockstats |
| Fundamentals Analyst | Walks balance sheet / cashflow / income statement / insider activity. | Yahoo Finance (via Nuxt /internal proxy) |
| News Analyst | Pulls symbol-specific + global market news. | Brave (primary) / Tavily (fallback) |
| Social Analyst | Sentiment read of recent chatter around the ticker. | Brave / Tavily |
| Bull / Bear Researchers | Debate the analyst reports for max_debate_rounds turns. |
Analyst reports |
| Trader | Synthesises debate into a concrete proposal (direction, sizing rationale). | Debate transcript |
| Aggressive / Neutral / Conservative Risk | Debate the trader's proposal from three risk stances. | Proposal + reports |
| Portfolio Manager | Authorises BUY / SELL / HOLD with reasoning. | Risk debate |
| Confidence judge | Reads the whole debate and the final call, then states the probability (0–100) that the rating is right over the next 7 trading days, scored the same way the backtest scores it. A failed or unreadable reply stores no confidence rather than a placeholder. | Debate + decision |
| Execution | Places the order against SIMULATE (paper) by default; REAL is gated by MAX_DAILY_LIVE_NOTIONAL_USD. |
Manager decision → moomoo OpenD |
Each run starts fresh: agents do not carry lessons from past decisions into later runs. The only extra context is the deterministic DCF valuation, which the Research Manager and Risk Manager see before they rule.
# api: pytest with fake OpenD client (no live OpenD needed)
cd apps/api && uv run pytest
# web: vitest unit + typecheck
cd apps/web && pnpm exec vitest run && pnpm exec nuxi typecheck
# web: playwright e2e (requires the docker stack running + a chat model chosen in Settings)
cd apps/web && pnpm exec playwright testThe e2e test passes if either a chart canvas OR an inline error message appears — so it works with or without a working model key.
scripts/repro/ holds runtime checks for the streaming behaviour above (dropped chat connections, long silent debate steps, api restarts mid-run, resume). They run against the compose stack; see scripts/repro/README.md. The debate checks restart the api with AGENTS_STUB_RUN_SECONDS set, which swaps the TradingAgents graph for a stub that sleeps that long and decides hold without calling a model. Never set it in .env.
- Push subscriptions: live ticker / orderbook streaming
- Options chain viewer + screener UI
- Financial planning hub: goals, target allocation, rebalancing, liabilities, cashflow, and net-worth history
- Trade-safety evals + tool-call trace normalization (framework-free, against existing Postgres)
- Live broker trading polish: stronger approval UX, max-notional policies, audit ledger, and account allowlists