From 0f3f4f76efd007c55c725042b40dcb5a6a22d95d Mon Sep 17 00:00:00 2001 From: toreleon Date: Mon, 4 May 2026 14:04:44 +0700 Subject: [PATCH 1/4] chore: prepare release 0.1.1 --- README.md | 4 +++- docs/releases/v0.1.1.md | 14 ++++++++++++++ package.json | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 docs/releases/v0.1.1.md diff --git a/README.md b/README.md index 1557a31..7021e70 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ autonomy and risk settings. > real orders against a real account. Use advisory or paper mode until you have > verified configuration, data quality, account state, and risk limits. -Latest release: [v0.1.0](docs/releases/v0.1.0.md) +Latest release: [v0.1.1](docs/releases/v0.1.1.md) ## Terminal UI @@ -325,6 +325,8 @@ reduce repeated network calls. ## Release Notes +- [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, guardrails, backtesting, DNSE integration foundations, npm packaging, and diff --git a/docs/releases/v0.1.1.md b/docs/releases/v0.1.1.md new file mode 100644 index 0000000..e4c323a --- /dev/null +++ b/docs/releases/v0.1.1.md @@ -0,0 +1,14 @@ +# Azoth v0.1.1 + +Release date: 2026-05-04 + +Azoth v0.1.1 republishes the v0.1 public baseline through the GitHub Actions +release pipeline after the locally published v0.1.0 artifact was unpublished. +There are no runtime feature changes from the v0.1.0 baseline. + +## Changes + +- Publish through the protected-branch GitHub release workflow. +- Preserve the single public `azoth` CLI command. +- Keep the v0.1 agent team, TUI, backtest, roadmap, and release documentation + baseline intact. diff --git a/package.json b/package.json index cbd36e8..8079a54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@toreleon/azoth", - "version": "0.1.0", + "version": "0.1.1", "description": "Professional agent CLI for Vietnam equity research, portfolio workflow, and broker-aware trading operations", "type": "module", "license": "MIT", From c2a3f3b074eabeb6694939c4e6a8ef5d50d5228a Mon Sep 17 00:00:00 2001 From: toreleon Date: Mon, 4 May 2026 14:20:29 +0700 Subject: [PATCH 2/4] Make stock discovery and backtest defaults dynamic --- README.md | 12 ++- docs/agent-team.md | 8 +- src/agent/backtestRunner.ts | 23 +++-- src/agent/orchestrator.ts | 2 +- src/agent/team/prompts.ts | 2 +- src/data/sources/vndirectFinfo.ts | 22 +++++ src/tools/discover.ts | 139 ++++++++++++++++++++++++---- src/tui/App.tsx | 26 +++++- src/tui/components/SlashSuggest.tsx | 2 +- tests/discover.test.ts | 75 +++++++++++++++ tests/tui.test.tsx | 57 +++++++++++- 11 files changed, 326 insertions(+), 42 deletions(-) create mode 100644 tests/discover.test.ts diff --git a/README.md b/README.md index 7021e70..ddd160b 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Check market, portfolio, journal, and backtest state: /quote VCB /positions /journal decisions 10 -/backtest 2025-01-03 2025-04-30 1000000000 +/backtest ``` ## Highlights @@ -166,7 +166,13 @@ Check market and portfolio state: /journal decisions 10 ``` -Run a backtest: +Run a backtest for the previous calendar week: + +```text +/backtest +``` + +You can still provide an explicit range and starting cash: ```text /backtest 2025-01-03 2025-04-30 1000000000 @@ -219,7 +225,7 @@ 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]` | Run a weekly backtest and render results inline. | +| `/backtest [start] [end] [cash]` | Run a weekly backtest and render results inline. Defaults to the previous calendar week. | | `/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. | diff --git a/docs/agent-team.md b/docs/agent-team.md index 22d3e79..ebc7971 100644 --- a/docs/agent-team.md +++ b/docs/agent-team.md @@ -143,7 +143,7 @@ broker orders, and records the resulting equity curve. ```mermaid flowchart TD - User["User: /backtest START END CASH"] --> Init["Create backtest run"] + User["User: /backtest [START END CASH]"] --> Init["Create backtest run"] Init --> Broker["Create isolated paper broker"] Init --> Data["Load universe OHLCV and VNINDEX"] Data --> Fridays["Find Friday trading turns"] @@ -178,7 +178,11 @@ The backtest loop has several important constraints: - The active clock is pinned to the historical Friday turn, so market tools do not see future bars. -- Candidate discovery defaults to a momentum scan over Azoth's default universe. +- When no date range is supplied, `/backtest` uses the previous calendar week. +- Weekly turns use the last available trading close in each week, so holiday + Fridays still produce a replay turn when the week has earlier trading data. +- Candidate discovery can scan listed equities or a caller-provided ticker + basket; backtests use the default liquid universe to keep replay cost bounded. - Each candidate receives a one-round team analysis to keep replay cost bounded. - Web search is disabled during backtests so historical turns rely on Azoth's market data and cached/local tools rather than current open-web context. diff --git a/src/agent/backtestRunner.ts b/src/agent/backtestRunner.ts index 1d69675..efb52a1 100644 --- a/src/agent/backtestRunner.ts +++ b/src/agent/backtestRunner.ts @@ -75,15 +75,18 @@ function dateOf(epochSec: number): string { return new Date(epochSec * 1000).toISOString().slice(0, 10); } -function fridayCloses(vnindexBars: Bar[], startSec: number, endSec: number): number[] { - const out: number[] = []; +function weeklyCloses(vnindexBars: Bar[], startSec: number, endSec: number): number[] { + const byWeek = new Map(); for (const b of vnindexBars) { if (b.time < startSec || b.time > endSec) continue; const d = new Date(b.time * 1000); const ict = new Date(d.getTime() + 7 * 3600 * 1000); - if (ict.getUTCDay() === 5) out.push(b.time); + const day = ict.getUTCDay(); + const monday = new Date(Date.UTC(ict.getUTCFullYear(), ict.getUTCMonth(), ict.getUTCDate())); + monday.setUTCDate(monday.getUTCDate() - (day === 0 ? 6 : day - 1)); + byWeek.set(monday.toISOString().slice(0, 10), b.time); } - return out; + return [...byWeek.values()].sort((a, b) => a - b); } function lotRound(qty: number): number { @@ -235,10 +238,10 @@ export async function runBacktestSession( } const vnindex = await getIndexOhlcv("VNINDEX", "1D", fetchFrom, fetchTo); - const fridays = fridayCloses(vnindex, startSec, endSec); - if (fridays.length === 0) throw new Error("no Friday trading days in range"); + const weeklyTurns = weeklyCloses(vnindex, startSec, endSec); + if (weeklyTurns.length === 0) throw new Error("no trading days in range"); - cb.onStart?.({ runId, strategy: BACKTEST_STRATEGY_NAME, brokerName, fridays, universe }); + cb.onStart?.({ runId, strategy: BACKTEST_STRATEGY_NAME, brokerName, fridays: weeklyTurns, universe }); db.prepare( `INSERT INTO backtest_runs @@ -269,14 +272,14 @@ export async function runBacktestSession( const series = vnindex.filter((b) => b.time <= asOf); return series.length ? series[series.length - 1]!.close : null; }; - const vnindexBaseline = vnindexAt(fridays[0]!); - if (vnindexBaseline == null) throw new Error("no VNINDEX data at first Friday"); + const vnindexBaseline = vnindexAt(weeklyTurns[0]!); + if (vnindexBaseline == null) throw new Error("no VNINDEX data at first weekly turn"); let peakMtm = opts.initialCash; let freezeBuys = false; try { - for (const asOf of fridays) { + for (const asOf of weeklyTurns) { throwIfAborted(cb.signal); const dateIso = dateOf(asOf); const priceOverride = (sym: string): number | null => { diff --git a/src/agent/orchestrator.ts b/src/agent/orchestrator.ts index d4afa45..a1c617f 100644 --- a/src/agent/orchestrator.ts +++ b/src/agent/orchestrator.ts @@ -54,7 +54,7 @@ export function buildSystemPrompt(): string { "- 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.", - "- discover_tickers: dynamically scan a curated VN30+midcap universe by criterion (momentum / breakout / oversold / low_volatility / high_volume / top_gainers / top_losers) and return 5–10 ranked candidates.", + "- 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" diff --git a/src/agent/team/prompts.ts b/src/agent/team/prompts.ts index 3b57105..e138c82 100644 --- a/src/agent/team/prompts.ts +++ b/src/agent/team/prompts.ts @@ -231,7 +231,7 @@ export function traderPrompt( "Tools:", "- portfolio_list (see existing exposure)", "- journal_read (recent decisions on this name)", - "- discover_tickers (only if you need to compare alternatives)", + "- discover_tickers (compare alternatives by signal/strategy; pass explicit tickers when the user names a basket)", "", "Analyst reports:", renderAnalysts(analysts), diff --git a/src/data/sources/vndirectFinfo.ts b/src/data/sources/vndirectFinfo.ts index 08a4e0a..7ea1521 100644 --- a/src/data/sources/vndirectFinfo.ts +++ b/src/data/sources/vndirectFinfo.ts @@ -85,3 +85,25 @@ export async function getCompanyProfile(ticker: string): Promise>(url); return json.data[0] ?? null; } + +export type ListedExchange = "HOSE" | "HNX" | "UPCOM"; + +export async function getCompanyProfilesByFloor( + floor: ListedExchange, + size = 1000, +): Promise { + const q = encodeURIComponent(`floor:${floor}`); + const url = `${FINFO}/v4/company_profiles?q=${q}&size=${size}`; + const json = await getJson>(url); + return json.data; +} + +export async function getListedEquityTickers( + floors: readonly ListedExchange[] = ["HOSE", "HNX", "UPCOM"], +): Promise { + const profiles = (await Promise.all(floors.map((floor) => getCompanyProfilesByFloor(floor)))).flat(); + const tickers = profiles + .map((p) => p.code.toUpperCase()) + .filter((code) => /^[A-Z]{3}$/.test(code)); + return Array.from(new Set(tickers)).sort(); +} diff --git a/src/tools/discover.ts b/src/tools/discover.ts index 5e75137..413e476 100644 --- a/src/tools/discover.ts +++ b/src/tools/discover.ts @@ -1,6 +1,7 @@ import { tool } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; import { getStockOhlcv } from "../data/sources/dnsePublic.js"; +import { getListedEquityTickers, type ListedExchange } from "../data/sources/vndirectFinfo.js"; import { nowSec } from "../agent/clock.js"; import { cached } from "../data/cache.js"; @@ -9,12 +10,10 @@ function asText(obj: unknown) { } /** - * Curated liquid universe (~28 tickers): VN30 large caps + the most active - * mid-caps across banking, real estate, retail, steel, brokers, F&B, energy. - * The discovery tool ranks within this set so we don't have to scan all - * ~1,600 listed names. Edit here to expand the search space. + * Default liquid universe used when the caller wants a fast scan. The tool can + * also scan exchange-wide listed tickers or a caller-provided ticker basket. */ -export const DISCOVERY_UNIVERSE: readonly string[] = [ +export const DEFAULT_DISCOVERY_UNIVERSE: readonly string[] = [ // Banks "VCB", "BID", "CTG", "TCB", "MBB", "ACB", "VPB", "STB", "HDB", // Real estate @@ -30,12 +29,14 @@ export const DISCOVERY_UNIVERSE: readonly string[] = [ ]; export const TICKER_UNIVERSES = { - default: DISCOVERY_UNIVERSE, + default: DEFAULT_DISCOVERY_UNIVERSE, vn30: ["VCB", "BID", "CTG", "TCB", "MBB", "ACB", "VPB", "STB", "HDB", "VHM", "VIC", "HPG", "GAS", "PLX", "VNM", "MWG", "MSN", "SAB", "FPT", "GVR", "POW", "SSI", "PNJ", "NVL"] as const, banks: ["VCB", "BID", "CTG", "TCB", "MBB", "ACB", "VPB", "STB", "HDB"] as const, bluechip: ["VCB", "FPT", "HPG", "VNM", "VHM", "VIC", "MWG", "GAS"] as const, } as const; +export const DISCOVERY_UNIVERSE = DEFAULT_DISCOVERY_UNIVERSE; + interface Candidate { ticker: string; metric: number | null; @@ -121,7 +122,22 @@ export type DiscoverCriterion = | "top_gainers" | "top_losers"; -export type DiscoverUniverse = keyof typeof TICKER_UNIVERSES; +export type DiscoverStrategy = + | "trend_following" + | "breakout" + | "mean_reversion" + | "defensive" + | "liquidity_surge" + | "relative_strength" + | "weakness"; + +export type DiscoverUniverse = + | keyof typeof TICKER_UNIVERSES + | "hose" + | "hnx" + | "upcom" + | "all_listed" + | "custom"; export interface DiscoverResult { criterion: DiscoverCriterion; @@ -140,15 +156,16 @@ export interface DiscoverResult { } export async function discoverTickers(input: { - criterion: DiscoverCriterion; + criterion?: DiscoverCriterion; + strategy?: DiscoverStrategy; limit?: number; universe?: DiscoverUniverse; + tickers?: readonly string[]; }): Promise { - const criterion = input.criterion; + const criterion = input.criterion ?? criterionForStrategy(input.strategy) ?? "momentum"; const limit = input.limit ?? 8; - const universe = input.universe ?? "default"; - const tickers = TICKER_UNIVERSES[universe]; - const candidates = await Promise.all(tickers.map(buildCandidate)); + const { universe, tickers } = await resolveUniverse(input.universe, input.tickers); + const candidates = await mapLimit(tickers, 12, buildCandidate); const valid = candidates.filter((c) => c.latest_close != null); let scored: Candidate[]; @@ -212,12 +229,86 @@ export async function discoverTickers(input: { }; } +function criterionForStrategy(strategy: DiscoverStrategy | undefined): DiscoverCriterion | undefined { + switch (strategy) { + case "trend_following": + return "momentum"; + case "breakout": + return "breakout"; + case "mean_reversion": + return "oversold"; + case "defensive": + return "low_volatility"; + case "liquidity_surge": + return "high_volume"; + case "relative_strength": + return "top_gainers"; + case "weakness": + return "top_losers"; + default: + return undefined; + } +} + +function normalizeTickers(tickers: readonly string[]): string[] { + return Array.from( + new Set( + tickers + .map((t) => t.trim().toUpperCase()) + .filter((t) => /^[A-Z0-9]{2,8}$/.test(t)), + ), + ); +} + +async function listedTickers(floors: readonly ListedExchange[]): Promise { + const key = `listed-equity-tickers:${floors.join(",")}`; + return cached(key, 24 * 3600, () => getListedEquityTickers(floors)); +} + +async function resolveUniverse( + universe: DiscoverUniverse | undefined, + tickers: readonly string[] | undefined, +): Promise<{ universe: DiscoverUniverse; tickers: string[] }> { + const custom = normalizeTickers(tickers ?? []); + if (custom.length > 0) return { universe: "custom", tickers: custom }; + + const selected = universe ?? "all_listed"; + if (selected in TICKER_UNIVERSES) { + return { + universe: selected, + tickers: [...TICKER_UNIVERSES[selected as keyof typeof TICKER_UNIVERSES]], + }; + } + if (selected === "hose") return { universe: selected, tickers: await listedTickers(["HOSE"]) }; + if (selected === "hnx") return { universe: selected, tickers: await listedTickers(["HNX"]) }; + if (selected === "upcom") return { universe: selected, tickers: await listedTickers(["UPCOM"]) }; + return { universe: "all_listed", tickers: await listedTickers(["HOSE", "HNX", "UPCOM"]) }; +} + +async function mapLimit( + items: readonly T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const out: R[] = new Array(items.length); + let next = 0; + async function worker(): Promise { + while (next < items.length) { + const idx = next++; + out[idx] = await fn(items[idx]!); + } + } + const workers = Array.from({ length: Math.min(concurrency, items.length) }, worker); + await Promise.all(workers); + return out; +} + export const discoverTickersTool = tool( "discover_tickers", [ - "Discover Vietnamese stocks matching a strategy criterion. Use this at the START of each turn to build a focused 5–10 ticker candidate set for THIS week.", - "Searches a curated liquid universe (~28 names: VN30 + active mid-caps). Returns candidates ranked by the chosen metric, with 1w/1m return, RSI14, and 5d/20d volume ratio.", - "Criteria:", + "Discover Vietnamese stocks matching a signal or strategy. Use this at the START of each turn to build a focused 5–10 ticker candidate set for THIS week.", + "By default this scans listed HOSE/HNX/UPCOM equities, not a fixed index. You may also pass an explicit tickers basket or a faster preset universe.", + "Signals / criteria:", "- 'momentum': highest 1-month return, breaking out (rsi 50-75, rising vol).", "- 'breakout': highest 1-week return with vol_ratio > 1.3.", "- 'oversold': lowest RSI14 (mean-reversion candidates).", @@ -234,12 +325,22 @@ export const discoverTickersTool = tool( "high_volume", "top_gainers", "top_losers", - ]), + ]).optional(), + strategy: z.enum([ + "trend_following", + "breakout", + "mean_reversion", + "defensive", + "liquidity_surge", + "relative_strength", + "weakness", + ]).optional(), limit: z.number().int().min(3).max(15).default(8), - universe: z.enum(["default", "vn30", "banks", "bluechip"]).default("default"), + universe: z.enum(["all_listed", "hose", "hnx", "upcom", "default", "vn30", "banks", "bluechip"]).default("all_listed"), + tickers: z.array(z.string()).min(1).max(250).optional(), }, - async ({ criterion, limit, universe }) => { - const result = await discoverTickers({ criterion, limit, universe }); + async ({ criterion, strategy, limit, universe, tickers }) => { + const result = await discoverTickers({ criterion, strategy, limit, universe, tickers }); return asText(result); }, ); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index c0496ba..5b969c9 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -27,9 +27,26 @@ import { JournalCard } from "./lib/cards.js"; type Autonomy = "advisory" | "confirm" | "auto"; const THINKING_ANIMATION_INTERVAL_MS = 80; -const BT_DEFAULTS = { start: "2025-01-03", end: "2025-04-30", cash: 1_000_000_000 }; +const BT_DEFAULTS = { cash: 1_000_000_000 }; const PACKAGE_VERSION = packageVersion(); +function isoLocalDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +export function previousWeekRange(now = new Date()): { start: string; end: string } { + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const day = end.getDay(); + const daysSincePreviousSunday = day === 0 ? 7 : day; + end.setDate(end.getDate() - daysSincePreviousSunday); + const start = new Date(end); + start.setDate(end.getDate() - 6); + return { start: isoLocalDate(start), end: isoLocalDate(end) }; +} + function teamRoleDesc(output: Record, mode: "analyze" | "question" | "backtest") { if ("score" in output) { const suffix = mode === "backtest" ? "" : ` ${truncate(String(output.summary ?? ""), 60)}`; @@ -382,7 +399,7 @@ function AppInner() { const runBacktest = async (args: string[]) => { if (args[0] === "help") { - stream.systemMessage("/backtest [YYYY-MM-DD start] [YYYY-MM-DD end] [cash VND] [--max-candidates N]"); + stream.systemMessage("/backtest [YYYY-MM-DD start] [YYYY-MM-DD end] [cash VND] [--max-candidates N]\nNo dates = previous calendar week."); return; } if (backtestRef.current?.running) { @@ -399,8 +416,9 @@ function AppInner() { : maxCandidatesEq ? Number.parseInt(maxCandidatesEq.slice("--max-candidates=".length), 10) : undefined; - const start = dates[0] ?? BT_DEFAULTS.start; - const end = dates[1] ?? BT_DEFAULTS.end; + const defaultRange = previousWeekRange(); + const start = dates[0] ?? defaultRange.start; + const end = dates[1] ?? defaultRange.end; const initialCash = cashArg ? Number.parseInt(cashArg, 10) : BT_DEFAULTS.cash; stream.beginLocalResponse(`/backtest ${start} ${end} ${initialCash}`); diff --git a/src/tui/components/SlashSuggest.tsx b/src/tui/components/SlashSuggest.tsx index fcb5fc2..7cf682b 100644 --- a/src/tui/components/SlashSuggest.tsx +++ b/src/tui/components/SlashSuggest.tsx @@ -10,7 +10,7 @@ export interface SlashCommand { 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]", description: "Run a weekly backtest, results inline" }, + { name: "backtest", args: "[start] [end] [cash]", description: "Run a weekly backtest, defaults to last week" }, { 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" }, diff --git a/tests/discover.test.ts b/tests/discover.test.ts new file mode 100644 index 0000000..d7811e7 --- /dev/null +++ b/tests/discover.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; + +const requestedSymbols: string[] = []; + +const seriesByTicker: Record> = { + AAA: makeSeries(10, 1_000_000), + BBB: makeSeries(20, 1_000_000, -0.2), + CCC: makeSeries(30, 1_000_000, 0.4), + DDD: makeSeries(40, 1_000_000), +}; + +vi.mock("../src/data/cache.js", () => ({ + cached: (_key: string, _ttl: number, fn: () => unknown) => fn(), +})); + +vi.mock("../src/data/sources/vndirectFinfo.js", () => ({ + getListedEquityTickers: vi.fn(async () => ["AAA", "BBB", "CCC"]), +})); + +vi.mock("../src/data/sources/dnsePublic.js", () => ({ + getStockOhlcv: vi.fn(async (ticker: string) => { + requestedSymbols.push(ticker); + return (seriesByTicker[ticker] ?? makeSeries(10, 1_000_000)).map((b, i) => ({ + time: i + 1, + open: b.close, + high: b.close, + low: b.close, + close: b.close, + volume: b.volume, + })); + }), +})); + +function makeSeries( + start: number, + volume: number, + dailyStep = 0.1, +): Array<{ close: number; volume: number }> { + return Array.from({ length: 30 }, (_, i) => ({ + close: start + i * dailyStep, + volume, + })); +} + +describe("discoverTickers", () => { + it("uses explicit ticker baskets instead of a fixed universe", async () => { + requestedSymbols.length = 0; + const { discoverTickers } = await import("../src/tools/discover.js"); + + const result = await discoverTickers({ + criterion: "top_gainers", + tickers: ["ccc", " ddd ", "CCC"], + limit: 3, + }); + + expect(result.universe).toBe("custom"); + expect(result.universe_size).toBe(2); + expect(requestedSymbols.sort()).toEqual(["CCC", "DDD"]); + expect(result.candidates.map((c) => c.ticker)).toEqual(["CCC", "DDD"]); + }); + + it("defaults to listed equities and maps strategy aliases to criteria", async () => { + requestedSymbols.length = 0; + const { discoverTickers } = await import("../src/tools/discover.js"); + + const result = await discoverTickers({ + strategy: "mean_reversion", + limit: 3, + }); + + expect(result.criterion).toBe("oversold"); + expect(result.universe).toBe("all_listed"); + expect(requestedSymbols.sort()).toEqual(["AAA", "BBB", "CCC"]); + }); +}); diff --git a/tests/tui.test.tsx b/tests/tui.test.tsx index 99fce8d..da5aca0 100644 --- a/tests/tui.test.tsx +++ b/tests/tui.test.tsx @@ -4,7 +4,7 @@ import { join } from "node:path"; import React from "react"; import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import { render } from "ink-testing-library"; -import { App } from "../src/tui/App.js"; +import { App, previousWeekRange } from "../src/tui/App.js"; import { LlmSetup } from "../src/tui/components/LlmSetup.js"; import { sparkline } from "../src/tui/lib/sparkline.js"; import { vnColor, pctColor } from "../src/tui/lib/colors.js"; @@ -269,9 +269,17 @@ describe("Azoth TUI", () => { const out = strip(lastFrame() ?? ""); expect(out).toContain("/backtest"); expect(out).toContain("[YYYY-MM-DD start]"); + expect(out).toContain("previous calendar week"); unmount(); }); + it("computes the previous calendar week for default backtests", () => { + expect(previousWeekRange(new Date(2026, 4, 4))).toEqual({ + start: "2026-04-27", + end: "2026-05-03", + }); + }); + it("/autonomy persists mode and updates the UI", async () => { const { lastFrame, stdin, unmount } = render(); await tick(); @@ -628,6 +636,53 @@ describe("Azoth TUI", () => { unmount(); }); + it("/backtest defaults to the previous week when no dates are supplied", async () => { + runnerMocks.runBacktestSession.mockImplementationOnce(async (opts: any, cb: any) => { + cb.onStart?.({ + runId: "bt-run-default", + strategy: "team-default", + brokerName: "paper-bt-test", + fridays: [1], + universe: ["HPG"], + }); + return { + runId: "bt-run-default", + strategy: "team-default", + start: opts.start, + end: opts.end, + initialCash: opts.initialCash, + finalMtm: opts.initialCash, + finalBench: opts.initialCash, + totalReturn: 0, + benchReturn: 0, + maxDD: 0, + totalCost: 0, + totalInTokens: 0, + totalOutTokens: 0, + weeks: 0, + trades: 0, + rejectedTrades: 0, + reportPath: null, + }; + }); + + const expected = previousWeekRange(); + const { stdin, unmount } = render(); + await tick(); + await type(stdin, "/backtest"); + await tick(); + + expect(runnerMocks.runBacktestSession).toHaveBeenCalledWith( + expect.objectContaining({ + start: expected.start, + end: expected.end, + initialCash: 1_000_000_000, + }), + expect.any(Object), + ); + unmount(); + }); + it("typing / shows the slash command suggest", async () => { const { lastFrame, stdin, unmount } = render(); await tick(); From 7243f70c9cf79d442450c043b6559f908af57210 Mon Sep 17 00:00:00 2001 From: toreleon Date: Mon, 4 May 2026 14:38:13 +0700 Subject: [PATCH 3/4] Add configurable backtest intervals --- README.md | 9 ++- docs/agent-team.md | 28 +++++---- src/agent/backtestRunner.ts | 92 +++++++++++++++++++---------- src/data/cache.ts | 2 +- src/risk/guardrails.ts | 4 +- src/storage/schema.sql | 2 +- src/tui/App.tsx | 30 ++++++---- src/tui/components/SlashSuggest.tsx | 2 +- src/tui/components/Welcome.tsx | 2 +- src/tui/lib/cards.tsx | 3 +- tests/tui.test.tsx | 20 ++++++- 11 files changed, 130 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index ddd160b..0179a09 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,13 @@ You can still provide an explicit range and starting cash: /backtest 2025-01-03 2025-04-30 1000000000 ``` +Backtests default to 30-minute turns. Use `--interval` to choose a slower +cadence such as `1h` or `2h`: + +```text +/backtest 2025-01-03 2025-04-30 1000000000 --interval 1h +``` + Manage sessions: ```text @@ -225,7 +232,7 @@ 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]` | Run a weekly backtest and render results inline. Defaults to the previous calendar week. | +| `/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. | diff --git a/docs/agent-team.md b/docs/agent-team.md index ebc7971..e56e486 100644 --- a/docs/agent-team.md +++ b/docs/agent-team.md @@ -137,18 +137,20 @@ The top-level chat agent can also route automatically: Azoth's `/backtest` command reuses the same team analysis engine in replay mode. Instead of asking the team to analyze one live ticker, the backtest runner -walks through historical Friday closes, discovers candidates, runs the team on -each candidate as of that historical date, converts final decisions into paper -broker orders, and records the resulting equity curve. +walks through historical interval closes, discovers candidates, runs the team +on each candidate as of that historical timestamp, converts final decisions into +paper broker orders, and records the resulting equity curve. The default +interval is 30 minutes; `/backtest --interval 1h` or `--interval 2h` can be used +to slow the replay cadence. ```mermaid flowchart TD User["User: /backtest [START END CASH]"] --> Init["Create backtest run"] Init --> Broker["Create isolated paper broker"] Init --> Data["Load universe OHLCV and VNINDEX"] - Data --> Fridays["Find Friday trading turns"] + Data --> Intervals["Find interval turns"] - Fridays --> Turn["Weekly turn"] + Intervals --> Turn["Interval turn"] Turn --> AsOf["Set simulated as-of date"] AsOf --> Discovery["Discover momentum candidates"] Discovery --> Held["Include existing holdings"] @@ -168,7 +170,7 @@ flowchart TD Persist --> Drawdown{"Drawdown above floor?"} Drawdown -- yes --> Freeze["Freeze new buys next turn"] Drawdown -- no --> Continue["Continue normally"] - Freeze --> Next{"More Fridays?"} + Freeze --> Next{"More intervals?"} Continue --> Next Next -- yes --> Turn Next -- no --> Summary["Return summary: return, benchmark, max DD, trades, rejected trades, tokens, cost"] @@ -176,11 +178,13 @@ flowchart TD The backtest loop has several important constraints: -- The active clock is pinned to the historical Friday turn, so market tools do - not see future bars. +- The active clock is pinned to each historical interval close, so market tools + do not see future bars. - When no date range is supplied, `/backtest` uses the previous calendar week. -- Weekly turns use the last available trading close in each week, so holiday - Fridays still produce a replay turn when the week has earlier trading data. +- The strategy makes decisions one configured interval at a time. It does not + batch the whole day or week into one decision. +- Team prompts and operating rules treat Vietnam listed equity settlement as + T+2 and prohibit same-day round trips. - Candidate discovery can scan listed equities or a caller-provided ticker basket; backtests use the default liquid universe to keep replay cost bounded. - Each candidate receives a one-round team analysis to keep replay cost bounded. @@ -198,9 +202,9 @@ The backtest loop has several important constraints: Backtest persistence uses the same local SQLite runtime: - `backtest_runs` stores strategy name, start/end dates, initial cash, universe, - model, broker name, candidate limit, and risk settings. + model, broker name, interval, candidate limit, and risk settings. - `backtest_turns` stores the prompt, team response summary, token usage, and - cost for each historical turn. + cost for each historical interval turn. - `backtest_equity` stores cash, mark-to-market equity, and benchmark equity. - Broker tables store paper orders, fills, rejects, positions, and cash. - Team-run tables store the role outputs generated during candidate analysis. diff --git a/src/agent/backtestRunner.ts b/src/agent/backtestRunner.ts index efb52a1..e91de2c 100644 --- a/src/agent/backtestRunner.ts +++ b/src/agent/backtestRunner.ts @@ -13,6 +13,7 @@ import type { BrokerPosition, Order, PlaceOrderInput } from "../broker/types.js" const BACKTEST_STRATEGY_NAME = "team-default"; const BACKTEST_DISCOVERY_CRITERION = "momentum"; const BACKTEST_DISCOVERY_UNIVERSE = "default"; +export const BACKTEST_DEFAULT_INTERVAL = "30m"; const BACKTEST_MAX_POSITION_PCT = 0.15; const BACKTEST_MAX_DRAWDOWN_FLOOR = 0.15; const BACKTEST_DEFAULT_CANDIDATES = 3; @@ -22,7 +23,9 @@ export interface BacktestOptions { start: string; end: string; initialCash: number; - /** Number of discovered names to run through the full team each week. */ + /** Replay cadence. Supports 30m, 60m, 1h, 2h, etc. Default: 30m. */ + interval?: string; + /** Number of discovered names to run through the full team each interval. */ maxCandidates?: number; } @@ -48,6 +51,11 @@ export interface SummaryPayload { totalCost: number; totalInTokens: number; totalOutTokens: number; + interval: string; + intervals: number; + /** @deprecated use intervals */ + sessions: number; + /** @deprecated use intervals */ weeks: number; trades: number; rejectedTrades: number; @@ -55,7 +63,7 @@ export interface SummaryPayload { } export interface BacktestCallbacks { - onStart?: (info: { runId: string; strategy: string; brokerName: string; fridays: number[]; universe: string[] }) => void; + onStart?: (info: { runId: string; strategy: string; brokerName: string; interval: string; turns: number[]; fridays: number[]; universe: string[] }) => void; onTurnStart?: (info: { asOf: number; dateIso: string }) => void; onTeamEvent?: (ev: TeamEvent, ctx: { asOf: number; dateIso: string; ticker: string }) => void; onOrder?: (order: Order, ctx: { asOf: number; dateIso: string; decision: FinalDecision }) => void; @@ -65,28 +73,46 @@ export interface BacktestCallbacks { signal?: AbortSignal; } -function isoSec(d: string): number { - const t = Date.parse(`${d}T15:00:00+07:00`); +function isoSec(d: string, time = "15:00:00"): number { + const t = Date.parse(`${d}T${time}+07:00`); if (Number.isNaN(t)) throw new Error(`bad date: ${d}`); return Math.floor(t / 1000); } -function dateOf(epochSec: number): string { - return new Date(epochSec * 1000).toISOString().slice(0, 10); +function ictLabel(epochSec: number): string { + const d = new Date(epochSec * 1000 + 7 * 3600 * 1000); + return d.toISOString().slice(0, 16).replace("T", " "); } -function weeklyCloses(vnindexBars: Bar[], startSec: number, endSec: number): number[] { - const byWeek = new Map(); - for (const b of vnindexBars) { - if (b.time < startSec || b.time > endSec) continue; - const d = new Date(b.time * 1000); - const ict = new Date(d.getTime() + 7 * 3600 * 1000); - const day = ict.getUTCDay(); - const monday = new Date(Date.UTC(ict.getUTCFullYear(), ict.getUTCMonth(), ict.getUTCDate())); - monday.setUTCDate(monday.getUTCDate() - (day === 0 ? 6 : day - 1)); - byWeek.set(monday.toISOString().slice(0, 10), b.time); +function parseBacktestInterval(value: string | undefined): { label: string; minutes: number } { + const raw = (value ?? BACKTEST_DEFAULT_INTERVAL).trim().toLowerCase(); + const m = raw.match(/^(\d+)\s*(m|min|h|hr)$/); + if (!m) throw new Error(`bad interval: ${value}. Use 30m, 1h, 2h, etc.`); + const n = Number.parseInt(m[1]!, 10); + const unit = m[2]!; + const minutes = unit.startsWith("h") ? n * 60 : n; + if (minutes < 30 || minutes % 30 !== 0) { + throw new Error(`bad interval: ${value}. Interval must be 30 minutes or a multiple of 30 minutes.`); } - return [...byWeek.values()].sort((a, b) => a - b); + if (minutes > 24 * 60) { + throw new Error(`bad interval: ${value}. Interval must be 24h or shorter.`); + } + return { label: minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`, minutes }; +} + +function intervalCloses(vnindexBars: Bar[], startSec: number, endSec: number, intervalMinutes: number): number[] { + const base = vnindexBars + .filter((b) => b.time >= startSec && b.time <= endSec) + .map((b) => b.time) + .sort((a, b) => a - b); + const step = Math.max(1, Math.round(intervalMinutes / 30)); + if (step === 1) return base; + + const out: number[] = []; + for (let i = step - 1; i < base.length; i += step) out.push(base[i]!); + const last = base[base.length - 1]; + if (last != null && out[out.length - 1] !== last) out.push(last); + return out; } function lotRound(qty: number): number { @@ -219,8 +245,9 @@ export async function runBacktestSession( 1, Math.min(opts.maxCandidates ?? BACKTEST_DEFAULT_CANDIDATES, BACKTEST_MAX_CANDIDATES), ); + const interval = parseBacktestInterval(opts.interval); - const startSec = isoSec(opts.start); + const startSec = isoSec(opts.start, "00:00:00"); const endSec = isoSec(opts.end); if (endSec <= startSec) throw new Error("end must be after start"); @@ -229,29 +256,30 @@ export async function runBacktestSession( const broker = getBacktestBroker(brokerName, opts.initialCash); broker.reset(opts.initialCash); - const fetchFrom = startSec - 90 * 86400; + const fetchFrom = startSec - 7 * 86400; const fetchTo = endSec + 7 * 86400; const bars: Record = {}; for (const t of universe) { - bars[t] = await getStockOhlcv(t, "1D", fetchFrom, fetchTo); + bars[t] = await getStockOhlcv(t, "30", fetchFrom, fetchTo); if (cb.signal?.aborted) throw new Error("aborted"); } - const vnindex = await getIndexOhlcv("VNINDEX", "1D", fetchFrom, fetchTo); + const vnindex = await getIndexOhlcv("VNINDEX", "30", fetchFrom, fetchTo); - const weeklyTurns = weeklyCloses(vnindex, startSec, endSec); - if (weeklyTurns.length === 0) throw new Error("no trading days in range"); + const intervalTurns = intervalCloses(vnindex, startSec, endSec, interval.minutes); + if (intervalTurns.length === 0) throw new Error(`no ${interval.label} trading intervals in range`); - cb.onStart?.({ runId, strategy: BACKTEST_STRATEGY_NAME, brokerName, fridays: weeklyTurns, universe }); + cb.onStart?.({ runId, strategy: BACKTEST_STRATEGY_NAME, brokerName, interval: interval.label, turns: intervalTurns, fridays: intervalTurns, universe }); db.prepare( `INSERT INTO backtest_runs (id, persona, start_date, end_date, cadence, initial_cash_vnd, config_json, created_at) - VALUES (?, ?, ?, ?, 'weekly', ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( runId, BACKTEST_STRATEGY_NAME, startSec, endSec, + interval.label, opts.initialCash, JSON.stringify({ universe, @@ -259,6 +287,7 @@ export async function runBacktestSession( broker: brokerName, strategy: BACKTEST_STRATEGY_NAME, engine: "team", + interval: interval.label, maxCandidates, discoveryCriterion: BACKTEST_DISCOVERY_CRITERION, discoveryUniverse: BACKTEST_DISCOVERY_UNIVERSE, @@ -272,16 +301,16 @@ export async function runBacktestSession( const series = vnindex.filter((b) => b.time <= asOf); return series.length ? series[series.length - 1]!.close : null; }; - const vnindexBaseline = vnindexAt(weeklyTurns[0]!); - if (vnindexBaseline == null) throw new Error("no VNINDEX data at first weekly turn"); + const vnindexBaseline = vnindexAt(intervalTurns[0]!); + if (vnindexBaseline == null) throw new Error(`no VNINDEX data at first ${interval.label} turn`); let peakMtm = opts.initialCash; let freezeBuys = false; try { - for (const asOf of weeklyTurns) { + for (const asOf of intervalTurns) { throwIfAborted(cb.signal); - const dateIso = dateOf(asOf); + const dateIso = ictLabel(asOf); const priceOverride = (sym: string): number | null => { const series = bars[sym]?.filter((b) => b.time <= asOf) ?? []; return series.length ? series[series.length - 1]!.close : null; @@ -289,7 +318,7 @@ export async function runBacktestSession( broker.setPriceOverride(priceOverride); cb.onTurnStart?.({ asOf, dateIso }); - const prompt = `Team backtest ${dateIso}: discover ${BACKTEST_DISCOVERY_CRITERION}/${BACKTEST_DISCOVERY_UNIVERSE}, analyze candidates, execute broker orders from final decisions.`; + const prompt = `Team backtest ${dateIso} ICT: ${interval.label} interval turn under T+2 settlement assumptions; discover ${BACKTEST_DISCOVERY_CRITERION}/${BACKTEST_DISCOVERY_UNIVERSE}, analyze candidates, execute broker orders from final decisions.`; let response = ""; let inTokens = 0; let outTokens = 0; @@ -426,6 +455,9 @@ export async function runBacktestSession( totalCost, totalInTokens: totalIn, totalOutTokens: totalOut, + interval: interval.label, + intervals: equityRows.length, + sessions: equityRows.length, weeks: equityRows.length, trades: orderRows.length, rejectedTrades: rejectedOrderRows.length, diff --git a/src/data/cache.ts b/src/data/cache.ts index 27b82f9..177814e 100644 --- a/src/data/cache.ts +++ b/src/data/cache.ts @@ -21,7 +21,7 @@ export function resetCacheStats() { /** * In backtest mode, prefix the key with the simulated as-of date so - * cached payloads from different weeks don't collide. Historical data + * cached payloads from different simulated sessions don't collide. Historical data * up to that as-of is immutable, so the prefix also unlocks safe reuse * across runs at the same as-of. */ diff --git a/src/risk/guardrails.ts b/src/risk/guardrails.ts index ab9aead..89edda3 100644 --- a/src/risk/guardrails.ts +++ b/src/risk/guardrails.ts @@ -92,8 +92,8 @@ export async function checkOrder( } // Backtest mode (per-run broker pinned via ALS): skip wall-clock checks. - // The harness only fires on simulated Friday closes, which by definition - // sit outside live trading hours of the real wall clock. + // The harness fires on simulated historical interval closes, which are + // independent of the real wall clock. if (inBacktest) { return { ok: reasons.length === 0, reasons }; } diff --git a/src/storage/schema.sql b/src/storage/schema.sql index 1b6371e..38b183c 100644 --- a/src/storage/schema.sql +++ b/src/storage/schema.sql @@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS backtest_runs ( persona TEXT NOT NULL, start_date INTEGER NOT NULL, end_date INTEGER NOT NULL, - cadence TEXT NOT NULL, -- "weekly" + cadence TEXT NOT NULL, -- e.g. "30m", "1h", "2h" initial_cash_vnd INTEGER NOT NULL, config_json TEXT NOT NULL, created_at INTEGER NOT NULL, diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 5b969c9..2c9e95e 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -18,7 +18,7 @@ import { packageVersion } from "../runtime/version.js"; import { classifySession } from "./lib/marketSession.js"; import { formatBigVnd, formatPct, truncate } from "./lib/format.js"; import { theme, glyph } from "./lib/theme.js"; -import { runBacktestSession, type EquityPayload, type SummaryPayload } from "../agent/backtestRunner.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"; @@ -147,7 +147,7 @@ function renderBacktestResult(start: string, end: string, initialCash: number, s return [ "", `Backtest ${start} -> ${end}`, - `Cash: ${formatBigVnd(initialCash)} weeks: ${summary.weeks} trades: ${summary.trades}${summary.rejectedTrades ? ` rejected: ${summary.rejectedTrades}` : ""}`, + `Cash: ${formatBigVnd(initialCash)} interval: ${summary.interval ?? BACKTEST_DEFAULT_INTERVAL} turns: ${summary.intervals ?? summary.sessions ?? summary.weeks} trades: ${summary.trades}${summary.rejectedTrades ? ` rejected: ${summary.rejectedTrades}` : ""}`, `Final: ${formatBigVnd(summary.finalMtm)} bench: ${formatBigVnd(summary.finalBench)}`, `Return: ${formatPct(summary.totalReturn)} bench: ${formatPct(summary.benchReturn)} alpha: ${formatPct(alpha)} maxDD: ${formatPct(summary.maxDD * 100)}`, `Cost: $${summary.totalCost.toFixed(4)}`, @@ -399,7 +399,7 @@ function AppInner() { const runBacktest = async (args: string[]) => { if (args[0] === "help") { - stream.systemMessage("/backtest [YYYY-MM-DD start] [YYYY-MM-DD end] [cash VND] [--max-candidates N]\nNo dates = previous calendar week."); + stream.systemMessage("/backtest [YYYY-MM-DD start] [YYYY-MM-DD end] [cash VND] [--interval 30m|1h|2h] [--max-candidates N]\nNo dates = previous calendar week. Default interval = 30m."); return; } if (backtestRef.current?.running) { @@ -410,6 +410,14 @@ function AppInner() { const cashArg = args.find((a) => /^\d{4,}$/.test(a)); const maxCandidatesArg = args.findIndex((a) => a === "--max-candidates"); const maxCandidatesEq = args.find((a) => a.startsWith("--max-candidates=")); + const intervalArg = args.findIndex((a) => a === "--interval"); + const intervalEq = args.find((a) => a.startsWith("--interval=")); + const interval = + intervalArg >= 0 && args[intervalArg + 1] + ? args[intervalArg + 1] + : intervalEq + ? intervalEq.slice("--interval=".length) + : BACKTEST_DEFAULT_INTERVAL; const maxCandidates = maxCandidatesArg >= 0 && args[maxCandidatesArg + 1] ? Number.parseInt(args[maxCandidatesArg + 1]!, 10) @@ -421,29 +429,29 @@ function AppInner() { const end = dates[1] ?? defaultRange.end; const initialCash = cashArg ? Number.parseInt(cashArg, 10) : BT_DEFAULTS.cash; - stream.beginLocalResponse(`/backtest ${start} ${end} ${initialCash}`); - stream.appendLocalResponse(`Team-driven replay from ${start} to ${end}, cash ${formatBigVnd(initialCash)}.\n`); + stream.beginLocalResponse(`/backtest ${start} ${end} ${initialCash} --interval ${interval}`); + stream.appendLocalResponse(`Team-driven replay from ${start} to ${end}, interval ${interval}, cash ${formatBigVnd(initialCash)}.\n`); stream.appendLocalResponse("Fetching market data... Ctrl+B to abort.\n"); const ctrl = new AbortController(); backtestRef.current = { ctrl, running: true }; let turnIdx = 0; - let totalFridays = 0; + let totalTurns = 0; try { const summary = await runBacktestSession( - { start, end, initialCash, maxCandidates }, + { start, end, initialCash, interval, maxCandidates }, { signal: ctrl.signal, - onStart: ({ fridays, universe }) => { - totalFridays = fridays.length; + onStart: ({ interval: startedInterval, turns, fridays, universe }) => { + totalTurns = (turns ?? fridays).length; stream.appendLocalResponse( - `Ready: ${universe.length} tickers, ${totalFridays} weeks, team analyzes ${maxCandidates ?? 3}/week.\n`, + `Ready: ${universe.length} tickers, ${totalTurns} ${startedInterval} intervals, team analyzes ${maxCandidates ?? 3}/interval.\n`, ); }, onTurnStart: ({ dateIso }) => { turnIdx += 1; - stream.appendLocalResponse(`\n${turnIdx}/${totalFridays} ${dateIso} team analysis...\n`); + stream.appendLocalResponse(`\n${turnIdx}/${totalTurns} ${dateIso} interval analysis...\n`); }, onTeamEvent: (ev, { ticker }) => { if (ev.type === "role_start") { diff --git a/src/tui/components/SlashSuggest.tsx b/src/tui/components/SlashSuggest.tsx index 7cf682b..4c6054b 100644 --- a/src/tui/components/SlashSuggest.tsx +++ b/src/tui/components/SlashSuggest.tsx @@ -10,7 +10,7 @@ export interface SlashCommand { 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]", description: "Run a weekly backtest, defaults to last week" }, + { 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" }, diff --git a/src/tui/components/Welcome.tsx b/src/tui/components/Welcome.tsx index d61adb1..fffaf7f 100644 --- a/src/tui/components/Welcome.tsx +++ b/src/tui/components/Welcome.tsx @@ -17,7 +17,7 @@ const TIPS = [ ["/positions", "portfolio · PnL · exposures"], ["/autonomy", "persist advisory · confirm · auto"], ["/health", "runtime · broker · provider checks"], - ["/backtest", "team-driven weekly simulation"], + ["/backtest", "team-driven interval simulation"], ["/journal", "decisions · orders · fills · alerts"], ]; diff --git a/src/tui/lib/cards.tsx b/src/tui/lib/cards.tsx index 2ca0eb1..2abe65f 100644 --- a/src/tui/lib/cards.tsx +++ b/src/tui/lib/cards.tsx @@ -20,7 +20,8 @@ export function BacktestCard({ data }: { data: BacktestCardInput }) { cash {formatBigVnd(data.initialCash)} - weeks {data.summary.weeks} + interval {data.summary.interval ?? "30m"} + turns {data.summary.intervals ?? data.summary.sessions ?? data.summary.weeks} trades {data.summary.trades} {data.summary.rejectedTrades ? ( <> rejected {data.summary.rejectedTrades} diff --git a/tests/tui.test.tsx b/tests/tui.test.tsx index da5aca0..890676d 100644 --- a/tests/tui.test.tsx +++ b/tests/tui.test.tsx @@ -28,6 +28,7 @@ vi.mock("../src/agent/team/index.js", () => ({ })); vi.mock("../src/agent/backtestRunner.js", () => ({ + BACKTEST_DEFAULT_INTERVAL: "30m", runBacktestSession: runnerMocks.runBacktestSession, })); @@ -513,6 +514,8 @@ describe("Azoth TUI", () => { runId: "bt-run-12345678", strategy: "team-default", brokerName: "paper-bt-test", + interval: "1h", + turns: [1, 2], fridays: [1, 2], universe: ["HPG", "VCB", "FPT"], }); @@ -589,6 +592,9 @@ describe("Azoth TUI", () => { totalCost: 0.0025, totalInTokens: 10, totalOutTokens: 20, + interval: "1h", + intervals: 1, + sessions: 1, weeks: 1, trades: 2, rejectedTrades: 0, @@ -598,7 +604,7 @@ describe("Azoth TUI", () => { const { lastFrame, stdin, unmount } = render(); await tick(); - await type(stdin, "/backtest 2025-01-03 2025-01-10 1000000000"); + await type(stdin, "/backtest 2025-01-03 2025-01-10 1000000000 --interval 1h"); await tick(); expect(runnerMocks.runBacktestSession).toHaveBeenCalledWith( @@ -606,6 +612,7 @@ describe("Azoth TUI", () => { start: "2025-01-03", end: "2025-01-10", initialCash: 1_000_000_000, + interval: "1h", maxCandidates: undefined, }, expect.objectContaining({ @@ -621,8 +628,9 @@ describe("Azoth TUI", () => { expect(out).toContain("/backtest 2025-01-03"); expect(out).toContain("Ready"); expect(out).toContain("3 tickers"); - expect(out).toContain("team analyzes 3/week"); - expect(out).toContain("team analysis"); + expect(out).toContain("1h intervals"); + expect(out).toContain("team analyzes 3/interval"); + expect(out).toContain("interval analysis"); expect(out).toContain("[HPG]"); expect(out).toContain("technical WebSearch: HPG steel demand Vietnam 2025"); expect(out).toContain("technical WebSearch result received: Search result: steel demand"); @@ -642,6 +650,8 @@ describe("Azoth TUI", () => { runId: "bt-run-default", strategy: "team-default", brokerName: "paper-bt-test", + interval: "30m", + turns: [1], fridays: [1], universe: ["HPG"], }); @@ -659,6 +669,9 @@ describe("Azoth TUI", () => { totalCost: 0, totalInTokens: 0, totalOutTokens: 0, + interval: "30m", + intervals: 0, + sessions: 0, weeks: 0, trades: 0, rejectedTrades: 0, @@ -677,6 +690,7 @@ describe("Azoth TUI", () => { start: expected.start, end: expected.end, initialCash: 1_000_000_000, + interval: "30m", }), expect.any(Object), ); From 65391543c66b817f30c146c631756a176c3dfa5d Mon Sep 17 00:00:00 2001 From: toreleon Date: Mon, 4 May 2026 14:41:34 +0700 Subject: [PATCH 4/4] chore: prepare release 0.1.2 --- .github/workflows/release.yml | 2 ++ README.md | 4 +++- docs/releases/v0.1.2.md | 32 ++++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 docs/releases/v0.1.2.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ab83e8..0e76b28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: push: branches: - master + - "release/**" + workflow_dispatch: permissions: contents: write diff --git a/README.md b/README.md index 0179a09..87e89e1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ autonomy and risk settings. > real orders against a real account. Use advisory or paper mode until you have > verified configuration, data quality, account state, and risk limits. -Latest release: [v0.1.1](docs/releases/v0.1.1.md) +Latest release: [v0.1.2](docs/releases/v0.1.2.md) ## Terminal UI @@ -338,6 +338,8 @@ reduce repeated network calls. ## Release Notes +- [v0.1.2](docs/releases/v0.1.2.md) - dynamic stock discovery, last-week + default backtests, and configurable interval replay cadence. - [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 diff --git a/docs/releases/v0.1.2.md b/docs/releases/v0.1.2.md new file mode 100644 index 0000000..d636dba --- /dev/null +++ b/docs/releases/v0.1.2.md @@ -0,0 +1,32 @@ +# Azoth v0.1.2 + +Release date: 2026-05-04 + +Azoth v0.1.2 improves stock discovery and backtest replay control for the +Vietnam equity agent workflow. + +## Highlights + +- Stock discovery can scan listed HOSE/HNX/UPCOM equities, exchange-specific + universes, preset baskets, or caller-provided ticker baskets instead of being + fixed to a small index-style list. +- Discovery supports signal and strategy aliases such as trend following, + breakout, mean reversion, defensive, liquidity surge, relative strength, and + weakness. +- `/backtest` defaults to the previous calendar week when no dates are + provided. +- Backtests replay interval turns instead of one whole-week decision. The + default cadence is 30 minutes, with `--interval` support for slower cadences + such as `1h` and `2h`. +- Backtest UI and documentation now report interval cadence and turn count. + +## Release Automation + +- The release workflow can now run on `release/**` branches and can be started + manually with `workflow_dispatch`, so package publication is not limited to + pushes on `master`. + +## Validation + +- `pnpm typecheck` +- `pnpm test -- tests/tui.test.tsx` diff --git a/package.json b/package.json index 8079a54..40b4354 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@toreleon/azoth", - "version": "0.1.1", + "version": "0.1.2", "description": "Professional agent CLI for Vietnam equity research, portfolio workflow, and broker-aware trading operations", "type": "module", "license": "MIT",