From e8c18720646f02714898970dca1ff885d9c7f887 Mon Sep 17 00:00:00 2001 From: toreleon Date: Thu, 14 May 2026 16:11:16 +0700 Subject: [PATCH 1/2] Release v0.1.3 --- CHANGELOG.md | 10 + README.md | 99 ++- ROADMAP.md | 153 ++--- docs/agent-team.md | 29 +- docs/releases/v0.1.0.md | 9 +- package.json | 6 +- scripts/fhsc-import-browser-session.ts | 144 +++++ scripts/fhsc-probe.ts | 149 +++++ src/agent/orchestrator.ts | 21 +- src/agent/team/prompts.ts | 7 +- src/agent/team/state.ts | 1 - src/agent/team/storage.ts | 23 +- src/agent/team/tools.ts | 12 +- src/broker/fhsc.ts | 838 +++++++++++++++++++++++++ src/broker/index.ts | 4 + src/broker/types.ts | 110 ++++ src/cli/args.ts | 3 + src/cli/azoth.tsx | 22 +- src/config/loader.ts | 29 +- src/risk/guardrails.ts | 5 +- src/runtime/defaultConfig.ts | 13 + src/runtime/health.ts | 4 +- src/storage/db.ts | 2 +- src/storage/schema.sql | 21 - src/storage/schemaDef.ts | 20 - src/tools/accountHistory.ts | 78 +++ src/tools/brokerConsent.ts | 50 ++ src/tools/journal.ts | 71 --- src/tools/order.ts | 79 ++- src/tools/portfolio.ts | 21 +- src/tui/App.tsx | 162 ++++- src/tui/components/FhscSetup.tsx | 293 +++++++++ src/tui/components/LlmSetup.tsx | 5 +- src/tui/components/SlashSuggest.tsx | 4 +- src/tui/components/Welcome.tsx | 3 +- src/tui/lib/cards.tsx | 32 +- src/tui/lib/journal.ts | 86 --- src/tui/lib/toolSummary.ts | 14 +- tests/broker.contract.test.ts | 28 + tests/cli.test.ts | 14 + tests/orchestrator.test.ts | 6 +- tests/orderConfirm.test.ts | 171 ++++- tests/team.test.ts | 19 +- tests/tui.test.tsx | 159 ++++- 44 files changed, 2493 insertions(+), 536 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 scripts/fhsc-import-browser-session.ts create mode 100644 scripts/fhsc-probe.ts create mode 100644 src/broker/fhsc.ts create mode 100644 src/cli/args.ts create mode 100644 src/tools/accountHistory.ts create mode 100644 src/tools/brokerConsent.ts delete mode 100644 src/tools/journal.ts create mode 100644 src/tui/components/FhscSetup.tsx delete mode 100644 src/tui/lib/journal.ts create mode 100644 tests/cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c17c2d0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# @toreleon/azoth + +## 0.1.3 + +### Patch Changes + +- Add release-sequenced roadmap documentation, CLI version commands, and the TUI `/about` command. + Add FHSC setup, read-only portfolio/account-history access, and broker approval prompts for live reads. + Remove the local journal UI/tools and keep team output in the team-run store. + Fix the packaged CLI executable bit after builds. diff --git a/README.md b/README.md index 245c55a..2827d71 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ market, built for equity research, portfolio analysis, backtesting, paper trading, and broker-aware trading workflows. It combines an interactive terminal UI, Claude Agent SDK orchestration, -Vietnam market-data tools, multi-agent stock research, local journaling, +Vietnam market-data tools, multi-agent stock research, configurable interval backtests, and optional DNSE Entrade X live broker integration. Azoth is designed for disciplined decision support across HOSE, -HNX, and UPCOM: every recommendation should be grounded in tool output, written -to a journal, and constrained by explicit autonomy and risk settings. +HNX, and UPCOM: every recommendation should be grounded in tool output and +constrained by explicit autonomy and risk settings. > Azoth is investment software, not financial advice. Live trading can place > real orders against a real account. Use advisory or paper mode until you have @@ -29,7 +29,7 @@ Latest release: [v0.1.2](docs/releases/v0.1.2.md) ![Azoth terminal UI showing team analysis, tool calls, and slash commands](assets/azoth-tui.svg) Azoth opens as a chat-first terminal workspace. Market data, team analysis, -portfolio state, journal rows, and backtest results render inline, so the +portfolio state, and backtest results render inline, so the conversation remains the primary workflow. ### TUI Examples @@ -46,12 +46,11 @@ Ask a portfolio-level question: /team Should we rotate from steel into banks this month? ``` -Check market, portfolio, journal, and backtest state: +Check market, portfolio, and backtest state: ```text /quote VCB /positions -/journal decisions 10 /backtest ``` @@ -60,26 +59,27 @@ Check market, portfolio, journal, and backtest state: - **Agent-native CLI**: run Azoth from the terminal with a rich Ink-based UI, streaming model output, tool chips, status bar, slash commands, and resumable project sessions. -- **Chat-first workflow**: market data, team analysis, journals, backtests, and +- **Chat-first workflow**: market data, team analysis, backtests, and broker state render inline in the conversation instead of a pinned dashboard. - **Automatic subagent routing**: broad portfolio questions use `team_question`; deep single-ticker recommendations use `team_analyze`; the outer agent waits for the team and summarizes role findings. - **VN market research tools**: quote, OHLCV, technical indicators, fundamentals, company news, macro indices, foreign flow, ticker discovery, - portfolio state, and decision journal. + and live portfolio state. - **Multi-agent desk**: structured analyst workflow with technical, fundamentals, news, sentiment, bull, bear, research manager, trader, risk, and portfolio roles. - **Broker-aware execution**: advisory, confirm, and auto autonomy modes with - paper broker support and DNSE Entrade X integration for live accounts. + explicit user approval before broker calls, paper broker support, and DNSE + Entrade X integration for live accounts. - **Risk controls**: position sizing limits, order notional limits, optional ticker whitelist checks, market-session checks, margin-disabled enforcement, daily-loss halt, and drawdown buy-freeze support. - **Backtesting**: replay strategy behavior with the paper broker to validate feeds, accounting, lot sizing, fees, and guardrails before using live tools. - **Local-first state**: configuration, SQLite cache, broker records, - journals, broker records, team runs, and session logs live under `~/.azoth` + team runs, and session logs live under `~/.azoth` by default. ## Quick Start @@ -123,6 +123,8 @@ Packaged CLI command: ```bash azoth +azoth --version +azoth version ``` The TUI requires an interactive terminal. In non-TTY environments, use the @@ -137,7 +139,7 @@ checks. | Agent orchestration | Claude Agent SDK, constrained MCP tool server, resumable sessions, local context replay, abortable turns. | | Team desk | Technical, fundamentals, news, sentiment, bull, bear, research manager, trader, risk, and portfolio roles. | | Market data | Quotes, OHLCV, technical indicators, fundamentals, CafeF news, macro indices, foreign flow, and ticker discovery. | -| Portfolio and journal | Broker state, positions, cash, unrealized P&L, decision journal, orders, fills, alerts. | +| Portfolio | Broker state, sub-accounts, positions, cash, market value, and unrealized P&L. | | Execution | Paper broker, optional DNSE broker, advisory/confirm/auto autonomy, human confirmation gate. | | Risk | Notional cap, concentration cap, whitelist, market session, no-margin cash check, daily-loss halt, drawdown buy freeze. | | Backtesting | Weekly team-driven replay, paper fills, fees, rejected guardrail orders, benchmark comparison, running-peak max drawdown. | @@ -164,7 +166,6 @@ Check market and portfolio state: ```text /quote VCB /positions -/journal decisions 10 ``` Run a backtest for the previous calendar week: @@ -234,11 +235,13 @@ still stream the full local team flow. | `/team ` | Run a multi-agent debate on a market or portfolio question. | | `/analyze [--rounds N]` | Run structured team analysis for one ticker. | | `/backtest [start] [end] [cash] [--interval 30m\|1h\|2h]` | Run an interval backtest and render results inline. Defaults to previous calendar week at 30-minute cadence. | -| `/journal [decisions\|orders\|fills\|alerts] [N]` | Show recent journal rows. | | `/quote ` | Request quote, technicals, and recent news for a ticker. | | `/positions` | Summarize current portfolio positions and exposures. | +| `/setup-llm` | Change LLM provider, API key, endpoint, and model after first-time setup. | +| `/setup-fhsc` | Configure FHSC broker access and switch `broker` to `fhsc`. | | `/autonomy ` | Persist the autonomy mode and rebuild tool access for new turns. | | `/health [--probe]` | Check API key, config, DB, broker state, live-trading arm flag, market session, and optionally data providers. | +| `/about` | Show version, runtime paths, broker, provider, and release references. | | `/new` | Start a new resumable session. | | `/resume [id]` | Resume the latest session or a specific session. | | `/sessions` | List recent project sessions. | @@ -251,7 +254,7 @@ Azoth stores runtime state in `~/.azoth` unless `AZOTH_HOME` is set. A fresh runtime contains: - `~/.azoth/config.yaml` - user configuration -- `~/.azoth/azoth.db` - SQLite cache, journal, broker, and run database +- `~/.azoth/azoth.db` - SQLite cache, broker, team, and run database - `~/.azoth/projects//*.jsonl` - per-project session logs Useful environment variables: @@ -264,6 +267,7 @@ Useful environment variables: | `AZOTH_ALT_SCREEN=1` | Run the TUI in the alternate screen buffer. | | `AZOTH_LIVE_TRADING=1` | Explicitly enable live trading paths. | | `DNSE_TEST_LIVE=1` | Run DNSE read-only live probes in tests. | +| `FHSC_TEST_LIVE=1` | Run FHSC read-only live probes in tests. | Default config: @@ -283,6 +287,18 @@ team: broker: paper +fhsc: + sub_account_id: "" + account_id: "" + base_url: https://api.vinasecurities.com + access_token: "" + access_key: "" + device_id: "" + user_id: "" + cust_id: "" + api_key: "" + api_secret: "" + risk: max_position_pct: 0.15 max_daily_loss_pct: 0.03 @@ -294,13 +310,17 @@ risk: Autonomy modes: - `advisory`: no order tools are exposed. Azoth recommends; the user executes. -- `confirm`: order tools are available, but each order requires CLI approval. -- `auto`: order tools run through configured guardrails before submission. +- `confirm`: broker tools are available, but each broker read/write requires + CLI approval before Azoth contacts the broker. +- `auto`: broker tools still require CLI approval before Azoth contacts the + broker; approved orders then run through configured guardrails before + submission. Broker modes: - `paper`: local paper broker backed by SQLite. - `dnse`: DNSE Entrade X / LightSpeed integration for live accounts. +- `fhsc`: Finhay Securities/FHSC read-only account integration. ## Data Sources @@ -316,7 +336,8 @@ Azoth uses public and broker APIs for Vietnam market context: - **News and disclosures**: CafeF company news, industry news, and filings. - **Macro indices**: VNINDEX, VN30, HNXINDEX, and UPCOMINDEX. - **Foreign flow**: per-ticker foreign buy, sell, net flow, and ownership. -- **Portfolio data**: local paper broker or DNSE account snapshots. +- **Portfolio data**: local paper broker, DNSE account snapshots, or FHSC + account snapshots. - **Open web context**: model WebSearch for current context not covered by the built-in market tools; responses should cite URLs and dates. @@ -328,14 +349,15 @@ reduce repeated network calls. | Integration | Purpose | | --- | --- | | Claude Agent SDK | Top-level agent orchestration, streaming, MCP tool hosting, and tool restrictions. | -| MCP tools | Azoth exposes market, portfolio, journal, team, and broker tools through a local SDK MCP server. | +| MCP tools | Azoth exposes market, portfolio, team, and broker tools through a local SDK MCP server. | | Ink | Rich terminal UI with chat, slash commands, cards, status, and keyboard handling. | -| SQLite / better-sqlite3 | Local cache, journal, broker state, orders, sessions, team runs, and backtest records. | +| SQLite / better-sqlite3 | Local cache, broker state, orders, sessions, team runs, and backtest records. | | DNSE public APIs | Market OHLCV and optional live broker adapter support. | | SSI iBoard | Public quote and reference price data. | | VNDirect Finfo | Fundamentals and valuation snapshots. | | CafeF | News, disclosures, and historical financial ratios. | | DNSE Entrade X / LightSpeed | Optional live account reads and order submission. | +| FHSC OpenAPI | Optional read-only account, portfolio, and order-history adapter. | ## Release Notes @@ -344,7 +366,7 @@ reduce repeated network calls. - [v0.1.1](docs/releases/v0.1.1.md) - pipeline-published package release for the v0.1 public baseline. - [v0.1.0](docs/releases/v0.1.0.md) - consolidated public baseline with chat - TUI, multi-agent desk, Vietnam market tools, local journaling, paper broker, + TUI, multi-agent desk, Vietnam market tools, paper broker, guardrails, backtesting, DNSE integration foundations, npm packaging, and automated release support. @@ -375,7 +397,8 @@ until the checklist below is complete. `GET https://api.dnse.com.vn/margin-service/loan-products` with the JWT and choose the correct loan product id for your equity sub-account. 4. Set `broker: dnse` in `~/.azoth/config.yaml`. -5. Set `autonomy: confirm` first, so every order prompts for approval. +5. Set `autonomy: confirm` first while testing. All broker calls prompt for + approval in both `confirm` and `auto`. 6. Run `pnpm test` and then `DNSE_TEST_LIVE=1 pnpm test` for read-only live probes. 7. Verify `broker_state` and `list_orders` return the expected cash, positions, @@ -384,7 +407,31 @@ until the checklist below is complete. 9. During market hours, place one small 100-share test order on a liquid ticker and verify the result with DNSE directly. -The first `place_order` in a live session may trigger an email OTP prompt. +The first `place_order` in a DNSE live session may trigger an email OTP prompt. + +## FHSC Account Integration + +FHSC integration is read-only by default. It supports portfolio snapshots, +recent order/fill history, cash transaction history, and dividend/rights issue +events through the account endpoints used by the FHSC web app; real order +placement is rejected until an official trading API contract is provided. + +1. Log in to `https://invest.fhsc.com.vn` in Chrome, then run + `pnpm exec tsx scripts/fhsc-import-browser-session.ts` to import the + browser-session credentials into `~/.azoth/config.yaml` without printing + secret values. +2. Alternatively, run `/setup-fhsc` in the TUI and choose either FHSC browser + session credentials or OpenAPI key/secret. The FHSC `/trade/...` portfolio + and history routes currently reject OpenAPI key/secret alone, so + browser-session credentials are the practical option for account reads. +3. Environment variables still override config for deployments that use a + secret manager: `FHSC_SUB_ACCOUNT_ID`, `FHSC_API_KEY`, `FHSC_API_SECRET`, + `FHSC_ACCESS_TOKEN`, `FHSC_ACCESS_KEY`, `FHSC_DEVICE_ID`, + `FHSC_USER_ID`, or `FHSC_CUST_ID`. +4. Optionally set `FHSC_BASE_URL`; the default is + `https://api.vinasecurities.com`. +5. Run `FHSC_TEST_LIVE=1 pnpm test -- tests/broker.contract.test.ts` to verify + read access before using the broker in Azoth. ## Backtesting @@ -417,7 +464,7 @@ src/ tui/ TUI components, hooks, cards, theme, commands agent/orchestrator.ts Agent SDK prompt, tools, sessions agent/team/ multi-agent research desk - tools/ market, portfolio, journal, broker tools + tools/ market, portfolio, broker tools data/sources/ DNSE, SSI, CafeF, VNDirect clients broker/ paper and DNSE broker implementations risk/ pre-trade guardrails @@ -440,6 +487,6 @@ Azoth is built around a few explicit constraints: - Buy, sell, or hold recommendations should include technicals, fundamentals, news, and macro context. - News citations should include source URL and publish date. -- Decisions should be persisted to the local journal with rationale and an exit - plan. -- Order placement is disabled in advisory mode and guarded in auto mode. +- Order placement is disabled in advisory mode. In confirm/auto modes, every + broker call requires user approval before Azoth contacts the broker, and + approved orders still run through guardrails. diff --git a/ROADMAP.md b/ROADMAP.md index 0d44206..e11aa56 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,7 @@ -# Azoth Roadmap +# Azoth 12-Month Product Roadmap -This roadmap describes planned product direction for Azoth. It is not a +This roadmap sequences Azoth from the current `v0.1.0` public baseline into a +daily-use Vietnam equity workflow product over May 2026-April 2027. It is not a commitment to ship every item in order; priorities may change based on broker API behavior, market data reliability, user feedback, and safety review. @@ -15,7 +16,7 @@ API behavior, market data reliability, user feedback, and safety review. ## Current Baseline -Azoth v0.1.0 provides the public baseline: +Azoth v0.1.0, released on May 4, 2026, provides the public baseline: - Chat-first Ink TUI with slash commands and resumable local sessions. - Claude Agent SDK orchestration with Azoth market, portfolio, journal, team, @@ -29,111 +30,87 @@ Azoth v0.1.0 provides the public baseline: - Optional DNSE Entrade X / LightSpeed live broker integration. - Public npm packaging and automated release workflow. -## Near Term +## Release Sequence -### Release And Install Quality +### v0.2 - Stabilization And Install Quality -- Publish `v0.1.0` as the stable public baseline. -- Keep the TUI version sourced from installed package metadata. -- Add a lightweight `azoth --version` / `azoth version` command. -- Add post-install smoke checks for packaged CLI binaries. -- Keep release docs consolidated and aligned with Changesets output. - -### Runtime Reliability - -- Move all test and runtime scratch artifacts under `~/.azoth`, `.azoth`, or - temp directories. -- Harden SQLite lifecycle handling for tests, CLI shutdown, and interrupted - sessions. -- Add safer migration coverage for existing user databases. -- Improve `/health` output for missing config, unavailable providers, and - broker-state inconsistencies. +- Ship `azoth --version`, `azoth -v`, and `azoth version`. +- Add packaged CLI smoke checks for the compiled binary. +- Harden SQLite lifecycle handling for tests, CLI shutdown, interrupted + sessions, and user database migrations. +- Improve `/health` for missing config, unavailable providers, broker-state + inconsistencies, live-trading arming, and provider probes. +- Improve first-run provider validation and error messages. +- Expand live trading setup docs with explicit DNSE read-only validation steps. -### TUI Workflow +### v0.3 - Daily Workflow Foundation -- Improve first-run setup validation and provider error messages. +- Improve compact `/quote`, `/positions`, and `/journal` output for repeated + daily use. +- Add `/about` with package version, runtime path, DB path, broker, provider, + and release references. - Add clearer session resume and session deletion flows. -- Add compact command output for repeated `/quote`, `/positions`, and - `/journal` workflows. -- Add a dedicated `/version` or `/about` card with package, runtime path, DB - path, broker, provider, and release link. - -### Documentation - -- Expand live trading setup docs with explicit DNSE read-only validation steps. -- Document the local SQLite schema at a high level. -- Add troubleshooting docs for npm install, native SQLite builds, provider - setup, and TTY requirements. -- Add example workflows for research, portfolio review, backtesting, and paper - trading. - -## Mid Term +- Add journal import/export for decisions, orders, fills, and alerts. +- Add workflow docs for morning review, watchlist review, end-of-day journaling, + paper trading, and backtesting. -### Market Data +### v0.4 - Watchlists, Alerts, And Data Trust -- Add provider health scoring and fallback diagnostics. -- Track source timestamps and freshness in rendered quote and analysis output. -- Improve ticker discovery with exchange, sector, liquidity, and watchlist - filters. -- Add richer corporate-action and disclosure context where reliable sources are - available. -- Add more cache controls for stale data, forced refresh, and provider probes. +- Add watchlist-aware ticker discovery with exchange, sector, liquidity, and + saved-list filters. +- Add provider health scoring, fallback diagnostics, source timestamps, and + freshness display in quote and analysis output. +- Add cache controls for stale data, forced refresh, and provider probes. +- Add alert rules for position size, loss limits, stale data, and market session + boundaries. +- Persist alert events locally so alerts are auditable alongside journal rows. -### Research And Team Desk +### v0.5 - Portfolio Review And Comparison -- Make analyst role configuration easier to tune from config. -- Persist full team artifacts in a more inspectable form. - Add comparison workflows for pairs, sectors, and portfolio candidates. -- Improve synthesis quality with structured evidence tables and source - timestamps. -- Add configurable model choices per role for cost and latency control. - -### Portfolio And Risk - -- Add configurable risk presets for conservative, balanced, and aggressive - operation. - Improve drawdown, realized P&L, turnover, exposure, and concentration reporting. - Add pre-trade impact previews before confirm or auto execution. -- Add alerting rules for position size, loss limits, stale data, and market - session boundaries. -- Add paper/live broker parity checks where APIs allow safe comparison. +- Add configurable risk presets for conservative, balanced, and aggressive + operation. +- Improve team synthesis with structured evidence tables and source timestamps. +- Add configurable model choices per role for cost and latency control. -### Backtesting +### v0.6 - Backtesting And Operations Depth - Add repeatable scenario files for backtests. - Store and compare backtest runs in the TUI. - Add benchmark and sector-relative analytics. - Improve fill assumptions, fee models, and rejected-order reporting. - Add export support for run summaries and journal evidence. - -## Later - -### Live Trading Operations - -- Add stronger live-trading arming workflows with explicit preflight checklists. -- Add order preview, dry-run, and emergency stop commands. -- Add reconciliation between local records and broker-reported orders/fills. -- Add richer handling for broker outages, expired sessions, and partial fills. -- Add optional notification hooks for fills, rejects, and risk halts. - -### Data And Integrations - -- Add optional import/export for journals, portfolios, and sessions. -- Support additional Vietnam-market data providers when licensing and quality - allow. -- Add optional document/context ingestion for company notes and user research. -- Add connector-style integration points without making cloud storage required. - -### Evaluation And Quality - -- Add evaluation datasets for team analysis, tool routing, and backtest - consistency. -- Add regression checks for prompts and role outputs. -- Add benchmark prompts for latency, token usage, and answer structure. -- Add more deterministic tests around risk controls and broker accounting. - -## Not Planned For Now +- Add paper/live broker parity checks where APIs allow safe comparison. +- Add order preview, dry-run, emergency stop, and stronger live-trading preflight + commands. + +## Public Interfaces + +- CLI: `azoth --version`, `azoth -v`, and `azoth version`. +- TUI: `/about`, watchlist-aware discovery flows, backtest scenario selection, + backtest run comparison, alert management, and order preview/dry-run. +- Config: risk presets, data freshness/cache controls, alert rules, watchlists, + and optional per-role model settings. +- Storage: provider health/freshness metadata, watchlists, alert rules/events, + inspectable team artifacts, backtest scenarios, and comparable backtest + summaries. + +## Validation + +- Keep every release gated by `pnpm typecheck`, `pnpm test`, `pnpm build`, and + packaged CLI smoke validation. +- Add focused tests for each new slash command, config migration, SQLite schema + migration, stale-data behavior, alert trigger, risk preset, and backtest + scenario. +- Keep DNSE live validation read-only by default; require explicit live env flags + for any broker integration checks. +- Add regression prompts for team output structure, evidence timestamps, tool + routing, and backtest consistency once `v0.5` begins. + +## Not Planned For This Roadmap - Fully autonomous live trading without explicit user arming and risk gates. - Custody of user credentials outside the local runtime or user-managed secret diff --git a/docs/agent-team.md b/docs/agent-team.md index e56e486..33de759 100644 --- a/docs/agent-team.md +++ b/docs/agent-team.md @@ -27,8 +27,8 @@ structure of a trading firm: The upstream project also emphasizes persistence and recovery through a decision log and checkpoint resume. Azoth uses the same general idea of -durable audit trails, but stores decisions, role outputs, journals, broker -state, and session logs in the local Azoth SQLite runtime. +durable runtime state, but stores role outputs, broker state, team runs, and +session logs in the local Azoth SQLite runtime. ## Azoth Team Roles @@ -45,7 +45,7 @@ Azoth's structured single-ticker analysis uses these roles: | Synthesis | Research Manager | Weighs the debate and produces a clear investment plan. | | Execution | Head Trader | Converts the research plan into rating, sizing, entry band, and exit plan. | | Control | Risk Manager | Applies concentration, macro, liquidity, market-session, and guardrail concerns. | -| Final | Portfolio Manager | Produces the final rating, allocation, rationale, and journal-ready decision. | +| Final | Portfolio Manager | Produces the final rating, allocation, rationale, and exit plan. | Azoth uses a five-tier rating scale: @@ -88,8 +88,8 @@ flowchart TD ResearchManager --> Trader["Head Trader proposal"] Trader --> Risk["Risk Manager review"] Risk --> Portfolio["Portfolio Manager final decision"] - Portfolio --> Journal["Persist team outputs and journal entry"] - Journal --> TUI["Render final answer in TUI"] + Portfolio --> Persist["Persist team outputs"] + Persist --> TUI["Render final answer in TUI"] ``` ```text @@ -99,8 +99,8 @@ single ticker -> research manager synthesizes a plan -> trader sizes the idea and proposes an entry/exit view -> risk manager approves, rejects, or adjusts sizing - -> portfolio manager writes the final decision - -> Azoth persists role outputs and journal records locally + -> portfolio manager writes the final recommendation + -> Azoth persists role outputs locally ``` For `/team `, Azoth uses a lighter question-oriented flow: @@ -251,10 +251,10 @@ flowchart LR Sentiment --> ForeignFlow["foreign_flow"] Trader --> PortfolioList["portfolio_list"] + Trader --> AccountHistory["account_history"] Trader --> Discover["discover_tickers"] - Trader --> JournalRead["journal_read"] - Risk --> PortfolioList + Risk --> AccountHistory Risk --> Macro Risk --> ForeignFlow @@ -271,9 +271,9 @@ flowchart LR | News | `ticker_news`, `macro_indices` | | Sentiment | `ticker_news`, `foreign_flow` | | Bull, Bear, Research Manager | No direct tools; synthesize prior evidence. | -| Trader | `portfolio_list`, `discover_tickers`, `journal_read` | -| Risk | `portfolio_list`, `macro_indices`, `foreign_flow` | -| Portfolio | No direct tools; final synthesis and journal-ready decision. | +| Trader | `portfolio_list`, `account_history`, `discover_tickers` | +| Risk | `portfolio_list`, `account_history`, `macro_indices`, `foreign_flow` | +| Portfolio | No direct tools; final synthesis. | This differs from a free-form agent that can call every tool at any time. The goal is to keep each role accountable for a narrow part of the decision process @@ -300,8 +300,7 @@ Azoth writes team activity to local SQLite: - Team run start and completion metadata. - Role outputs and usage details. -- Final decisions. -- Journal entries. +- Final team recommendations. - Broker orders, fills, rejects, cash, and positions. - Project session logs for TUI resume. @@ -333,7 +332,7 @@ configured. | Orchestration | LangGraph-based trading graph | Claude Agent SDK roles with local MCP tools | | Market focus | General stock-market research framework | Vietnam equities and DNSE/SSI/VNDirect/CafeF data | | UI | Interactive CLI screens | Chat-first terminal workspace | -| Persistence | Decision log and checkpoint resume | SQLite cache, journals, broker records, team runs, and session logs | +| Persistence | Decision log and checkpoint resume | SQLite cache, broker records, team runs, and session logs | | Execution | Simulated exchange flow in framework | Paper broker plus optional DNSE live broker adapter | | Safety | Research framework warning and portfolio approval | Explicit autonomy modes, broker arming, and runtime guardrails | diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md index f863c4d..e2dcc68 100644 --- a/docs/releases/v0.1.0.md +++ b/docs/releases/v0.1.0.md @@ -5,7 +5,7 @@ Release date: 2026-05-04 Azoth v0.1.0 is the first consolidated public release of the terminal-native Vietnam equity research and trading copilot. It packages the full Azoth workflow: chat-first research, multi-agent investment analysis, market-data -tools, local journaling, paper trading, backtesting, risk controls, and optional +tools, paper trading, backtesting, risk controls, and optional DNSE Entrade X live broker support. ## Highlights @@ -17,10 +17,9 @@ DNSE Entrade X live broker support. - Multi-agent research desk with technical, fundamentals, news, sentiment, bull, bear, research manager, trader, risk, and portfolio roles. - Vietnam market tooling for quotes, OHLCV, technical indicators, fundamentals, - CafeF news, macro indices, foreign flow, ticker discovery, portfolio state, - and decision journals. + CafeF news, macro indices, foreign flow, ticker discovery, and portfolio state. - Chat-first workflows for `/team`, `/analyze`, `/quote`, `/positions`, - `/journal`, `/backtest`, `/health`, and `/autonomy`. + `/backtest`, `/health`, and `/autonomy`. - Broker-aware execution with advisory, confirm, and auto autonomy modes. - Paper broker with persistent SQLite cash, positions, orders, fills, and rejected-order records. @@ -31,7 +30,7 @@ DNSE Entrade X live broker support. - Team-driven backtesting with a built-in replay strategy, paper fills, fees, guardrail rejections, benchmark comparison, and max-drawdown accounting. - Local-first runtime state under `~/.azoth` for config, SQLite database, - market cache, journals, broker records, team runs, and session logs. + market cache, broker records, team runs, and session logs. - Public npm package support for `npx @toreleon/azoth` and global CLI installs. ## Package Command diff --git a/package.json b/package.json index 57411cb..dd2a5c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@toreleon/azoth", - "version": "0.1.2", + "version": "0.1.3", "description": "AI stock trading assistant CLI for Vietnam equity research, portfolio analysis, backtesting, paper trading, and DNSE broker workflows", "type": "module", "license": "MIT", @@ -36,16 +36,18 @@ "dist/src", "assets", "docs", + "ROADMAP.md", "README.md", "package.json" ], "scripts": { "azoth": "tsx src/cli/azoth.tsx", - "build": "rm -rf dist && tsc -p tsconfig.json", + "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/src/cli/azoth.js", "changeset": "changeset", "changeset:version": "changeset version", "prepack": "pnpm build", "release": "pnpm build && pnpm publish --access public --provenance", + "smoke:packaged": "pnpm build && node dist/src/cli/azoth.js --version", "start": "node dist/src/cli/azoth.js", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/scripts/fhsc-import-browser-session.ts b/scripts/fhsc-import-browser-session.ts new file mode 100644 index 0000000..d2ee1cd --- /dev/null +++ b/scripts/fhsc-import-browser-session.ts @@ -0,0 +1,144 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { loadConfig, updateConfig } from "../src/config/loader.js"; +import { azothPaths } from "../src/runtime/paths.js"; + +const ORIGIN = "invest.fhsc.com.vn"; +const DEFAULT_BASE = "https://api.vinasecurities.com"; + +interface Candidate { + profile: string; + file: string; + mtimeMs: number; + accessToken?: string; + accessKey?: string; + deviceId?: string; + userId?: string; + custId?: string; +} + +function profileDirs(): Array<{ profile: string; dir: string }> { + const home = process.env.HOME ?? ""; + const roots = [ + join(home, "Library/Application Support/Google/Chrome"), + join(home, "Library/Application Support/Chromium"), + join(home, "Library/Application Support/BraveSoftware/Brave-Browser"), + join(home, "Library/Application Support/Microsoft Edge"), + ]; + const dirs: Array<{ profile: string; dir: string }> = []; + for (const root of roots) { + if (!existsSync(root)) continue; + for (const profile of readdirSync(root)) { + if (!/^(Default|Profile \d+)$/i.test(profile)) continue; + const dir = join(root, profile, "Local Storage/leveldb"); + if (existsSync(dir)) dirs.push({ profile: `${root.split("/").at(-1) ?? "browser"} ${profile}`, dir }); + } + } + return dirs; +} + +function printable(value: string): string { + return value.replace(/[^\x20-\x7e]+/g, " "); +} + +function tokensAfter(window: string, key: string): string[] { + const i = window.indexOf(key); + if (i < 0) return []; + const tail = printable(window.slice(i + key.length, i + key.length + 600)); + return tail.match(/[A-Za-z0-9._:-]{3,}/g) ?? []; +} + +function rejectToken(token: string): boolean { + return ( + token.includes(ORIGIN) || + token.startsWith("https:") || + token.startsWith("_https:") || + ["access_key", "access_token", "device_id", "cust_id", "persist:root"].includes(token) + ); +} + +function firstTokenAfter(window: string, key: string): string | undefined { + return tokensAfter(window, key).find((token) => !rejectToken(token)); +} + +function extractFromWindow(window: string): Omit { + const accessToken = window.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/)?.[0]; + const accessKey = firstTokenAfter(window, "access_key") ?? firstTokenAfter(window, "accessKey"); + const deviceId = firstTokenAfter(window, "device_id") ?? firstTokenAfter(window, "deviceId"); + const userId = tokensAfter(window, "user_id").find((token) => /^\d{3,}$/.test(token)); + const custId = tokensAfter(window, "cust_id").find((token) => /^\d{6,}$/.test(token)); + return { accessToken, accessKey, deviceId, userId, custId }; +} + +function candidatesFromFile(profile: string, file: string): Candidate[] { + const buf = readFileSync(file); + const text = buf.toString("latin1"); + const stat = statSync(file); + const candidates: Candidate[] = []; + let index = 0; + while ((index = text.indexOf(ORIGIN, index)) >= 0) { + const window = text.slice(Math.max(0, index - 250), index + 2_500); + const found = extractFromWindow(window); + if (found.accessToken || found.accessKey || found.deviceId) { + candidates.push({ profile, file, mtimeMs: stat.mtimeMs, ...found }); + } + index += ORIGIN.length; + } + return candidates; +} + +function score(candidate: Candidate): number { + return ( + (candidate.accessToken ? 4 : 0) + + (candidate.accessKey ? 4 : 0) + + (candidate.deviceId ? 2 : 0) + + (candidate.custId ? 1 : 0) + ); +} + +function bestCandidate(): Candidate | undefined { + const candidates: Candidate[] = []; + for (const { profile, dir } of profileDirs()) { + for (const name of readdirSync(dir)) { + if (!/\.(ldb|log)$/i.test(name)) continue; + candidates.push(...candidatesFromFile(profile, join(dir, name))); + } + } + return candidates + .filter((candidate) => candidate.accessToken && candidate.accessKey) + .sort((a, b) => score(b) - score(a) || b.mtimeMs - a.mtimeMs)[0]; +} + +function main() { + const candidate = bestCandidate(); + if (!candidate?.accessToken || !candidate.accessKey) { + console.error( + `No FHSC browser session found. Log in to https://${ORIGIN} in Chrome, then rerun this command.`, + ); + process.exitCode = 1; + return; + } + + const current = loadConfig(); + updateConfig({ + broker: "fhsc", + fhsc: { + ...current.fhsc, + base_url: current.fhsc.base_url.trim() || DEFAULT_BASE, + access_token: candidate.accessToken, + access_key: candidate.accessKey, + device_id: candidate.deviceId ?? current.fhsc.device_id, + user_id: candidate.userId ?? current.fhsc.user_id, + cust_id: candidate.custId ?? current.fhsc.cust_id, + }, + }); + + const fields = ["access_token", "access_key"]; + if (candidate.deviceId) fields.push("device_id"); + if (candidate.userId) fields.push("user_id"); + if (candidate.custId) fields.push("cust_id"); + console.log(`Imported FHSC browser session from ${candidate.profile}.`); + console.log(`Updated ${azothPaths().config} (${fields.join(", ")}). Secret values were not printed.`); +} + +main(); diff --git a/scripts/fhsc-probe.ts b/scripts/fhsc-probe.ts new file mode 100644 index 0000000..19f7707 --- /dev/null +++ b/scripts/fhsc-probe.ts @@ -0,0 +1,149 @@ +import { loadConfig } from "../src/config/loader.js"; + +const LEGACY_BASE = "https://api.finhay.com.vn/gw"; +const CURRENT_BASE = "https://api.vinasecurities.com"; + +function normalizeBaseUrl(value: string): string { + const trimmed = value.trim().replace(/\/+$/, ""); + return trimmed === LEGACY_BASE ? CURRENT_BASE : trimmed; +} + +function todayIso() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; +} + +function redactBody(text: string): string { + return text + .replace(/ak_[A-Za-z0-9]+/g, "ak_REDACTED") + .replace(/[A-Fa-f0-9]{32,}/g, "HEX_REDACTED") + .slice(0, 360) + .replace(/\s+/g, " ") + .trim(); +} + +function headerVariants(apiKey: string, apiSecret: string, accessToken: string, accessKey: string, deviceId: string) { + const common = { + accept: "application/json", + "accept-language": "vi", + "content-type": "application/json", + "device-type": "WEB", + "x-channel": "ONLINE", + }; + const variants: Array<{ name: string; headers: Record }> = []; + if (accessToken && accessKey) { + variants.push({ + name: "browser session access_token + access_key", + headers: { + ...common, + authorization: `Bearer ${accessToken}`, + "x-access-key": accessKey, + ...(deviceId ? { "device-id": deviceId } : {}), + }, + }); + } + if (apiKey && apiSecret) { + variants.push( + { name: "x-api-key + x-api-secret", headers: { ...common, "x-api-key": apiKey, "x-api-secret": apiSecret } }, + { name: "x-access-key + x-api-secret", headers: { ...common, "x-access-key": apiKey, "x-api-secret": apiSecret } }, + { name: "x-access-key + x-secret-key", headers: { ...common, "x-access-key": apiKey, "x-secret-key": apiSecret } }, + { name: "api-key + secret-key", headers: { ...common, "api-key": apiKey, "secret-key": apiSecret } }, + { name: "bearer api key + x-api-secret", headers: { ...common, authorization: `Bearer ${apiKey}`, "x-api-secret": apiSecret } }, + { + name: "basic api key:secret", + headers: { + ...common, + authorization: `Basic ${Buffer.from(`${apiKey}:${apiSecret}`, "utf8").toString("base64")}`, + }, + }, + ); + } + if (accessToken) { + variants.push({ name: "bearer access token", headers: { ...common, authorization: `Bearer ${accessToken}` } }); + } + return variants; +} + +async function probe(url: string, headers: Record) { + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), 10_000); + try { + const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal }); + const body = await res.text().catch(() => ""); + const contentType = res.headers.get("content-type") ?? ""; + return { + status: res.status, + contentType, + body: redactBody(body), + }; + } catch (e) { + return { + status: 0, + contentType: "", + body: (e as Error).message, + }; + } finally { + clearTimeout(timeout); + } +} + +async function main() { + const cfg = loadConfig(); + const fhsc = cfg.fhsc; + const subAccountId = process.env.FHSC_SUB_ACCOUNT_ID?.trim() || fhsc.sub_account_id.trim(); + const accountId = process.env.FHSC_ACCOUNT_ID?.trim() || fhsc.account_id.trim() || subAccountId; + const apiKey = process.env.FHSC_API_KEY?.trim() || fhsc.api_key.trim(); + const apiSecret = process.env.FHSC_API_SECRET?.trim() || fhsc.api_secret.trim(); + const accessToken = process.env.FHSC_ACCESS_TOKEN?.trim() || fhsc.access_token.trim(); + const accessKey = process.env.FHSC_ACCESS_KEY?.trim() || fhsc.access_key.trim(); + const deviceId = process.env.FHSC_DEVICE_ID?.trim() || fhsc.device_id.trim(); + const userId = process.env.FHSC_USER_ID?.trim() || fhsc.user_id.trim(); + const configuredBase = normalizeBaseUrl(process.env.FHSC_BASE_URL?.trim() || fhsc.base_url.trim() || CURRENT_BASE); + + if (!subAccountId) throw new Error("Missing FHSC sub-account id in config or FHSC_SUB_ACCOUNT_ID."); + if (!(apiKey && apiSecret) && !(accessToken && accessKey)) { + throw new Error("Missing FHSC auth in config/env: api_key + api_secret, or access_token + access_key."); + } + + const bases = Array.from(new Set([configuredBase, CURRENT_BASE, LEGACY_BASE])); + const day = todayIso(); + const paths = [ + { name: "portfolio", path: `/trade/v2/sub-accounts/${encodeURIComponent(subAccountId)}/portfolio` }, + { name: "sub-account", path: `/trade/sub-accounts/${encodeURIComponent(subAccountId)}` }, + { + name: "order-history", + path: `/trade/accounts/${encodeURIComponent(accountId)}/order-history?from_date=${day}&to_date=${day}&page=1`, + }, + ...(userId + ? [ + { + name: "payment sub-accounts", + path: `/payments/v2/users/${encodeURIComponent(userId)}/sub-account`, + }, + ] + : []), + { name: "openapi portfolio guess", path: `/openapi/v1/sub-accounts/${encodeURIComponent(subAccountId)}/portfolio` }, + { name: "open-api portfolio guess", path: `/open-api/v1/sub-accounts/${encodeURIComponent(subAccountId)}/portfolio` }, + ]; + const variants = headerVariants(apiKey, apiSecret, accessToken, accessKey, deviceId); + + console.log(`FHSC probe sub_account=${subAccountId} account=${accountId || "(same)"} bases=${bases.join(", ")}`); + for (const base of bases) { + for (const p of paths) { + for (const variant of variants) { + const url = `${base}${p.path}`; + const res = await probe(url, variant.headers); + const interesting = res.status === 200 || res.status === 401 || res.status === 403 || res.status === 404; + if (!interesting) continue; + console.log(`${res.status} ${p.name} ${variant.name} ${base}`); + console.log(` ${res.contentType || "no-content-type"} ${res.body || "(empty)"}`); + if (res.status === 200) return; + } + } + } +} + +main().catch((e) => { + console.error((e as Error).message); + process.exitCode = 1; +}); diff --git a/src/agent/orchestrator.ts b/src/agent/orchestrator.ts index a1c617f..32647ee 100644 --- a/src/agent/orchestrator.ts +++ b/src/agent/orchestrator.ts @@ -11,7 +11,7 @@ import { indicesTool, foreignFlowTool } from "../tools/macro.js"; import { listPositionsTool, } from "../tools/portfolio.js"; -import { journalAppendTool, journalReadTool } from "../tools/journal.js"; +import { accountHistoryTool } from "../tools/accountHistory.js"; import { discoverTickersTool } from "../tools/discover.js"; import { teamAnalyzeTool, teamQuestionTool } from "../tools/team.js"; import { @@ -41,7 +41,7 @@ export function buildSystemPrompt(): string { "You are Azoth, an investment analyst for the Vietnamese stock market (HOSE / HNX / UPCOM).", cfg.autonomy === "advisory" ? "Current autonomy mode: advisory. Order-placement tools are unavailable." - : `Current autonomy mode: ${cfg.autonomy}. Order-placement tools are available through the configured ${cfg.broker} broker.`, + : `Current autonomy mode: ${cfg.autonomy}. Broker tools are available through the configured ${cfg.broker} broker, but every broker call requires explicit CLI approval first.`, "", "Tools available:", "- market_quote: latest reference / ceiling / floor / company info (SSI iBoard).", @@ -53,13 +53,13 @@ export function buildSystemPrompt(): string { "- macro_indices: VNINDEX/VN30/HNXINDEX/UPCOMINDEX latest close + 1d/1w/1m % change.", "- foreign_flow: per-ticker foreign buy/sell/net week-to-date and ownership %.", "- portfolio_list: read broker cash, positions, exposure, and unrealized P&L. Avg cost and last close are in thousand VND; monetary totals are in VND.", - "- journal_append / journal_read: persist and review past decisions.", + "- account_history: read-only broker account history: past orders/fills, cash transaction log, and dividend/rights issue events. Every live broker call asks the user for explicit CLI approval first.", "- discover_tickers: scan listed Vietnamese equities, an explicit ticker basket, or a preset universe by signal/strategy (momentum, breakout, mean reversion, defensive, liquidity surge, relative strength, weakness) and return 5–10 ranked candidates.", "- team_question: delegate complex market, portfolio, or allocation questions to Azoth's bull/bear/risk/portfolio team.", "- team_analyze: delegate deep single-ticker buy/sell/hold analysis to Azoth's full analyst/research/trader/risk/portfolio team.", cfg.autonomy === "advisory" ? "- (no order tools — autonomy=advisory)" - : "- place_order / cancel_order / list_orders / broker_state: trade through the configured broker (paper or dnse). Quantity must be a multiple of 100. Prices are in thousand VND.", + : "- place_order / cancel_order / list_orders / broker_state: use the configured broker. Outside backtests, every broker call asks the user for explicit CLI approval first. Quantity must be a multiple of 100. Prices are in thousand VND.", "", "Operating rules:", "1. Always ground recommendations in tool output, not memory. Cite the value, ticker, and source you used.", @@ -70,11 +70,10 @@ export function buildSystemPrompt(): string { "4a. For broad allocation questions or complex multi-factor decisions, call team_question. For a deep recommendation on one ticker, call team_analyze instead of manually recreating the whole team workflow.", "4b. When you call team_question or team_analyze, wait for that team tool to finish and then summarize its findings. Do not call duplicate market/fundamental/news/technical tools in parallel unless the user explicitly asks for raw source data.", "5. When citing news, include the URL and publish date so the user can verify.", - "6. After delivering a recommendation, call journal_append to persist the rationale and exit plan.", - "7. Keep replies concise. Show the numbers, then a one-paragraph synthesis covering all four dimensions (technical / fundamental / news / macro).", + "6. Keep replies concise. Show the numbers, then a one-paragraph synthesis covering all four dimensions (technical / fundamental / news / macro).", cfg.autonomy === "advisory" - ? "8. Order-placement tools are NOT available in advisory mode. Recommend; do not claim to have placed an order. The user executes manually." - : `8. Order tools ARE available (autonomy=${cfg.autonomy}, broker=${cfg.broker}). In 'confirm' mode the user is prompted in the CLI; in 'auto' the order goes through risk guardrails. Always call journal_append after a fill.`, + ? "7. Order-placement tools are NOT available in advisory mode. Recommend; do not claim to have placed an order. The user executes manually." + : `7. Broker tools ARE available (autonomy=${cfg.autonomy}, broker=${cfg.broker}). Outside backtests, every broker read or write first asks the user for explicit CLI approval; approved orders then go through risk guardrails.`, ].join("\n"); } @@ -89,8 +88,7 @@ export function buildMcpServer() { indicesTool, foreignFlowTool, listPositionsTool, - journalAppendTool, - journalReadTool, + accountHistoryTool, discoverTickersTool, teamQuestionTool, teamAnalyzeTool, @@ -137,8 +135,7 @@ export function buildOptions(opts: { resume?: string; abortController?: AbortCon "mcp__azoth__macro_indices", "mcp__azoth__foreign_flow", "mcp__azoth__portfolio_list", - "mcp__azoth__journal_append", - "mcp__azoth__journal_read", + "mcp__azoth__account_history", "mcp__azoth__discover_tickers", "mcp__azoth__team_question", "mcp__azoth__team_analyze", diff --git a/src/agent/team/prompts.ts b/src/agent/team/prompts.ts index e138c82..1aaaf6a 100644 --- a/src/agent/team/prompts.ts +++ b/src/agent/team/prompts.ts @@ -230,7 +230,7 @@ export function traderPrompt( "You translate the Research Manager's investment plan into an actionable, sized recommendation.", "Tools:", "- portfolio_list (see existing exposure)", - "- journal_read (recent decisions on this name)", + "- account_history (see past fills, cash transactions, and dividend/rights events when trading context matters)", "- discover_tickers (compare alternatives by signal/strategy; pass explicit tickers when the user names a basket)", "", "Analyst reports:", @@ -257,6 +257,7 @@ export function riskPrompt( "", "You are the last gate. Evaluate the trader's proposal against:", " - Current portfolio concentration (portfolio_list).", + " - Recent broker account history when relevant (account_history).", " - Macro backdrop (macro_indices).", " - Foreign positioning (foreign_flow).", "", @@ -283,7 +284,7 @@ export function portfolioPrompt( return [ header("Portfolio Manager", ticker, asOfDateIso), "", - "Synthesize the entire team's work into ONE final, journal-ready decision. The journal entry is the audit trail.", + "Synthesize the entire team's work into ONE final portfolio recommendation.", "", "Rating Scale (use exactly one):", "- Buy: Strong conviction to enter or add to position.", @@ -359,7 +360,7 @@ export function teamRiskQuestionPrompt( return [ questionHeader("Risk Manager", question, asOfDateIso), "", - "Evaluate the debate from a portfolio-risk perspective. Use portfolio_list, macro_indices, and foreign_flow when relevant.", + "Evaluate the debate from a portfolio-risk perspective. Use portfolio_list, account_history, macro_indices, and foreign_flow when relevant.", "Approve only if the proposed direction is compatible with concentration, market regime, and risk limits. If the question is informational, approve means the answer is safe to act on as advisory context.", "", "Debate transcript:", diff --git a/src/agent/team/state.ts b/src/agent/team/state.ts index dd00d5e..7a21553 100644 --- a/src/agent/team/state.ts +++ b/src/agent/team/state.ts @@ -58,7 +58,6 @@ export interface FinalDecision { sizingPct: number; rationale: string; exitPlan?: string; - journalId?: number; teamRunId: string; } diff --git a/src/agent/team/storage.ts b/src/agent/team/storage.ts index 1f45b06..a647e32 100644 --- a/src/agent/team/storage.ts +++ b/src/agent/team/storage.ts @@ -27,8 +27,7 @@ function ensureTeamTables(): void { final_action TEXT, final_rating TEXT, final_sizing REAL, - final_rationale TEXT, - decision_id INTEGER + final_rationale TEXT ); CREATE TABLE IF NOT EXISTS team_role_outputs ( @@ -113,29 +112,13 @@ export function finalizeTeamRun(args: FinalizeArgs): FinalDecision { const db = getDb(); const now = Math.floor(Date.now() / 1000); const legacyAction = legacyActionFromRating(args.final.rating); - const info = db - .prepare( - `INSERT INTO decisions (created_at, ticker, action, rating, rationale, exit_plan, source_run) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - now, - args.ticker.toUpperCase(), - legacyAction, - args.final.rating, - args.final.rationale, - args.final.exitPlan ?? null, - args.runId, - ); - const decisionId = Number(info.lastInsertRowid); db.prepare( `UPDATE team_runs SET finished_at = ?, final_action = ?, final_rating = ?, final_sizing = ?, - final_rationale = ?, - decision_id = ? + final_rationale = ? WHERE id = ?`, ).run( now, @@ -143,7 +126,6 @@ export function finalizeTeamRun(args: FinalizeArgs): FinalDecision { args.final.rating, args.final.sizingPct, args.final.rationale, - decisionId, args.runId, ); return { @@ -152,7 +134,6 @@ export function finalizeTeamRun(args: FinalizeArgs): FinalDecision { sizingPct: args.final.sizingPct, rationale: args.final.rationale, exitPlan: args.final.exitPlan, - journalId: decisionId, teamRunId: args.runId, }; } diff --git a/src/agent/team/tools.ts b/src/agent/team/tools.ts index 749f3ed..eb04231 100644 --- a/src/agent/team/tools.ts +++ b/src/agent/team/tools.ts @@ -5,7 +5,7 @@ import { fundamentalsTool } from "../../tools/fundamentals.js"; import { newsTool } from "../../tools/news.js"; import { indicesTool, foreignFlowTool } from "../../tools/macro.js"; import { listPositionsTool } from "../../tools/portfolio.js"; -import { journalReadTool } from "../../tools/journal.js"; +import { accountHistoryTool } from "../../tools/accountHistory.js"; import { discoverTickersTool } from "../../tools/discover.js"; import type { RoleName } from "./state.js"; @@ -20,15 +20,13 @@ const TOOL_BY_NAME: Record = { macro_indices: indicesTool as unknown as AnyTool, foreign_flow: foreignFlowTool as unknown as AnyTool, portfolio_list: listPositionsTool as unknown as AnyTool, - journal_read: journalReadTool as unknown as AnyTool, + account_history: accountHistoryTool as unknown as AnyTool, discover_tickers: discoverTickersTool as unknown as AnyTool, }; /** * Whitelist of tool names each role can call. Researcher / portfolio-manager - * roles get nothing — their job is synthesis over prior state. Journal-append - * is performed by the runner after the portfolio role decides, not as a tool - * call, so it is intentionally absent everywhere. + * roles get nothing — their job is synthesis over prior state. */ export const ROLE_TOOLS: Record = { technical: ["market_quote", "market_ohlcv", "technical_indicators"], @@ -38,8 +36,8 @@ export const ROLE_TOOLS: Record = { bull: [], bear: [], researchManager: [], - trader: ["portfolio_list", "discover_tickers", "journal_read"], - risk: ["portfolio_list", "macro_indices", "foreign_flow"], + trader: ["portfolio_list", "account_history", "discover_tickers"], + risk: ["portfolio_list", "account_history", "macro_indices", "foreign_flow"], portfolio: [], }; diff --git a/src/broker/fhsc.ts b/src/broker/fhsc.ts new file mode 100644 index 0000000..a91073b --- /dev/null +++ b/src/broker/fhsc.ts @@ -0,0 +1,838 @@ +import { randomUUID } from "node:crypto"; +import { request } from "undici"; +import { loadConfig } from "../config/loader.js"; +import { getDb } from "../storage/db.js"; +import type { + Broker, + BrokerAccountHistory, + BrokerAccountHistoryFilter, + BrokerCashTransaction, + BrokerHistoryFill, + BrokerHistoryOrder, + BrokerHistoryUnavailable, + BrokerPosition, + BrokerSubAccount, + BrokerRightEvent, + BrokerSnapshot, + Order, + OrderStatus, + PlaceOrderInput, +} from "./types.js"; + +/** + * Finhay Securities (FHSC, formerly VNSC) broker integration. + * + * FHSC exposes API-key management in the web app with read:account/read:market + * scopes. The stable public surface observed from the app covers account, + * portfolio, and order-history reads. Order placement is deliberately gated + * until an official trading API contract is provided. + * + * Required config or env: + * FHSC_SUB_ACCOUNT_ID — numeric sub-account id used by FHSC endpoints + * + * Auth config or env, choose one: + * FHSC_ACCESS_TOKEN — browser/API bearer token + * FHSC_ACCESS_KEY — browser session access key used as x-access-key + * FHSC_DEVICE_ID — browser session device id used as device-id + * FHSC_API_KEY — OpenAPI key from invest.fhsc.com.vn/quan-ly-api + * FHSC_API_SECRET — OpenAPI secret paired with FHSC_API_KEY + * + * Optional env: + * FHSC_BASE_URL — defaults to https://api.vinasecurities.com + * FHSC_ACCOUNT_ID — separate account id for order-history, if needed + */ +const NAME = "fhsc"; +const DEFAULT_BASE = "https://api.vinasecurities.com"; +const LEGACY_FINHAY_GW_BASE = "https://api.finhay.com.vn/gw"; + +interface FhscEnvelope { + error_code?: string | number; + message?: string; + data?: T; + result?: T; + [key: string]: unknown; +} + +interface FhscSubAccount { + balance?: number; + cash?: number; + cash_balance?: number; + buying_power?: number; + margin_used?: number; + marginUsed?: number; + sub_account_id?: string | number; + sub_account_ext?: string; + [key: string]: unknown; +} + +interface FhscPortfolioItem { + sub_account_id?: string | number; + symbol?: string; + ticker?: string; + total?: number; + trade?: number; + quantity?: number; + close_price?: number; + basic_price?: number; + pnl_amount?: number; + pnl_rate?: number; + basic_price_amount?: number; + custodycd?: string; + cost_price?: number; + avg_cost?: number; + average_cost?: number; + [key: string]: unknown; +} + +interface FhscPaymentSubAccount { + customer_id?: string; + sub_account_id?: string | number; + balance?: number; + total_balance?: number; + blocked_balance?: number; + type?: string; + sub_account_ext?: string; + [key: string]: unknown; +} + +interface FhscOrderItem { + order_id?: string | number; + id?: string | number; + symbol?: string; + side?: string; + orderType?: string; + order_type?: string; + order_qtty?: number; + quantity?: number; + price?: number; + order_price?: number; + status?: string; + reject_reason?: string; + tx_date?: string; + created_at?: string; + exec_price?: number; + exec_qtty?: number; + fee_amt?: number; + tax_amt?: number; + [key: string]: unknown; +} + +interface FhscCashTransactionItem { + id?: string | number; + sub_account_id?: string | number; + transaction_date?: string; + bus_date?: string; + transaction_number?: string; + transaction_type?: string; + transaction_flow?: string; + transaction_status?: string; + amount?: number; + title?: string; + description?: string; + code?: string; + tr_desc?: string; + [key: string]: unknown; +} + +interface FhscRightItem { + caMastId?: string | number; + id?: string | number; + symbol?: string; + type?: string; + catType?: string; + userRightRegisterStatus?: string; + status?: string; + reportDate?: string; + startDate?: string; + endDate?: string; + finishDate?: string; + lastRegisterDate?: string; + ratio?: string; + ownNumberOfShare?: number; + numberOfWaitingStock?: number; + amount?: number; + price?: number; + maxRegisterQuantity?: number; + registeredQuantity?: number; + [key: string]: unknown; +} + +function numberOf(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const n = Number(value.replace(/[,_\s]/g, "")); + return Number.isFinite(n) ? n : 0; + } + return 0; +} + +function vndToThousand(vnd: unknown): number { + return numberOf(vnd) / 1000; +} + +function payload(json: FhscEnvelope): T { + return (json.data ?? json.result ?? json) as T; +} + +function rowsFrom(raw: unknown, keys: string[] = ["data", "items", "rows", "records", "list"]): T[] { + if (Array.isArray(raw)) return raw as T[]; + if (!raw || typeof raw !== "object") return []; + const obj = raw as Record; + for (const key of keys) { + const value = obj[key]; + if (Array.isArray(value)) return value as T[]; + } + return []; +} + +function isSuccess(json: FhscEnvelope): boolean { + const code = json.error_code; + return code == null || code === 0 || code === "0" || code === "SUCCESS"; +} + +function mapStatus(raw: string | undefined): OrderStatus { + switch ((raw ?? "").toUpperCase()) { + case "MATCHED_ALL": + case "COMPLETED": + case "FILLED": + return "FILLED"; + case "CANCELLED": + case "CANCELED": + return "CANCELLED"; + case "REJECTING": + case "REJECTED": + case "EXPIRED": + return "REJECTED"; + default: + return "PENDING"; + } +} + +function mapSide(raw: unknown) { + const s = String(raw ?? "").toUpperCase(); + return s === "S" || s.includes("SELL") || s.includes("BAN") || s.includes("BÁN") ? "SELL" : "BUY"; +} + +function parseDateSec(raw: unknown): number { + if (typeof raw !== "string" || !raw.trim()) return 0; + const direct = Date.parse(raw); + if (Number.isFinite(direct)) return Math.floor(direct / 1000); + const m = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})/); + if (!m) return 0; + const [, dd, mm, yyyy] = m; + return Math.floor(Date.parse(`${yyyy}-${mm}-${dd}T00:00:00+07:00`) / 1000); +} + +function normalizeDate(raw: unknown): string | null { + if (typeof raw !== "string" || !raw.trim()) return null; + const s = raw.trim(); + const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; + const dmy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/); + if (dmy) return `${dmy[3]}-${dmy[2]}-${dmy[1]}`; + const parsed = Date.parse(s); + if (!Number.isFinite(parsed)) return null; + const d = new Date(parsed); + return [ + d.getFullYear(), + String(d.getMonth() + 1).padStart(2, "0"), + String(d.getDate()).padStart(2, "0"), + ].join("-"); +} + +function todayIso(): string { + const now = new Date(); + return [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + ].join("-"); +} + +function daysAgoIso(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + return [ + d.getFullYear(), + String(d.getMonth() + 1).padStart(2, "0"), + String(d.getDate()).padStart(2, "0"), + ].join("-"); +} + +function inDateRange(raw: unknown, fromDate: string, toDate: string): boolean { + const d = normalizeDate(raw); + return d == null || (d >= fromDate && d <= toDate); +} + +function todayRange(): { from: string; to: string } { + const date = todayIso(); + return { from: date, to: date }; +} + +function normalizeBaseUrl(value: string): string { + const trimmed = value.trim().replace(/\/+$/, ""); + return trimmed === LEGACY_FINHAY_GW_BASE ? DEFAULT_BASE : trimmed; +} + +function normalizeOrder(o: FhscOrderItem): Order { + const rawType = String(o.orderType ?? o.order_type ?? "").toUpperCase(); + const status = mapStatus(o.status); + const filledQty = numberOf(o.exec_qtty); + return { + id: String(o.order_id ?? o.id ?? ""), + broker: NAME, + ticker: String(o.symbol ?? "").toUpperCase(), + side: mapSide(o.side), + type: rawType === "LO" || rawType === "LIMIT" ? "LIMIT" : "MARKET", + quantity: numberOf(o.order_qtty ?? o.quantity), + limitPrice: o.price || o.order_price ? vndToThousand(o.price ?? o.order_price) : null, + status, + rejectReason: o.reject_reason ? String(o.reject_reason) : null, + createdAt: parseDateSec(o.tx_date ?? o.created_at), + filledAt: status === "FILLED" ? parseDateSec(o.tx_date ?? o.created_at) : null, + filledPrice: o.exec_price ? vndToThousand(o.exec_price) : null, + filledQty: filledQty || null, + notes: null, + }; +} + +export class FHSCBroker implements Broker { + readonly name = NAME; + private readonly baseUrl: string; + private readonly subAccountId: string; + private readonly accountId: string; + private readonly accessToken: string; + private readonly accessKey: string; + private readonly deviceId: string; + private readonly userId: string; + private readonly custId: string; + private readonly apiKey: string; + private readonly apiSecret: string; + + constructor() { + const cfg = loadConfig().fhsc; + this.baseUrl = normalizeBaseUrl(process.env.FHSC_BASE_URL?.trim() || cfg.base_url.trim() || DEFAULT_BASE); + const subAccountId = process.env.FHSC_SUB_ACCOUNT_ID?.trim() || cfg.sub_account_id.trim(); + const accessToken = process.env.FHSC_ACCESS_TOKEN?.trim() || cfg.access_token.trim(); + const accessKey = process.env.FHSC_ACCESS_KEY?.trim() || cfg.access_key.trim(); + const deviceId = process.env.FHSC_DEVICE_ID?.trim() || cfg.device_id.trim(); + const userId = process.env.FHSC_USER_ID?.trim() || cfg.user_id.trim(); + const custId = process.env.FHSC_CUST_ID?.trim() || cfg.cust_id.trim(); + const apiKey = process.env.FHSC_API_KEY?.trim() || cfg.api_key.trim(); + const apiSecret = process.env.FHSC_API_SECRET?.trim() || cfg.api_secret.trim(); + if (!subAccountId) { + throw new Error("FHSCBroker requires FHSC sub_account_id in config or env FHSC_SUB_ACCOUNT_ID"); + } + if (!(accessToken && accessKey) && !(apiKey && apiSecret)) { + throw new Error( + "FHSCBroker requires config/env auth: access_token + access_key, or api_key + api_secret", + ); + } + this.subAccountId = subAccountId; + this.accountId = process.env.FHSC_ACCOUNT_ID?.trim() || cfg.account_id.trim() || subAccountId; + this.accessToken = accessToken; + this.accessKey = accessKey; + this.deviceId = deviceId; + this.userId = userId; + this.custId = custId; + this.apiKey = apiKey; + this.apiSecret = apiSecret; + } + + private authHeaders(): Record { + const headers: Record = { + accept: "application/json", + "accept-language": "vi", + "content-type": "application/json", + "device-type": "WEB", + "x-channel": "ONLINE", + }; + if (this.accessToken && this.accessKey) { + headers.authorization = `Bearer ${this.accessToken}`; + headers["x-access-key"] = this.accessKey; + if (this.deviceId) headers["device-id"] = this.deviceId; + return headers; + } + if (this.apiKey) headers["x-api-key"] = this.apiKey; + if (this.apiSecret) headers["x-api-secret"] = this.apiSecret; + return headers; + } + + private async getJson(path: string, params: Record = {}): Promise { + const url = new URL(`${this.baseUrl}${path}`); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, String(value)); + } + const { statusCode, body } = await request(url, { + method: "GET", + headers: this.authHeaders(), + }); + const text = await body.text(); + if (statusCode < 200 || statusCode >= 300) { + const hint = + text.includes("InvalidTokenException") || text.includes("Thông tin xác thực không hợp lệ") + ? " FHSC /trade endpoints expect browser-session credentials (access_token + access_key + device_id); OpenAPI key/secret alone is rejected by this endpoint." + : ""; + throw new Error(`FHSC GET ${path} ${statusCode}: ${text.slice(0, 200)}${hint}`); + } + const json = JSON.parse(text) as FhscEnvelope; + if (!isSuccess(json)) { + throw new Error(`FHSC GET ${path}: ${json.message ?? JSON.stringify(json).slice(0, 200)}`); + } + return payload(json); + } + + private async paymentSubAccounts(): Promise { + if (!this.userId) return []; + return this.getJson( + `/payments/v2/users/${encodeURIComponent(this.userId)}/sub-account`, + ).catch(() => []); + } + + private async portfolioForSubAccount(subAccountId: string): Promise { + const raw = await this.getJson<{ portfolio?: FhscPortfolioItem[] } | FhscPortfolioItem[]>( + `/trade/v2/sub-accounts/${encodeURIComponent(subAccountId)}/portfolio`, + ); + return Array.isArray(raw) ? raw : (raw.portfolio ?? []); + } + + private async historySubAccounts(): Promise { + const paymentAccounts = await this.paymentSubAccounts(); + if (paymentAccounts.length > 0) { + return paymentAccounts + .map((account) => ({ + id: String(account.sub_account_id ?? "").trim(), + type: account.type, + cashVnd: numberOf(account.balance), + blockedCashVnd: numberOf(account.blocked_balance), + totalCashVnd: numberOf(account.total_balance), + label: account.sub_account_ext, + })) + .filter((account) => account.id); + } + return [{ + id: this.subAccountId, + cashVnd: 0, + label: this.accountId !== this.subAccountId ? this.accountId : undefined, + }]; + } + + private async orderHistoryForSubAccount( + subAccountId: string, + fromDate: string, + toDate: string, + maxPages = 5, + ): Promise { + const rows: FhscOrderItem[] = []; + const seen = new Set(); + for (let page = 1; page <= maxPages; page += 1) { + const raw = await this.getJson( + `/trade/accounts/${encodeURIComponent(subAccountId)}/order-history`, + { from_date: fromDate, to_date: toDate, page }, + ); + const pageRows = rowsFrom(raw, ["data", "orders", "items", "rows"]); + if (pageRows.length === 0) break; + for (const row of pageRows) { + const key = String(row.order_id ?? row.id ?? `${subAccountId}-${JSON.stringify(row)}`); + if (seen.has(key)) continue; + seen.add(key); + rows.push(row); + } + if (pageRows.length < 20) break; + } + return rows; + } + + private async cashTransactionsForSubAccount( + subAccountId: string, + fromDate: string, + toDate: string, + maxPages = 5, + ): Promise { + if (!this.userId) { + throw new Error("FHSC transaction history requires user_id in config"); + } + const rows: FhscCashTransactionItem[] = []; + const seen = new Set(); + const size = 50; + for (let page = 1; page <= maxPages; page += 1) { + const raw = await this.getJson( + `/payments/v3/users/${encodeURIComponent(this.userId)}/sub-accounts/${encodeURIComponent(subAccountId)}/transactions`, + { size, page, transaction_status: "COMPLETED" }, + ); + const pageRows = rowsFrom(raw, [ + "transactions", + "data", + "items", + "rows", + ]); + if (pageRows.length === 0) break; + for (const row of pageRows) { + if (!inDateRange(row.transaction_date ?? row.bus_date, fromDate, toDate)) continue; + const key = String(row.id ?? row.transaction_number ?? `${subAccountId}-${JSON.stringify(row)}`); + if (seen.has(key)) continue; + seen.add(key); + rows.push(row); + } + if (pageRows.length < size) break; + } + return rows; + } + + private async rightsForSubAccount( + subAccountId: string, + filter: BrokerAccountHistoryFilter, + fromDate: string, + toDate: string, + ): Promise { + const raw = await this.getJson( + `/trade/v5/account/${encodeURIComponent(subAccountId)}/user-rights`, + { + fromDate, + toDate, + ...(filter.ticker ? { symbol: filter.ticker.toUpperCase() } : {}), + }, + ); + return rowsFrom(raw, ["data", "rights", "items", "rows", "list"]); + } + + private normalizeHistoryOrder(row: FhscOrderItem, subAccountId: string): BrokerHistoryOrder { + const qty = numberOf(row.order_qtty ?? row.quantity); + const filledQty = numberOf(row.exec_qtty); + return { + id: String(row.order_id ?? row.id ?? ""), + subAccountId, + ticker: String(row.symbol ?? "").toUpperCase(), + side: mapSide(row.side), + type: String(row.orderType ?? row.order_type ?? ""), + status: String(row.status ?? ""), + orderDate: normalizeDate(row.tx_date ?? row.created_at), + quantity: qty, + limitPriceThousandVnd: row.price || row.order_price ? vndToThousand(row.price ?? row.order_price) : null, + filledQty, + filledPriceThousandVnd: row.exec_price ? vndToThousand(row.exec_price) : null, + feeVnd: numberOf(row.fee_amt) || undefined, + taxVnd: numberOf(row.tax_amt) || undefined, + }; + } + + private normalizeFill(row: FhscOrderItem, subAccountId: string): BrokerHistoryFill | null { + const quantity = numberOf(row.exec_qtty); + if (quantity <= 0) return null; + const priceVnd = numberOf(row.exec_price); + return { + orderId: String(row.order_id ?? row.id ?? ""), + subAccountId, + ticker: String(row.symbol ?? "").toUpperCase(), + side: mapSide(row.side), + tradeDate: normalizeDate(row.tx_date ?? row.created_at), + quantity, + priceThousandVnd: priceVnd > 0 ? priceVnd / 1000 : null, + grossValueVnd: priceVnd > 0 ? priceVnd * quantity : null, + feeVnd: numberOf(row.fee_amt) || undefined, + taxVnd: numberOf(row.tax_amt) || undefined, + }; + } + + private normalizeCashTransaction(row: FhscCashTransactionItem, subAccountId: string): BrokerCashTransaction { + return { + id: String(row.id ?? row.transaction_number ?? ""), + subAccountId: String(row.sub_account_id ?? subAccountId), + transactionDate: normalizeDate(row.transaction_date), + businessDate: normalizeDate(row.bus_date), + type: row.transaction_type, + flow: row.transaction_flow, + status: row.transaction_status, + amountVnd: numberOf(row.amount), + title: row.title, + description: row.description ?? row.tr_desc, + code: row.code, + }; + } + + private normalizeRight(row: FhscRightItem, subAccountId: string): BrokerRightEvent { + return { + id: String(row.caMastId ?? row.id ?? ""), + subAccountId, + ticker: String(row.symbol ?? "").toUpperCase(), + type: row.type ?? row.catType, + status: row.userRightRegisterStatus ?? row.status, + reportDate: normalizeDate(row.reportDate), + startDate: normalizeDate(row.startDate), + endDate: normalizeDate(row.endDate ?? row.lastRegisterDate), + finishDate: normalizeDate(row.finishDate), + ratio: row.ratio, + ownedShares: numberOf(row.ownNumberOfShare) || undefined, + waitingShares: numberOf(row.numberOfWaitingStock) || undefined, + amountVnd: numberOf(row.amount) || undefined, + priceVnd: numberOf(row.price) || undefined, + maxRegisterQuantity: numberOf(row.maxRegisterQuantity) || undefined, + registeredQuantity: numberOf(row.registeredQuantity) || undefined, + }; + } + + private normalizePositions(portfolio: FhscPortfolioItem[]): BrokerPosition[] { + const byTicker = new Map< + string, + { + quantity: number; + costValue: number; + marketValueVnd: number; + unrealizedPnlVnd: number; + subAccountIds: Set; + custodyCodes: Set; + lastPrice?: number; + } + >(); + for (const p of portfolio) { + const ticker = String(p.symbol ?? p.ticker ?? "").toUpperCase().trim(); + const quantity = numberOf(p.trade ?? p.total ?? p.quantity); + if (!ticker || quantity <= 0) continue; + const avgCost = vndToThousand(p.cost_price ?? p.avg_cost ?? p.average_cost); + const lastPrice = vndToThousand(p.close_price ?? p.basic_price); + const marketValueVnd = numberOf(p.basic_price_amount); + const unrealizedPnlVnd = numberOf(p.pnl_amount); + const subAccountId = String(p.sub_account_id ?? "").trim(); + const custodyCode = String(p.custodycd ?? "").trim(); + const current = byTicker.get(ticker) ?? { + quantity: 0, + costValue: 0, + marketValueVnd: 0, + unrealizedPnlVnd: 0, + subAccountIds: new Set(), + custodyCodes: new Set(), + }; + current.quantity += quantity; + current.costValue += avgCost * quantity; + current.marketValueVnd += marketValueVnd; + current.unrealizedPnlVnd += unrealizedPnlVnd; + if (lastPrice > 0) current.lastPrice = lastPrice; + if (subAccountId) current.subAccountIds.add(subAccountId); + if (custodyCode) current.custodyCodes.add(custodyCode); + byTicker.set(ticker, current); + } + return Array.from(byTicker.entries()).map(([ticker, value]) => ({ + ticker, + quantity: value.quantity, + avgCost: value.quantity > 0 ? value.costValue / value.quantity : 0, + lastPrice: value.lastPrice, + marketValueVnd: value.marketValueVnd || undefined, + unrealizedPnlVnd: value.unrealizedPnlVnd || undefined, + unrealizedPnlPct: + value.costValue > 0 ? (value.unrealizedPnlVnd / (value.costValue * 1000)) * 100 : undefined, + subAccountId: Array.from(value.subAccountIds).join(",") || undefined, + custodyCode: Array.from(value.custodyCodes).join(",") || undefined, + })); + } + + async snapshot(): Promise { + const paymentAccounts = await this.paymentSubAccounts(); + const subAccountIds = Array.from( + new Set( + [ + this.subAccountId, + ...paymentAccounts.map((account) => String(account.sub_account_id ?? "").trim()), + ].filter(Boolean), + ), + ); + const [accountResult, ...portfolioResults] = await Promise.allSettled([ + this.getJson(`/trade/sub-accounts/${encodeURIComponent(this.subAccountId)}`), + ...subAccountIds.map((id) => this.portfolioForSubAccount(id)), + ]); + if ( + paymentAccounts.length === 0 && + accountResult.status === "rejected" && + portfolioResults.every((result) => result.status === "rejected") + ) { + throw new Error( + `FHSC snapshot failed: ${String(accountResult.reason)}; ${portfolioResults + .map((result) => (result.status === "rejected" ? String(result.reason) : "portfolio ok")) + .join("; ")}`, + ); + } + const account = accountResult.status === "fulfilled" ? accountResult.value : ({} as FhscSubAccount); + const portfolio = portfolioResults.flatMap((result) => (result.status === "fulfilled" ? result.value : [])); + const positions = this.normalizePositions(portfolio); + const paymentCash = paymentAccounts.reduce( + (sum, account) => sum + numberOf(account.balance ?? account.total_balance), + 0, + ); + const blockedBalance = paymentAccounts.reduce((sum, account) => sum + numberOf(account.blocked_balance), 0); + return { + broker: NAME, + cashVnd: + paymentAccounts.length > 0 + ? paymentCash + : numberOf(account.balance ?? account.cash ?? account.cash_balance ?? account.buying_power), + positions, + subAccounts: + paymentAccounts.length > 0 + ? paymentAccounts.map((account) => ({ + id: String(account.sub_account_id ?? ""), + type: account.type, + cashVnd: numberOf(account.balance), + blockedCashVnd: numberOf(account.blocked_balance), + totalCashVnd: numberOf(account.total_balance), + label: account.sub_account_ext, + })) + : undefined, + marginUsedVnd: + paymentAccounts.length > 0 ? blockedBalance : numberOf(account.margin_used ?? account.marginUsed), + }; + } + + async accountHistory(filter: BrokerAccountHistoryFilter = {}): Promise { + const fromDate = filter.fromDate ?? daysAgoIso(365); + const toDate = filter.toDate ?? todayIso(); + const limit = Math.max(1, Math.min(filter.limit ?? 100, 500)); + const ticker = filter.ticker?.toUpperCase(); + const accounts = (await this.historySubAccounts()).filter((account) => account.id); + const unavailable: BrokerHistoryUnavailable[] = []; + + const orderResults = await Promise.allSettled( + accounts.map(async (account) => ({ + subAccountId: account.id, + rows: await this.orderHistoryForSubAccount(account.id, fromDate, toDate), + })), + ); + const transactionResults = await Promise.allSettled( + accounts.map(async (account) => ({ + subAccountId: account.id, + rows: await this.cashTransactionsForSubAccount(account.id, fromDate, toDate), + })), + ); + const rightResults = await Promise.allSettled( + accounts.map(async (account) => ({ + subAccountId: account.id, + rows: await this.rightsForSubAccount(account.id, filter, fromDate, toDate), + })), + ); + + const orders: BrokerHistoryOrder[] = []; + const fills: BrokerHistoryFill[] = []; + for (const result of orderResults) { + if (result.status === "rejected") { + unavailable.push({ source: "order_history", error: String(result.reason).slice(0, 240) }); + continue; + } + for (const row of result.value.rows) { + if (ticker && String(row.symbol ?? "").toUpperCase() !== ticker) continue; + orders.push(this.normalizeHistoryOrder(row, result.value.subAccountId)); + const fill = this.normalizeFill(row, result.value.subAccountId); + if (fill) fills.push(fill); + } + } + + const transactions: BrokerCashTransaction[] = []; + for (const result of transactionResults) { + if (result.status === "rejected") { + unavailable.push({ source: "transactions", error: String(result.reason).slice(0, 240) }); + continue; + } + transactions.push( + ...result.value.rows.map((row) => this.normalizeCashTransaction(row, result.value.subAccountId)), + ); + } + + const rights: BrokerRightEvent[] = []; + for (const result of rightResults) { + if (result.status === "rejected") { + unavailable.push({ source: "rights", error: String(result.reason).slice(0, 240) }); + continue; + } + rights.push( + ...result.value.rows + .filter((row) => !ticker || String(row.symbol ?? "").toUpperCase() === ticker) + .map((row) => this.normalizeRight(row, result.value.subAccountId)), + ); + } + + const dateDesc = (value: string | null | undefined) => value ?? ""; + orders.sort((a, b) => dateDesc(b.orderDate).localeCompare(dateDesc(a.orderDate))); + fills.sort((a, b) => dateDesc(b.tradeDate).localeCompare(dateDesc(a.tradeDate))); + transactions.sort((a, b) => dateDesc(b.transactionDate).localeCompare(dateDesc(a.transactionDate))); + rights.sort((a, b) => dateDesc(b.reportDate).localeCompare(dateDesc(a.reportDate))); + + return { + broker: NAME, + fromDate, + toDate, + subAccounts: accounts, + orders: orders.slice(0, limit), + fills: fills.slice(0, limit), + transactions: transactions.slice(0, limit), + rights: rights.slice(0, limit), + ...(unavailable.length > 0 ? { unavailable } : {}), + }; + } + + async listOrders( + filter: { ticker?: string; status?: OrderStatus; limit?: number } = {}, + ): Promise { + const { from, to } = todayRange(); + const rows = await this.orderHistoryForSubAccount(this.accountId, from, to, 1).catch(() => []); + let orders = rows.map(normalizeOrder); + if (filter.ticker) { + const t = filter.ticker.toUpperCase(); + orders = orders.filter((o) => o.ticker === t); + } + if (filter.status) orders = orders.filter((o) => o.status === filter.status); + return orders.slice(0, filter.limit ?? 50); + } + + async placeOrder(input: PlaceOrderInput): Promise { + return this.recordRejectedOrder( + input, + "FHSC trading API is not enabled: read-only OpenAPI integration supports snapshot and order history only", + ); + } + + async recordRejectedOrder(input: PlaceOrderInput, reason: string): Promise { + const order: Order = { + id: randomUUID(), + broker: NAME, + ticker: input.ticker.toUpperCase(), + side: input.side, + type: input.type, + quantity: input.quantity, + limitPrice: input.limitPrice ?? null, + status: "REJECTED", + rejectReason: reason, + createdAt: Math.floor(Date.now() / 1000), + filledAt: null, + filledPrice: null, + filledQty: null, + notes: input.notes ?? null, + }; + this.audit(order); + return order; + } + + async cancelOrder(id: string): Promise { + throw new Error(`FHSC cancel is not available in read-only OpenAPI mode for order ${id}`); + } + + private audit(order: Order) { + const db = getDb(); + db.prepare( + `INSERT OR REPLACE INTO broker_orders + (id,broker,ticker,side,type,quantity,limit_price,status,reject_reason,created_at,filled_at,filled_price,filled_qty,notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ).run( + order.id, + order.broker, + order.ticker, + order.side, + order.type, + order.quantity, + order.limitPrice, + order.status, + order.rejectReason, + order.createdAt, + order.filledAt, + order.filledPrice, + order.filledQty, + order.notes, + ); + } +} diff --git a/src/broker/index.ts b/src/broker/index.ts index c8ac3e3..f7aec86 100644 --- a/src/broker/index.ts +++ b/src/broker/index.ts @@ -1,5 +1,6 @@ import { PaperBroker } from "./paper.js"; import { DNSEBroker } from "./dnse.js"; +import { FHSCBroker } from "./fhsc.js"; import type { Broker } from "./types.js"; import { loadConfig } from "../config/loader.js"; import { currentBrokerName } from "../agent/clock.js"; @@ -30,6 +31,9 @@ export function getBroker(): Broker { case "dnse": cached = new DNSEBroker(); return cached; + case "fhsc": + cached = new FHSCBroker(); + return cached; } } diff --git a/src/broker/types.ts b/src/broker/types.ts index 7eebab4..8f26fa3 100644 --- a/src/broker/types.ts +++ b/src/broker/types.ts @@ -32,18 +32,127 @@ export interface BrokerPosition { ticker: string; quantity: number; avgCost: number; // thousand VND + /** Broker-reported latest/close price in thousand VND, when available. */ + lastPrice?: number; + /** Broker-reported position market value in VND, when available. */ + marketValueVnd?: number; + /** Broker-reported unrealized P&L in VND, when available. */ + unrealizedPnlVnd?: number; + /** Broker-reported unrealized P&L percentage, when available. */ + unrealizedPnlPct?: number; + /** Broker sub-account holding this position, when available. */ + subAccountId?: string; + /** Broker custody account, when available. */ + custodyCode?: string; +} + +export interface BrokerSubAccount { + id: string; + type?: string; + cashVnd: number; + blockedCashVnd?: number; + totalCashVnd?: number; + label?: string; } export interface BrokerSnapshot { broker: string; cashVnd: number; positions: BrokerPosition[]; + subAccounts?: BrokerSubAccount[]; /** Optional equity baseline used for loss guardrails. VND. */ initialCashVnd?: number; /** Optional broker-reported margin usage. VND. */ marginUsedVnd?: number; } +export interface BrokerAccountHistoryFilter { + fromDate?: string; + toDate?: string; + ticker?: string; + limit?: number; +} + +export interface BrokerHistoryOrder { + id: string; + subAccountId?: string; + ticker: string; + side: Side; + type: string; + status: string; + orderDate: string | null; + quantity: number; + limitPriceThousandVnd: number | null; + filledQty: number; + filledPriceThousandVnd: number | null; + feeVnd?: number; + taxVnd?: number; +} + +export interface BrokerHistoryFill { + orderId: string; + subAccountId?: string; + ticker: string; + side: Side; + tradeDate: string | null; + quantity: number; + priceThousandVnd: number | null; + grossValueVnd: number | null; + feeVnd?: number; + taxVnd?: number; +} + +export interface BrokerCashTransaction { + id: string; + subAccountId?: string; + transactionDate: string | null; + businessDate: string | null; + type?: string; + flow?: string; + status?: string; + amountVnd: number; + title?: string; + description?: string; + code?: string; +} + +export interface BrokerRightEvent { + id: string; + subAccountId?: string; + ticker: string; + type?: string; + status?: string; + reportDate: string | null; + startDate?: string | null; + endDate?: string | null; + finishDate?: string | null; + ratio?: string; + ownedShares?: number; + waitingShares?: number; + amountVnd?: number; + priceVnd?: number; + maxRegisterQuantity?: number; + registeredQuantity?: number; +} + +export interface BrokerHistoryUnavailable { + source: string; + subAccountId?: string; + error: string; +} + +export interface BrokerAccountHistory { + broker: string; + fromDate: string; + toDate: string; + subAccounts: BrokerSubAccount[]; + orders: BrokerHistoryOrder[]; + fills: BrokerHistoryFill[]; + transactions: BrokerCashTransaction[]; + rights: BrokerRightEvent[]; + unavailable?: BrokerHistoryUnavailable[]; +} + export interface Broker { readonly name: string; @@ -57,4 +166,5 @@ export interface Broker { }): Promise; snapshot(): Promise; + accountHistory?(filter?: BrokerAccountHistoryFilter): Promise; } diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..a485d08 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,3 @@ +export function isVersionCommand(args: string[]): boolean { + return args.length === 1 && ["--version", "-v", "version"].includes(args[0] ?? ""); +} diff --git a/src/cli/azoth.tsx b/src/cli/azoth.tsx index 4fce4c6..7aed988 100644 --- a/src/cli/azoth.tsx +++ b/src/cli/azoth.tsx @@ -1,11 +1,31 @@ #!/usr/bin/env node -import "../runtime/bootstrap.js"; import { render } from "ink"; import { App } from "../tui/App.js"; import { getDb, closeDb } from "../storage/db.js"; import { loadConfig } from "../config/loader.js"; +import { initializeAzothRuntime } from "../runtime/init.js"; +import { packageVersion } from "../runtime/version.js"; +import { isVersionCommand } from "./args.js"; + +function sanitizeRuntimeEnv() { + for (const key of ["AZOTH_DB", "AZOTH_CONFIG", "AZOTH_HOME"] as const) { + if (process.env[key]?.trim() === "") delete process.env[key]; + } +} + +function printVersion() { + console.log(`azoth ${packageVersion()}`); +} function main() { + const args = process.argv.slice(2); + if (isVersionCommand(args)) { + printVersion(); + return; + } + + sanitizeRuntimeEnv(); + initializeAzothRuntime(); loadConfig(); getDb(); diff --git a/src/config/loader.ts b/src/config/loader.ts index b3991d8..38f7942 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -24,7 +24,33 @@ const ConfigSchema = z.object({ }) .optional() .default({}), - broker: z.enum(["paper", "dnse"]), + broker: z.enum(["paper", "dnse", "fhsc"]), + fhsc: z + .object({ + sub_account_id: z.string().default(""), + account_id: z.string().default(""), + base_url: z.string().default("https://api.vinasecurities.com"), + access_token: z.string().default(""), + access_key: z.string().default(""), + device_id: z.string().default(""), + user_id: z.string().default(""), + cust_id: z.string().default(""), + api_key: z.string().default(""), + api_secret: z.string().default(""), + }) + .optional() + .default({ + sub_account_id: "", + account_id: "", + base_url: "https://api.vinasecurities.com", + access_token: "", + access_key: "", + device_id: "", + user_id: "", + cust_id: "", + api_key: "", + api_secret: "", + }), risk: z.object({ max_position_pct: z.number().min(0).max(1), max_daily_loss_pct: z.number().min(0).max(1), @@ -96,6 +122,7 @@ export function updateConfig(patch: Partial): Config { ...patch, llm: patch.llm ? { ...loadConfig().llm, ...patch.llm } : loadConfig().llm, team: patch.team ? { ...loadConfig().team, ...patch.team } : loadConfig().team, + fhsc: patch.fhsc ? { ...loadConfig().fhsc, ...patch.fhsc } : loadConfig().fhsc, risk: patch.risk ? { ...loadConfig().risk, ...patch.risk } : loadConfig().risk, }); } diff --git a/src/risk/guardrails.ts b/src/risk/guardrails.ts index 89edda3..fdf29d4 100644 --- a/src/risk/guardrails.ts +++ b/src/risk/guardrails.ts @@ -25,8 +25,9 @@ function equityValue( } /** - * Pre-trade checks for order-capable autonomy modes. `confirm` adds a human - * gate after these checks pass; `advisory` never reaches the broker. + * Pre-trade checks for order-capable autonomy modes. Live/user-facing flows + * obtain explicit broker consent before reaching this function because the + * checks read broker state. */ export async function checkOrder( broker: Broker, diff --git a/src/runtime/defaultConfig.ts b/src/runtime/defaultConfig.ts index 842387c..4be52d6 100644 --- a/src/runtime/defaultConfig.ts +++ b/src/runtime/defaultConfig.ts @@ -23,6 +23,19 @@ team: # Broker selection. broker: paper +# FHSC broker settings. Use /setup-fhsc in the TUI to fill these safely. +fhsc: + sub_account_id: "" + account_id: "" + base_url: https://api.vinasecurities.com + access_token: "" + access_key: "" + device_id: "" + user_id: "" + cust_id: "" + api_key: "" + api_secret: "" + # Risk guardrails. risk: max_position_pct: 0.15 diff --git a/src/runtime/health.ts b/src/runtime/health.ts index 943ad0b..dd552f2 100644 --- a/src/runtime/health.ts +++ b/src/runtime/health.ts @@ -51,7 +51,9 @@ export async function collectHealth(opts: { probeProviders?: boolean } = {}): Pr detail: cfg.broker === "dnse" ? `dnse armed=${process.env.AZOTH_LIVE_TRADING === "1"}` - : "paper broker selected", + : cfg.broker === "fhsc" + ? `fhsc read-only sub_account=${Boolean(cfg.fhsc.sub_account_id || process.env.FHSC_SUB_ACCOUNT_ID) ? "set" : "missing"}` + : "paper broker selected", }); try { const broker = getBroker(); diff --git a/src/storage/db.ts b/src/storage/db.ts index cb81e3c..c6912c1 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -41,7 +41,7 @@ function migrate(d: Database.Database) { const decisionCols = d.prepare("PRAGMA table_info(decisions)").all() as { name: string }[]; const haveDecisions = new Set(decisionCols.map((c) => c.name)); - if (!haveDecisions.has("rating")) { + if (decisionCols.length > 0 && !haveDecisions.has("rating")) { d.exec("ALTER TABLE decisions ADD COLUMN rating TEXT"); } diff --git a/src/storage/schema.sql b/src/storage/schema.sql index 38b183c..7542eed 100644 --- a/src/storage/schema.sql +++ b/src/storage/schema.sql @@ -6,19 +6,6 @@ CREATE TABLE IF NOT EXISTS kv_cache ( CREATE INDEX IF NOT EXISTS kv_cache_expires_idx ON kv_cache(expires_at); -CREATE TABLE IF NOT EXISTS decisions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at INTEGER NOT NULL, - ticker TEXT NOT NULL, - action TEXT NOT NULL, -- legacy BUY | SELL | HOLD - rating TEXT, -- Buy | Overweight | Hold | Underweight | Sell - rationale TEXT NOT NULL, -- 4-dimension synthesis - exit_plan TEXT, -- thresholds for stop/take-profit - source_run TEXT -- session id, optional -); - -CREATE INDEX IF NOT EXISTS decisions_ticker_idx ON decisions(ticker, created_at); - -- Paper / live broker state (Phase 4+) CREATE TABLE IF NOT EXISTS broker_state ( @@ -101,11 +88,3 @@ CREATE TABLE IF NOT EXISTS backtest_equity ( benchmark_mtm_vnd REAL NOT NULL, PRIMARY KEY (run_id, as_of) ); - -CREATE TABLE IF NOT EXISTS alerts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at INTEGER NOT NULL, - ticker TEXT, - level TEXT NOT NULL, -- info | warn | critical - message TEXT NOT NULL -); diff --git a/src/storage/schemaDef.ts b/src/storage/schemaDef.ts index a999526..1128832 100644 --- a/src/storage/schemaDef.ts +++ b/src/storage/schemaDef.ts @@ -6,19 +6,6 @@ export const SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS kv_cache ( CREATE INDEX IF NOT EXISTS kv_cache_expires_idx ON kv_cache(expires_at); -CREATE TABLE IF NOT EXISTS decisions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at INTEGER NOT NULL, - ticker TEXT NOT NULL, - action TEXT NOT NULL, - rating TEXT, - rationale TEXT NOT NULL, - exit_plan TEXT, - source_run TEXT -); - -CREATE INDEX IF NOT EXISTS decisions_ticker_idx ON decisions(ticker, created_at); - CREATE TABLE IF NOT EXISTS broker_state ( broker TEXT PRIMARY KEY, cash_vnd REAL NOT NULL, @@ -98,11 +85,4 @@ CREATE TABLE IF NOT EXISTS backtest_equity ( PRIMARY KEY (run_id, as_of) ); -CREATE TABLE IF NOT EXISTS alerts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at INTEGER NOT NULL, - ticker TEXT, - level TEXT NOT NULL, - message TEXT NOT NULL -); `; diff --git a/src/tools/accountHistory.ts b/src/tools/accountHistory.ts new file mode 100644 index 0000000..b8e2926 --- /dev/null +++ b/src/tools/accountHistory.ts @@ -0,0 +1,78 @@ +import { tool } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; +import { getBroker } from "../broker/index.js"; +import type { BrokerAccountHistory } from "../broker/types.js"; +import { currentBrokerName } from "../agent/clock.js"; +import { requireBrokerConsent } from "./brokerConsent.js"; + +function asText(obj: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(obj) }] }; +} + +type HistoryKind = "all" | "orders" | "fills" | "transactions" | "rights"; + +function filterHistory(history: BrokerAccountHistory, kind: HistoryKind): BrokerAccountHistory { + if (kind === "all") return history; + return { + ...history, + orders: kind === "orders" ? history.orders : [], + fills: kind === "orders" || kind === "fills" ? history.fills : [], + transactions: kind === "transactions" ? history.transactions : [], + rights: kind === "rights" ? history.rights : [], + }; +} + +export const accountHistoryTool = tool( + "account_history", + "Read-only broker account history: past orders/fills, cash transaction log, and dividend/rights issue events. Outside backtests, the user is always prompted in the CLI before any broker call. Dates are YYYY-MM-DD; default range is the last 365 days.", + { + kind: z.enum(["all", "orders", "fills", "transactions", "rights"]).default("all"), + from_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + to_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + ticker: z.string().optional(), + limit: z.number().int().min(1).max(500).default(100), + }, + async ({ kind, from_date, to_date, ticker, limit }) => { + const selectedKind = (kind ?? "all") as HistoryKind; + if (currentBrokerName() == null) { + const detail = [ + `kind=${selectedKind}`, + from_date ? `from=${from_date}` : "", + to_date ? `to=${to_date}` : "", + ticker ? `ticker=${ticker.toUpperCase()}` : "", + `limit=${limit ?? 100}`, + ].filter(Boolean).join(" "); + const ok = await requireBrokerConsent( + "account_history", + `read broker account history ${detail}`, + ); + if (!ok) return asText({ ok: false, error: "user_declined" }); + } + + const broker = getBroker(); + if (!broker.accountHistory) { + return asText({ + ok: false, + error: `broker ${broker.name} does not support account_history`, + }); + } + const history = await broker.accountHistory({ + fromDate: from_date, + toDate: to_date, + ticker: ticker?.toUpperCase(), + limit, + }); + const filtered = filterHistory(history, selectedKind); + return asText({ + ok: true, + counts: { + orders: filtered.orders.length, + fills: filtered.fills.length, + transactions: filtered.transactions.length, + rights: filtered.rights.length, + unavailable: filtered.unavailable?.length ?? 0, + }, + ...filtered, + }); + }, +); diff --git a/src/tools/brokerConsent.ts b/src/tools/brokerConsent.ts new file mode 100644 index 0000000..285b409 --- /dev/null +++ b/src/tools/brokerConsent.ts @@ -0,0 +1,50 @@ +import * as readline from "node:readline/promises"; +import { currentBrokerName } from "../agent/clock.js"; +import { loadConfig } from "../config/loader.js"; + +export interface BrokerConsentRequest { + action: string; + detail: string; + broker: string; + autonomy: string; +} + +type BrokerConsentHandler = (request: BrokerConsentRequest) => Promise; + +let brokerConsentHandler: BrokerConsentHandler | null = null; + +export function setBrokerConsentHandler(handler: BrokerConsentHandler | null): void { + brokerConsentHandler = handler; +} + +async function confirmOnCli(prompt: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const ans = (await rl.question(prompt)).trim().toLowerCase(); + return ans === "y" || ans === "yes"; + } finally { + rl.close(); + } +} + +export async function requireBrokerConsent(action: string, detail: string): Promise { + if (currentBrokerName() != null) return true; + const cfg = loadConfig(); + if (brokerConsentHandler) { + return brokerConsentHandler({ + action, + detail, + broker: cfg.broker, + autonomy: cfg.autonomy, + }); + } + return confirmOnCli( + `\n >> Allow broker action: ${action}` + + `\n broker=${cfg.broker} autonomy=${cfg.autonomy}` + + (detail ? `\n ${detail}` : "") + + `\n proceed? [y/N]: `, + ); +} diff --git a/src/tools/journal.ts b/src/tools/journal.ts deleted file mode 100644 index 10e4d0a..0000000 --- a/src/tools/journal.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { tool } from "@anthropic-ai/claude-agent-sdk"; -import { z } from "zod"; -import { getDb } from "../storage/db.js"; - -function asText(obj: unknown) { - return { content: [{ type: "text" as const, text: JSON.stringify(obj) }] }; -} - -const ACTIONS = ["BUY", "SELL", "HOLD", "WATCH"] as const; - -export const journalAppendTool = tool( - "journal_append", - "Persist a buy/sell/hold/watch decision with the four-dimension rationale. Always call this when delivering a recommendation so it can be reviewed later.", - { - ticker: z.string(), - action: z.enum(ACTIONS), - rationale: z - .string() - .min(20) - .describe( - "Plain-text synthesis citing technical, fundamental, news, and macro evidence. Include the indicator values, ratios, and dates you relied on.", - ), - exit_plan: z - .string() - .optional() - .describe( - "Optional: stop-loss / take-profit / time-based exit thresholds.", - ), - }, - async ({ ticker, action, rationale, exit_plan }) => { - const db = getDb(); - const now = Math.floor(Date.now() / 1000); - const info = db - .prepare( - `INSERT INTO decisions (created_at, ticker, action, rationale, exit_plan) - VALUES (?, ?, ?, ?, ?)`, - ) - .run(now, ticker.toUpperCase(), action, rationale, exit_plan ?? null); - return asText({ ok: true, id: info.lastInsertRowid, created_at: now }); - }, -); - -export const journalReadTool = tool( - "journal_read", - "Read recent decisions from the journal. Optionally filter by ticker.", - { - ticker: z.string().optional(), - limit: z.number().int().min(1).max(50).default(10), - }, - async ({ ticker, limit }) => { - const db = getDb(); - const rows = ticker - ? (db - .prepare( - `SELECT id, created_at, ticker, action, rationale, exit_plan - FROM decisions WHERE ticker = ? ORDER BY created_at DESC LIMIT ?`, - ) - .all(ticker.toUpperCase(), limit) as Record[]) - : (db - .prepare( - `SELECT id, created_at, ticker, action, rationale, exit_plan - FROM decisions ORDER BY created_at DESC LIMIT ?`, - ) - .all(limit) as Record[]); - const items = rows.map((r) => ({ - ...r, - created_at: new Date((r.created_at as number) * 1000).toISOString(), - })); - return asText({ count: items.length, items }); - }, -); diff --git a/src/tools/order.ts b/src/tools/order.ts index 24be655..7e7b0e4 100644 --- a/src/tools/order.ts +++ b/src/tools/order.ts @@ -1,4 +1,3 @@ -import * as readline from "node:readline/promises"; import { tool } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; import { getBroker } from "../broker/index.js"; @@ -7,6 +6,7 @@ import { checkOrder } from "../risk/guardrails.js"; import { getStockOhlcv } from "../data/sources/dnsePublic.js"; import type { OrderStatus, PlaceOrderInput } from "../broker/types.js"; import { nowSec, currentBrokerName } from "../agent/clock.js"; +import { requireBrokerConsent } from "./brokerConsent.js"; function asText(obj: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(obj) }] }; @@ -19,22 +19,9 @@ async function lastClose(ticker: string): Promise { return bars.length ? bars[bars.length - 1]!.close : null; } -async function confirmOnCli(prompt: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - try { - const ans = (await rl.question(prompt)).trim().toLowerCase(); - return ans === "y" || ans === "yes"; - } finally { - rl.close(); - } -} - export const placeOrderTool = tool( "place_order", - "Place a paper or live broker order. Quantity must be a multiple of 100 (HOSE lot). limit_price is in thousand VND (e.g. 28.5 = 28,500 VND). In 'confirm' autonomy the user is prompted in the CLI before submission; in 'auto' the order is run through risk guardrails and then submitted. Always call journal_append after a successful fill to record the rationale.", + "Place a paper or live broker order. Quantity must be a multiple of 100 (HOSE lot). limit_price is in thousand VND (e.g. 28.5 = 28,500 VND). Outside backtests, the user is always prompted in the CLI before any broker call. If approved, the order is run through risk guardrails before submission.", { ticker: z.string(), side: z.enum(["BUY", "SELL"]), @@ -53,7 +40,6 @@ export const placeOrderTool = tool( }); } - const broker = getBroker(); const input: PlaceOrderInput = { ticker: ticker.toUpperCase(), side, @@ -64,6 +50,22 @@ export const placeOrderTool = tool( }; const refPrice = (await lastClose(input.ticker)) ?? input.limitPrice ?? null; + const px = + refPrice != null ? `${refPrice} (last close/reference)` : "(price unavailable)"; + + if (!inBacktest) { + const ok = await requireBrokerConsent( + "place_order", + `${side} ${quantity} ${input.ticker} ${type}` + + (limit_price ? ` @ ${limit_price}` : "") + + ` ref=${px}`, + ); + if (!ok) { + return asText({ ok: false, error: "user_declined" }); + } + } + + const broker = getBroker(); if (inBacktest || cfg.autonomy === "auto" || cfg.autonomy === "confirm") { if (refPrice == null) { @@ -85,20 +87,6 @@ export const placeOrderTool = tool( } } - if (!inBacktest && cfg.autonomy === "confirm") { - const px = - refPrice != null ? `${refPrice} (last close)` : "(price unavailable)"; - const ok = await confirmOnCli( - `\n >> ${side} ${quantity} ${input.ticker} ${type}` + - (limit_price ? ` @ ${limit_price}` : "") + - ` ref=${px}` + - `\n proceed? [y/N]: `, - ); - if (!ok) { - return asText({ ok: false, error: "user_declined" }); - } - } - const order = await broker.placeOrder(input); return asText({ ok: order.status === "FILLED" || order.status === "PENDING", @@ -109,9 +97,21 @@ export const placeOrderTool = tool( export const cancelOrderTool = tool( "cancel_order", - "Cancel a pending broker order by id.", + "Cancel a pending broker order by id. Outside backtests, the user is always prompted in the CLI before any broker call.", { id: z.string() }, async ({ id }) => { + const cfg = loadConfig(); + const inBacktest = currentBrokerName() != null; + if (!inBacktest && cfg.autonomy === "advisory") { + return asText({ + ok: false, + error: "autonomy=advisory; cancel_order is disabled.", + }); + } + if (!inBacktest) { + const ok = await requireBrokerConsent("cancel_order", `order_id=${id}`); + if (!ok) return asText({ ok: false, error: "user_declined" }); + } const broker = getBroker(); const order = await broker.cancelOrder(id); return asText({ ok: order.status === "CANCELLED", order }); @@ -120,13 +120,22 @@ export const cancelOrderTool = tool( export const listOrdersTool = tool( "list_orders", - "List recent broker orders (paper or live), optionally filtered by ticker or status.", + "List recent broker orders (paper or live), optionally filtered by ticker or status. Outside backtests, the user is always prompted in the CLI before any broker call.", { ticker: z.string().optional(), status: z.enum(["PENDING", "FILLED", "CANCELLED", "REJECTED"]).optional(), limit: z.number().int().min(1).max(200).default(20), }, async ({ ticker, status, limit }) => { + if (currentBrokerName() == null) { + const detail = [ + ticker ? `ticker=${ticker.toUpperCase()}` : "", + status ? `status=${status}` : "", + `limit=${limit}`, + ].filter(Boolean).join(" "); + const ok = await requireBrokerConsent("list_orders", detail); + if (!ok) return asText({ ok: false, error: "user_declined" }); + } const broker = getBroker(); const orders = await broker.listOrders({ ticker, @@ -139,9 +148,13 @@ export const listOrdersTool = tool( export const brokerStateTool = tool( "broker_state", - "Get the broker's current cash + open positions (paper or live).", + "Get the broker's current cash + open positions (paper or live). Outside backtests, the user is always prompted in the CLI before any broker call.", {}, async () => { + if (currentBrokerName() == null) { + const ok = await requireBrokerConsent("broker_state", "read cash and positions"); + if (!ok) return asText({ ok: false, error: "user_declined" }); + } const broker = getBroker(); const snap = await broker.snapshot(); return asText(snap); diff --git a/src/tools/portfolio.ts b/src/tools/portfolio.ts index eb23ed1..406d74a 100644 --- a/src/tools/portfolio.ts +++ b/src/tools/portfolio.ts @@ -3,6 +3,7 @@ import { getStockOhlcv } from "../data/sources/dnsePublic.js"; import { nowSec } from "../agent/clock.js"; import { getBroker } from "../broker/index.js"; import type { BrokerSnapshot } from "../broker/types.js"; +import { requireBrokerConsent } from "./brokerConsent.js"; function asText(obj: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(obj) }] }; @@ -21,18 +22,20 @@ export async function shapeBrokerPortfolio( ) { const positions = await Promise.all( snap.positions.map(async (p) => { - const px = await priceFor(p.ticker); + const px = p.lastPrice ?? await priceFor(p.ticker); const cost_basis_vnd = p.avgCost * p.quantity * 1000; - const market_value_vnd = px != null ? px * p.quantity * 1000 : null; + const market_value_vnd = p.marketValueVnd ?? (px != null ? px * p.quantity * 1000 : null); const unrealized_pnl_vnd = - px != null ? (px - p.avgCost) * p.quantity * 1000 : null; + p.unrealizedPnlVnd ?? (px != null ? (px - p.avgCost) * p.quantity * 1000 : null); const unrealized_pnl_pct = - px != null && p.avgCost > 0 + p.unrealizedPnlPct ?? (px != null && p.avgCost > 0 ? ((px - p.avgCost) / p.avgCost) * 100 - : null; + : null); return { ticker: p.ticker, quantity: p.quantity, + sub_account_id: p.subAccountId ?? null, + custody_code: p.custodyCode ?? null, avg_cost_thousand_vnd: p.avgCost, last_close_thousand_vnd: px, cost_basis_vnd, @@ -52,10 +55,14 @@ export async function shapeBrokerPortfolio( }, { cost_basis_vnd: 0, market_value_vnd: 0, unrealized_pnl_vnd: 0 }, ); + const total_equity_vnd = snap.cashVnd + totals.market_value_vnd; return { broker: snap.broker, cash_vnd: snap.cashVnd, + total_equity_vnd, + margin_used_vnd: snap.marginUsedVnd ?? 0, + sub_accounts: snap.subAccounts ?? [], positions, totals, }; @@ -63,9 +70,11 @@ export async function shapeBrokerPortfolio( export const listPositionsTool = tool( "portfolio_list", - "List broker positions with current price, cash, and unrealized P&L. Prices and avg_cost are in thousand VND (e.g. 28.5 = 28,500 VND). Monetary totals are returned in VND.", + "List broker positions with current price, cash, and unrealized P&L. Outside backtests, the user is always prompted in the CLI before any broker call. Prices and avg_cost are in thousand VND (e.g. 28.5 = 28,500 VND). Monetary totals are returned in VND.", {}, async () => { + const ok = await requireBrokerConsent("portfolio_list", "read cash, positions, and exposure"); + if (!ok) return asText({ ok: false, error: "user_declined" }); const snap = await getBroker().snapshot(); return asText(await shapeBrokerPortfolio(snap, lastClose)); }, diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 2c9e95e..c5516d7 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -6,6 +6,7 @@ import { ErrorBoundary } from "./components/ErrorBoundary.js"; import { ToolChip } from "./components/ToolChip.js"; import { Welcome } from "./components/Welcome.js"; import { LlmSetup } from "./components/LlmSetup.js"; +import { FhscSetup } from "./components/FhscSetup.js"; import { SLASH_COMMANDS, SlashSuggest, matchSlash } from "./components/SlashSuggest.js"; import { AgentStreamProvider, useAgentStreamCtx } from "./hooks/useAgentStreamContext.js"; import { type ChatBlock } from "./hooks/useAgentStream.js"; @@ -13,7 +14,8 @@ import { useNow } from "./hooks/useNow.js"; import { loadConfig, updateConfig } from "../config/loader.js"; import { resetBrokerCache } from "../broker/index.js"; import { collectHealth, renderHealth } from "../runtime/health.js"; -import { hasLlmEnvironment } from "../runtime/llmSetup.js"; +import { hasLlmEnvironment, type LlmVerificationInput } from "../runtime/llmSetup.js"; +import { azothPaths } from "../runtime/paths.js"; import { packageVersion } from "../runtime/version.js"; import { classifySession } from "./lib/marketSession.js"; import { formatBigVnd, formatPct, truncate } from "./lib/format.js"; @@ -21,11 +23,14 @@ import { theme, glyph } from "./lib/theme.js"; import { BACKTEST_DEFAULT_INTERVAL, runBacktestSession, type EquityPayload, type SummaryPayload } from "../agent/backtestRunner.js"; import { runTeamAnalysis, runTeamQuestion } from "../agent/team/index.js"; import type { FinalDecision, TeamEvent, TeamState } from "../agent/team/state.js"; -import { loadJournal, type JournalTab } from "./lib/journal.js"; -import { JournalCard } from "./lib/cards.js"; +import { setBrokerConsentHandler, type BrokerConsentRequest } from "../tools/brokerConsent.js"; type Autonomy = "advisory" | "confirm" | "auto"; +type PendingBrokerConsent = BrokerConsentRequest & { + resolve: (approved: boolean) => void; +}; + const THINKING_ANIMATION_INTERVAL_MS = 80; const BT_DEFAULTS = { cash: 1_000_000_000 }; const PACKAGE_VERSION = packageVersion(); @@ -92,7 +97,7 @@ function renderAnalyzeResult(state: TeamState, decision: FinalDecision) { const lines = [ "", `Final: ${decision.rating} ${(decision.sizingPct * 100).toFixed(1)}% ${decision.ticker}`, - `Run: ${decision.teamRunId.slice(0, 8)}${decision.journalId ? ` journal #${decision.journalId}` : ""}`, + `Run: ${decision.teamRunId.slice(0, 8)}`, ]; if (state.analysts.length) { lines.push("", "Analysts:"); @@ -154,6 +159,25 @@ function renderBacktestResult(start: string, end: string, initialCash: number, s ].join("\n"); } +function renderAbout(): string { + const cfg = loadConfig(); + const paths = azothPaths(); + return [ + `Azoth ${PACKAGE_VERSION}`, + "Professional agent CLI for Vietnam equity research, portfolio workflow, and broker-aware trading operations.", + "", + `Runtime: ${paths.home}`, + `Config: ${process.env.AZOTH_CONFIG ?? paths.config}`, + `Database: ${process.env.AZOTH_DB ?? paths.db}`, + `Broker: ${cfg.broker}`, + `Autonomy: ${cfg.autonomy}`, + `LLM: ${cfg.llm.provider}${cfg.llm.provider === "compatible" && cfg.llm.base_url ? " (custom base_url set)" : ""}`, + "", + "Release: docs/releases/v0.1.0.md", + "Roadmap: ROADMAP.md", + ].join("\n"); +} + function renderBlock(b: ChatBlock, toolResults: Map, columns = 80): React.ReactNode { switch (b.role) { case "user": { @@ -204,12 +228,16 @@ function renderBlock(b: ChatBlock, toolResults: Map, columns = 8 } } -export function App() { +export interface AppProps { + verifyLlm?: (input: LlmVerificationInput) => Promise; +} + +export function App({ verifyLlm }: AppProps = {}) { const [llmReady, setLlmReady] = useState(hasLlmEnvironment()); if (!llmReady) { return ( - setLlmReady(true)} /> + setLlmReady(true)} verify={verifyLlm} /> ); } @@ -217,13 +245,13 @@ export function App() { return ( - + ); } -function AppInner() { +function AppInner({ verifyLlm }: AppProps) { const cfg = loadConfig(); useApp(); const stream = useAgentStreamCtx(); @@ -235,6 +263,10 @@ function AppInner() { const [input, setInput] = useState(""); const [suggestSel, setSuggestSel] = useState(0); const [tick, setTick] = useState(0); + const [setupLlm, setSetupLlm] = useState(false); + const [setupFhsc, setSetupFhsc] = useState(false); + const [pendingBrokerConsent, setPendingBrokerConsent] = useState(null); + const [brokerConsentSel, setBrokerConsentSel] = useState(1); const now = useNow(30_000); const session = classifySession(now); @@ -254,6 +286,14 @@ function AppInner() { return () => clearInterval(id); }, [stream.streaming]); + useEffect(() => { + setBrokerConsentHandler((request) => new Promise((resolve) => { + setBrokerConsentSel(1); + setPendingBrokerConsent({ ...request, resolve }); + })); + return () => setBrokerConsentHandler(null); + }, []); + const thinkingDots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][tick % 10]; // Tool results map. Only committed blocks contribute (tool_result is always @@ -357,22 +397,6 @@ function AppInner() { } }; - const runJournal = (args: string[]) => { - const validTabs: JournalTab[] = ["decisions", "orders", "fills", "alerts"]; - let tab: JournalTab = "decisions"; - let limit = 10; - for (const a of args) { - if ((validTabs as string[]).includes(a)) tab = a as JournalTab; - else if (/^\d+$/.test(a)) limit = Number.parseInt(a, 10); - } - try { - const rows = loadJournal(tab, limit); - stream.appendCard(); - } catch (e) { - stream.systemMessage(`✗ journal error: ${(e as Error).message}`); - } - }; - const runAutonomy = (args: string[]) => { const next = args[0] as Autonomy | undefined; if (!next || !["advisory", "confirm", "auto"].includes(next)) { @@ -490,7 +514,34 @@ function AppInner() { } }; + const resolveBrokerConsent = (approved: boolean) => { + pendingBrokerConsent?.resolve(approved); + setPendingBrokerConsent(null); + setBrokerConsentSel(1); + }; + useInput((inp, key) => { + if (pendingBrokerConsent) { + if (key.ctrl && inp === "c") { + resolveBrokerConsent(false); + stream.abort(); + return; + } + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.tab) { + setBrokerConsentSel((sel) => (sel === 0 ? 1 : 0)); + return; + } + const normalized = inp.toLowerCase(); + if (normalized === "y") { + resolveBrokerConsent(true); + return; + } + if (normalized === "n" || key.escape || key.return) { + resolveBrokerConsent(key.return ? brokerConsentSel === 0 : false); + return; + } + return; + } if (key.ctrl && inp === "c" && stream.streaming) stream.abort(); if (key.ctrl && inp === "b" && backtestRef.current?.running) { backtestRef.current.ctrl.abort(); @@ -507,11 +558,13 @@ function AppInner() { }); const handleChange = (next: string) => { + if (pendingBrokerConsent) return; setInput(next); setSuggestSel(0); }; const handleSubmit = (raw: string) => { + if (pendingBrokerConsent) return; const v = raw.trim(); setInput(""); setSuggestSel(0); @@ -537,9 +590,11 @@ function AppInner() { else if (cmd === "team") void runTeamChat(arg); else if (cmd === "analyze") void runAnalyze(rest); else if (cmd === "backtest") void runBacktest(rest); - else if (cmd === "journal") runJournal(rest); + else if (cmd === "setup-llm") setSetupLlm(true); + else if (cmd === "setup-fhsc") setSetupFhsc(true); else if (cmd === "autonomy") runAutonomy(rest); else if (cmd === "health") void runHealth(rest); + else if (cmd === "about" || cmd === "version") stream.systemMessage(renderAbout()); else if (cmd === "quote" && arg) void stream.send(`Give me a market quote with technicals and recent news for ${arg.toUpperCase()}.`); else if (cmd === "positions") void stream.send("Summarize my current portfolio positions, unrealized PnL, and exposures."); else if (cmd === "help") { @@ -554,6 +609,30 @@ function AppInner() { const inputBorder = stream.streaming ? theme.thinking : theme.accent; + if (setupLlm) { + return ( + { + setSetupLlm(false); + stream.systemMessage("LLM setup saved. New turns will use the updated model configuration."); + }} + /> + ); + } + + if (setupFhsc) { + return ( + { + resetBrokerCache(); + setSetupFhsc(false); + stream.systemMessage("FHSC setup saved. Broker set to fhsc."); + }} + /> + ); + } + return ( @@ -589,13 +668,42 @@ function AppInner() { ) : null} + {pendingBrokerConsent ? ( + + Allow broker action? + action {pendingBrokerConsent.action} + broker {pendingBrokerConsent.broker} + autonomy {pendingBrokerConsent.autonomy} + {pendingBrokerConsent.detail ? detail {pendingBrokerConsent.detail} : null} + + {[ + { label: "Yes, allow once", hint: "Run this broker action now." }, + { label: "No, deny", hint: "Do not contact the broker." }, + ].map((option, i) => { + const active = brokerConsentSel === i; + return ( + + {active ? "› " : " "} + {option.label} + {option.hint} + + ); + })} + + ↑/↓ select · Enter confirm · y allow · n deny · Esc deny + + ) : null} {showSuggest ? : null} {"› "} - + {pendingBrokerConsent ? ( + broker approval pending + ) : ( + + )} - ↵ send · / cmds + {pendingBrokerConsent ? "↑↓ select · ↵ confirm" : "↵ send · / cmds"} = [ + { id: "session", label: "FHSC browser session", detail: "Access token, access key, and device id from the FHSC web app." }, + { id: "openapi", label: "FHSC OpenAPI key", detail: "Key/secret from quan-ly-api; may not authorize web /trade routes." }, +]; + +export interface FhscSetupProps { + onComplete: () => void; +} + +export function FhscSetup({ onComplete }: FhscSetupProps) { + const cfg = loadConfig(); + const paths = azothPaths(); + const initialMode: AuthMode = cfg.fhsc.access_token.trim() && cfg.fhsc.access_key.trim() ? "session" : "openapi"; + const [step, setStep] = useState("authMode"); + const [authMode, setAuthMode] = useState(initialMode); + const [authModeIdx, setAuthModeIdx] = useState(initialMode === "openapi" ? 1 : 0); + const [subAccountId, setSubAccountId] = useState(cfg.fhsc.sub_account_id); + const [accountId, setAccountId] = useState(cfg.fhsc.account_id); + const [baseUrl, setBaseUrl] = useState(normalizeFhscBaseUrl(cfg.fhsc.base_url || DEFAULT_FHSC_BASE_URL)); + const [accessToken, setAccessToken] = useState(cfg.fhsc.access_token); + const [accessKey, setAccessKey] = useState(cfg.fhsc.access_key); + const [deviceId, setDeviceId] = useState(cfg.fhsc.device_id); + const [apiKey, setApiKey] = useState(cfg.fhsc.api_key); + const [apiSecret, setApiSecret] = useState(cfg.fhsc.api_secret); + const [input, setInput] = useState(""); + const [error, setError] = useState(); + const [savedConfig, setSavedConfig] = useState(); + + useInput((inp, key) => { + if (step !== "authMode") return; + if (key.upArrow || key.leftArrow) setAuthModeIdx((idx) => (idx - 1 + AUTH_MODES.length) % AUTH_MODES.length); + if (key.downArrow || key.rightArrow || key.tab) setAuthModeIdx((idx) => (idx + 1) % AUTH_MODES.length); + if (key.return) { + const selected = AUTH_MODES[authModeIdx]!; + setAuthMode(selected.id); + setInput(subAccountId); + setStep("subAccount"); + } + if (inp === "1" || inp.toLowerCase() === "s") { + setAuthMode("session"); + setAuthModeIdx(0); + setInput(subAccountId); + setStep("subAccount"); + } + if (inp === "2" || inp.toLowerCase() === "o") { + setAuthMode("openapi"); + setAuthModeIdx(1); + setInput(subAccountId); + setStep("subAccount"); + } + }); + + const persist = (nextAccountId: string) => { + updateConfig({ + broker: "fhsc", + fhsc: { + sub_account_id: subAccountId, + account_id: nextAccountId, + base_url: baseUrl, + access_token: authMode === "session" ? accessToken : "", + access_key: authMode === "session" ? accessKey : "", + device_id: authMode === "session" ? deviceId : "", + user_id: cfg.fhsc.user_id, + cust_id: cfg.fhsc.cust_id, + api_key: authMode === "openapi" ? apiKey : "", + api_secret: authMode === "openapi" ? apiSecret : "", + }, + }); + setSavedConfig(paths.config); + setStep("done"); + }; + + const submit = (raw: string) => { + const value = raw.trim(); + setError(undefined); + + if (step === "subAccount") { + if (!value) { + setError("FHSC sub-account ID is required."); + return; + } + setSubAccountId(value); + setInput(authMode === "openapi" ? apiKey : accessToken); + setStep(authMode === "openapi" ? "apiKey" : "accessToken"); + return; + } + + if (step === "apiKey") { + if (!value) { + setError("FHSC API key is required."); + return; + } + setApiKey(value); + setInput(apiSecret); + setStep("apiSecret"); + return; + } + + if (step === "apiSecret") { + if (!value) { + setError("FHSC API secret is required."); + return; + } + setApiSecret(value); + setInput(baseUrl); + setStep("baseUrl"); + return; + } + + if (step === "accessToken") { + if (!value) { + setError("FHSC access token is required."); + return; + } + setAccessToken(value); + setInput(accessKey); + setStep("accessKey"); + return; + } + + if (step === "accessKey") { + if (!value) { + setError("FHSC access key is required."); + return; + } + setAccessKey(value); + setInput(deviceId); + setStep("deviceId"); + return; + } + + if (step === "deviceId") { + setDeviceId(value); + setInput(baseUrl); + setStep("baseUrl"); + return; + } + + if (step === "baseUrl") { + const nextBaseUrl = normalizeFhscBaseUrl(value || DEFAULT_FHSC_BASE_URL); + setBaseUrl(nextBaseUrl); + setInput(accountId); + setStep("accountId"); + return; + } + + if (step === "accountId") { + setAccountId(value); + setInput(""); + persist(value); + } + }; + + const label = + step === "subAccount" + ? "FHSC sub-account ID" + : step === "apiKey" + ? "FHSC API key" + : step === "apiSecret" + ? "FHSC API secret" + : step === "accessToken" + ? "FHSC access token" + : step === "accessKey" + ? "FHSC access key" + : step === "deviceId" + ? "FHSC device ID" + : step === "baseUrl" + ? "FHSC base URL" + : step === "accountId" + ? "FHSC account ID" + : ""; + + const placeholder = + step === "subAccount" + ? "numeric sub-account id" + : step === "apiKey" + ? "FHSC OpenAPI key" + : step === "apiSecret" + ? "FHSC OpenAPI secret" + : step === "accessToken" + ? "Bearer access token" + : step === "accessKey" + ? "x-access-key" + : step === "deviceId" + ? "optional browser device id" + : step === "baseUrl" + ? DEFAULT_FHSC_BASE_URL + : step === "accountId" + ? "optional; defaults to sub-account id" + : ""; + + const secretStep = step === "apiKey" || step === "apiSecret" || step === "accessToken" || step === "accessKey"; + + return ( + + + + {AZOTH_LOGO.map((line, i) => ( + {line} + ))} + + FHSC broker setup + + + config {paths.config} + + + + + FHSC Account + Configure read-only account, portfolio, order, transaction, and rights history access. + + {step === "authMode" ? ( + + Authentication method + {AUTH_MODES.map((m, i) => ( + + {i === authModeIdx ? "› " : " "} + {i + 1}. {m.label} + {m.detail} + + ))} + ↑/↓ select · Enter confirm · 1/2 quick select + + ) : null} + + {step !== "authMode" && step !== "done" ? ( + + {label} + + + + {step === "baseUrl" ? Leave blank to use {DEFAULT_FHSC_BASE_URL}. : null} + {step === "accountId" ? Leave blank unless FHSC uses a different account id for order history. : null} + + ) : null} + + {step === "done" ? ( + + FHSC broker saved. + broker fhsc + sub account {subAccountId} + auth {AUTH_MODES.find((m) => m.id === authMode)!.label} + base URL {baseUrl} + {accountId ? account ID {accountId} : null} + config {savedConfig} + + Press Enter to return to Azoth + + {}} onSubmit={onComplete} /> + + ) : null} + + {error ? ✗ {error} : null} + + + + ); +} diff --git a/src/tui/components/LlmSetup.tsx b/src/tui/components/LlmSetup.tsx index 389c43d..22b5454 100644 --- a/src/tui/components/LlmSetup.tsx +++ b/src/tui/components/LlmSetup.tsx @@ -49,16 +49,19 @@ export function LlmSetup({ onComplete, verify = verifyLlmEnvironment }: LlmSetup if (key.return) { const selected = PROVIDERS[providerIdx]!; setProvider(selected.id); + setInput(selected.id === "compatible" ? baseUrl : ""); setStep(selected.id === "compatible" ? "baseUrl" : "apiKey"); } if (inp === "1" || inp.toLowerCase() === "a") { setProvider("anthropic"); setProviderIdx(0); + setInput(""); setStep("apiKey"); } if (inp === "2" || inp.toLowerCase() === "c") { setProvider("compatible"); setProviderIdx(1); + setInput(baseUrl); setStep("baseUrl"); } } @@ -86,7 +89,7 @@ export function LlmSetup({ onComplete, verify = verifyLlmEnvironment }: LlmSetup } setBaseUrl(value); setInput(""); - setStep(apiKey.trim() ? "model" : "apiKey"); + setStep("apiKey"); return; } diff --git a/src/tui/components/SlashSuggest.tsx b/src/tui/components/SlashSuggest.tsx index 4c6054b..fef7916 100644 --- a/src/tui/components/SlashSuggest.tsx +++ b/src/tui/components/SlashSuggest.tsx @@ -11,11 +11,13 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: "team", args: "", description: "Run multi-agent debate on a question" }, { name: "analyze", args: " [--rounds N]", description: "Run structured team analysis on a ticker" }, { name: "backtest", args: "[start] [end] [cash] [--interval 1h]", description: "Run interval backtest, defaults to 30m" }, - { name: "journal", args: "[decisions|orders|fills|alerts] [N]", description: "Print latest journal rows inline" }, { name: "quote", args: "", description: "Quick quote for a ticker" }, { name: "positions", description: "Show current portfolio positions" }, + { name: "setup-llm", description: "Change LLM provider, API key, endpoint, and model" }, + { name: "setup-fhsc", description: "Configure FHSC broker access" }, { name: "autonomy", args: "", description: "Persist autonomy mode" }, { name: "health", args: "[--probe]", description: "Check local runtime and optional data provider reachability" }, + { name: "about", description: "Show version, runtime paths, broker, and provider" }, { name: "new", description: "Start a fresh resumable session" }, { name: "resume", args: "[id]", description: "Resume latest or a specific session" }, { name: "sessions", description: "List recent project sessions" }, diff --git a/src/tui/components/Welcome.tsx b/src/tui/components/Welcome.tsx index fffaf7f..d5ff2e7 100644 --- a/src/tui/components/Welcome.tsx +++ b/src/tui/components/Welcome.tsx @@ -18,7 +18,6 @@ const TIPS = [ ["/autonomy", "persist advisory · confirm · auto"], ["/health", "runtime · broker · provider checks"], ["/backtest", "team-driven interval simulation"], - ["/journal", "decisions · orders · fills · alerts"], ]; export interface WelcomeProps { @@ -76,7 +75,7 @@ export function Welcome({ version, autonomy, broker, cwd }: WelcomeProps) { What's new · Chat-first layout — market data flows into chat, no dashboard grid. - · /team, /analyze, /quote, /positions, /journal, /backtest render inline. + · /team, /analyze, /quote, /positions, and /backtest render inline. · /autonomy persists advisory, confirm, or auto mode. diff --git a/src/tui/lib/cards.tsx b/src/tui/lib/cards.tsx index 2abe65f..02a2fd0 100644 --- a/src/tui/lib/cards.tsx +++ b/src/tui/lib/cards.tsx @@ -3,7 +3,6 @@ import { Panel } from "../components/Panel.js"; import { theme } from "./theme.js"; import { pctColor, pnlColor } from "./colors.js"; import { formatBigVnd, formatPct, truncate } from "./format.js"; -import type { JournalTab, JournalRow } from "./journal.js"; import type { SummaryPayload } from "../../agent/backtestRunner.js"; import type { FinalDecision, TeamState } from "../../agent/team/state.js"; @@ -66,7 +65,7 @@ export function TeamDecisionCard({ data }: { data: TeamDecisionCardInput }) { {decision.rating} @@ -156,32 +155,3 @@ export function TeamQuestionCard({ data }: { data: TeamQuestionCardInput }) { ); } - -const HEADER: Record = { - decisions: "DECISIONS", - orders: "ORDERS", - fills: "FILLS", - alerts: "ALERTS", -}; - -export function JournalCard({ tab, rows }: { tab: JournalTab; rows: JournalRow[] }) { - return ( - - {rows.length === 0 ? ( - - ) : ( - rows.map((r) => ( - - {r.secondary.padEnd(12)} - {truncate(r.primary, 26).padEnd(26)} - {truncate(r.detail.replace(/\s+/g, " "), 60)} - - )) - )} - - ); -} diff --git a/src/tui/lib/journal.ts b/src/tui/lib/journal.ts deleted file mode 100644 index a1769f4..0000000 --- a/src/tui/lib/journal.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { getDb } from "../../storage/db.js"; -import { formatDate, formatPrice } from "./format.js"; - -export type JournalTab = "decisions" | "orders" | "fills" | "alerts"; - -export interface JournalRow { - id: string | number; - primary: string; - secondary: string; - detail: string; - ts: number; - color?: string; -} - -export function loadJournal(tab: JournalTab, limit = 10): JournalRow[] { - const db = getDb(); - const n = Math.max(1, Math.min(200, Math.floor(limit))); - if (tab === "decisions") { - const rows = db - .prepare( - "SELECT id, ticker, action, rating, rationale, exit_plan, created_at FROM decisions ORDER BY created_at DESC LIMIT ?", - ) - .all(n) as Array<{ id: number; ticker: string; action: string; rating: string | null; rationale: string; exit_plan: string; created_at: number }>; - return rows.map((r) => { - const rating = r.rating ?? r.action; - return { - id: r.id, - primary: `${rating.padEnd(11)} ${r.ticker}`, - secondary: formatDate(r.created_at), - detail: `Rating: ${rating}\nLegacy action: ${r.action}\nTicker: ${r.ticker}\nDate: ${formatDate(r.created_at)}\n\nRationale:\n${r.rationale ?? "—"}\n\nExit plan:\n${r.exit_plan ?? "—"}`, - ts: r.created_at, - color: - rating === "Buy" || rating === "Overweight" || r.action === "BUY" - ? "green" - : rating === "Sell" || rating === "Underweight" || r.action === "SELL" - ? "red" - : "yellow", - }; - }); - } - if (tab === "orders") { - const rows = db - .prepare( - "SELECT id, ticker, side, status, quantity, type, limit_price, filled_price, created_at, notes FROM broker_orders ORDER BY created_at DESC LIMIT ?", - ) - .all(n) as Array<{ id: string; ticker: string; side: string; status: string; quantity: number; type: string; limit_price: number | null; filled_price: number | null; created_at: number; notes: string | null }>; - return rows.map((r) => ({ - id: r.id, - primary: `${r.side.padEnd(4)} ${r.ticker} ${r.quantity}`, - secondary: `${r.status} · ${formatDate(r.created_at)}`, - detail: `Order ${r.id}\n${r.side} ${r.ticker} qty=${r.quantity} type=${r.type}\nlimit=${formatPrice(r.limit_price)} filled=${formatPrice(r.filled_price)}\nstatus=${r.status}\ndate=${formatDate(r.created_at)}\nnotes: ${r.notes ?? "—"}`, - ts: r.created_at, - color: r.side === "BUY" ? "green" : "red", - })); - } - if (tab === "fills") { - const rows = db - .prepare( - "SELECT id, ticker, side, quantity, filled_price, filled_at FROM broker_orders WHERE status = 'FILLED' ORDER BY filled_at DESC LIMIT ?", - ) - .all(n) as Array<{ id: string; ticker: string; side: string; quantity: number; filled_price: number; filled_at: number }>; - return rows.map((r) => ({ - id: r.id, - primary: `${r.side.padEnd(4)} ${r.ticker} ${r.quantity} @ ${formatPrice(r.filled_price)}`, - secondary: formatDate(r.filled_at), - detail: `${r.side} ${r.ticker}\nqty=${r.quantity}\nprice=${formatPrice(r.filled_price)}\nfilled=${formatDate(r.filled_at)}`, - ts: r.filled_at, - color: r.side === "BUY" ? "green" : "red", - })); - } - try { - const rows = db - .prepare("SELECT id, level, message, created_at FROM alerts ORDER BY created_at DESC LIMIT ?") - .all(n) as Array<{ id: number; level: string; message: string; created_at: number }>; - return rows.map((r) => ({ - id: r.id, - primary: r.level.toUpperCase(), - secondary: formatDate(r.created_at), - detail: r.message, - ts: r.created_at, - color: r.level === "critical" ? "red" : r.level === "warn" ? "yellow" : "white", - })); - } catch { - return []; - } -} diff --git a/src/tui/lib/toolSummary.ts b/src/tui/lib/toolSummary.ts index 8ba5c7c..eac6f99 100644 --- a/src/tui/lib/toolSummary.ts +++ b/src/tui/lib/toolSummary.ts @@ -83,13 +83,15 @@ export function summarizeToolResult(name: string, raw: string | undefined): stri const positions = obj.positions?.length ?? 0; return `cash ${formatBigVnd(cash)} · ${positions} positions`; } - case "journal_read": { - const rows = obj.rows ?? obj; - if (Array.isArray(rows)) return `${rows.length} entries`; - break; + case "portfolio_list": { + const positions = obj.positions?.length ?? 0; + const equity = obj.total_equity_vnd ?? obj.totals?.market_value_vnd; + return `equity ${formatBigVnd(equity)} · cash ${formatBigVnd(obj.cash_vnd)} · ${positions} positions`; + } + case "account_history": { + const counts = obj.counts ?? obj; + return `orders ${counts.orders ?? 0} · fills ${counts.fills ?? 0} · tx ${counts.transactions ?? 0} · rights ${counts.rights ?? 0}`; } - case "journal_append": - return obj.ok ? "appended" : "ok"; } } catch {} diff --git a/tests/broker.contract.test.ts b/tests/broker.contract.test.ts index 0a1ba93..161a4ea 100644 --- a/tests/broker.contract.test.ts +++ b/tests/broker.contract.test.ts @@ -6,12 +6,19 @@ * AZOTH_LIVE_TRADING=1 * For DNSE the live test is read-only (snapshot, listOrders) — it does NOT * place orders. + * + * FHSCBroker runs only when: + * FHSC_TEST_LIVE=1 + * FHSC_SUB_ACCOUNT_ID set + * FHSC_ACCESS_TOKEN or FHSC_API_KEY / FHSC_API_SECRET set + * The live test is read-only (snapshot, listOrders) — it does NOT place orders. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { existsSync, mkdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { PaperBroker } from "../src/broker/paper.js"; import { DNSEBroker } from "../src/broker/dnse.js"; +import { FHSCBroker } from "../src/broker/fhsc.js"; import { closeDb } from "../src/storage/db.js"; import type { Broker } from "../src/broker/types.js"; @@ -147,3 +154,24 @@ describe.skipIf(!liveDnse)("DNSEBroker (read-only, requires live env)", () => { expect(Array.isArray(orders)).toBe(true); }); }); + +const liveFhsc = process.env.FHSC_TEST_LIVE === "1"; + +describe.skipIf(!liveFhsc)("FHSCBroker (read-only, requires live env)", () => { + let broker: Broker; + beforeAll(() => { + broker = new FHSCBroker(); + }); + + it("snapshot returns cash + positions", async () => { + const snap = await broker.snapshot(); + expect(snap.broker).toBe("fhsc"); + expect(typeof snap.cashVnd).toBe("number"); + expect(Array.isArray(snap.positions)).toBe(true); + }); + + it("listOrders returns today's orders", async () => { + const orders = await broker.listOrders({ limit: 5 }); + expect(Array.isArray(orders)).toBe(true); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..ba1f8f4 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { isVersionCommand } from "../src/cli/args.js"; + +describe("Azoth CLI args", () => { + it.each([["--version"], ["-v"], ["version"]])("recognizes %s as a version command", (arg) => { + expect(isVersionCommand([arg])).toBe(true); + }); + + it("does not treat TUI or mixed args as version commands", () => { + expect(isVersionCommand([])).toBe(false); + expect(isVersionCommand(["--version", "--help"])).toBe(false); + expect(isVersionCommand(["about"])).toBe(false); + }); +}); diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts index dbdb4c2..ec4c9c0 100644 --- a/tests/orchestrator.test.ts +++ b/tests/orchestrator.test.ts @@ -3,6 +3,8 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +vi.setConfig({ testTimeout: 15_000 }); + vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ tool: (name: string, _desc: string, _schema: unknown, handler: unknown) => ({ name, @@ -54,6 +56,7 @@ describe("outer agent team delegation", () => { const prompt = buildSystemPrompt(); expect(prompt).toContain("team_question"); expect(prompt).toContain("team_analyze"); + expect(prompt).toContain("account_history"); expect(prompt).toContain("wait for that team tool to finish"); expect(prompt).toContain("Do not call duplicate market/fundamental/news/technical tools in parallel"); expect(prompt).toContain("Formal settlement"); @@ -63,10 +66,11 @@ describe("outer agent team delegation", () => { const opts = buildOptions(); expect(opts.allowedTools).toContain("mcp__azoth__team_question"); expect(opts.allowedTools).toContain("mcp__azoth__team_analyze"); + expect(opts.allowedTools).toContain("mcp__azoth__account_history"); const server = opts.mcpServers?.azoth as unknown as { tools: Array<{ name?: string }> }; expect(server.tools.map((t) => t.name)).toEqual( - expect.arrayContaining(["team_question", "team_analyze"]), + expect.arrayContaining(["team_question", "team_analyze", "account_history"]), ); }); }); diff --git a/tests/orderConfirm.test.ts b/tests/orderConfirm.test.ts index 827f450..52c628f 100644 --- a/tests/orderConfirm.test.ts +++ b/tests/orderConfirm.test.ts @@ -6,6 +6,10 @@ import { resetConfigCacheForTests } from "../src/config/loader.js"; const mocks = vi.hoisted(() => ({ placeOrder: vi.fn(), + cancelOrder: vi.fn(), + listOrders: vi.fn(), + snapshot: vi.fn(), + accountHistory: vi.fn(), question: vi.fn(), })); @@ -24,36 +28,96 @@ vi.mock("../src/data/sources/dnsePublic.js", () => ({ getStockOhlcv: vi.fn(async () => [{ time: 1, open: 30, high: 30, low: 30, close: 30, volume: 1000 }]), })); +vi.mock("../src/risk/vnMarketSession.js", () => ({ + checkVnMarketSession: vi.fn(() => ({ open: true, ictTime: "2026-05-14 10:00", session: "morning" })), +})); + vi.mock("../src/broker/index.js", () => ({ getBroker: () => ({ name: "paper", placeOrder: mocks.placeOrder, - cancelOrder: vi.fn(), - listOrders: vi.fn(), - snapshot: vi.fn(async () => ({ broker: "paper", cashVnd: 1_000_000_000, positions: [] })), + cancelOrder: mocks.cancelOrder, + listOrders: mocks.listOrders, + snapshot: mocks.snapshot, + accountHistory: mocks.accountHistory, }), })); let tmp: string; -beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "azoth-order-confirm-")); - process.env.AZOTH_CONFIG = join(tmp, "config.yaml"); - writeFileSync(process.env.AZOTH_CONFIG, [ - "autonomy: confirm", +function writeConfig(autonomy: "advisory" | "confirm" | "auto", maxOrderNotionalVnd: number) { + const configPath = process.env.AZOTH_CONFIG; + if (!configPath) throw new Error("AZOTH_CONFIG must be set for order confirmation tests"); + writeFileSync(configPath, [ + `autonomy: ${autonomy}`, "model: test-model", "broker: paper", "risk:", " max_position_pct: 0.15", " max_daily_loss_pct: 0.03", - " max_order_notional_vnd: 1", + ` max_order_notional_vnd: ${maxOrderNotionalVnd}`, " ticker_whitelist: []", " allow_margin: false", "", ].join("\n")); resetConfigCacheForTests(); +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "azoth-order-confirm-")); + process.env.AZOTH_CONFIG = join(tmp, "config.yaml"); + writeConfig("confirm", 1); mocks.placeOrder.mockReset(); + mocks.cancelOrder.mockReset(); + mocks.listOrders.mockReset(); + mocks.snapshot.mockReset(); + mocks.accountHistory.mockReset(); mocks.question.mockReset(); + mocks.question.mockResolvedValue("y"); + mocks.snapshot.mockResolvedValue({ broker: "paper", cashVnd: 1_000_000_000, positions: [] }); + mocks.placeOrder.mockResolvedValue({ + id: "order-1", + broker: "paper", + ticker: "FPT", + side: "BUY", + type: "MARKET", + quantity: 100, + limitPrice: null, + status: "FILLED", + rejectReason: null, + createdAt: 1, + filledAt: 1, + filledPrice: 30, + filledQty: 100, + notes: null, + }); + mocks.cancelOrder.mockResolvedValue({ + id: "order-1", + broker: "paper", + ticker: "FPT", + side: "BUY", + type: "LIMIT", + quantity: 100, + limitPrice: 30, + status: "CANCELLED", + rejectReason: null, + createdAt: 1, + filledAt: null, + filledPrice: null, + filledQty: null, + notes: null, + }); + mocks.listOrders.mockResolvedValue([]); + mocks.accountHistory.mockResolvedValue({ + broker: "paper", + fromDate: "2026-01-01", + toDate: "2026-05-14", + subAccounts: [], + orders: [], + fills: [], + transactions: [], + rights: [], + }); }); afterEach(() => { @@ -63,7 +127,7 @@ afterEach(() => { }); describe("confirm order flow", () => { - it("runs guardrails before prompting or submitting", async () => { + it("asks for broker consent before guardrails or submitting", async () => { const { placeOrderTool } = await import("../src/tools/order.js"); const result = await (placeOrderTool as unknown as { handler: (input: unknown) => Promise<{ content: Array<{ text: string }> }>; @@ -78,8 +142,91 @@ describe("confirm order flow", () => { expect(body.ok).toBe(false); expect(body.error).toBe("guardrail_blocked"); expect(body.reasons.join(" ")).toContain("exceeds max"); - expect(mocks.question).not.toHaveBeenCalled(); + expect(mocks.question).toHaveBeenCalledTimes(1); + expect(mocks.question.mock.calls[0]![0]).toContain("Allow broker action: place_order"); expect(mocks.placeOrder).not.toHaveBeenCalled(); }); -}); + it("does not contact the broker when the user declines a place_order", async () => { + writeConfig("auto", 1_000_000_000); + mocks.question.mockResolvedValue("n"); + const { placeOrderTool } = await import("../src/tools/order.js"); + + const result = await (placeOrderTool as unknown as { + handler: (input: unknown) => Promise<{ content: Array<{ text: string }> }>; + }).handler({ + ticker: "FPT", + side: "BUY", + type: "MARKET", + quantity: 100, + }); + + const body = JSON.parse(result.content[0]!.text) as { ok: boolean; error: string }; + expect(body.ok).toBe(false); + expect(body.error).toBe("user_declined"); + expect(mocks.snapshot).not.toHaveBeenCalled(); + expect(mocks.placeOrder).not.toHaveBeenCalled(); + }); + + it("prompts in auto mode before placing an approved order", async () => { + writeConfig("auto", 1_000_000_000); + const { placeOrderTool } = await import("../src/tools/order.js"); + + const result = await (placeOrderTool as unknown as { + handler: (input: unknown) => Promise<{ content: Array<{ text: string }> }>; + }).handler({ + ticker: "FPT", + side: "BUY", + type: "MARKET", + quantity: 100, + }); + + const body = JSON.parse(result.content[0]!.text) as { ok: boolean; order: { id: string } }; + expect(body.ok).toBe(true); + expect(body.order.id).toBe("order-1"); + expect(mocks.question).toHaveBeenCalledTimes(1); + expect(mocks.snapshot).toHaveBeenCalled(); + expect(mocks.placeOrder).toHaveBeenCalled(); + }); + + it("prompts before cancel_order", async () => { + const { cancelOrderTool } = await import("../src/tools/order.js"); + const result = await (cancelOrderTool as unknown as { + handler: (input: unknown) => Promise<{ content: Array<{ text: string }> }>; + }).handler({ id: "order-1" }); + + const body = JSON.parse(result.content[0]!.text) as { ok: boolean; order: { status: string } }; + expect(body.ok).toBe(true); + expect(body.order.status).toBe("CANCELLED"); + expect(mocks.question.mock.calls[0]![0]).toContain("Allow broker action: cancel_order"); + expect(mocks.cancelOrder).toHaveBeenCalledWith("order-1"); + }); + + it("prompts before broker read tools", async () => { + const { listOrdersTool, brokerStateTool } = await import("../src/tools/order.js"); + const { accountHistoryTool } = await import("../src/tools/accountHistory.js"); + + await (listOrdersTool as unknown as { + handler: (input: unknown) => Promise<{ content: Array<{ text: string }> }>; + }).handler({ limit: 5 }); + await (brokerStateTool as unknown as { + handler: (input: unknown) => Promise<{ content: Array<{ text: string }> }>; + }).handler({}); + await (accountHistoryTool as unknown as { + handler: (input: unknown) => Promise<{ content: Array<{ text: string }> }>; + }).handler({ kind: "all", from_date: "2026-01-01", to_date: "2026-05-14", limit: 5 }); + + expect(mocks.question).toHaveBeenCalledTimes(3); + expect(mocks.question.mock.calls[0]![0]).toContain("Allow broker action: list_orders"); + expect(mocks.question.mock.calls[1]![0]).toContain("Allow broker action: broker_state"); + expect(mocks.question.mock.calls[2]![0]).toContain("Allow broker action: account_history"); + expect(mocks.listOrders).toHaveBeenCalled(); + expect(mocks.snapshot).toHaveBeenCalled(); + expect(mocks.accountHistory).toHaveBeenCalledWith({ + fromDate: "2026-01-01", + toDate: "2026-05-14", + ticker: undefined, + limit: 5, + }); + }); +}); diff --git a/tests/team.test.ts b/tests/team.test.ts index 00ed3f1..7d7bd03 100644 --- a/tests/team.test.ts +++ b/tests/team.test.ts @@ -118,7 +118,7 @@ describe("runTeamAnalysis", () => { expect(pm).toContain("Write the user-facing summary, rationale, and narrative fields in Vietnamese"); }); - it("runs analysts → debate → trader → risk → portfolio in order and writes a journal entry", async () => { + it("runs analysts → debate → trader → risk → portfolio in order and records team outputs", async () => { pushResponse("technical", { summary: "uptrend with RSI 62", score: 0.4, detail: { rsi: 62 } }); pushResponse("fundamentals", { summary: "P/E 12, ROE 18", score: 0.3, detail: { pe: 12 } }); pushResponse("news", { summary: "no negative catalysts", score: 0.1, detail: {} }); @@ -180,22 +180,21 @@ describe("runTeamAnalysis", () => { "portfolio#", ]); - // Final decision matches portfolio output, persisted to decisions. + // Final decision matches portfolio output. expect(decision.rating).toBe("Overweight"); expect(decision.sizingPct).toBeCloseTo(0.04, 5); - expect(decision.journalId).toBeTypeOf("number"); expect(state.analysts).toHaveLength(4); expect(state.research).toHaveLength(4); expect(state.researchPlan?.recommendation).toBe("Overweight"); const db = getDb(); - const rows = db - .prepare("SELECT ticker, action, rating, source_run FROM decisions WHERE source_run = ?") - .all(state.runId) as Array<{ ticker: string; action: string; rating: string; source_run: string }>; - expect(rows).toHaveLength(1); - expect(rows[0]!.ticker).toBe("FPT"); - expect(rows[0]!.action).toBe("BUY"); - expect(rows[0]!.rating).toBe("Overweight"); + const runs = db + .prepare("SELECT ticker, final_action, final_rating FROM team_runs WHERE id = ?") + .all(state.runId) as Array<{ ticker: string; final_action: string; final_rating: string }>; + expect(runs).toHaveLength(1); + expect(runs[0]!.ticker).toBe("FPT"); + expect(runs[0]!.final_action).toBe("BUY"); + expect(runs[0]!.final_rating).toBe("Overweight"); const roleRows = db .prepare("SELECT role FROM team_role_outputs WHERE run_id = ?") diff --git a/tests/tui.test.tsx b/tests/tui.test.tsx index 890676d..48770ba 100644 --- a/tests/tui.test.tsx +++ b/tests/tui.test.tsx @@ -12,9 +12,10 @@ import { classifySession } from "../src/tui/lib/marketSession.js"; import { formatBigVnd, formatPct, formatPrice } from "../src/tui/lib/format.js"; import { getDb } from "../src/storage/db.js"; import { appendSessionRecord, createSession, latestSession, readSessionRecords } from "../src/runtime/sessionStore.js"; -import { resetConfigCacheForTests, updateConfig } from "../src/config/loader.js"; +import { loadConfig, resetConfigCacheForTests, updateConfig } from "../src/config/loader.js"; import { resetBrokerCache } from "../src/broker/index.js"; import { emitTeamToolEvent } from "../src/agent/team/toolEventBus.js"; +import { requireBrokerConsent } from "../src/tools/brokerConsent.js"; const runnerMocks = vi.hoisted(() => ({ runTeamAnalysis: vi.fn(), @@ -254,15 +255,6 @@ describe("Azoth TUI", () => { unmount(); }); - it("/journal prints rows inline in chat", async () => { - const { lastFrame, stdin, unmount } = render(); - await tick(); - await type(stdin, "/journal decisions 5"); - const out = strip(lastFrame() ?? ""); - expect(out).toContain("DECISIONS"); - unmount(); - }); - it("/backtest help prints usage in chat", async () => { const { lastFrame, stdin, unmount } = render(); await tick(); @@ -291,6 +283,104 @@ describe("Azoth TUI", () => { unmount(); }); + it("/setup-llm reopens LLM setup after first-time setup", async () => { + const verify = vi.fn().mockResolvedValue(undefined); + const { lastFrame, stdin, unmount } = render(); + await tick(); + await type(stdin, "/setup-llm"); + expect(strip(lastFrame() ?? "")).toContain("Azoth first-time LLM setup"); + expect(strip(lastFrame() ?? "")).toContain("Select provider"); + + await enter(stdin); + await type(stdin, "sk-updated"); + await type(stdin, "glm-5.1-updated"); + + const saved = strip(lastFrame() ?? ""); + expect(saved).toContain("LLM environment saved."); + expect(saved).toContain("glm-5.1-updated"); + + await enter(stdin); + const cfg = loadConfig(); + expect(cfg.llm.provider).toBe("anthropic"); + expect(cfg.llm.api_key).toBe("sk-updated"); + expect(cfg.model).toBe("glm-5.1-updated"); + expect(verify).toHaveBeenCalledWith({ + provider: "anthropic", + apiKey: "sk-updated", + baseUrl: "", + model: "glm-5.1-updated", + }); + expect(strip(lastFrame() ?? "")).toContain("LLM setup saved"); + unmount(); + }); + + it("/setup-llm asks for API key when reconfiguring a compatible provider", async () => { + updateConfig({ + model: "old-model", + llm: { + provider: "compatible", + api_key: "old-key", + base_url: "https://provider.example.com/api/anthropic", + }, + }); + const verify = vi.fn().mockResolvedValue(undefined); + const { lastFrame, stdin, unmount } = render(); + await tick(); + await type(stdin, "/setup-llm"); + expect(strip(lastFrame() ?? "")).toContain("Select provider"); + + await enter(stdin); + expect(strip(lastFrame() ?? "")).toContain("Custom endpoint base URL"); + await enter(stdin); + expect(strip(lastFrame() ?? "")).toContain("API key for Anthropic-compatible provider"); + + await type(stdin, "new-key"); + await type(stdin, "new-compatible-model"); + + await enter(stdin); + const cfg = loadConfig(); + expect(cfg.llm.provider).toBe("compatible"); + expect(cfg.llm.api_key).toBe("new-key"); + expect(cfg.llm.base_url).toBe("https://provider.example.com/api/anthropic"); + expect(cfg.model).toBe("new-compatible-model"); + unmount(); + }); + + it("/setup-fhsc collects FHSC config and switches broker", async () => { + const { lastFrame, stdin, unmount } = render(); + await tick(); + await type(stdin, "/setup-fhsc"); + expect(strip(lastFrame() ?? "")).toContain("FHSC broker setup"); + expect(strip(lastFrame() ?? "")).toContain("Authentication method"); + + await type(stdin, "2"); + await type(stdin, "123456"); + await type(stdin, "fhsc-key"); + await type(stdin, "fhsc-secret"); + await enter(stdin); + await enter(stdin); + + const saved = strip(lastFrame() ?? ""); + expect(saved).toContain("FHSC broker saved."); + expect(saved).toContain("123456"); + expect(saved).toContain("https://api.vinasecurities.com"); + + await enter(stdin); + const cfg = loadConfig(); + expect(cfg.broker).toBe("fhsc"); + expect(cfg.fhsc.sub_account_id).toBe("123456"); + expect(cfg.fhsc.api_key).toBe("fhsc-key"); + expect(cfg.fhsc.api_secret).toBe("fhsc-secret"); + expect(cfg.fhsc.access_token).toBe(""); + expect(cfg.fhsc.access_key).toBe(""); + expect(cfg.fhsc.device_id).toBe(""); + expect(cfg.fhsc.user_id).toBe(""); + expect(cfg.fhsc.cust_id).toBe(""); + expect(cfg.fhsc.base_url).toBe("https://api.vinasecurities.com"); + expect(strip(lastFrame() ?? "")).toContain("FHSC setup saved"); + unmount(); + }); + it("/health prints local runtime checks", async () => { const { lastFrame, stdin, unmount } = render(); await tick(); @@ -304,6 +394,28 @@ describe("Azoth TUI", () => { unmount(); }); + it("renders broker consent as a selectable TUI prompt", async () => { + const { lastFrame, stdin, unmount } = render(); + await tick(); + + const decision = requireBrokerConsent("portfolio_list", "read cash, positions, and exposure"); + await tick(); + let out = strip(lastFrame() ?? ""); + expect(out).toContain("Allow broker action?"); + expect(out).toContain("Yes, allow once"); + expect(out).toContain("No, deny"); + expect(out).toContain("↑/↓ select"); + expect(out).toContain("› No, deny"); + + stdin.write("\u001B[A"); + await tick(); + out = strip(lastFrame() ?? ""); + expect(out).toContain("› Yes, allow once"); + await enter(stdin); + await expect(decision).resolves.toBe(true); + unmount(); + }); + it("/team streams the team chat flow as a plain response", async () => { runnerMocks.runTeamQuestion.mockImplementationOnce(async (question: string, opts: any) => { opts.emit?.({ type: "role_start", role: "bull", round: 1 }); @@ -483,7 +595,6 @@ describe("Azoth TUI", () => { rationale: "Momentum and fundamentals justify a modest overweight.", exitPlan: "Cut if trend breaks.", teamRunId: "team-run-87654321", - journalId: 12, }, }; }); @@ -706,7 +817,7 @@ describe("Azoth TUI", () => { expect(out).toContain("/team"); expect(out).toContain("/analyze"); expect(out).toContain("/backtest"); - expect(out).toContain("/journal"); + expect(out).not.toContain("/journal"); expect(out).not.toContain("/chart"); expect(out).not.toContain("/alerts"); expect(out).toContain("Tab to complete"); @@ -724,25 +835,29 @@ describe("Azoth TUI", () => { unmount(); }); - it("/journal renders as a bordered card", async () => { + it("/help prints local slash command help", async () => { const { lastFrame, stdin, unmount } = render(); await tick(); - await type(stdin, "/journal decisions 5"); + await type(stdin, "/help"); const out = strip(lastFrame() ?? ""); - expect(out).toContain("DECISIONS"); - // Panel uses round borders; assert at least one border glyph appears with the card. - expect(out).toMatch(/[╭╮╰╯─│]/); + expect(out).toContain("/team "); + expect(out).toContain("/analyze "); + expect(out).not.toContain("/journal"); + expect(out).toContain("/about"); unmount(); }); - it("/help prints local slash command help", async () => { + it("/about prints version and runtime context", async () => { const { lastFrame, stdin, unmount } = render(); await tick(); - await type(stdin, "/help"); + await type(stdin, "/about"); const out = strip(lastFrame() ?? ""); - expect(out).toContain("/team "); - expect(out).toContain("/analyze "); - expect(out).toContain("/journal [decisions|orders|fills|alerts]"); + expect(out).toMatch(/Azoth 0\.1\.\d+/); + expect(out).toContain("Runtime:"); + expect(out).toContain("Database:"); + expect(out).toContain("Broker: paper"); + expect(out).toContain("Autonomy: advisory"); + expect(out).toContain("Roadmap: ROADMAP.md"); unmount(); }); From bb74c7589158b9c6a92f50aa70b48fea9427f2eb Mon Sep 17 00:00:00 2001 From: toreleon Date: Thu, 14 May 2026 16:16:52 +0700 Subject: [PATCH 2/2] Redact FHSC probe config output --- scripts/fhsc-probe.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/fhsc-probe.ts b/scripts/fhsc-probe.ts index 19f7707..18bda79 100644 --- a/scripts/fhsc-probe.ts +++ b/scripts/fhsc-probe.ts @@ -127,7 +127,9 @@ async function main() { ]; const variants = headerVariants(apiKey, apiSecret, accessToken, accessKey, deviceId); - console.log(`FHSC probe sub_account=${subAccountId} account=${accountId || "(same)"} bases=${bases.join(", ")}`); + console.log( + `FHSC probe sub_account=${subAccountId ? "set" : "missing"} account=${accountId ? "set" : "missing"} bases=${bases.join(", ")}`, + ); for (const base of bases) { for (const p of paths) { for (const variant of variants) {