Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
branches:
- master
- "release/**"
workflow_dispatch:

permissions:
contents: write
Expand Down
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -166,12 +166,25 @@ 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
```

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
Expand Down Expand Up @@ -219,7 +232,7 @@ still stream the full local team flow.
| --- | --- |
| `/team <message>` | Run a multi-agent debate on a market or portfolio question. |
| `/analyze <ticker> [--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] [--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 <ticker>` | Request quote, technicals, and recent news for a ticker. |
| `/positions` | Summarize current portfolio positions and exposures. |
Expand Down Expand Up @@ -325,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
Expand Down
32 changes: 20 additions & 12 deletions docs/agent-team.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
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"]
Expand All @@ -168,17 +170,23 @@ 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"]
```

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.
- 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.
- 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.
- 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.
Expand All @@ -194,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.
Expand Down
32 changes: 32 additions & 0 deletions docs/releases/v0.1.2.md
Original file line number Diff line number Diff line change
@@ -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`
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
87 changes: 61 additions & 26 deletions src/agent/backtestRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -48,14 +51,19 @@ 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;
reportPath: string | null;
}

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;
Expand All @@ -65,24 +73,45 @@ 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 fridayCloses(vnindexBars: Bar[], startSec: number, endSec: number): number[] {
const out: number[] = [];
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);
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.`);
}
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;
}

Expand Down Expand Up @@ -216,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");

Expand All @@ -226,36 +256,38 @@ 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<string, Bar[]> = {};
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 fridays = fridayCloses(vnindex, startSec, endSec);
if (fridays.length === 0) throw new Error("no Friday 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, 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,
model: cfg.model,
broker: brokerName,
strategy: BACKTEST_STRATEGY_NAME,
engine: "team",
interval: interval.label,
maxCandidates,
discoveryCriterion: BACKTEST_DISCOVERY_CRITERION,
discoveryUniverse: BACKTEST_DISCOVERY_UNIVERSE,
Expand All @@ -269,24 +301,24 @@ 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(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 fridays) {
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;
};
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;
Expand Down Expand Up @@ -423,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,
Expand Down
2 changes: 1 addition & 1 deletion src/agent/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/agent/team/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading